fix(operator): 读不出玩家数时暂停空闲自动停服,支持 1.12/Bukkit/EssentialsX 与颜色码
This commit is contained in:
6 files changed
+213
-57
No files matched your search
+27
-14
@@ -716,24 +716,37 @@ Check it first.
|
||||
kubectl get minecraftserver <name> -o jsonpath='{.spec.rcon.enabled}'
|
||||
```
|
||||
|
||||
The player tally is a by-product of the RCON readiness probe — `prober.go:63`
|
||||
runs `list` on the same connection that just authenticated, and `parseListReply`
|
||||
extracts the tally from `There are (\d+) of a max of (\d+) players online`. With
|
||||
RCON disabled the probe never runs, `players` keeps its zero value, and
|
||||
`markRunningReady` (`reconciler.go:413`) writes that zero into
|
||||
`status.players.online`. So a permanent 0 means "never sampled", not "nobody
|
||||
online".
|
||||
The player tally is a by-product of the RCON readiness probe: `prober.go` runs
|
||||
`list` on the same connection that just authenticated, and `parseListReply`
|
||||
reads the tally from the vanilla/Paper/Fabric/Forge reply (`There are 3 of a
|
||||
max of 20 players online`), the 1.12/Bukkit reply (`There are 3/20 players
|
||||
online`) or the EssentialsX reply (`There are 3 out of maximum 20 players
|
||||
online`, vanished players included), with `§` color codes stripped. With RCON
|
||||
disabled the probe never runs and no tally is ever read, so a permanent 0 means
|
||||
"never sampled", not "nobody online".
|
||||
|
||||
Idle auto-stop (`reconciler.go:175`) reads that same tally, which is why it
|
||||
carries the RCON condition explicitly:
|
||||
A tally that could not be read counts as **unknown**, never as zero. Idle
|
||||
auto-stop only acts on a count it actually read:
|
||||
|
||||
```go
|
||||
if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
||||
if players.Known && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
||||
```
|
||||
|
||||
The comment above it says why: with RCON off the zero tally "would read as
|
||||
'empty' and use to stop a server full of people". So the guard is deliberate —
|
||||
enabling `spec.idle.*` without RCON is a no-op by design, not a missing feature.
|
||||
An unknown tally leaves `status.players` at the last real count, neither starts
|
||||
nor clears the empty countdown, and sets the `PlayersCounted` condition to
|
||||
`False` with reason `ListUnreadable`. So enabling `spec.idle.*` without RCON is
|
||||
a no-op by design, and a server whose `list` reply is in a format Felis does
|
||||
not know (a plugin that rewrites `/list`, a translated reply) never idles out:
|
||||
|
||||
```sh
|
||||
kubectl get minecraftserver <name> -o jsonpath='{.status.conditions[?(@.type=="PlayersCounted")]}'
|
||||
# Reason ListUnreadable: run `list` in the server's panel console to see
|
||||
# what the server actually answers.
|
||||
```
|
||||
|
||||
Fix it by restoring a supported `/list` (for example, drop the plugin's
|
||||
override or its translation of that one message). The server keeps running
|
||||
either way; only the idle stop waits.
|
||||
|
||||
Both fields set and still nothing happens? Then the probe is failing rather than
|
||||
disabled: the server would be stuck in `Starting` with `RconNotReachable`
|
||||
@@ -1286,7 +1299,7 @@ for 10 seconds (the Free plan's limits).
|
||||
| Build push 400 / SA denied / egress hang / Failed / executor ImagePullBackOff | §8, §8e |
|
||||
| Registry push/pull unreachable | §9 |
|
||||
| World deleted unexpectedly / backup skipped | §10 |
|
||||
| Idle auto-stop not firing; player count 0 | §11 |
|
||||
| Idle auto-stop not firing; player count 0; `PlayersCounted=False` | §11 |
|
||||
| A config field seems ignored | §12 |
|
||||
| PVC left behind after delete | §13 |
|
||||
| Node out of disk; pods evicted / ImagePullBackOff | §13b |
|
||||
|
||||
@@ -72,6 +72,9 @@ const (
|
||||
ConditionReady = "Ready"
|
||||
ConditionRconReached = "RconReached"
|
||||
ConditionProvisioned = "Provisioned"
|
||||
// ConditionPlayersCounted is False while the RCON `list` reply cannot be
|
||||
// read; idle auto-stop waits for a real count (spec §8).
|
||||
ConditionPlayersCounted = "PlayersCounted"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
+55
-23
@@ -10,17 +10,20 @@ import (
|
||||
)
|
||||
|
||||
// PlayerCount is a server's online/max player tally as read from RCON `list`.
|
||||
// Both fields are zero when the count could not be read; that is not an error,
|
||||
// only the absence of a fresh sample (see Prober).
|
||||
// Known is false when the count could not be read (the command failed, or its
|
||||
// reply matched no format below); that is not a probe error, only the absence
|
||||
// of a fresh sample. The zero value is "unknown", so a caller that forgets to
|
||||
// check can never mistake a failed read for an empty server.
|
||||
type PlayerCount struct {
|
||||
Online int32
|
||||
Max int32
|
||||
Known bool
|
||||
}
|
||||
|
||||
// Prober reports whether a server's RCON endpoint is reachable and accepts the
|
||||
// password, and best-effort returns its current player tally. A nil error is the
|
||||
// loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and is
|
||||
// zero (with a nil error) whenever the tally could not be sampled. It is an
|
||||
// loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and has
|
||||
// Known=false (with a nil error) whenever the tally could not be sampled. It is an
|
||||
// interface so the reconciler can be tested without a live server.
|
||||
type Prober interface {
|
||||
Probe(ctx context.Context, addr, password string) (PlayerCount, error)
|
||||
@@ -37,7 +40,7 @@ type RconProber struct {
|
||||
// Probe dials addr and authenticates with password, honoring the smaller of the
|
||||
// configured timeout and any deadline already on ctx. Auth success gates
|
||||
// readiness; the player tally is then read with `list` on a best-effort basis —
|
||||
// a failed or unparseable `list` yields a zero PlayerCount, never a probe error,
|
||||
// a failed or unparseable `list` yields an unknown PlayerCount, never a probe error,
|
||||
// so a transient count-read hiccup can never flap a healthy server out of Ready.
|
||||
func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) {
|
||||
timeout := p.Timeout
|
||||
@@ -68,26 +71,55 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCou
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// listReplyPattern matches the vanilla/Paper `list` response, e.g.
|
||||
// "There are 3 of a max of 20 players online: alice, bob, carol". The search is
|
||||
// unanchored so leading color codes or trailing player names do not defeat it.
|
||||
var listReplyPattern = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`)
|
||||
// listReplyPatterns match the `list` replies of the loaders Felis runs, tried in
|
||||
// order against the reply with § color codes stripped:
|
||||
//
|
||||
// - vanilla 1.13+ / Paper / Fabric / Forge:
|
||||
// "There are 3 of a max of 20 players online: alice, bob, carol"
|
||||
// - vanilla 1.12 and older, Bukkit's own list:
|
||||
// "There are 3/20 players online:"
|
||||
// - EssentialsX (its /list replaces the vanilla one, RCON included); with
|
||||
// vanished players it prints visible/hidden, and both count as online:
|
||||
// "There are 3 out of maximum 20 players online."
|
||||
// "There are 3/1 out of maximum 20 players online."
|
||||
//
|
||||
// Each has groups (online, hidden, max); hidden is empty where the format has
|
||||
// none. The search is unanchored so trailing player names do not defeat it.
|
||||
var listReplyPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`There are (\d+)() of a max(?:imum)? of (\d+) players online`),
|
||||
regexp.MustCompile(`There are (\d+)(?:/(\d+))? out of (?:a )?maximum (?:of )?(\d+) players online`),
|
||||
regexp.MustCompile(`There are (\d+)()/(\d+) players online`),
|
||||
}
|
||||
|
||||
// colorCode matches a legacy § formatting code (color, bold, reset, ...).
|
||||
var colorCode = regexp.MustCompile(`(?i)§[0-9a-fk-orx]`)
|
||||
|
||||
// parseListReply extracts the online/max tally from a `list` reply. ok is false
|
||||
// (and the PlayerCount zero) when the reply does not match the known format, so
|
||||
// callers can distinguish "no sample" from a genuine "0 of N".
|
||||
// (and the PlayerCount unknown) when the reply matches none of the known
|
||||
// formats, so callers can distinguish "no sample" from a genuine "0 of N".
|
||||
func parseListReply(reply string) (PlayerCount, bool) {
|
||||
m := listReplyPattern.FindStringSubmatch(reply)
|
||||
if m == nil {
|
||||
return PlayerCount{}, false
|
||||
plain := colorCode.ReplaceAllString(reply, "")
|
||||
for _, re := range listReplyPatterns {
|
||||
m := re.FindStringSubmatch(plain)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
online, err := strconv.ParseInt(m[1], 10, 32)
|
||||
if err != nil {
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
if m[2] != "" {
|
||||
hidden, err := strconv.ParseInt(m[2], 10, 32)
|
||||
if err != nil {
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
online += hidden
|
||||
}
|
||||
max, err := strconv.ParseInt(m[3], 10, 32)
|
||||
if err != nil {
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
return PlayerCount{Online: int32(online), Max: int32(max), Known: true}, true
|
||||
}
|
||||
online, err := strconv.ParseInt(m[1], 10, 32)
|
||||
if err != nil {
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
max, err := strconv.ParseInt(m[2], 10, 32)
|
||||
if err != nil {
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
return PlayerCount{Online: int32(online), Max: int32(max)}, true
|
||||
return PlayerCount{}, false
|
||||
}
|
||||
@@ -12,21 +12,51 @@ func TestParseListReply(t *testing.T) {
|
||||
{
|
||||
name: "empty server",
|
||||
reply: "There are 0 of a max of 20 players online:",
|
||||
want: PlayerCount{Online: 0, Max: 20},
|
||||
want: PlayerCount{Online: 0, Max: 20, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "with player names",
|
||||
reply: "There are 3 of a max of 20 players online: alice, bob, carol",
|
||||
want: PlayerCount{Online: 3, Max: 20},
|
||||
want: PlayerCount{Online: 3, Max: 20, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "full server",
|
||||
reply: "There are 20 of a max of 20 players online: ...",
|
||||
want: PlayerCount{Online: 20, Max: 20},
|
||||
want: PlayerCount{Online: 20, Max: 20, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "color codes around the numbers",
|
||||
reply: "§6There are §c2§6 of a max of §c50§6 players online:§r alice, bob",
|
||||
want: PlayerCount{Online: 2, Max: 50, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "vanilla 1.12 and Bukkit slash form",
|
||||
reply: "There are 4/32 players online:\nalice, bob, carol, dave",
|
||||
want: PlayerCount{Online: 4, Max: 32, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "EssentialsX",
|
||||
reply: "§6There are §c0§6 out of maximum §c20§6 players online.",
|
||||
want: PlayerCount{Online: 0, Max: 20, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "EssentialsX with vanished players counts them online",
|
||||
reply: "§6There are §c1§6/§c2§6 out of maximum §c20§6 players online.",
|
||||
want: PlayerCount{Online: 3, Max: 20, Known: true},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "translated reply yields no sample",
|
||||
reply: "当前有 0 个玩家在线,最大在线人数为 20 个玩家。",
|
||||
want: PlayerCount{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "unrecognized reply yields no sample",
|
||||
reply: "Unknown command. Try /help for a list of commands.",
|
||||
|
||||
@@ -217,16 +217,27 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
|
||||
return ctrl.Result{RequeueAfter: requeueStarting}, nil
|
||||
}
|
||||
players = pc
|
||||
// A tally that could not be read pauses idle auto-stop (below) instead of
|
||||
// counting as an empty server; the condition says so, so a server that
|
||||
// never stops idle shows why.
|
||||
if pc.Known {
|
||||
r.setCondition(server, v1alpha1.ConditionPlayersCounted, metav1.ConditionTrue, "Counted", "RCON list reply read")
|
||||
} else {
|
||||
r.setCondition(server, v1alpha1.ConditionPlayersCounted, metav1.ConditionFalse, "ListUnreadable",
|
||||
"RCON list failed or its reply matched no known format; idle auto-stop is paused until the player count can be read")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Rcon.Enabled is part of the condition because `players` is only a real tally
|
||||
// when the probe above ran: with RCON off it keeps its zero value, which this
|
||||
// branch would read as "empty" and use to stop a server full of people.
|
||||
if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
||||
// players.Known gates the whole branch: an unread tally (RCON off, `list`
|
||||
// failed, or a reply format the parser does not know) must never read as
|
||||
// "empty" and stop a server full of people. It neither stamps nor clears
|
||||
// EmptySince, so a flaky read does not restart the countdown either; the
|
||||
// stop itself only ever follows a sample that really said zero.
|
||||
if players.Known && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
||||
if players.Online == 0 {
|
||||
if server.Status.EmptySince == nil {
|
||||
t := r.now()
|
||||
@@ -525,8 +536,11 @@ func (r *Reconciler) markRunningReady(server *v1alpha1.MinecraftServer, players
|
||||
server.Status.Ready = true
|
||||
server.Status.ObservedGeneration = server.Generation
|
||||
// Refresh the player tally sampled by this reconcile's RCON probe so the panel
|
||||
// reports live occupancy instead of the 0/0 markStopped leaves behind.
|
||||
server.Status.Players = v1alpha1.PlayersStatus{Online: players.Online, Max: players.Max}
|
||||
// reports live occupancy instead of the 0/0 markStopped leaves behind. An
|
||||
// unread tally keeps the last one shown.
|
||||
if players.Known {
|
||||
server.Status.Players = v1alpha1.PlayersStatus{Online: players.Online, Max: players.Max}
|
||||
}
|
||||
if server.Status.ReadySignalAt == nil {
|
||||
t := r.now()
|
||||
server.Status.ReadySignalAt = &t
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@@ -254,7 +255,7 @@ func TestReconcileRunning_RconProbeGatesReadiness(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReconcileRunning_PopulatesPlayerTally(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
reconcile(t, r, "survival") // creates workload, Starting
|
||||
markPodReady(t, c, "survival")
|
||||
@@ -331,7 +332,7 @@ func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) {
|
||||
// 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())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, 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")
|
||||
@@ -357,7 +358,7 @@ func TestIdleAutoStop_EmptyServerGetsTimestamp(t *testing.T) {
|
||||
// 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}}
|
||||
prober := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
|
||||
r, c := newReconciler(t, prober, runningServer(), rconSecret())
|
||||
|
||||
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||
@@ -397,7 +398,7 @@ func TestIdleAutoStop_StopsAfterTimeout(t *testing.T) {
|
||||
// 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}}
|
||||
emptyProber := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
|
||||
r, c := newReconciler(t, emptyProber, runningServer(), rconSecret())
|
||||
|
||||
s := getServer(t, c, "survival")
|
||||
@@ -417,7 +418,7 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
|
||||
}
|
||||
|
||||
// Swap to a prober that reports players online.
|
||||
populatedProber := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}
|
||||
populatedProber := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}
|
||||
r.Prober = populatedProber
|
||||
reconcile(t, r, "survival")
|
||||
|
||||
@@ -427,13 +428,76 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleAutoStop_UnreadTallyNeverStops pins the safety rule behind
|
||||
// PlayerCount.Known: a `list` reply the parser cannot read is no sample at all.
|
||||
// It must not stamp EmptySince, must not stop a server whose countdown already
|
||||
// ran out, must keep the last known tally on display, and must say why on the
|
||||
// PlayersCounted condition.
|
||||
func TestIdleAutoStop_UnreadTallyNeverStops(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 4, Max: 20, Known: true}}, 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) }
|
||||
|
||||
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)
|
||||
}
|
||||
reconcile(t, r, "survival")
|
||||
markPodReady(t, c, "survival")
|
||||
reconcile(t, r, "survival")
|
||||
|
||||
// The tally becomes unreadable: nothing is stamped, the last count stays.
|
||||
r.Prober = fakeProber{}
|
||||
reconcile(t, r, "survival")
|
||||
server := getServer(t, c, "survival")
|
||||
if server.Status.EmptySince != nil {
|
||||
t.Fatalf("EmptySince = %v, want nil: an unread tally is not an empty server", server.Status.EmptySince)
|
||||
}
|
||||
if server.Status.Players.Online != 4 {
|
||||
t.Errorf("players.online = %d, want the last known 4", server.Status.Players.Online)
|
||||
}
|
||||
cond := meta.FindStatusCondition(server.Status.Conditions, v1alpha1.ConditionPlayersCounted)
|
||||
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "ListUnreadable" {
|
||||
t.Fatalf("PlayersCounted = %+v, want False/ListUnreadable", cond)
|
||||
}
|
||||
|
||||
// A countdown already past its deadline does not fire on an unread tally.
|
||||
r.Prober = fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
|
||||
reconcile(t, r, "survival")
|
||||
server = getServer(t, c, "survival")
|
||||
cond = meta.FindStatusCondition(server.Status.Conditions, v1alpha1.ConditionPlayersCounted)
|
||||
if cond == nil || cond.Status != metav1.ConditionTrue {
|
||||
t.Fatalf("PlayersCounted = %+v, want True after a readable reply", cond)
|
||||
}
|
||||
r.Prober = fakeProber{}
|
||||
clock = base.Add(10 * time.Minute)
|
||||
reconcile(t, r, "survival")
|
||||
server = getServer(t, c, "survival")
|
||||
if server.Spec.DesiredState != v1alpha1.DesiredRunning {
|
||||
t.Fatalf("desiredState = %s, want Running while the tally is unread", server.Spec.DesiredState)
|
||||
}
|
||||
if server.Status.EmptySince == nil {
|
||||
t.Fatal("EmptySince was cleared by an unread tally; a flaky read must not restart the countdown")
|
||||
}
|
||||
|
||||
// The next real zero stops it.
|
||||
r.Prober = fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
|
||||
reconcile(t, r, "survival")
|
||||
server = getServer(t, c, "survival")
|
||||
if server.Spec.DesiredState != v1alpha1.DesiredStopped {
|
||||
t.Fatalf("desiredState = %s, want Stopped once a real zero is read past the deadline", server.Spec.DesiredState)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an
|
||||
// empty Running server must wake the controller at the auto-stop deadline with
|
||||
// no external event to lean on. Live, the EmptySince stamp sat unexamined for
|
||||
// minutes because nothing re-triggered the reconcile loop — this test fails if
|
||||
// the requeue is ever dropped again.
|
||||
func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||
r.Now = func() metav1.Time { return metav1.NewTime(base) }
|
||||
|
||||
@@ -456,7 +520,7 @@ func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) {
|
||||
// notices the last player leaving: with players online there is no deadline to
|
||||
// aim at, but the tally must still be re-sampled.
|
||||
func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
s := getServer(t, c, "survival")
|
||||
s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900}
|
||||
@@ -476,7 +540,7 @@ func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) {
|
||||
// TestNoIdleRequeueWhenDisabled guards against a blanket requeue: servers
|
||||
// without idle auto-stop keep the old quiescent behaviour.
|
||||
func TestNoIdleRequeueWhenDisabled(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
reconcile(t, r, "survival")
|
||||
markPodReady(t, c, "survival")
|
||||
@@ -491,7 +555,7 @@ func TestNoIdleRequeueWhenDisabled(t *testing.T) {
|
||||
// not drift while the Secret is untouched — a drifting value would roll the
|
||||
// pod on every reconcile.
|
||||
func TestRconStamp_StableAcrossReconciles(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
reconcile(t, r, "survival")
|
||||
markPodReady(t, c, "survival")
|
||||
@@ -512,7 +576,7 @@ func TestRconStamp_StableAcrossReconciles(t *testing.T) {
|
||||
// rolled onto it — otherwise the old pod keeps authenticating with the lost
|
||||
// password and the RCON gate fails forever.
|
||||
func TestRconSecretRecreation_RollsTemplate(t *testing.T) {
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
reconcile(t, r, "survival")
|
||||
markPodReady(t, c, "survival")
|
||||
@@ -553,7 +617,7 @@ func stsTemplateStamp(t *testing.T, c client.Client, name string) string {
|
||||
// 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())
|
||||
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
|
||||
|
||||
// Idle is NOT enabled (default).
|
||||
s := getServer(t, c, "survival")
|
||||
|
||||
Reference in new issue
Block a user