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:
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
|
// clears this on stop so the next start re-anchors. Persisted in status
|
||||||
// because the two endpoints fall in different reconcile passes.
|
// because the two endpoints fall in different reconcile passes.
|
||||||
StartRequestedAt *metav1.Time `json:"startRequestedAt,omitempty"`
|
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 is the spec generation this status reflects.
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
// Conditions are the standard metav1 conditions (Ready, RconReached, ...).
|
// Conditions are the standard metav1 conditions (Ready, RconReached, ...).
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) {
|
|||||||
if in.StartRequestedAt != nil {
|
if in.StartRequestedAt != nil {
|
||||||
out.StartRequestedAt = in.StartRequestedAt.DeepCopy()
|
out.StartRequestedAt = in.StartRequestedAt.DeepCopy()
|
||||||
}
|
}
|
||||||
|
if in.EmptySince != nil {
|
||||||
|
out.EmptySince = in.EmptySince.DeepCopy()
|
||||||
|
}
|
||||||
if in.Conditions != nil {
|
if in.Conditions != nil {
|
||||||
l := make([]metav1.Condition, len(in.Conditions))
|
l := make([]metav1.Condition, len(in.Conditions))
|
||||||
for i := range in.Conditions {
|
for i := range in.Conditions {
|
||||||
|
|||||||
@@ -131,6 +131,29 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
|
|||||||
players = pc
|
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)
|
r.markRunningReady(server, players)
|
||||||
return ctrl.Result{}, r.patchStatus(ctx, server)
|
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
|
// 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.
|
// felis_start_duration_seconds measures the new start, not since the last one.
|
||||||
server.Status.StartRequestedAt = nil
|
server.Status.StartRequestedAt = nil
|
||||||
|
server.Status.EmptySince = nil // reset idle auto-stop timer
|
||||||
server.Status.Players = v1alpha1.PlayersStatus{}
|
server.Status.Players = v1alpha1.PlayersStatus{}
|
||||||
server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
|
server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
|
||||||
server.Status.LiveMotd = server.Spec.Motd.Stopped
|
server.Status.LiveMotd = server.Spec.Motd.Stopped
|
||||||
|
|||||||
@@ -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 ---------------------------------------------------------------
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
func getSTSErr(c client.Client, name string) (*appsv1.StatefulSet, error) {
|
func getSTSErr(c client.Client, name string) (*appsv1.StatefulSet, error) {
|
||||||
|
|||||||
Reference in new issue
Block a user