From c272e2ea24954091a41cf91541f6494e4ea137da Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 13:32:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(operator):=20=E5=9B=9E=E5=88=B0=20Starting?= =?UTF-8?q?=20=E6=97=B6=E6=B8=85=E6=8E=89=20readySignalAt=EF=BC=8C?= =?UTF-8?q?=E6=AF=8F=E6=AC=A1=E5=90=AF=E5=8A=A8=E9=83=BD=E8=AE=B0=E5=85=A5?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=B6=E9=95=BF=E6=8C=87=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../felis.lolicon.best_minecraftservers.yaml | 4 +- .../felis/v1alpha1/minecraftserver_types.go | 3 +- internal/operator/reconciler.go | 5 +++ internal/operator/reconciler_test.go | 42 +++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/deploy/crd/felis.lolicon.best_minecraftservers.yaml b/deploy/crd/felis.lolicon.best_minecraftservers.yaml index 92756c5..de66cd3 100644 --- a/deploy/crd/felis.lolicon.best_minecraftservers.yaml +++ b/deploy/crd/felis.lolicon.best_minecraftservers.yaml @@ -420,7 +420,9 @@ spec: status ping is never sufficient — spec §5). type: boolean readySignalAt: - description: ReadySignalAt is when the first RCON probe succeeded. + description: |- + ReadySignalAt is when the first RCON probe of the current run succeeded. + Every Starting pass clears it, so each start is measured once. format: date-time type: string startRequestedAt: diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index d254159..6928916 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -328,7 +328,8 @@ type MinecraftServerStatus struct { Players PlayersStatus `json:"players,omitempty"` // LiveMotd is the MOTD currently advertised for the active phase. LiveMotd string `json:"liveMotd,omitempty"` - // ReadySignalAt is when the first RCON probe succeeded. + // ReadySignalAt is when the first RCON probe of the current run succeeded. + // Every Starting pass clears it, so each start is measured once. ReadySignalAt *metav1.Time `json:"readySignalAt,omitempty"` // StartRequestedAt is when the current start attempt was first observed // (the first Starting reconcile after desiredState=Running). It anchors the diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 4708396..9ceec6f 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -923,6 +923,11 @@ func (r *Reconciler) markStarting(server *v1alpha1.MinecraftServer, reason, msg t := r.now() server.Status.StartRequestedAt = &t } + // A server back in Starting is on its way to a new ready: a pod that dropped + // out under a Running server, a retry after Failed, an auto-restart. Keeping + // the last run's ready time would make markRunningReady treat the start as + // already observed, and every start after the first would go unmeasured. + server.Status.ReadySignalAt = nil server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer} server.Status.LiveMotd = server.Spec.Motd.Starting r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, reason, msg) diff --git a/internal/operator/reconciler_test.go b/internal/operator/reconciler_test.go index 59f0267..5ec21bf 100644 --- a/internal/operator/reconciler_test.go +++ b/internal/operator/reconciler_test.go @@ -1025,6 +1025,48 @@ func TestReconcileRunning_StartDurationObservedOnce(t *testing.T) { } } +// A pod that drops out of readiness under a Running server sends it back through +// Starting; its way back to ready is a start like any other, and is observed as +// one, measured from the new Starting pass. Before, readySignalAt kept the first +// run's time, so every start after the first went unobserved. +func TestReconcileRunning_ObservesStartDurationAfterAPodBlip(t *testing.T) { + r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret()) + base := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + clock := base + r.Now = func() metav1.Time { return metav1.NewTime(clock) } + + reconcile(t, r, "survival") // Starting + markPodReady(t, c, "survival") + reconcile(t, r, "survival") // Running: the first start's observation + beforeCount, beforeSum := startDurationState(t) + + clock = base.Add(10 * time.Minute) + sts := getSTS(t, c, "survival") + sts.Status.ReadyReplicas = 0 + if err := c.Status().Update(context.Background(), sts); err != nil { + t.Fatalf("update sts status: %v", err) + } + reconcile(t, r, "survival") // the pod is not ready: back to Starting + if s := getServer(t, c, "survival"); s.Status.Phase != v1alpha1.PhaseStarting || s.Status.ReadySignalAt != nil { + t.Fatalf("after the blip: phase %s, readySignalAt %v; want Starting and none", s.Status.Phase, s.Status.ReadySignalAt) + } + + clock = base.Add(10*time.Minute + 45*time.Second) + markPodReady(t, c, "survival") + reconcile(t, r, "survival") // Running again + + afterCount, afterSum := startDurationState(t) + if got := afterCount - beforeCount; got != 1 { + t.Fatalf("histogram sample count delta = %d, want exactly 1 observation for the restart", got) + } + if got := afterSum - beforeSum; got != 45 { + t.Errorf("observed restart duration = %vs, want 45s", got) + } + if s := getServer(t, c, "survival"); s.Status.ReadySignalAt == nil || !s.Status.ReadySignalAt.Equal(ptrTime(metav1.NewTime(clock))) { + t.Errorf("readySignalAt = %v, want the restart's ready time %v", s.Status.ReadySignalAt, clock) + } +} + // A server whose RCON Secret does not exist yet is the normal case on first // reconcile — felis-api writes spec.rcon.secretRef but holds secrets:get, not // create, so the name it points at is a promise the operator has to keep. Before