fix(operator): 有玩家在线时停服先在游戏里预告 30 秒,期间改回运行会取消

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:18:41 +08:00
1 parent 4ae64cc2e8
commit 6d5aca79fb
16 files changed
+453 -13

No files matched your search

@@ -433,6 +433,14 @@ spec:
because the two endpoints fall in different reconcile passes. because the two endpoints fall in different reconcile passes.
format: date-time format: date-time
type: string type: string
stopNoticeAt:
description: |-
StopNoticeAt is when the operator told the players on a server that it is
about to stop (desiredState flipped to Stopped with players online). The stop
itself waits until StopNoticeWindow has passed since then; the stamp is cleared
once the server is scaled down, or when desiredState goes back to Running first.
format: date-time
type: string
type: object type: object
type: object type: object
served: true served: true
+12 -1
View File
@@ -284,8 +284,19 @@ panel shows `Stopped` means the game pod is still terminating (its shutdown save
can take a while); retry once `kubectl -n minecraft get pods -l can take a while); retry once `kubectl -n minecraft get pods -l
felis.lolicon.best/server=<name>` shows nothing. felis.lolicon.best/server=<name>` shows nothing.
A stop that leaves the server `Running` for about 30 s is the players' warning.
When desiredState turns `Stopped` and the RCON tally does not say the server is
empty, the operator sends everyone online a yellow chat line (`tellraw @a`),
stamps `status.stopNoticeAt`, records a `StopNotice` Event, and scales down
once `StopNoticeWindow` (30 s) has passed, with a last "stopping now" line
before the save. Starting the server again inside the window calls the stop
off, and the players are told so. An empty server, RCON off, or a probe or
broadcast that fails stops at once. Idle auto-stop only fires on an empty
server, so it never waits.
[GO-TESTED: `internal/maintenance`, `k8scluster_maintenance_test.go`, [GO-TESTED: `internal/maintenance`, `k8scluster_maintenance_test.go`,
`handlers_maintenance_test.go`, operator `maintenance_test.go`.] `handlers_maintenance_test.go`, operator `maintenance_test.go`,
`stopnotice_test.go`.]
--- ---
@@ -342,6 +342,11 @@ type MinecraftServerStatus struct {
// or the server stops, so the empty-duration counter starts fresh each time // or the server stops, so the empty-duration counter starts fresh each time
// the server becomes unoccupied. // the server becomes unoccupied.
EmptySince *metav1.Time `json:"emptySince,omitempty"` EmptySince *metav1.Time `json:"emptySince,omitempty"`
// StopNoticeAt is when the operator told the players on a server that it is
// about to stop (desiredState flipped to Stopped with players online). The stop
// itself waits until StopNoticeWindow has passed since then; the stamp is cleared
// once the server is scaled down, or when desiredState goes back to Running first.
StopNoticeAt *metav1.Time `json:"stopNoticeAt,omitempty"`
// AutoRestarts counts how often the operator recreated the pod of a start // AutoRestarts counts how often the operator recreated the pod of a start
// that timed out (at most MaxAutoRestarts, with a doubling backoff); reaching Ready or // that timed out (at most MaxAutoRestarts, with a doubling backoff); reaching Ready or
// stopping resets it. // stopping resets it.
@@ -121,6 +121,9 @@ func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) {
if in.EmptySince != nil { if in.EmptySince != nil {
out.EmptySince = in.EmptySince.DeepCopy() out.EmptySince = in.EmptySince.DeepCopy()
} }
if in.StopNoticeAt != nil {
out.StopNoticeAt = in.StopNoticeAt.DeepCopy()
}
if in.LastAutoRestartAt != nil { if in.LastAutoRestartAt != nil {
out.LastAutoRestartAt = in.LastAutoRestartAt.DeepCopy() out.LastAutoRestartAt = in.LastAutoRestartAt.DeepCopy()
} }
+2
View File
@@ -122,6 +122,8 @@ func (p switchProber) Probe(context.Context, string, string) (operator.PlayerCou
func (switchProber) Save(context.Context, string, string) error { return nil } func (switchProber) Save(context.Context, string, string) error { return nil }
func (switchProber) Broadcast(context.Context, string, string, string) error { return nil }
// A Running server keeps its phase and endpoint through two missed probes, // A Running server keeps its phase and endpoint through two missed probes,
// re-probing every 10s; the third consecutive miss degrades it to Starting, // re-probing every 10s; the third consecutive miss degrades it to Starting,
// and any success in between starts the count over. // and any success in between starts the count over.
+47
View File
@@ -2,9 +2,13 @@ package operator
import ( import (
"context" "context"
"encoding/json"
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
"unicode/utf16"
"felis.lolicon.best/internal/rcon" "felis.lolicon.best/internal/rcon"
) )
@@ -31,9 +35,13 @@ type PlayerCount struct {
// Save flushes the world to disk (`save-all flush`) and returns once the server // Save flushes the world to disk (`save-all flush`) and returns once the server
// has answered, i.e. once the save is done. The reconciler runs it right before // has answered, i.e. once the save is done. The reconciler runs it right before
// scaling a server to zero (spec §7). // scaling a server to zero (spec §7).
//
// Broadcast shows text in every online player's chat (`tellraw @a`). The reconciler
// uses it to warn players before a stop.
type Prober interface { type Prober interface {
Probe(ctx context.Context, addr, password string) (PlayerCount, error) Probe(ctx context.Context, addr, password string) (PlayerCount, error)
Save(ctx context.Context, addr, password string) error Save(ctx context.Context, addr, password string) error
Broadcast(ctx context.Context, addr, password, text string) error
} }
// RconProber is the production Prober: a successful Dial (TCP connect + auth) // RconProber is the production Prober: a successful Dial (TCP connect + auth)
@@ -110,6 +118,45 @@ func (p RconProber) Save(ctx context.Context, addr, password string) error {
return err return err
} }
// Broadcast runs `tellraw @a` with text as one yellow chat line. tellraw takes a
// JSON text component on every loader and version Felis runs, and unlike `say` it
// shows the text without a "[Server]" or "[Rcon]" prefix.
func (p RconProber) Broadcast(ctx context.Context, addr, password, text string) error {
timeout := boundTimeout(ctx, p.Timeout, 5*time.Second)
conn, err := rcon.Dial(addr, password, timeout)
if err != nil {
return err
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return err
}
_, err = conn.Execute(tellrawCommand(text))
return err
}
// tellrawCommand is the `tellraw @a` command that shows text to everyone online.
// Everything past ASCII is written as a JSON \u escape, so the command survives
// an RCON implementation that does not read its payload as UTF-8; the game's JSON
// parser turns the escapes back into the text.
func tellrawCommand(text string) string {
component, _ := json.Marshal(map[string]string{"text": text, "color": "yellow"})
var b strings.Builder
b.WriteString("tellraw @a ")
for _, r := range string(component) {
switch {
case r < 0x80:
b.WriteRune(r)
case r > 0xFFFF:
hi, lo := utf16.EncodeRune(r)
fmt.Fprintf(&b, `\u%04x\u%04x`, hi, lo)
default:
fmt.Fprintf(&b, `\u%04x`, r)
}
}
return b.String()
}
// listReplyPatterns match the `list` replies of the loaders Felis runs, tried in // listReplyPatterns match the `list` replies of the loaders Felis runs, tried in
// order against the reply with § color codes stripped: // order against the reply with § color codes stripped:
// //
+35
View File
@@ -3,8 +3,10 @@ package operator
import ( import (
"context" "context"
"encoding/binary" "encoding/binary"
"encoding/json"
"io" "io"
"net" "net"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -159,3 +161,36 @@ func TestRconProberSaveTimesOut(t *testing.T) {
t.Errorf("Save took %v, want it bounded by SaveTimeout", elapsed) t.Errorf("Save took %v, want it bounded by SaveTimeout", elapsed)
} }
} }
// The broadcast is plain ASCII on the wire and decodes back to the exact text, CJK
// and a character outside the BMP included.
func TestTellrawCommand(t *testing.T) {
text := `[Felis] 服务器将在 30 秒后关闭 / "stops" 🙂`
cmd := tellrawCommand(text)
want := `tellraw @a {"color":"yellow","text":"[Felis] \u670d\u52a1\u5668\u5c06\u5728 30 \u79d2\u540e\u5173\u95ed / \"stops\" \ud83d\ude42"}`
if cmd != want {
t.Errorf("command =\n%s\nwant\n%s", cmd, want)
}
for i := 0; i < len(cmd); i++ {
if cmd[i] >= 0x80 {
t.Fatalf("byte %d of %q is not ASCII", i, cmd)
}
}
var component map[string]string
if err := json.Unmarshal([]byte(strings.TrimPrefix(cmd, "tellraw @a ")), &component); err != nil {
t.Fatalf("component does not parse: %v", err)
}
if component["text"] != text {
t.Errorf("decoded text = %q, want %q", component["text"], text)
}
}
func TestRconProberBroadcastSendsTellraw(t *testing.T) {
addr, cmds := serveFakeRcon(t, 0)
if err := (RconProber{}).Broadcast(context.Background(), addr, "pw", "关闭 / stop"); err != nil {
t.Fatalf("Broadcast: %v", err)
}
if got, want := <-cmds, tellrawCommand("关闭 / stop"); got != want {
t.Errorf("command = %q, want %q", got, want)
}
}
+89
View File
@@ -277,6 +277,11 @@ func (r *Reconciler) event(server *v1alpha1.MinecraftServer, eventType, reason,
} }
func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) { func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
if server.Status.StopNoticeAt != nil {
if err := r.callOffStop(ctx, server); err != nil {
return ctrl.Result{}, err
}
}
if kind, held, err := r.maintenanceHold(ctx, server); err != nil { if kind, held, err := r.maintenanceHold(ctx, server); err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} else if held { } else if held {
@@ -495,7 +500,11 @@ func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.Mine
// which sends the server SIGTERM and so its own shutdown save within the // which sends the server SIGTERM and so its own shutdown save within the
// grace period. // grace period.
if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 { if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 {
if wait, err := r.noticeStop(ctx, server, &sts); err != nil || wait > 0 {
return ctrl.Result{RequeueAfter: wait}, err
}
r.saveBeforeStop(ctx, server, &sts) r.saveBeforeStop(ctx, server, &sts)
server.Status.StopNoticeAt = nil
zero := int32(0) zero := int32(0)
sts.Spec.Replicas = &zero sts.Spec.Replicas = &zero
if err := r.Update(ctx, &sts); err != nil { if err := r.Update(ctx, &sts); err != nil {
@@ -515,6 +524,86 @@ func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.Mine
return ctrl.Result{}, r.patchStatus(ctx, server) return ctrl.Result{}, r.patchStatus(ctx, server)
} }
// StopNoticeWindow is how long the players on a server get between the warning
// and the stop: long enough to finish a fight or get off a boat, short enough that
// the owner's stop (and the backup or restore waiting behind it) is not held up.
const StopNoticeWindow = 30 * time.Second
// The lines players see around a stop. The operator does not know a player's
// language, so each carries both.
const (
stopNoticeText = "[Felis] 服务器将在 30 秒后关闭,请尽快找安全的地方 / This server stops in 30 seconds"
stopNowText = "[Felis] 正在保存世界并关闭服务器 / Saving the world and stopping now"
stopCalledOffText = "[Felis] 关闭已取消 / The stop was called off"
)
// noticeStop warns the players on a server that is about to be scaled down, and
// returns how long the stop must still wait for them. The first pass tells everyone
// online and stamps StopNoticeAt; the stop goes ahead once StopNoticeWindow has
// passed since, with a last line as it does.
//
// There is no wait when nobody can be told or nobody is there: RCON off, no ready
// pod, a probe or broadcast that fails, or a tally that says zero players. A tally
// the server did not give (an unfamiliar `list` reply) still gets the warning,
// since players may well be on it. Idle auto-stop therefore never waits: it only
// fires on an empty server.
func (r *Reconciler) noticeStop(ctx context.Context, server *v1alpha1.MinecraftServer, sts *appsv1.StatefulSet) (time.Duration, error) {
if at := server.Status.StopNoticeAt; at != nil {
if wait := at.Time.Add(StopNoticeWindow).Sub(r.now().Time); wait > 0 {
return wait, nil
}
r.broadcast(ctx, server, stopNowText)
return 0, nil
}
if !server.Spec.Rcon.Enabled || sts.Status.ReadyReplicas == 0 {
return 0, nil
}
password, err := r.rconPassword(ctx, server)
if err != nil {
return 0, nil
}
addr := rconAddress(server)
players, err := r.Prober.Probe(ctx, addr, password)
if err != nil || (players.Known && players.Online == 0) {
return 0, nil
}
if err := r.Prober.Broadcast(ctx, addr, password, stopNoticeText); err != nil {
ctrl.LoggerFrom(ctx).Info("could not warn players before the stop; stopping now", "error", err.Error())
return 0, nil
}
now := r.now()
server.Status.StopNoticeAt = &now
if err := r.patchStatus(ctx, server); err != nil {
return 0, err
}
r.event(server, corev1.EventTypeNormal, "StopNotice",
fmt.Sprintf("players warned; stopping in %s", StopNoticeWindow))
return StopNoticeWindow, nil
}
// callOffStop answers a desiredState that went back to Running inside the notice
// window: the players who were warned hear that the stop is off.
func (r *Reconciler) callOffStop(ctx context.Context, server *v1alpha1.MinecraftServer) error {
r.broadcast(ctx, server, stopCalledOffText)
server.Status.StopNoticeAt = nil
return r.patchStatus(ctx, server)
}
// broadcast shows text to everyone on server, best-effort: a server that cannot be
// reached has nobody listening either.
func (r *Reconciler) broadcast(ctx context.Context, server *v1alpha1.MinecraftServer, text string) {
if !server.Spec.Rcon.Enabled {
return
}
password, err := r.rconPassword(ctx, server)
if err != nil {
return
}
if err := r.Prober.Broadcast(ctx, rconAddress(server), password, text); err != nil {
ctrl.LoggerFrom(ctx).Info("player broadcast failed", "error", err.Error())
}
}
// saveBeforeStop runs `save-all flush` on a server that is about to be scaled to // saveBeforeStop runs `save-all flush` on a server that is about to be scaled to
// zero. The SIGTERM that follows makes the server save again on its way out, but // zero. The SIGTERM that follows makes the server save again on its way out, but
// that save races the grace period: a large world killed mid-save rolls back to // that save races the grace period: a large world killed mid-save rolls back to
+14 -4
View File
@@ -31,6 +31,9 @@ type fakeProber struct {
saveErr error saveErr error
// saves, when set, records each Save's address. // saves, when set, records each Save's address.
saves *[]string saves *[]string
// broadcasts, when set, records each Broadcast's text.
broadcasts *[]string
broadcastErr error
} }
func (f fakeProber) Probe(context.Context, string, string) (operator.PlayerCount, error) { func (f fakeProber) Probe(context.Context, string, string) (operator.PlayerCount, error) {
@@ -44,6 +47,13 @@ func (f fakeProber) Save(_ context.Context, addr, _ string) error {
return f.saveErr return f.saveErr
} }
func (f fakeProber) Broadcast(_ context.Context, _, _, text string) error {
if f.broadcasts != nil {
*f.broadcasts = append(*f.broadcasts, text)
}
return f.broadcastErr
}
func newScheme(t *testing.T) *runtime.Scheme { func newScheme(t *testing.T) *runtime.Scheme {
t.Helper() t.Helper()
scheme := runtime.NewScheme() scheme := runtime.NewScheme()
@@ -316,7 +326,7 @@ func TestReconcileStopped_NoWorkloadIsStopped(t *testing.T) {
} }
func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) { func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) {
r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
reconcile(t, r, "survival") reconcile(t, r, "survival")
markPodReady(t, c, "survival") markPodReady(t, c, "survival")
@@ -353,7 +363,7 @@ func stopRunningServer(t *testing.T, r *operator.Reconciler, c client.Client) {
func TestReconcileStopped_SavesBeforeScalingDown(t *testing.T) { func TestReconcileStopped_SavesBeforeScalingDown(t *testing.T) {
var saves []string var saves []string
r, c := newReconciler(t, fakeProber{saves: &saves}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{saves: &saves, players: operator.PlayerCount{Known: true}}, runningServer(), rconSecret())
stopRunningServer(t, r, c) stopRunningServer(t, r, c)
@@ -374,7 +384,7 @@ func TestReconcileStopped_SavesBeforeScalingDown(t *testing.T) {
func TestReconcileStopped_FailedSaveStillStops(t *testing.T) { func TestReconcileStopped_FailedSaveStillStops(t *testing.T) {
var saves []string var saves []string
prober := fakeProber{saves: &saves, saveErr: errors.New("i/o timeout")} prober := fakeProber{saves: &saves, saveErr: errors.New("i/o timeout"), players: operator.PlayerCount{Known: true}}
r, c := newReconciler(t, prober, runningServer(), rconSecret()) r, c := newReconciler(t, prober, runningServer(), rconSecret())
stopRunningServer(t, r, c) stopRunningServer(t, r, c)
@@ -1149,7 +1159,7 @@ func TestFelisUpgradeLeavesARunningServerAlone(t *testing.T) {
// TestFelisUpgradeReachesAServerOnItsNextStart: a stopped server's next start // TestFelisUpgradeReachesAServerOnItsNextStart: a stopped server's next start
// writes the whole template, the new felis image included. // writes the whole template, the new felis image included.
func TestFelisUpgradeReachesAServerOnItsNextStart(t *testing.T) { func TestFelisUpgradeReachesAServerOnItsNextStart(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 1, Max: 20, Known: true}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
r.FelisImage = felisV1 r.FelisImage = felisV1
stopRunningServer(t, r, c) stopRunningServer(t, r, c)
markPodTerminated(t, c, "survival") markPodTerminated(t, c, "survival")
+185
View File
@@ -0,0 +1,185 @@
package operator_test
import (
"context"
"errors"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/operator"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// runThenStop takes survival to Running with a ready pod, flips it to Stopped and
// returns the result of the first Stopped pass.
func runThenStop(t *testing.T, r *operator.Reconciler, c client.Client) time.Duration {
t.Helper()
reconcile(t, r, "survival")
markPodReady(t, c, "survival")
reconcile(t, r, "survival")
setDesired(t, c, v1alpha1.DesiredStopped)
return reconcile(t, r, "survival").RequeueAfter
}
func setDesired(t *testing.T, c client.Client, desired v1alpha1.DesiredState) {
t.Helper()
server := getServer(t, c, "survival")
server.Spec.DesiredState = desired
if err := c.Update(context.Background(), server); err != nil {
t.Fatalf("set desiredState %s: %v", desired, err)
}
}
func at(offset time.Duration) func() metav1.Time {
return func() metav1.Time { return metav1.NewTime(fixedNow().Add(offset)) }
}
func replicas(t *testing.T, c client.Client) int32 {
t.Helper()
sts := getSTS(t, c, "survival")
if sts.Spec.Replicas == nil {
return 1
}
return *sts.Spec.Replicas
}
// A stop with players on the server warns them, holds the pod for the window, then
// saves and scales down with a last line.
func TestStopNotice_WarnsPlayersThenStops(t *testing.T) {
var saves, said []string
prober := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}, saves: &saves, broadcasts: &said}
r, c := newReconciler(t, prober, runningServer(), rconSecret())
if wait := runThenStop(t, r, c); wait != operator.StopNoticeWindow {
t.Fatalf("requeue = %v, want the %v window", wait, operator.StopNoticeWindow)
}
if len(said) != 1 || !strings.Contains(said[0], "30 秒") || !strings.Contains(said[0], "30 seconds") {
t.Fatalf("broadcasts = %q, want the 30-second warning in both languages", said)
}
server := getServer(t, c, "survival")
if server.Status.StopNoticeAt == nil || !server.Status.StopNoticeAt.Equal(ptrTime(fixedNow())) {
t.Errorf("stopNoticeAt = %v, want %v", server.Status.StopNoticeAt, fixedNow())
}
if server.Status.Phase != v1alpha1.PhaseRunning || !server.Status.Ready {
t.Errorf("phase = %s ready=%v, want the server still Running while players are warned", server.Status.Phase, server.Status.Ready)
}
if n := replicas(t, c); n != 1 || len(saves) != 0 {
t.Fatalf("replicas = %d saves = %d, want the pod left alone during the window", n, len(saves))
}
// A pass inside the window waits out the rest and says nothing new.
r.Now = at(10 * time.Second)
if wait := reconcile(t, r, "survival").RequeueAfter; wait != 20*time.Second {
t.Errorf("requeue 10s in = %v, want 20s", wait)
}
if len(said) != 1 || len(saves) != 0 || replicas(t, c) != 1 {
t.Fatalf("10s in: broadcasts=%q saves=%d replicas=%d, want nothing new", said, len(saves), replicas(t, c))
}
r.Now = at(operator.StopNoticeWindow)
reconcile(t, r, "survival")
if len(said) != 2 || !strings.Contains(said[1], "正在保存") {
t.Errorf("broadcasts = %q, want the stopping-now line last", said)
}
if len(saves) != 1 || replicas(t, c) != 0 {
t.Fatalf("saves = %d replicas = %d, want one save and the scale-down once the window is over", len(saves), replicas(t, c))
}
server = getServer(t, c, "survival")
if server.Status.StopNoticeAt != nil || server.Status.Phase != v1alpha1.PhaseStopping {
t.Errorf("stopNoticeAt = %v phase = %s, want the stamp cleared and Stopping", server.Status.StopNoticeAt, server.Status.Phase)
}
reconcile(t, r, "survival")
if len(said) != 2 || len(saves) != 1 {
t.Errorf("after the scale-down: broadcasts=%q saves=%d, want no more", said, len(saves))
}
}
// Nobody to warn, or no way to warn them: the stop goes ahead on the first pass.
// A tally the server did not give still counts as maybe-populated.
func TestStopNotice_OnlyWhenSomeoneMayBeWarned(t *testing.T) {
cases := []struct {
name string
prober fakeProber
noRcon bool
wantNotice bool
wantSaid int
}{
{name: "empty server", prober: fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}},
{name: "probe fails", prober: fakeProber{err: errors.New("connection refused")}},
{name: "broadcast fails", prober: fakeProber{players: operator.PlayerCount{Online: 2, Max: 20, Known: true}, broadcastErr: errors.New("i/o timeout")}, wantSaid: 1},
{name: "rcon disabled", prober: fakeProber{players: operator.PlayerCount{Online: 2, Max: 20, Known: true}}, noRcon: true},
{name: "unfamiliar list reply", prober: fakeProber{}, wantNotice: true, wantSaid: 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var saves, said []string
tc.prober.saves, tc.prober.broadcasts = &saves, &said
s := runningServer()
if tc.noRcon {
// The Secret and its reference stay, as they do when RCON is switched off.
s.Spec.Rcon.Enabled = false
}
r, c := newReconciler(t, tc.prober, s, rconSecret())
wait := runThenStop(t, r, c)
if len(said) != tc.wantSaid {
t.Errorf("broadcasts = %q, want %d", said, tc.wantSaid)
}
stamped := getServer(t, c, "survival").Status.StopNoticeAt != nil
if tc.wantNotice {
if wait != operator.StopNoticeWindow || replicas(t, c) != 1 || !stamped {
t.Errorf("requeue=%v replicas=%d stamped=%v, want the notice window", wait, replicas(t, c), stamped)
}
return
}
if replicas(t, c) != 0 || stamped {
t.Errorf("replicas=%d stamped=%v, want an immediate stop", replicas(t, c), stamped)
}
})
}
}
// Starting the server again inside the window calls the stop off: the warned
// players hear so, the pod stays, and a later stop warns afresh.
func TestStopNotice_CalledOffByStart(t *testing.T) {
var saves, said []string
prober := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}, saves: &saves, broadcasts: &said}
r, c := newReconciler(t, prober, runningServer(), rconSecret())
runThenStop(t, r, c)
r.Now = at(12 * time.Second)
setDesired(t, c, v1alpha1.DesiredRunning)
reconcile(t, r, "survival")
if len(said) != 2 || !strings.Contains(said[1], "取消") {
t.Errorf("broadcasts = %q, want the called-off line", said)
}
server := getServer(t, c, "survival")
if server.Status.StopNoticeAt != nil {
t.Errorf("stopNoticeAt = %v, want it cleared", server.Status.StopNoticeAt)
}
if replicas(t, c) != 1 || len(saves) != 0 || server.Status.Phase != v1alpha1.PhaseRunning {
t.Fatalf("replicas=%d saves=%d phase=%s, want the server left running", replicas(t, c), len(saves), server.Status.Phase)
}
// A Running pass after that says nothing more.
reconcile(t, r, "survival")
if len(said) != 2 {
t.Errorf("broadcasts = %q, want no repeat of the called-off line", said)
}
r.Now = at(40 * time.Second)
setDesired(t, c, v1alpha1.DesiredStopped)
if wait := reconcile(t, r, "survival").RequeueAfter; wait != operator.StopNoticeWindow {
t.Errorf("requeue = %v, want a full window for the new stop", wait)
}
if len(said) != 3 || !strings.Contains(said[2], "30 秒") {
t.Errorf("broadcasts = %q, want a fresh warning", said)
}
if got := getServer(t, c, "survival").Status.StopNoticeAt; got == nil || !got.Equal(ptrTime(at(40*time.Second)())) {
t.Errorf("stopNoticeAt = %v, want the new stop's time", got)
}
}
+1 -1
View File
@@ -52,7 +52,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"restore_started_short": "Restore started", "restore_started_short": "Restore started",
"restore_snapshot_started_short": "Snapshot, then restore", "restore_snapshot_started_short": "Snapshot, then restore",
"stopping": "Stopping the server…", "stopping": "Stopping the server (players online get a 30-second warning first)…",
"restoring": "Restoring…", "restoring": "Restoring…",
"stop_timeout": "The server did not stop in time. Close this window and try again shortly.", "stop_timeout": "The server did not stop in time. Close this window and try again shortly.",
"corrupt_badge": "Corrupt", "corrupt_badge": "Corrupt",
+2 -2
View File
@@ -217,8 +217,8 @@
"idle_stop_seconds": "{{count}} s", "idle_stop_seconds": "{{count}} s",
"idle_stop_minutes": "{{count}} min", "idle_stop_minutes": "{{count}} min",
"idle_stop_hours": "{{count}} h", "idle_stop_hours": "{{count}} h",
"stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?", "stop_confirm_players_one": "1 player is online. They get a 30-second warning in game, then are disconnected. Stop anyway?",
"stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?", "stop_confirm_players_other": "{{count}} players are online. They get a 30-second warning in game, then are disconnected. Stop anyway?",
"stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?", "stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?",
"retire_card_title": "Give up server", "retire_card_title": "Give up server",
"retire_card_title_admin": "Give up or delete server", "retire_card_title_admin": "Give up or delete server",
+1 -1
View File
@@ -52,7 +52,7 @@
"cancel": "取消", "cancel": "取消",
"restore_started_short": "已启动恢复", "restore_started_short": "已启动恢复",
"restore_snapshot_started_short": "快照中,随后恢复", "restore_snapshot_started_short": "快照中,随后恢复",
"stopping": "正在停止服务器……", "stopping": "正在停止服务器(有玩家在线时先在游戏里预告 30 秒)……",
"restoring": "正在恢复备份……", "restoring": "正在恢复备份……",
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。", "stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
"corrupt_badge": "已损坏", "corrupt_badge": "已损坏",
+1 -1
View File
@@ -217,7 +217,7 @@
"idle_stop_seconds": "{{count}} 秒", "idle_stop_seconds": "{{count}} 秒",
"idle_stop_minutes": "{{count}} 分钟", "idle_stop_minutes": "{{count}} 分钟",
"idle_stop_hours": "{{count}} 小时", "idle_stop_hours": "{{count}} 小时",
"stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?", "stop_confirm_players": "{{count}} 名玩家在线。停服会先在游戏里提醒他们,30 秒后断开。确定停止?",
"stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?", "stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?",
"retire_card_title": "放弃服务器", "retire_card_title": "放弃服务器",
"retire_card_title_admin": "放弃或删除服务器", "retire_card_title_admin": "放弃或删除服务器",
+43 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
@@ -12,6 +12,8 @@ const calls = vi.hoisted(() => ({
myServers: vi.fn(), myServers: vi.fn(),
serverJobs: vi.fn(), serverJobs: vi.fn(),
listBackups: vi.fn(), listBackups: vi.fn(),
stop: vi.fn(),
restoreBackup: vi.fn(),
})); }));
vi.mock("@/lib/tier", () => ({ vi.mock("@/lib/tier", () => ({
useTier: () => ({ useTier: () => ({
@@ -53,6 +55,7 @@ beforeEach(() => {
})); }));
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
return i18next.changeLanguage("en-US"); return i18next.changeLanguage("en-US");
}); });
@@ -111,4 +114,43 @@ describe("ServerBackups", () => {
expect(await screen.findByText("Latest backup")).toBeTruthy(); expect(await screen.findByText("Latest backup")).toBeTruthy();
expect(screen.queryByText(/^Page \d+ of/)).toBeNull(); expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
}); });
// Restoring a running server stops it first and restores only once it is down.
// With players online the operator warns them for 30 s before the stop, and the
// saves follow, so the wait must outlast that.
async function restoreRunningServer(stopsAfterMs: number) {
let stoppedAt = Infinity;
calls.status.mockImplementation(async () => {
const since = Date.now() - stoppedAt;
// Running through the warning, Stopping while the pod saves and goes.
const phase = since >= stopsAfterMs ? "Stopped" : since >= 30_000 ? "Stopping" : "Running";
return { name: "survival", displayName: "Survival", phase };
});
calls.stop.mockImplementation(async () => {
stoppedAt = Date.now();
});
calls.restoreBackup.mockResolvedValue({ safety_snapshot: true });
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Restore" }));
const confirm = await screen.findByRole("button", { name: "Confirm restore" });
vi.useFakeTimers();
await act(async () => {
fireEvent.click(confirm);
});
expect(calls.stop).toHaveBeenCalledWith("survival");
await act(() => vi.advanceTimersByTimeAsync(130_000));
}
it("waits out a stop held for the players' 30-second warning, then restores", async () => {
await restoreRunningServer(75_000);
expect(calls.restoreBackup).toHaveBeenCalledWith("survival", "bk-1", true);
expect(screen.queryByText(i18next.t("backups:stop_timeout"))).toBeNull();
});
it("gives up without restoring when the server never goes down", async () => {
await restoreRunningServer(Infinity);
expect(calls.restoreBackup).not.toHaveBeenCalled();
expect(screen.getByText(i18next.t("backups:stop_timeout"))).toBeTruthy();
});
}); });
+5 -2
View File
@@ -283,13 +283,16 @@ function ChainNote({ job }: { job: ServerJob }) {
* server plus consciously confirming a player-kicking, world-overwriting act. * server plus consciously confirming a player-kicking, world-overwriting act.
* *
* The stop-and-wait is a bounded async loop (not an effect): after api.stop it polls * The stop-and-wait is a bounded async loop (not an effect): after api.stop it polls
* status until Stopped is observed, giving up after ~60s with a retryable timeout — so * status until Stopped is observed, giving up after ~2 min with a retryable timeout — so
* restore only fires once the volume is provably free. While the chain runs the dialog * restore only fires once the volume is provably free. While the chain runs the dialog
* is locked (no ✕, no dismiss) so a mid-flight close can't strand it. A 202 is * is locked (no ✕, no dismiss) so a mid-flight close can't strand it. A 202 is
* terminal: the dialog closes and the row shows a "restore started" note in place of * terminal: the dialog closes and the row shows a "restore started" note in place of
* the button, so a second restore Job can't race the first. */ * the button, so a second restore Job can't race the first. */
const POLL_MS = 2500; const POLL_MS = 2500;
const MAX_POLLS = 24; // ~60s ceiling before we stop waiting for Stopped // ~2 min before we stop waiting for Stopped: with players online the operator warns
// them in game and holds the stop 30 s, then the pre-stop save and the pod's own
// shutdown save follow.
const MAX_POLLS = 48;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function RestoreControls({ function RestoreControls({