package controller import ( "context" "errors" "strings" "testing" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/reconcile" v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" "github.com/YuzuZensai/Minikura/operator/internal/resources" ) func TestMinecraftReconcileStatelessCreatesResources(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("reconcile: %v", err) } name := resources.ServerName(mc.Name) ns := mc.Namespace mustGet(t, c, client.ObjectKey{Name: resources.ConfigMapName(name), Namespace: ns}, &corev1.ConfigMap{}) mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &corev1.Service{}) mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &appsv1.Deployment{}) var sts appsv1.StatefulSet if err := c.Get(context.Background(), client.ObjectKey{Name: name, Namespace: ns}, &sts); !apierrors.IsNotFound(err) { t.Fatalf("expected no statefulset, got %v", err) } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhasePending { t.Errorf("phase = %q, want Pending", got.Status.Phase) } if got.Status.ObservedGeneration != 1 { t.Errorf("observedGeneration = %d, want 1", got.Status.ObservedGeneration) } if got.Status.Endpoint != "minecraft-lobby.minikura.svc.cluster.local:25565" { t.Errorf("endpoint = %q", got.Status.Endpoint) } } func TestMinecraftReconcileRejectsInvalidSpec(t *testing.T) { mc := testMinecraft("invalid", v1alpha1.ServerStateless) mc.Spec.Type = "" mc.Spec.ListenPort = 0 c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil || !strings.Contains(err.Error(), "unsupported server type") { t.Fatalf("reconcile error = %v, want invalid spec error", err) } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhaseFailed || got.Status.ObservedGeneration != mc.Generation { t.Fatalf("status = %#v, want failed observed generation", got.Status) } } func TestMinecraftReconcileStatefulCreatesStatefulSet(t *testing.T) { mc := testMinecraft("smp", v1alpha1.ServerStateful) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("reconcile: %v", err) } name := resources.ServerName(mc.Name) mustGet(t, c, client.ObjectKey{Name: name, Namespace: mc.Namespace}, &appsv1.StatefulSet{}) var dep appsv1.Deployment if err := c.Get(context.Background(), client.ObjectKey{Name: name, Namespace: mc.Namespace}, &dep); !apierrors.IsNotFound(err) { t.Fatalf("expected no deployment, got %v", err) } } func TestMinecraftReconcilePrunesOppositeWorkload(t *testing.T) { mc := testMinecraft("swap", v1alpha1.ServerStateless) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("first reconcile: %v", err) } var current v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), ¤t) current.Spec.Type = v1alpha1.ServerStateful current.Generation = 2 if err := c.Update(context.Background(), ¤t); err != nil { t.Fatalf("update type: %v", err) } if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { t.Fatalf("second reconcile: %v", err) } name := resources.ServerName(mc.Name) key := client.ObjectKey{Name: name, Namespace: mc.Namespace} mustGet(t, c, key, &appsv1.StatefulSet{}) var dep appsv1.Deployment if err := c.Get(context.Background(), key, &dep); !apierrors.IsNotFound(err) { t.Fatalf("expected deployment pruned, got %v", err) } } func TestMinecraftReconcileIgnoresNotFound(t *testing.T) { r := &MinecraftServerReconciler{Client: newFakeClient(t), Scheme: testScheme(t)} res, err := r.Reconcile(context.Background(), reconcile.Request{ NamespacedName: types.NamespacedName{Name: "missing", Namespace: "minikura"}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if res != (ctrl.Result{}) { t.Errorf("result = %#v, want empty", res) } } func TestMinecraftReconcileSkipsDeleting(t *testing.T) { now := metav1.Now() mc := testMinecraft("gone", v1alpha1.ServerStateless) mc.DeletionTimestamp = &now mc.Finalizers = []string{"test.finalizer"} c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("reconcile: %v", err) } var cm corev1.ConfigMap err := c.Get(context.Background(), client.ObjectKey{ Name: resources.ConfigMapName(resources.ServerName(mc.Name)), Namespace: mc.Namespace, }, &cm) if !apierrors.IsNotFound(err) { t.Fatalf("expected no resources for deleting object, got %v", err) } } func TestMinecraftReconcileInvalidStorageFails(t *testing.T) { mc := testMinecraft("bad", v1alpha1.ServerStateful) mc.Spec.StorageSize = "not-a-size" c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { t.Fatal("expected error for invalid storageSize") } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhaseFailed { t.Errorf("phase = %q, want Failed", got.Status.Phase) } if got.Status.Message == "" { t.Error("expected a failure message") } if got.Status.ObservedGeneration != mc.Generation { t.Errorf("observedGeneration = %d, want %d", got.Status.ObservedGeneration, mc.Generation) } } func TestMinecraftPruneSkipsUnownedWorkload(t *testing.T) { mc := testMinecraft("swap", v1alpha1.ServerStateful) stale := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: resources.ServerName(mc.Name), Namespace: mc.Namespace, }, Spec: appsv1.DeploymentSpec{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "x"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "busybox"}}}, }, }, } c := newFakeClient(t, mc, stale) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if err := r.pruneOppositeWorkload(context.Background(), mc, true); err != nil { t.Fatalf("prune: %v", err) } mustGet(t, c, client.ObjectKeyFromObject(stale), &appsv1.Deployment{}) } func TestMinecraftStatusUsesReadyReplicas(t *testing.T) { mc := testMinecraft("ready", v1alpha1.ServerStateless) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("reconcile: %v", err) } var dep appsv1.Deployment mustGet(t, c, client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}, &dep) dep.Status.Replicas = 1 dep.Status.ReadyReplicas = 1 if err := c.Status().Update(context.Background(), &dep); err != nil { t.Fatalf("update deployment status: %v", err) } if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("second reconcile: %v", err) } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhaseRunning { t.Errorf("phase = %q, want Running", got.Status.Phase) } if got.Status.ReadyReplicas != 1 { t.Errorf("readyReplicas = %d, want 1", got.Status.ReadyReplicas) } } func TestMinecraftReconcileMutatesServiceAndWorkload(t *testing.T) { mc := testMinecraft("mutable", v1alpha1.ServerStateless) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("first reconcile: %v", err) } var current v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), ¤t) current.Spec.ListenPort = 25566 current.Spec.Resources.MemoryLimitMB = 4096 current.Generation = 2 if err := c.Update(context.Background(), ¤t); err != nil { t.Fatalf("update server: %v", err) } if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { t.Fatalf("second reconcile: %v", err) } key := client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace} var svc corev1.Service mustGet(t, c, key, &svc) if svc.Spec.Ports[0].Port != 25566 { t.Errorf("service port = %d", svc.Spec.Ports[0].Port) } var dep appsv1.Deployment mustGet(t, c, key, &dep) if got := dep.Spec.Template.Spec.Containers[0].Resources.Limits.Memory().String(); got != "4Gi" { t.Errorf("memory limit = %q", got) } } func TestMinecraftReconcileApplyFailure(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { if _, ok := obj.(*corev1.ConfigMap); ok { return errors.New("configmap apply failed") } return c.Patch(ctx, obj, patch, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { t.Fatal("expected apply failure") } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhaseFailed { t.Errorf("phase = %q, want Failed", got.Status.Phase) } if !strings.Contains(got.Status.Message, "configmap apply failed") { t.Errorf("message = %q", got.Status.Message) } } func TestMinecraftReconcileServiceFailure(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { if _, ok := obj.(*corev1.Service); ok { return errors.New("service apply failed") } return c.Patch(ctx, obj, patch, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { t.Fatal("expected service apply failure") } } func TestMinecraftReconcileReportsPruneError(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if _, ok := obj.(*appsv1.StatefulSet); ok { return errors.New("sts lookup failed") } return c.Get(ctx, key, obj, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { t.Fatal("expected prune failure") } } func TestMinecraftUpdateStatusWorkloadGetError(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if _, ok := obj.(*appsv1.Deployment); ok { return errors.New("deployment get failed") } return c.Get(ctx, key, obj, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if err := r.updateStatus(context.Background(), mc, false); err == nil { t.Fatal("expected workload get error") } } func TestMinecraftUpdateStatusStatefulGetError(t *testing.T) { mc := testMinecraft("smp", v1alpha1.ServerStateful) c := newInterceptedClient(t, interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if _, ok := obj.(*appsv1.StatefulSet); ok { return errors.New("sts get failed") } return c.Get(ctx, key, obj, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if err := r.updateStatus(context.Background(), mc, true); err == nil { t.Fatal("expected statefulset get error") } } func TestMinecraftUpdateStatusEndpointError(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if _, ok := obj.(*corev1.Service); ok { return errors.New("service get failed") } return c.Get(ctx, key, obj, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if err := r.updateStatus(context.Background(), mc, false); err == nil { t.Fatal("expected endpoint get error") } } func TestMinecraftEndpointGetError(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) c := newInterceptedClient(t, interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if _, ok := obj.(*corev1.Service); ok { return errors.New("service get failed") } return c.Get(ctx, key, obj, opts...) }, }, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.endpoint(context.Background(), mc); err == nil { t.Fatal("expected service get error") } } func TestMinecraftEndpointMissingService(t *testing.T) { mc := testMinecraft("lobby", v1alpha1.ServerStateless) r := &MinecraftServerReconciler{Client: newFakeClient(t, mc), Scheme: testScheme(t)} got, err := r.endpoint(context.Background(), mc) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "" { t.Errorf("endpoint = %q, want empty", got) } } func TestMinecraftStatusUsesStatefulReadyReplicas(t *testing.T) { mc := testMinecraft("smp", v1alpha1.ServerStateful) c := newFakeClient(t, mc) r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("reconcile: %v", err) } var sts appsv1.StatefulSet mustGet(t, c, client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}, &sts) sts.Status.Replicas = 1 sts.Status.ReadyReplicas = 1 if err := c.Status().Update(context.Background(), &sts); err != nil { t.Fatalf("update sts status: %v", err) } if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { t.Fatalf("second reconcile: %v", err) } var got v1alpha1.MinecraftServer mustGet(t, c, client.ObjectKeyFromObject(mc), &got) if got.Status.Phase != v1alpha1.PhaseRunning || got.Status.ReadyReplicas != 1 { t.Errorf("status = %+v", got.Status) } } func TestApplyRejectsCrossNamespaceOwner(t *testing.T) { owner := testMinecraft("smp", v1alpha1.ServerStateful) cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "other"}} if err := apply(context.Background(), newFakeClient(t), owner, cm, testScheme(t)); err == nil { t.Fatal("expected setOwner to fail across namespaces") } } func TestApplyRejectsUnknownType(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatal(err) } owner := testMinecraft("smp", v1alpha1.ServerStateful) cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: owner.Namespace}} if err := apply(context.Background(), newFakeClient(t, owner), owner, cm, scheme); err == nil { t.Fatal("expected GVK lookup to fail") } } func mustGet(t *testing.T, c client.Client, key client.ObjectKey, obj client.Object) { t.Helper() if err := c.Get(context.Background(), key, obj); err != nil { t.Fatalf("get %T %s: %v", obj, key, err) } }