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

+13
View File
@@ -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
}
+13
View File
@@ -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")
+34 -2
View File
@@ -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
}
+46
View File
@@ -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)
}
}