Loading cmd/felis/apply.go +13 −0 Changes for cmd/felis/apply.go: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading cmd/felis/apply_test.go +13 −0 Changes for cmd/felis/apply_test.go: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" ) Loading Loading @@ -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") Loading cmd/felis/converge.go +34 −2 Changes for cmd/felis/converge.go: 34 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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 } cmd/felis/converge_test.go +46 −0 Changes for cmd/felis/converge_test.go: 46 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) } } docs/openapi.yaml +20 −4 Changes for docs/openapi.yaml: 20 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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: Loading @@ -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. Loading Loading
cmd/felis/apply.go +13 −0 Changes for cmd/felis/apply.go: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading
cmd/felis/apply_test.go +13 −0 Changes for cmd/felis/apply_test.go: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" ) Loading Loading @@ -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") Loading
cmd/felis/converge.go +34 −2 Changes for cmd/felis/converge.go: 34 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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 }
cmd/felis/converge_test.go +46 −0 Changes for cmd/felis/converge_test.go: 46 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) } }
docs/openapi.yaml +20 −4 Changes for docs/openapi.yaml: 20 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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: Loading @@ -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. Loading