Loading cmd/felis/converge.go +53 −1 Changes for cmd/felis/converge.go: 53 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -11,12 +11,14 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/platform" "sigs.k8s.io/controller-runtime/pkg/client" ) // cmdConverge is the explicit convergence pass over already-installed system // servers (#1), plus the idle-stop default for user servers that predate it. Provisioning is create-if-absent, so a field the desired spec // servers (#1), plus the idle-stop default for user servers that predate it, and // with -user-rcon their RCON block (#3). 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 @@ -29,6 +31,7 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("converge", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml") userRcon := fs.Bool("user-rcon", false, "also turn RCON on for user servers created before it was the default") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 Loading Loading @@ -59,6 +62,7 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) outcomes = append(outcomes, convergeUserServerIdle(context.Background(), cl, cfg.K8s.Namespace)...) outcomes = append(outcomes, convergeUserServerRcon(context.Background(), cl, cfg.K8s.Namespace, *userRcon)...) fmt.Fprintln(stdout, "felis converge: filling fields an installed server predates (operator-set values are never overwritten):") exit := 0 Loading Loading @@ -113,3 +117,51 @@ func convergeUserServerIdle(ctx context.Context, cl client.Client, namespace str } return out } // convergeUserServerRcon handles user servers created before RCON was part of every // new server (694e3cb): spec.rcon entirely unset. Such a server has a dead console, // reports nobody online, and never idles out, because all three ride RCON. // // Only with fill does it turn RCON on, with the same block CreateServer writes // today; without it each such server gets a line saying so. The fill is opt-in // because the operator gates readiness on the RCON probe: a server whose image does // not open the listener RCON_PASSWORD asks for would sit in Starting until it is // marked Failed. Felis's own paper and lobby images open it; an image a user brought // may not, and only the operator running this can tell. A server that already // carries any RCON setting (on or off) is left alone and produces no line. func convergeUserServerRcon(ctx context.Context, cl client.Client, namespace string, fill bool) []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.Rcon != (v1alpha1.RconSpec{}) { continue } if !fill { out = append(out, systemServerOutcome{name: ms.Name, available: true, skipped: "no RCON (console, online count and idle stop are off); once its image serves RCON, sudo felis converge -user-rcon turns it on"}) continue } changed, err := patchOnConflictRetry(ctx, cl, ms, func() bool { if ms.Spec.Rcon != (v1alpha1.RconSpec{}) { return false } ms.Spec.Rcon = v1alpha1.RconSpec{ Enabled: true, SecretRef: v1alpha1.SecretKeyRef{Name: naming.RconSecretName(ms.Name), Key: naming.RconSecretKey}, } return true }) if err != nil { out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)}) continue } if changed { out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true, changes: []string{"spec.rcon"}}) } } return out } cmd/felis/converge_test.go +64 −0 Changes for cmd/felis/converge_test.go: 64 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -229,3 +229,67 @@ func TestConvergeUserServerIdle(t *testing.T) { t.Fatalf("second pass = %+v, want nothing to do", again) } } // TestConvergeUserServerRcon reports a user server with no RCON block at all and // fills it only when asked, with the block CreateServer writes. RCON turned off on // purpose, a server with its own secret, and a system server stay as they are and // produce no line. func TestConvergeUserServerRcon(t *testing.T) { scheme := newSystemServerScheme(t) ctx := context.Background() mk := func(name string, rcon v1alpha1.RconSpec, role string) *v1alpha1.MinecraftServer { ms := &v1alpha1.MinecraftServer{} ms.Name, ms.Namespace = name, "minecraft" ms.Spec.Rcon = rcon if role != "" { ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role} } return ms } own := v1alpha1.RconSpec{Enabled: true, Port: 25580, SecretRef: v1alpha1.SecretKeyRef{Name: "own", Key: "pw"}} off := v1alpha1.RconSpec{SecretRef: v1alpha1.SecretKeyRef{Name: "rcon-off", Key: naming.RconSecretKey}} cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( mk("demo", v1alpha1.RconSpec{}, ""), mk("off", off, ""), mk("own", own, ""), mk(naming.SystemLobbyServer, v1alpha1.RconSpec{}, naming.SystemLobbyServer), ).Build() get := func(name string) v1alpha1.RconSpec { 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) } return ms.Spec.Rcon } report := convergeUserServerRcon(ctx, cl, "minecraft", false) if len(report) != 1 || report[0].name != "demo" || report[0].updated || report[0].err != nil || !strings.Contains(report[0].skipped, "-user-rcon") { t.Fatalf("report = %+v, want one skipped line for demo naming -user-rcon", report) } if got := get("demo"); got != (v1alpha1.RconSpec{}) { t.Fatalf("the report-only pass wrote demo's rcon: %+v", got) } filled := convergeUserServerRcon(ctx, cl, "minecraft", true) if len(filled) != 1 || filled[0].name != "demo" || !filled[0].updated || filled[0].err != nil { t.Fatalf("fill = %+v, want exactly one update for demo", filled) } want := map[string]v1alpha1.RconSpec{ "demo": {Enabled: true, SecretRef: v1alpha1.SecretKeyRef{ Name: naming.RconSecretName("demo"), Key: naming.RconSecretKey}}, "off": off, "own": own, naming.SystemLobbyServer: {}, } for name, rcon := range want { if got := get(name); got != rcon { t.Errorf("%s rcon = %+v, want %+v", name, got, rcon) } } for _, fill := range []bool{false, true} { if again := convergeUserServerRcon(ctx, cl, "minecraft", fill); len(again) != 0 { t.Fatalf("second pass (fill=%v) = %+v, want nothing to do", fill, again) } } } docs/troubleshooting.md +18 −0 Changes for docs/troubleshooting.md: 18 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1225,6 +1225,24 @@ 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. A **user** server created before every new server got RCON (`spec.rcon` wholly unset) has a dead console, always shows 0 online, and never idles out, since all three ride RCON. Plain `felis converge` lists each such server and changes nothing; `-user-rcon` turns RCON on for them with the block a server created today gets (the `<name>-rcon` Secret the operator provisions): ``` sudo felis converge -user-rcon kubectl -n minecraft get minecraftserver -o custom-columns=NAME:.metadata.name,RCON:.spec.rcon.enabled ``` It is opt-in for the reason above: the new start waits on the RCON probe, and a server whose image does not open the listener that `RCON_PASSWORD` asks for stays in `Starting` until it is marked `Failed`. Felis's own paper and lobby images open it; check a server running an image a user brought before filling it. A server whose RCON someone set, on or off, is never touched. The change applies at the server's next start. --- ## 13. World PVC survives after I deleted the MinecraftServer Loading Loading
cmd/felis/converge.go +53 −1 Changes for cmd/felis/converge.go: 53 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -11,12 +11,14 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/platform" "sigs.k8s.io/controller-runtime/pkg/client" ) // cmdConverge is the explicit convergence pass over already-installed system // servers (#1), plus the idle-stop default for user servers that predate it. Provisioning is create-if-absent, so a field the desired spec // servers (#1), plus the idle-stop default for user servers that predate it, and // with -user-rcon their RCON block (#3). 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 @@ -29,6 +31,7 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("converge", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml") userRcon := fs.Bool("user-rcon", false, "also turn RCON on for user servers created before it was the default") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 Loading Loading @@ -59,6 +62,7 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int { defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) outcomes = append(outcomes, convergeUserServerIdle(context.Background(), cl, cfg.K8s.Namespace)...) outcomes = append(outcomes, convergeUserServerRcon(context.Background(), cl, cfg.K8s.Namespace, *userRcon)...) fmt.Fprintln(stdout, "felis converge: filling fields an installed server predates (operator-set values are never overwritten):") exit := 0 Loading Loading @@ -113,3 +117,51 @@ func convergeUserServerIdle(ctx context.Context, cl client.Client, namespace str } return out } // convergeUserServerRcon handles user servers created before RCON was part of every // new server (694e3cb): spec.rcon entirely unset. Such a server has a dead console, // reports nobody online, and never idles out, because all three ride RCON. // // Only with fill does it turn RCON on, with the same block CreateServer writes // today; without it each such server gets a line saying so. The fill is opt-in // because the operator gates readiness on the RCON probe: a server whose image does // not open the listener RCON_PASSWORD asks for would sit in Starting until it is // marked Failed. Felis's own paper and lobby images open it; an image a user brought // may not, and only the operator running this can tell. A server that already // carries any RCON setting (on or off) is left alone and produces no line. func convergeUserServerRcon(ctx context.Context, cl client.Client, namespace string, fill bool) []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.Rcon != (v1alpha1.RconSpec{}) { continue } if !fill { out = append(out, systemServerOutcome{name: ms.Name, available: true, skipped: "no RCON (console, online count and idle stop are off); once its image serves RCON, sudo felis converge -user-rcon turns it on"}) continue } changed, err := patchOnConflictRetry(ctx, cl, ms, func() bool { if ms.Spec.Rcon != (v1alpha1.RconSpec{}) { return false } ms.Spec.Rcon = v1alpha1.RconSpec{ Enabled: true, SecretRef: v1alpha1.SecretKeyRef{Name: naming.RconSecretName(ms.Name), Key: naming.RconSecretKey}, } return true }) if err != nil { out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)}) continue } if changed { out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true, changes: []string{"spec.rcon"}}) } } return out }
cmd/felis/converge_test.go +64 −0 Changes for cmd/felis/converge_test.go: 64 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -229,3 +229,67 @@ func TestConvergeUserServerIdle(t *testing.T) { t.Fatalf("second pass = %+v, want nothing to do", again) } } // TestConvergeUserServerRcon reports a user server with no RCON block at all and // fills it only when asked, with the block CreateServer writes. RCON turned off on // purpose, a server with its own secret, and a system server stay as they are and // produce no line. func TestConvergeUserServerRcon(t *testing.T) { scheme := newSystemServerScheme(t) ctx := context.Background() mk := func(name string, rcon v1alpha1.RconSpec, role string) *v1alpha1.MinecraftServer { ms := &v1alpha1.MinecraftServer{} ms.Name, ms.Namespace = name, "minecraft" ms.Spec.Rcon = rcon if role != "" { ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role} } return ms } own := v1alpha1.RconSpec{Enabled: true, Port: 25580, SecretRef: v1alpha1.SecretKeyRef{Name: "own", Key: "pw"}} off := v1alpha1.RconSpec{SecretRef: v1alpha1.SecretKeyRef{Name: "rcon-off", Key: naming.RconSecretKey}} cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( mk("demo", v1alpha1.RconSpec{}, ""), mk("off", off, ""), mk("own", own, ""), mk(naming.SystemLobbyServer, v1alpha1.RconSpec{}, naming.SystemLobbyServer), ).Build() get := func(name string) v1alpha1.RconSpec { 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) } return ms.Spec.Rcon } report := convergeUserServerRcon(ctx, cl, "minecraft", false) if len(report) != 1 || report[0].name != "demo" || report[0].updated || report[0].err != nil || !strings.Contains(report[0].skipped, "-user-rcon") { t.Fatalf("report = %+v, want one skipped line for demo naming -user-rcon", report) } if got := get("demo"); got != (v1alpha1.RconSpec{}) { t.Fatalf("the report-only pass wrote demo's rcon: %+v", got) } filled := convergeUserServerRcon(ctx, cl, "minecraft", true) if len(filled) != 1 || filled[0].name != "demo" || !filled[0].updated || filled[0].err != nil { t.Fatalf("fill = %+v, want exactly one update for demo", filled) } want := map[string]v1alpha1.RconSpec{ "demo": {Enabled: true, SecretRef: v1alpha1.SecretKeyRef{ Name: naming.RconSecretName("demo"), Key: naming.RconSecretKey}}, "off": off, "own": own, naming.SystemLobbyServer: {}, } for name, rcon := range want { if got := get(name); got != rcon { t.Errorf("%s rcon = %+v, want %+v", name, got, rcon) } } for _, fill := range []bool{false, true} { if again := convergeUserServerRcon(ctx, cl, "minecraft", fill); len(again) != 0 { t.Fatalf("second pass (fill=%v) = %+v, want nothing to do", fill, again) } } }
docs/troubleshooting.md +18 −0 Changes for docs/troubleshooting.md: 18 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1225,6 +1225,24 @@ 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. A **user** server created before every new server got RCON (`spec.rcon` wholly unset) has a dead console, always shows 0 online, and never idles out, since all three ride RCON. Plain `felis converge` lists each such server and changes nothing; `-user-rcon` turns RCON on for them with the block a server created today gets (the `<name>-rcon` Secret the operator provisions): ``` sudo felis converge -user-rcon kubectl -n minecraft get minecraftserver -o custom-columns=NAME:.metadata.name,RCON:.spec.rcon.enabled ``` It is opt-in for the reason above: the new start waits on the RCON probe, and a server whose image does not open the listener that `RCON_PASSWORD` asks for stays in `Starting` until it is marked `Failed`. Felis's own paper and lobby images open it; check a server running an image a user brought before filling it. A server whose RCON someone set, on or off, is never touched. The change applies at the server's next start. --- ## 13. World PVC survives after I deleted the MinecraftServer Loading