package operator_test import ( "context" "errors" "strings" "testing" "time" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/operator" 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" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) type fakeProber struct{ err error } func (f fakeProber) Probe(context.Context, string, string) error { return f.err } func newScheme(t *testing.T) *runtime.Scheme { t.Helper() scheme := runtime.NewScheme() if err := clientgoscheme.AddToScheme(scheme); err != nil { t.Fatalf("clientgo scheme: %v", err) } if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("v1alpha1 scheme: %v", err) } return scheme } // fixedNow yields a deterministic timestamp so ReadySignalAt is assertable. func fixedNow() metav1.Time { return metav1.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) } func runningServer() *v1alpha1.MinecraftServer { return &v1alpha1.MinecraftServer{ ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft", Generation: 1}, Spec: v1alpha1.MinecraftServerSpec{ Subdomain: "survival", DesiredState: v1alpha1.DesiredRunning, Image: "registry.internal/felis/paper:latest", JavaMemory: "4G", Storage: v1alpha1.StorageSpec{Size: "10Gi"}, FallbackServer: "lobby", Motd: v1alpha1.MotdSpec{Running: "up", Stopped: "down", Starting: "booting"}, Lifecycle: v1alpha1.LifecycleSpec{PreStopSaveAndStop: true}, Rcon: v1alpha1.RconSpec{ Enabled: true, Port: 25575, SecretRef: v1alpha1.SecretKeyRef{Name: "survival-rcon", Key: "password"}, }, }, } } func rconSecret() *corev1.Secret { return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "survival-rcon", Namespace: "minecraft"}, Data: map[string][]byte{"password": []byte("hunter2")}, } } func newReconciler(t *testing.T, prober operator.Prober, objs ...client.Object) (*operator.Reconciler, client.Client) { t.Helper() scheme := newScheme(t) c := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). WithStatusSubresource(&v1alpha1.MinecraftServer{}, &appsv1.StatefulSet{}). Build() return &operator.Reconciler{Client: c, Scheme: scheme, Prober: prober, Now: fixedNow}, c } func reconcile(t *testing.T, r *operator.Reconciler, name string) ctrl.Result { t.Helper() res, err := r.Reconcile(context.Background(), ctrl.Request{ NamespacedName: types.NamespacedName{Namespace: "minecraft", Name: name}, }) if err != nil { t.Fatalf("Reconcile(%s): %v", name, err) } return res } func getServer(t *testing.T, c client.Client, name string) *v1alpha1.MinecraftServer { t.Helper() var s v1alpha1.MinecraftServer if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: name}, &s); err != nil { t.Fatalf("get server: %v", err) } return &s } func getSTS(t *testing.T, c client.Client, name string) *appsv1.StatefulSet { t.Helper() var sts appsv1.StatefulSet if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: name}, &sts); err != nil { t.Fatalf("get statefulset: %v", err) } return &sts } // markPodReady simulates the kubelet flipping the StatefulSet to ready. func markPodReady(t *testing.T, c client.Client, name string) { t.Helper() sts := getSTS(t, c, name) 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) } } func TestReconcileRunning_CreatesWorkloadAndInjectsGracefulShutdown(t *testing.T) { r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret()) res := reconcile(t, r, "survival") if res.RequeueAfter == 0 { t.Errorf("expected a requeue while Starting, got %+v", res) } // Services exist. var svc corev1.Service if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: "survival"}, &svc); err != nil { t.Fatalf("client service not created: %v", err) } var hl corev1.Service if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: "survival-hl"}, &hl); err != nil { t.Fatalf("headless service not created: %v", err) } if hl.Spec.ClusterIP != corev1.ClusterIPNone { t.Errorf("headless service ClusterIP = %q, want None", hl.Spec.ClusterIP) } // StatefulSet exists with graceful-shutdown injection. sts := getSTS(t, c, "survival") if got := sts.Spec.Template.Spec.TerminationGracePeriodSeconds; got == nil || *got != 300 { t.Errorf("terminationGracePeriodSeconds = %v, want 300", got) } container := sts.Spec.Template.Spec.Containers[0] if container.Lifecycle == nil || container.Lifecycle.PreStop == nil || container.Lifecycle.PreStop.Exec == nil { t.Fatal("expected preStop exec hook to be injected") } preStop := strings.Join(container.Lifecycle.PreStop.Exec.Command, " ") if !strings.Contains(preStop, "save-all flush") || !strings.Contains(preStop, "stop") { t.Errorf("preStop hook missing save/stop sequence: %q", preStop) } // RCON password is sourced from the Secret, never inlined. var sawRconPassword bool for _, e := range container.Env { if e.Name == "RCON_PASSWORD" { sawRconPassword = true if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { t.Error("RCON_PASSWORD must come from a SecretKeyRef") } if e.Value != "" { t.Error("RCON_PASSWORD must not be inlined as a literal value") } } } if !sawRconPassword { t.Error("RCON_PASSWORD env not injected") } // Owner reference points back at the MinecraftServer. if len(sts.OwnerReferences) != 1 || sts.OwnerReferences[0].Name != "survival" { t.Errorf("statefulset owner refs = %+v, want one ref to survival", sts.OwnerReferences) } // The server pod must NOT mount a ServiceAccount token (spec §21): it runs // untrusted user worlds/plugins and has no business reaching the K8s API. if amt := sts.Spec.Template.Spec.AutomountServiceAccountToken; amt == nil || *amt { t.Error("server pod must set AutomountServiceAccountToken=false — untrusted workloads must not reach the API") } // Status is Starting (pod not yet ready). server := getServer(t, c, "survival") if server.Status.Phase != v1alpha1.PhaseStarting || server.Status.Ready { t.Errorf("status = %s ready=%v, want Starting not-ready", server.Status.Phase, server.Status.Ready) } if server.Status.Endpoint.Mode != v1alpha1.EndpointFallback || server.Status.Endpoint.Address != "lobby" { t.Errorf("starting endpoint = %+v, want fallback->lobby", server.Status.Endpoint) } } func TestReconcileRunning_RconProbeGatesReadiness(t *testing.T) { r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret()) reconcile(t, r, "survival") // creates workload, Starting markPodReady(t, c, "survival") res := reconcile(t, r, "survival") // pod ready + probe OK -> Running if res.RequeueAfter != 0 { t.Errorf("expected no requeue once Running, got %+v", res) } server := getServer(t, c, "survival") if server.Status.Phase != v1alpha1.PhaseRunning || !server.Status.Ready { t.Fatalf("status = %s ready=%v, want Running ready", server.Status.Phase, server.Status.Ready) } if server.Status.ReadySignalAt == nil || !server.Status.ReadySignalAt.Equal(ptrTime(fixedNow())) { t.Errorf("readySignalAt = %v, want %v", server.Status.ReadySignalAt, fixedNow()) } if server.Status.Endpoint.Mode != v1alpha1.EndpointDirect { t.Errorf("endpoint mode = %s, want direct", server.Status.Endpoint.Mode) } if !isConditionTrue(server, v1alpha1.ConditionReady) { t.Error("Ready condition should be True") } if !isConditionTrue(server, v1alpha1.ConditionRconReached) { t.Error("RconReached condition should be True") } } func TestReconcileRunning_RconProbeFailureStaysStarting(t *testing.T) { r, c := newReconciler(t, fakeProber{err: errors.New("connection refused")}, runningServer(), rconSecret()) reconcile(t, r, "survival") markPodReady(t, c, "survival") res := reconcile(t, r, "survival") // pod ready but probe fails if res.RequeueAfter == 0 { t.Error("expected requeue while RCON not reachable") } server := getServer(t, c, "survival") if server.Status.Phase != v1alpha1.PhaseStarting || server.Status.Ready { t.Errorf("status = %s ready=%v, want Starting not-ready", server.Status.Phase, server.Status.Ready) } if isConditionTrue(server, v1alpha1.ConditionReady) { t.Error("Ready condition must not be True when the probe fails") } } func TestReconcileStopped_NoWorkloadIsStopped(t *testing.T) { s := runningServer() s.Spec.DesiredState = v1alpha1.DesiredStopped r, c := newReconciler(t, fakeProber{}, s) reconcile(t, r, "survival") if _, err := getSTSErr(c, "survival"); !apierrors.IsNotFound(err) { t.Errorf("stopped server should not create a StatefulSet, got err=%v", err) } server := getServer(t, c, "survival") if server.Status.Phase != v1alpha1.PhaseStopped || server.Status.Ready { t.Errorf("status = %s ready=%v, want Stopped not-ready", server.Status.Phase, server.Status.Ready) } } func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) { r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret()) reconcile(t, r, "survival") markPodReady(t, c, "survival") reconcile(t, r, "survival") // now Running with replicas=1 // Flip desiredState to Stopped. server := getServer(t, c, "survival") server.Spec.DesiredState = v1alpha1.DesiredStopped if err := c.Update(context.Background(), server); err != nil { t.Fatalf("flip desiredState: %v", err) } reconcile(t, r, "survival") sts := getSTS(t, c, "survival") if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 { t.Errorf("replicas = %v, want 0 after stop", sts.Spec.Replicas) } } // --- helpers --------------------------------------------------------------- func getSTSErr(c client.Client, name string) (*appsv1.StatefulSet, error) { var sts appsv1.StatefulSet err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: name}, &sts) return &sts, err } func isConditionTrue(server *v1alpha1.MinecraftServer, condType string) bool { for _, cond := range server.Status.Conditions { if cond.Type == condType { return cond.Status == metav1.ConditionTrue } } return false } func ptrTime(t metav1.Time) *metav1.Time { return &t }