package main import ( "context" "errors" "flag" "fmt" "io" "os" "strings" "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, 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 // and why it is a separate, operator-timed step rather than part of setup. // // It reads the same host config as setup (the control plane's felis.toml) and // talks to the cluster with the local kubeconfig, so it must run as root on the // control-plane host. 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 } return 2 } if os.Geteuid() != 0 { fmt.Fprintln(stderr, "felis converge: refused — converging needs the cluster credentials, so it must run as root (try: sudo felis converge)") return 1 } cfg, err := config.Load(*cfgPath) if err != nil { fmt.Fprintf(stderr, "felis converge: %v\n", err) fmt.Fprintln(stderr, "If this host was never installed, run `sudo felis setup` first.") return 1 } cl, err := buildSystemServerClient() if err != nil { fmt.Fprintf(stderr, "felis converge: %v\n", err) return 1 } controlNS := platform.DefaultControlNamespace outcomes := convergeSystemServers(context.Background(), cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, platform.InternalAPIBaseURL(controlNS), cfg.Server.RootDomain, 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 for _, o := range outcomes { switch { case o.err != nil: fmt.Fprintf(stdout, " - %s: ERROR %v\n", o.name, o.err) exit = 1 case len(o.changes) > 0: fmt.Fprintf(stdout, " - %s: updated (%s)\n", o.name, strings.Join(o.changes, ", ")) default: fmt.Fprintf(stdout, " - %s: %s\n", o.name, o.skipped) } } 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 } // Re-checked on the copy each attempt reads: an idle setting the panel saved // meanwhile is the user's, and the default must not land over it. changed, err := patchOnConflictRetry(ctx, cl, ms, func() bool { if ms.Spec.Idle != (v1alpha1.IdleSpec{}) { return false } ms.Spec.Idle = v1alpha1.DefaultIdle() return true }) if err != nil { out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)}) continue } if !changed { 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 } // 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 }