From c1796bea17883dfa32db8995c2be403109116ad5 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 16:12:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(servers):=20=E6=96=B0=E5=BB=BA=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=BB=98=E8=AE=A4=E7=A9=BA=E9=97=B2=2010=20?= =?UTF-8?q?=E5=88=86=E9=92=9F=E8=87=AA=E5=8A=A8=E5=81=9C=E6=9C=8D=EF=BC=8C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=8F=AF=E8=B0=83=EF=BC=8Cconverge=20?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E6=97=A7=E6=9C=8D=EF=BC=8C=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=9C=8D=E4=B8=8D=E4=BC=91=E7=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/apply.go | 13 ++++ cmd/felis/apply_test.go | 13 ++++ cmd/felis/converge.go | 36 +++++++++- cmd/felis/converge_test.go | 46 +++++++++++++ docs/openapi.yaml | 24 +++++-- docs/troubleshooting.md | 21 +++++- internal/api/api_test.go | 3 + internal/api/cluster.go | 9 +++ internal/api/handlers_patch_test.go | 36 ++++++++++ internal/api/handlers_user.go | 24 ++++++- internal/api/k8scluster.go | 29 ++++++++ internal/api/k8scluster_test.go | 69 +++++++++++++++++++ .../felis/v1alpha1/minecraftserver_types.go | 16 +++++ internal/operator/reconciler.go | 12 +++- internal/operator/reconciler_test.go | 28 ++++++++ panel/src/components/EditServerDialog.tsx | 58 +++++++++++++++- panel/src/i18n/resources/en-US/errors.json | 1 + panel/src/i18n/resources/en-US/servers.json | 11 ++- panel/src/i18n/resources/zh-CN/errors.json | 1 + panel/src/i18n/resources/zh-CN/servers.json | 11 ++- panel/src/lib/api.ts | 4 ++ panel/src/lib/types.ts | 4 ++ panel/src/pages/ServerConsole.tsx | 2 + 23 files changed, 453 insertions(+), 18 deletions(-) diff --git a/cmd/felis/apply.go b/cmd/felis/apply.go index de494b0..bf9a682 100644 --- a/cmd/felis/apply.go +++ b/cmd/felis/apply.go @@ -243,6 +243,19 @@ func buildMinecraftServerFromApplyRequest(req applyRequest, namespace string) (* AutostartPolicy: policy, Storage: v1alpha1.StorageSpec{Size: storageQ.String()}, Resources: corev1.ResourceRequirements{Limits: limits, Requests: requests}, + // The rest matches what felis-api's create writes (K8sCluster.CreateServer): + // fall back to the login gate while stopped, RCON on (readiness, the + // player count and the console all ride it; the operator mints the + // password), and the default idle stop. + FallbackServer: naming.SystemLoginServer, + Rcon: v1alpha1.RconSpec{ + Enabled: true, + SecretRef: v1alpha1.SecretKeyRef{ + Name: naming.RconSecretName(req.Name), + Key: naming.RconSecretKey, + }, + }, + Idle: v1alpha1.DefaultIdle(), }, }, nil } diff --git a/cmd/felis/apply_test.go b/cmd/felis/apply_test.go index c08e094..a52179b 100644 --- a/cmd/felis/apply_test.go +++ b/cmd/felis/apply_test.go @@ -6,6 +6,7 @@ import ( "testing" "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" ) @@ -167,6 +168,18 @@ func TestBuildMinecraftServerFromApplyRequest_Valid(t *testing.T) { if ms.Spec.Storage.Size != "20Gi" { t.Errorf("Storage.Size = %q, want 20Gi", ms.Spec.Storage.Size) } + // Same operational defaults as the API create path: without RCON the server + // never reports players and the console answers 503; without spec.idle it + // never stops on its own. + if !ms.Spec.Rcon.Enabled || ms.Spec.Rcon.SecretRef.Name != naming.RconSecretName("test-server") { + t.Errorf("Rcon = %+v, want enabled with the operator-minted secret", ms.Spec.Rcon) + } + if ms.Spec.Idle != v1alpha1.DefaultIdle() { + t.Errorf("Idle = %+v, want the default %+v", ms.Spec.Idle, v1alpha1.DefaultIdle()) + } + if ms.Spec.FallbackServer != naming.SystemLoginServer { + t.Errorf("FallbackServer = %q, want the login gate", ms.Spec.FallbackServer) + } mem, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory] if !ok { t.Fatal("memory limit missing") diff --git a/cmd/felis/converge.go b/cmd/felis/converge.go index 0861fd5..89c0229 100644 --- a/cmd/felis/converge.go +++ b/cmd/felis/converge.go @@ -9,12 +9,14 @@ import ( "os" "strings" + "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/platform" + "sigs.k8s.io/controller-runtime/pkg/client" ) // cmdConverge is the explicit convergence pass over already-installed system -// servers (#1). Provisioning is create-if-absent, so a field the desired spec +// servers (#1), plus the idle-stop default for user servers that predate it. Provisioning is create-if-absent, so a field the desired spec // gained after an install (spec.rcon, spec.startup.healthHTTPPort, a derived env // key) never reaches the existing CR — and nothing says so. This command fills // exactly those zero-value fields; see convergeSystemServers for the full contract @@ -56,7 +58,9 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { platform.InternalAPIBaseURL(controlNS), cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) - fmt.Fprintln(stdout, "felis converge: filling fields an installed system server predates (operator-set values are never overwritten):") + outcomes = append(outcomes, convergeUserServerIdle(context.Background(), cl, cfg.K8s.Namespace)...) + + fmt.Fprintln(stdout, "felis converge: filling fields an installed server predates (operator-set values are never overwritten):") exit := 0 for _, o := range outcomes { switch { @@ -71,3 +75,31 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { } return exit } + +// convergeUserServerIdle gives every user server that predates the idle default +// (spec.idle entirely unset) the default idle stop. A server whose idle stop was +// turned off keeps a duration on its spec, so it is not "unset" and is left +// alone; system servers never idle out and are skipped. Servers that already +// carry a value produce no line, so a converged fleet prints nothing here. +func convergeUserServerIdle(ctx context.Context, cl client.Client, namespace string) []systemServerOutcome { + var list v1alpha1.MinecraftServerList + if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return []systemServerOutcome{{name: "user servers", err: fmt.Errorf("list servers: %w", err)}} + } + var out []systemServerOutcome + for i := range list.Items { + ms := &list.Items[i] + if ms.Labels[v1alpha1.LabelSystemRole] != "" || ms.Spec.Idle != (v1alpha1.IdleSpec{}) { + continue + } + patch := client.MergeFrom(ms.DeepCopy()) + ms.Spec.Idle = v1alpha1.DefaultIdle() + if err := cl.Patch(ctx, ms, patch); err != nil { + out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)}) + continue + } + out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true, + changes: []string{fmt.Sprintf("spec.idle (stop after %ds empty)", v1alpha1.DefaultEmptySecondsBeforeStop)}}) + } + return out +} diff --git a/cmd/felis/converge_test.go b/cmd/felis/converge_test.go index b18ee0f..9cb5c67 100644 --- a/cmd/felis/converge_test.go +++ b/cmd/felis/converge_test.go @@ -183,3 +183,49 @@ func TestConvergeSystemServersGuards(t *testing.T) { } }) } + +// TestConvergeUserServerIdle fills the idle default only where spec.idle was +// never set: a server whose idle stop was turned off (duration kept), one with +// its own duration, and a system server all stay as they are. +func TestConvergeUserServerIdle(t *testing.T) { + scheme := newSystemServerScheme(t) + ctx := context.Background() + mk := func(name string, idle v1alpha1.IdleSpec, role string) *v1alpha1.MinecraftServer { + ms := &v1alpha1.MinecraftServer{} + ms.Name, ms.Namespace = name, "minecraft" + ms.Spec.Idle = idle + if role != "" { + ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role} + } + return ms + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + mk("legacy", v1alpha1.IdleSpec{}, ""), + mk("off", v1alpha1.IdleSpec{EmptySecondsBeforeStop: 600}, ""), + mk("custom", v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 1800}, ""), + mk(naming.SystemLobbyServer, v1alpha1.IdleSpec{}, naming.SystemLobbyServer), + ).Build() + + outcomes := convergeUserServerIdle(ctx, cl, "minecraft") + if len(outcomes) != 1 || outcomes[0].name != "legacy" || outcomes[0].err != nil { + t.Fatalf("outcomes = %+v, want exactly one fill for legacy", outcomes) + } + want := map[string]v1alpha1.IdleSpec{ + "legacy": v1alpha1.DefaultIdle(), + "off": {EmptySecondsBeforeStop: 600}, + "custom": {AutoStopEnabled: true, EmptySecondsBeforeStop: 1800}, + naming.SystemLobbyServer: {}, + } + for name, idle := range want { + var ms v1alpha1.MinecraftServer + if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil { + t.Fatalf("get %s: %v", name, err) + } + if ms.Spec.Idle != idle { + t.Errorf("%s idle = %+v, want %+v", name, ms.Spec.Idle, idle) + } + } + if again := convergeUserServerIdle(ctx, cl, "minecraft"); len(again) != 0 { + t.Fatalf("second pass = %+v, want nothing to do", again) + } +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 13afb3d..e87f045 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -298,6 +298,18 @@ components: endpointAddress: { type: string } playersOnline: { type: integer, format: int32 } playersMax: { type: integer, format: int32 } + displayName: { type: string } + image: { type: string } + javaMemory: { type: string } + storageSize: { type: string } + cpu: { type: string } + idleStopSeconds: + type: integer + format: int32 + description: Seconds the server may sit empty before idle auto-stop scales it down; 0 when it never idles out (off, RCON disabled, or a system server). + playerCountUnknown: + type: boolean + description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. MyServerView: type: object @@ -4550,8 +4562,8 @@ paths: type: object description: Only the supplied fields are patched; an empty patch is rejected. properties: - display_name: { type: string } - autostart_policy: { type: string } + displayName: { type: string } + autostartPolicy: { type: string } image: { type: string } memory: { type: string } storage: @@ -4561,9 +4573,13 @@ paths: type: object properties: cpu: { type: string } - cpu_request: { type: string } + cpuRequest: { type: string } memory: { type: string } - memory_request: { type: string } + memoryRequest: { type: string } + idleStopSeconds: + type: integer + format: int32 + description: Idle auto-stop. 0 turns it off; otherwise the server stops after this many seconds with nobody online (60–86400, else 400 bad_idle_stop). responses: '200': description: Patched. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8b19354..3ffc021 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -709,8 +709,19 @@ resolver cannot fix: ## 11. Idle auto-stop never fires; player count always shows 0 -Both are implemented, and both hang off the same switch: **`spec.rcon.enabled`**. -Check it first. +Every server created from the panel or `felis apply` stops itself after 600 s +with nobody online (`spec.idle`), and the next join wakes it. An admin changes +or turns it off under **Edit server → Idle auto-stop**. A server created before +this default has no `spec.idle` and never stops; `sudo felis converge` gives it +the default (§12b). The login gate and the lobby never idle out, whatever their +spec says. + +```sh +kubectl get minecraftserver -o jsonpath='{.spec.idle}' +``` + +Both idle stop and the player count also hang off **`spec.rcon.enabled`**. +Check it next. ```sh kubectl get minecraftserver -o jsonpath='{.spec.rcon.enabled}' @@ -824,6 +835,12 @@ gate, on a server whose image predates the listener would hold that server in this is a command you run rather than something setup does on every re-run. System servers that are already current report `already converged`. +The same pass gives every **user** server whose `spec.idle` was never set (one +created before idle stop became the default) the default: stop after 600 s +empty. A server whose idle stop an admin turned off keeps a duration on its +spec (`autoStopEnabled: false`, `emptySecondsBeforeStop` set), so converge +leaves it off; only servers it actually filled get a line. + --- ## 13. World PVC survives after I deleted the MinecraftServer diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7fa5297..17b709f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1576,6 +1576,9 @@ func (c *fakeCluster) PatchServerSpec(_ context.Context, n string, p ServerSpecP if p.AutostartPolicy != nil { info.AutostartPolicy = string(*p.AutostartPolicy) } + if p.IdleStopSeconds != nil { + info.IdleStopSeconds = *p.IdleStopSeconds + } return nil } diff --git a/internal/api/cluster.go b/internal/api/cluster.go index d722b43..1f9eb8b 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -28,6 +28,12 @@ type ServerInfo struct { JavaMemory string `json:"javaMemory,omitempty"` StorageSize string `json:"storageSize,omitempty"` CPU string `json:"cpu,omitempty"` + // IdleStopSeconds is how long the server may sit empty before idle + // auto-stop scales it down; 0 means it never idles out. + IdleStopSeconds int32 `json:"idleStopSeconds"` + // PlayerCountUnknown is true while the operator cannot read the player + // count over RCON; idle auto-stop waits until it can. + PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"` } // CreateServerInput is the validated, structured create-server form (spec §15). @@ -67,6 +73,9 @@ type ServerSpecPatch struct { // (felis-api resolves both from the same form) or both stay nil. JavaMemory *string Resources *corev1.ResourceRequirements + // IdleStopSeconds sets idle auto-stop: 0 turns it off, anything else is the + // empty duration before the stop (already range-checked). + IdleStopSeconds *int32 } // Cluster is the lifecycle-layer access the API depends on: reads of the diff --git a/internal/api/handlers_patch_test.go b/internal/api/handlers_patch_test.go index f8599b8..a71caa2 100644 --- a/internal/api/handlers_patch_test.go +++ b/internal/api/handlers_patch_test.go @@ -273,3 +273,39 @@ func TestPatchServerImageWithoutBuilderIs503(t *testing.T) { t.Error("no image may be patched without a Builder") } } + +// TestPatchServerIdleStop covers the idle auto-stop knob: 0 turns it off, a +// value inside the range is carried to the cluster, and one outside is refused +// before anything is written. +func TestPatchServerIdleStop(t *testing.T) { + for _, tc := range []struct { + body string + wantCode int + want int32 + }{ + {`{"idleStopSeconds":0}`, http.StatusOK, 0}, + {`{"idleStopSeconds":900}`, http.StatusOK, 900}, + {`{"idleStopSeconds":59}`, http.StatusBadRequest, 0}, + {`{"idleStopSeconds":86401}`, http.StatusBadRequest, 0}, + {`{"idleStopSeconds":-5}`, http.StatusBadRequest, 0}, + } { + api, _, cl, _ := newPatchAPI() + w := patchSurvival(api, tc.body) + if w.Code != tc.wantCode { + t.Fatalf("%s: code = %d, want %d (%s)", tc.body, w.Code, tc.wantCode, w.Body.String()) + } + p, patched := cl.patched["survival"] + if tc.wantCode != http.StatusOK { + if patched { + t.Fatalf("%s: a refused value reached the cluster: %+v", tc.body, p) + } + continue + } + if !patched || p.IdleStopSeconds == nil || *p.IdleStopSeconds != tc.want { + t.Fatalf("%s: patched idle = %v, want %d", tc.body, p.IdleStopSeconds, tc.want) + } + if got := cl.byName["survival"].IdleStopSeconds; got != tc.want { + t.Fatalf("%s: view idleStopSeconds = %d, want %d", tc.body, got, tc.want) + } + } +} diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 136102f..fa47930 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -618,6 +618,9 @@ type patchServerRequest struct { Image *string `json:"image,omitempty"` Memory *string `json:"memory,omitempty"` Resources *resourceRequest `json:"resources,omitempty"` + // IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server + // stops after that many seconds with nobody online (60 to 86400). + IdleStopSeconds *int32 `json:"idleStopSeconds,omitempty"` // Storage is recognized only so the endpoint can reject it with a precise // reason rather than an opaque "unknown field": a StatefulSet's PVC capacity // is immutable except for storage-class-gated expansion, which this build does @@ -626,6 +629,12 @@ type patchServerRequest struct { Storage *string `json:"storage,omitempty"` } +// The idle auto-stop range an admin may pick through PATCH /servers/{name}. +const ( + minIdleStopSeconds = 60 + maxIdleStopSeconds = 86400 +) + // handlePatchServer (spec §7 PATCH /servers/{name}) is the admin-tier spec // mutation: it validates the structured form, re-admits any new image against the // whitelist, re-derives the §22 memory ceiling, and applies a merge patch to the @@ -647,7 +656,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { // An empty patch is a client mistake, not a no-op success. if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil && - body.Memory == nil && body.Resources == nil && body.Storage == nil { + body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "patch must set at least one field")) return @@ -686,6 +695,19 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { changed = append(changed, "autostartPolicy") } + if body.IdleStopSeconds != nil { + // A minute is the floor: below it a player who drops for a reconnect + // finds the server stopping under them. A day is the ceiling; longer is + // what "off" is for. + if s := *body.IdleStopSeconds; s != 0 && (s < minIdleStopSeconds || s > maxIdleStopSeconds) { + writeError(w, r, newError(http.StatusBadRequest, "bad_idle_stop", + "idleStopSeconds must be 0 (off) or between %d and %d", minIdleStopSeconds, maxIdleStopSeconds)) + return + } + patch.IdleStopSeconds = body.IdleStopSeconds + changed = append(changed, "idleStopSeconds") + } + if body.Image != nil { // A new image must be re-admitted against the whitelist, exactly as create // does — admission is the only source of a legal image. With no Builder diff --git a/internal/api/k8scluster.go b/internal/api/k8scluster.go index 4506a6d..95507de 100644 --- a/internal/api/k8scluster.go +++ b/internal/api/k8scluster.go @@ -11,6 +11,7 @@ import ( batchv1 "k8s.io/api/batch/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/types" "k8s.io/client-go/util/retry" @@ -119,6 +120,8 @@ func (k *K8sCluster) CreateServer(ctx context.Context, in CreateServerInput) err FallbackServer: naming.SystemLoginServer, Storage: v1alpha1.StorageSpec{Size: in.StorageSize}, Resources: in.Resources, + // A server nobody plays on stops itself; the next join wakes it. + Idle: v1alpha1.DefaultIdle(), // RCON is what makes a server manageable at all: the operator gates // phase=Running on the probe and samples the player tally from it (spec // §5), and every write — console commands, the LuckPerms grants behind the @@ -328,6 +331,18 @@ func (k *K8sCluster) PatchServerSpec(ctx context.Context, name string, p ServerS if p.Resources != nil { ms.Spec.Resources = *p.Resources } + if p.IdleStopSeconds != nil { + // Off keeps the duration (or the default) on the spec, which is what + // marks it as a choice: converge only fills a server with none at all. + if *p.IdleStopSeconds == 0 { + ms.Spec.Idle.AutoStopEnabled = false + if ms.Spec.Idle.EmptySecondsBeforeStop <= 0 { + ms.Spec.Idle.EmptySecondsBeforeStop = v1alpha1.DefaultEmptySecondsBeforeStop + } + } else { + ms.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: *p.IdleStopSeconds} + } + } return k.c.Patch(ctx, &ms, patch) } @@ -356,5 +371,19 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo { JavaMemory: ms.Spec.JavaMemory, StorageSize: ms.Spec.Storage.Size, CPU: cpuStr, + IdleStopSeconds: idleStopSeconds(ms), + PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning && + meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted), } } + +// idleStopSeconds is the effective idle auto-stop duration, 0 when the server +// never idles out. It mirrors the operator's own rule: RCON must be on (the +// count comes from it) and system servers are exempt. +func idleStopSeconds(ms *v1alpha1.MinecraftServer) int32 { + if !ms.Spec.Rcon.Enabled || ms.Labels[v1alpha1.LabelSystemRole] != "" || + !ms.Spec.Idle.AutoStopEnabled || ms.Spec.Idle.EmptySecondsBeforeStop <= 0 { + return 0 + } + return ms.Spec.Idle.EmptySecondsBeforeStop +} diff --git a/internal/api/k8scluster_test.go b/internal/api/k8scluster_test.go index 9259558..cc0374f 100644 --- a/internal/api/k8scluster_test.go +++ b/internal/api/k8scluster_test.go @@ -63,3 +63,72 @@ func TestCreateServerEnablesRcon(t *testing.T) { t.Fatalf("rcon port = %d, want 0 so the operator default is the only copy", ms.Spec.Rcon.Port) } } + +// TestCreateServerDefaultsIdleStop pins the other half of "a server nobody plays +// on stops itself": the operator only idles out a server whose spec asks for +// it, so a create that leaves spec.idle empty ships a server that runs forever. +func TestCreateServerDefaultsIdleStop(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("scheme: %v", err) + } + c := fake.NewClientBuilder().WithScheme(scheme).Build() + k := NewK8sCluster(c, "minecraft") + if err := k.CreateServer(context.Background(), CreateServerInput{ + Name: "survival", Subdomain: "survival", Image: "reg/paper:1", JavaMemory: "2G", StorageSize: "10Gi", + }); err != nil { + t.Fatalf("CreateServer: %v", err) + } + info, err := k.GetServer(context.Background(), "survival") + if err != nil { + t.Fatalf("GetServer: %v", err) + } + if info.IdleStopSeconds != v1alpha1.DefaultEmptySecondsBeforeStop { + t.Fatalf("idleStopSeconds = %d, want the default %d", info.IdleStopSeconds, v1alpha1.DefaultEmptySecondsBeforeStop) + } +} + +// TestPatchIdleStopKeepsTheChoiceVisible: turning idle stop off must leave a +// duration on the spec, because a spec with none at all is what converge fills +// with the default. Off followed by a converge must stay off. +func TestPatchIdleStopKeepsTheChoiceVisible(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("scheme: %v", err) + } + legacy := &v1alpha1.MinecraftServer{} + legacy.Name, legacy.Namespace = "survival", "minecraft" + legacy.Spec.Rcon.Enabled = true + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(legacy).Build() + k := NewK8sCluster(c, "minecraft") + get := func() v1alpha1.IdleSpec { + var ms v1alpha1.MinecraftServer + if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: "survival"}, &ms); err != nil { + t.Fatalf("get: %v", err) + } + return ms.Spec.Idle + } + + off := int32(0) + if err := k.PatchServerSpec(context.Background(), "survival", ServerSpecPatch{IdleStopSeconds: &off}); err != nil { + t.Fatalf("patch off: %v", err) + } + if got := get(); got.AutoStopEnabled || got.EmptySecondsBeforeStop <= 0 { + t.Fatalf("idle after off = %+v, want disabled with a duration kept", got) + } + + thirty := int32(1800) + if err := k.PatchServerSpec(context.Background(), "survival", ServerSpecPatch{IdleStopSeconds: &thirty}); err != nil { + t.Fatalf("patch on: %v", err) + } + if got := get(); !got.AutoStopEnabled || got.EmptySecondsBeforeStop != 1800 { + t.Fatalf("idle after 1800 = %+v, want enabled at 1800", got) + } + info, err := k.GetServer(context.Background(), "survival") + if err != nil { + t.Fatalf("GetServer: %v", err) + } + if info.IdleStopSeconds != 1800 { + t.Fatalf("view idleStopSeconds = %d, want 1800", info.IdleStopSeconds) + } +} diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index 25dc62a..2c16c64 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -226,7 +226,23 @@ type StartupSpec struct { HealthHTTPPath string `json:"healthHTTPPath,omitempty"` } +// DefaultEmptySecondsBeforeStop is how long a user server may sit empty before +// idle auto-stop scales it down, when nobody chose another value (spec §8). +const DefaultEmptySecondsBeforeStop int32 = 600 + +// DefaultIdle is the idle policy every user server is created with: stop after +// DefaultEmptySecondsBeforeStop of an empty server. System servers (the login +// gate, the lobby) never take it; they must stay up. +func DefaultIdle() IdleSpec { + return IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: DefaultEmptySecondsBeforeStop} +} + // IdleSpec configures empty-server auto-stop (spec §8). +// +// The two fields together tell a choice from its absence: turning auto-stop +// off keeps EmptySecondsBeforeStop set (AutoStopEnabled=false, seconds > 0), +// while a server that predates the default has both zero, and only that one +// is filled in by `felis converge`. type IdleSpec struct { // AutoStopEnabled turns on idle auto-stop. AutoStopEnabled bool `json:"autoStopEnabled,omitempty"` diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 864cffd..3d253a2 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -237,7 +237,7 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine // "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.Known && idleStopApplies(server) { if players.Online == 0 { if server.Status.EmptySince == nil { t := r.now() @@ -271,7 +271,7 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine // (observed live: the stamp sat unexamined for minutes). Wake at the exact // deadline while the tally says empty, or on a slow cadence while players // are online, to notice the moment the last one leaves. - if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { + if server.Spec.Rcon.Enabled && idleStopApplies(server) { if server.Status.EmptySince != nil { deadline := server.Status.EmptySince.Time.Add(time.Duration(server.Spec.Idle.EmptySecondsBeforeStop) * time.Second) if wait := deadline.Sub(r.now().Time); wait > 0 { @@ -283,6 +283,14 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine return ctrl.Result{}, nil } +// idleStopApplies reports whether idle auto-stop is configured for server. A +// system server (the login gate, the lobby) never idles out whatever its spec +// says: stopping the gate locks every player out, and nothing would wake it. +func idleStopApplies(server *v1alpha1.MinecraftServer) bool { + return server.Labels[v1alpha1.LabelSystemRole] == "" && + server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 +} + func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) { var sts appsv1.StatefulSet err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &sts) diff --git a/internal/operator/reconciler_test.go b/internal/operator/reconciler_test.go index 6940e54..14f0c0d 100644 --- a/internal/operator/reconciler_test.go +++ b/internal/operator/reconciler_test.go @@ -491,6 +491,34 @@ func TestIdleAutoStop_UnreadTallyNeverStops(t *testing.T) { } } +// TestIdleAutoStop_SystemServerNeverIdles: the login gate and the lobby must +// stay up whatever their spec says. A stopped gate locks every player out, and +// nothing would wake it. +func TestIdleAutoStop_SystemServerNeverIdles(t *testing.T) { + srv := runningServer() + srv.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"} + srv.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 60} + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, srv, rconSecret()) + base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + clock := base + r.Now = func() metav1.Time { return metav1.NewTime(clock) } + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + reconcile(t, r, "survival") + clock = base.Add(time.Hour) + res := reconcile(t, r, "survival") + + server := getServer(t, c, "survival") + if server.Spec.DesiredState != v1alpha1.DesiredRunning || server.Status.EmptySince != nil { + t.Fatalf("system server: desiredState=%s emptySince=%v, want Running and no countdown", + server.Spec.DesiredState, server.Status.EmptySince) + } + if res.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want none for a server that never idles", res.RequeueAfter) + } +} + // 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 diff --git a/panel/src/components/EditServerDialog.tsx b/panel/src/components/EditServerDialog.tsx index f1e0e7f..915eda3 100644 --- a/panel/src/components/EditServerDialog.tsx +++ b/panel/src/components/EditServerDialog.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Settings, ChevronRight } from "lucide-react"; +import { Settings, ChevronRight, AlertTriangle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ConfirmFooter } from "@/components/ConfirmFooter"; import { @@ -25,6 +25,17 @@ import type { AutostartPolicy } from "@/lib/types"; const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; +/** Idle auto-stop presets in seconds; "0" is Never. The server default is 600. */ +const IDLE_OPTIONS = ["0", "300", "600", "900", "1800", "3600", "7200"]; + +/** idleLabel renders an idle-stop duration the way a person says it. */ +function idleLabel(t: (key: string, opts?: Record) => string, seconds: number): string { + if (seconds <= 0) return t("idle_stop_never"); + if (seconds % 3600 === 0) return t("idle_stop_hours", { count: seconds / 3600 }); + if (seconds % 60 === 0) return t("idle_stop_minutes", { count: seconds / 60 }); + return t("idle_stop_seconds", { count: seconds }); +} + function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] { return [ { value: "ownerOnly", label: t("create_server_policy_owner") }, @@ -39,6 +50,8 @@ interface EditServerForm { image: string; memory: string; cpu: string; + /** Idle auto-stop seconds as a Select value; "0" is Never. */ + idleStop: string; } interface Props { @@ -49,6 +62,9 @@ interface Props { currentMemory?: string; currentStorage?: string; currentCpu?: string; + currentIdleStopSeconds?: number; + /** The operator cannot read the player count, so idle stop is paused. */ + playerCountUnknown?: boolean; onUpdated: () => void; } @@ -60,8 +76,11 @@ export function EditServerDialog({ currentMemory = "", currentStorage = "", currentCpu = "", + currentIdleStopSeconds = 0, + playerCountUnknown = false, onUpdated, }: Props) { + const currentIdleStop = String(currentIdleStopSeconds); const { t } = useTranslation("servers"); const [open, setOpen] = useState(false); const images = useAsync(() => api.listImages(), []); @@ -72,6 +91,7 @@ export function EditServerDialog({ image: currentImage, memory: currentMemory, cpu: currentCpu, + idleStop: currentIdleStop, }); const [error, setError] = useState(null); @@ -86,10 +106,11 @@ export function EditServerDialog({ image: currentImage, memory: currentMemory, cpu: currentCpu, + idleStop: currentIdleStop, }); setError(null); } - }, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu]); + }, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]); function set(k: K, v: EditServerForm[K]) { setForm((f) => ({ ...f, [k]: v })); @@ -103,7 +124,8 @@ export function EditServerDialog({ form.autostartPolicy !== currentPolicy || form.image !== currentImage || form.memory !== currentMemory || - form.cpu !== currentCpu; + form.cpu !== currentCpu || + form.idleStop !== currentIdleStop; const canSubmit = hasChanges && !submitting; @@ -130,6 +152,9 @@ export function EditServerDialog({ cpu: form.cpu.trim(), }; } + if (form.idleStop !== currentIdleStop) { + payload.idleStopSeconds = Number(form.idleStop); + } await api.patchServer(serverName, payload); setOpen(false); @@ -265,6 +290,33 @@ export function EditServerDialog({ +
+ + +

{t("edit_server_idle_hint")}

+ {playerCountUnknown && form.idleStop !== "0" && ( +

+ + {t("edit_server_idle_unknown")} +

+ )} +
+ {error &&

{error}

} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index c96b614..0f7961d 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -35,6 +35,7 @@ "bad_subdomain": "That subdomain is invalid — use 3–32 lowercase letters, digits or dashes, and avoid reserved names.", "at_capacity": "The cluster is at its running-server cap — try again once a server stops.", "storage_immutable": "Storage size can't be changed online.", + "bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.", "email_taken": "That email is already verified on another account — sign in with it or use another address.", "mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.", "bad_path": "That path is invalid — use a relative path inside the world directory.", diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index e39d1c9..1f576bc 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -126,7 +126,7 @@ "create_server_cancel": "Cancel", "create_server_submit": "Create", "edit_server_title": "Edit Server Config", - "edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU", + "edit_server_desc": "Configure display name, autostart policy, image, memory, CPU, and idle stop", "edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.", "edit_server_submit": "Save Config", "luckperms_group_name": "Group Name", @@ -167,5 +167,12 @@ "luckperms_clear_history": "Clear history", "edit_server_cpu": "CPU Limit", "edit_server_cpu_placeholder": "e.g. 1, 2, 500m", - "owned_filter_mine": "me" + "owned_filter_mine": "me", + "edit_server_idle": "Idle auto-stop", + "edit_server_idle_hint": "Stops the server after this long with nobody online, freeing memory and CPU. The next player to join wakes it.", + "edit_server_idle_unknown": "The player count can’t be read right now, so idle stop is paused until it can.", + "idle_stop_never": "Never", + "idle_stop_seconds": "{{count}} s", + "idle_stop_minutes": "{{count}} min", + "idle_stop_hours": "{{count}} h" } diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 725534f..c439a85 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -35,6 +35,7 @@ "bad_subdomain": "子域名不合法:需为 3–32 位小写字母、数字或连字符,且不能使用保留名。", "at_capacity": "集群的在线服务器已达上限——等一台服务器停止后再试。", "storage_immutable": "存储容量不支持在线调整。", + "bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。", "email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。", "mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。", "bad_path": "路径不合法——请使用世界目录内的相对路径。", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 9e8a908..3316e50 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -126,7 +126,7 @@ "create_server_cancel": "取消", "create_server_submit": "创建", "edit_server_title": "编辑服务器配置", - "edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU", + "edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服", "edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。", "edit_server_submit": "保存配置", "luckperms_group_name": "用户组名称", @@ -167,5 +167,12 @@ "luckperms_clear_history": "清除历史记录", "edit_server_cpu": "CPU 限制", "edit_server_cpu_placeholder": "例如 1, 2, 500m", - "owned_filter_mine": "我" + "owned_filter_mine": "我", + "edit_server_idle": "空闲自动停服", + "edit_server_idle_hint": "没人在线达到这个时长就自动停服,省下内存和 CPU;玩家下次进服时自动唤醒。", + "edit_server_idle_unknown": "现在读不到在线人数,空闲停服暂停中,直到能读到为止。", + "idle_stop_never": "从不", + "idle_stop_seconds": "{{count}} 秒", + "idle_stop_minutes": "{{count}} 分钟", + "idle_stop_hours": "{{count}} 小时" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index b2fd60f..c53f90d 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -323,6 +323,8 @@ export const api = { autostartPolicy?: AutostartPolicy; image?: string; memory?: string; + /** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */ + idleStopSeconds?: number; resources?: { cpu?: string; cpuRequest?: string; @@ -738,6 +740,8 @@ export function humanizeError(e: unknown): string { return t("at_capacity"); case "storage_immutable": return t("storage_immutable"); + case "bad_idle_stop": + return t("bad_idle_stop"); // Email identity: the verified-email uniqueness index (migration 0020) plus // VerifyEmailOTP's guard make a second verified holder impossible; the OTP // relay can also refuse to deliver at all. diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 338d07b..90f0086 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -36,6 +36,10 @@ export interface ServerInfo { javaMemory?: string; storageSize?: string; cpu?: string; + /** Seconds empty before idle auto-stop; 0 when the server never idles out. */ + idleStopSeconds?: number; + /** True while the operator cannot read the player count; idle stop waits. */ + playerCountUnknown?: boolean; } /** ServerStatus is GET /servers/{name}/status. It never carries `owned` or diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index bf9081d..9fb7bd0 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -286,6 +286,8 @@ export function ServerConsole() { currentMemory={data.javaMemory} currentStorage={data.storageSize} currentCpu={data.cpu} + currentIdleStopSeconds={data.idleStopSeconds} + playerCountUnknown={data.playerCountUnknown} onUpdated={reload} /> )}