feat(operator): implement idle auto-stop (spec §8)

Adds empty-server auto-stop to the reconciler. When Idle.AutoStopEnabled
is true and the RCON player tally is zero for EmptySecondsBeforeStop seconds,
the operator flips desiredState to Stopped, which triggers the normal
graceful-shutdown path.

- Add EmptySince status field to track empty duration
- Clear EmptySince on stop and when players return
- Reuse existing RCON probe's player count (zero extra network cost)
- 4 new test cases covering timestamp, timeout, player-join reset, and
  disabled-by-default
This commit is contained in:
Lemon-miaow committed 2026-07-05 01:52:45 +08:00
1 parent bd49313e7e
commit 91bfa27e8c
4 files changed
+154

No files matched your search

@@ -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, ...).
@@ -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 {
+24
View File
@@ -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
+122
View File
@@ -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) {