feat(servers): 新建服务器默认空闲 10 分钟自动停服,面板可调,converge 回填旧服,系统服不休眠
This commit is contained in:
23 files changed
+453
-18
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user