diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index 6266f28..f467d0a 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -253,6 +253,11 @@ type MinecraftServerStatus struct { // clears this on stop so the next start re-anchors. Persisted in status // because the two endpoints fall in different reconcile passes. StartRequestedAt *metav1.Time `json:"startRequestedAt,omitempty"` + // EmptySince is when the operator first observed 0 online players during a + // Running phase (spec §8 idle auto-stop). It is reset when a player joins + // or the server stops, so the empty-duration counter starts fresh each time + // the server becomes unoccupied. + EmptySince *metav1.Time `json:"emptySince,omitempty"` // ObservedGeneration is the spec generation this status reflects. ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Conditions are the standard metav1 conditions (Ready, RconReached, ...). diff --git a/internal/apis/felis/v1alpha1/zz_generated.deepcopy.go b/internal/apis/felis/v1alpha1/zz_generated.deepcopy.go index 7119443..de5276f 100644 --- a/internal/apis/felis/v1alpha1/zz_generated.deepcopy.go +++ b/internal/apis/felis/v1alpha1/zz_generated.deepcopy.go @@ -118,6 +118,9 @@ func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) { if in.StartRequestedAt != nil { out.StartRequestedAt = in.StartRequestedAt.DeepCopy() } + if in.EmptySince != nil { + out.EmptySince = in.EmptySince.DeepCopy() + } if in.Conditions != nil { l := make([]metav1.Condition, len(in.Conditions)) for i := range in.Conditions { diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 25ed4b6..33a9cdc 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -131,6 +131,29 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine players = pc } + // Idle auto-stop (spec §8): when enabled, the server is Running, and the + // player tally is zero, track the empty duration and auto-stop when the + // configured timeout expires. The existing RCON probe already supplies + // the player count — no extra network cost. + if server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { + if players.Online == 0 { + if server.Status.EmptySince == nil { + t := r.now() + server.Status.EmptySince = &t + } else if r.now().Time.Sub(server.Status.EmptySince.Time).Seconds() >= + float64(server.Spec.Idle.EmptySecondsBeforeStop) { + server.Spec.DesiredState = v1alpha1.DesiredStopped + server.Status.EmptySince = nil + if err := r.Update(ctx, server); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + } else if server.Status.EmptySince != nil { + server.Status.EmptySince = nil + } + } + r.markRunningReady(server, players) return ctrl.Result{}, r.patchStatus(ctx, server) } @@ -297,6 +320,7 @@ func (r *Reconciler) markStopped(server *v1alpha1.MinecraftServer) { // Clear the start anchor so the next Running transition re-anchors and // felis_start_duration_seconds measures the new start, not since the last one. server.Status.StartRequestedAt = nil + server.Status.EmptySince = nil // reset idle auto-stop timer server.Status.Players = v1alpha1.PlayersStatus{} server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer} server.Status.LiveMotd = server.Spec.Motd.Stopped diff --git a/internal/operator/reconciler_test.go b/internal/operator/reconciler_test.go index 07e6e94..0b8c6cf 100644 --- a/internal/operator/reconciler_test.go +++ b/internal/operator/reconciler_test.go @@ -314,6 +314,128 @@ func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) { } } +// --- idle auto-stop tests (spec §8) --------------------------------------- + +// TestIdleAutoStop_EmptyServerGetsTimestamp verifies that the first Running +// reconcile with zero players stamps EmptySince and keeps the server Running. +func TestIdleAutoStop_EmptyServerGetsTimestamp(t *testing.T) { + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) + // Enable idle auto-stop with a generous timeout so we don't trigger the + // actual stop in this test. + s := getServer(t, c, "survival") + s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900} + if err := c.Update(context.Background(), s); err != nil { + t.Fatalf("enable idle: %v", err) + } + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + reconcile(t, r, "survival") + + server := getServer(t, c, "survival") + if server.Status.Phase != v1alpha1.PhaseRunning || !server.Status.Ready { + t.Fatalf("phase = %s ready=%v, want Running ready", server.Status.Phase, server.Status.Ready) + } + if server.Status.EmptySince == nil { + t.Fatal("EmptySince should be set for an empty server with idle autostop enabled") + } +} + +// TestIdleAutoStop_StopsAfterTimeout exercises the full auto-stop path: +// first reconcile stamps EmptySince; after advancing the clock past the +// timeout, the next reconcile flips desiredState to Stopped. +func TestIdleAutoStop_StopsAfterTimeout(t *testing.T) { + prober := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}} + r, c := newReconciler(t, prober, runningServer(), rconSecret()) + + base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + clock := base + r.Now = func() metav1.Time { return metav1.NewTime(clock) } + + // Enable idle auto-stop with a 60s timeout. + s := getServer(t, c, "survival") + s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 60} + if err := c.Update(context.Background(), s); err != nil { + t.Fatalf("enable idle: %v", err) + } + + // First reconcile: Running, 0 players → stamp EmptySince = base. + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + reconcile(t, r, "survival") + + server := getServer(t, c, "survival") + if server.Status.Phase != v1alpha1.PhaseRunning { + t.Fatalf("phase = %s, want Running", server.Status.Phase) + } + if server.Status.EmptySince == nil || !server.Status.EmptySince.Equal(ptrTime(metav1.NewTime(base))) { + t.Fatalf("EmptySince = %v, want %v", server.Status.EmptySince, base) + } + + // Advance past timeout. + clock = base.Add(61 * time.Second) + reconcile(t, r, "survival") + + server = getServer(t, c, "survival") + if server.Spec.DesiredState != v1alpha1.DesiredStopped { + t.Fatalf("desiredState = %s, want Stopped after idle timeout", server.Spec.DesiredState) + } +} + +// TestIdleAutoStop_ResetsWhenPlayerJoins verifies that EmptySince is cleared +// when the player tally goes from zero to non-zero. +func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) { + emptyProber := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}} + r, c := newReconciler(t, emptyProber, runningServer(), rconSecret()) + + s := getServer(t, c, "survival") + s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900} + if err := c.Update(context.Background(), s); err != nil { + t.Fatalf("enable idle: %v", err) + } + + // First reconcile: Running, 0 players → stamp EmptySince. + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + reconcile(t, r, "survival") + + server := getServer(t, c, "survival") + if server.Status.EmptySince == nil { + t.Fatal("EmptySince should be set when empty") + } + + // Swap to a prober that reports players online. + populatedProber := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}} + r.Prober = populatedProber + reconcile(t, r, "survival") + + server = getServer(t, c, "survival") + if server.Status.EmptySince != nil { + t.Fatalf("EmptySince = %v, want nil after players join", server.Status.EmptySince) + } +} + +// TestIdleAutoStop_SkipsWhenDisabled verifies that a Running empty server does +// NOT get an EmptySince timestamp when AutoStopEnabled is false. +func TestIdleAutoStop_SkipsWhenDisabled(t *testing.T) { + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) + + // Idle is NOT enabled (default). + s := getServer(t, c, "survival") + if s.Spec.Idle.AutoStopEnabled { + t.Fatal("idle autostop should be disabled by default") + } + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + reconcile(t, r, "survival") + + server := getServer(t, c, "survival") + if server.Status.EmptySince != nil { + t.Fatalf("EmptySince = %v, want nil when idle autostop is disabled", server.Status.EmptySince) + } +} + // --- helpers --------------------------------------------------------------- func getSTSErr(c client.Client, name string) (*appsv1.StatefulSet, error) {