Loading deploy/crd/felis.lolicon.best_minecraftservers.yaml +3 −1 Changes for deploy/crd/felis.lolicon.best_minecraftservers.yaml: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading internal/apis/felis/v1alpha1/minecraftserver_types.go +2 −1 Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading internal/operator/reconciler.go +5 −0 Changes for internal/operator/reconciler.go: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading internal/operator/reconciler_test.go +42 −0 Changes for internal/operator/reconciler_test.go: 42 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading
deploy/crd/felis.lolicon.best_minecraftservers.yaml +3 −1 Changes for deploy/crd/felis.lolicon.best_minecraftservers.yaml: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading
internal/apis/felis/v1alpha1/minecraftserver_types.go +2 −1 Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading
internal/operator/reconciler.go +5 −0 Changes for internal/operator/reconciler.go: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading
internal/operator/reconciler_test.go +42 −0 Changes for internal/operator/reconciler_test.go: 42 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading