feat(servers): 新建服务器默认空闲 10 分钟自动停服,面板可调,converge 回填旧服,系统服不休眠

This commit is contained in:
Lemon-miaow committed 2026-09-24 16:12:53 +08:00
1 parent bd909d5858
commit c1796bea17
23 files changed
+453 -18

No files matched your search

+3
View File
@@ -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
}
+9
View File
@@ -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
+36
View File
@@ -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)
}
}
}
+23 -1
View File
@@ -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
+29
View File
@@ -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
}
+69
View File
@@ -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)
}
}