diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index d21b3e0..4372031 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -12,6 +12,7 @@ import ( "felis.lolicon.best/internal/api" "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/store" ) @@ -144,9 +145,84 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } } + + // After a real setup pass (Owner provisioned and/or edge configured), make + // sure the always-on login/lobby system services exist. This is idempotent + // and best-effort — it never fails the setup that got this far. + if res.provisioned || res.connectConfigured { + provisionSystemServers(ctx, setup.cfg, stdout) + } return 0 } +// provisionSystemServers ensures the login limbo and lobby system services exist +// after setup, then prints the off-cluster Velocity wiring the operator must +// apply by hand (Felis never writes the off-cluster proxy config). It is +// best-effort: unconfigured images or an unreachable cluster degrade to guidance +// rather than failing setup. +func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer) { + if cfg.Velocity.LoginImage == "" && cfg.Velocity.LobbyImage == "" { + fmt.Fprintln(out, "\nfelis setup: login/lobby system servers NOT provisioned — set [velocity] login_image "+ + "and lobby_image in felis.toml (build them from deploy/limbo and deploy/lobby), then re-run `sudo felis setup`.") + return + } + cl, err := buildSystemServerClient() + if err != nil { + fmt.Fprintf(out, "\nfelis setup: could not reach the cluster to provision the login/lobby system servers: %v\n"+ + "Re-run `sudo felis setup` on the control-plane host once the cluster is reachable.\n", err) + return + } + // The login limbo authenticates to the felis-api INTERNAL face, so it needs the + // internal base URL, the root domain (to link players at the console), and the + // service token. The first two are plain env baked into the pod here; the token + // is a Secret the operator injects by reference — but a secretKeyRef is + // namespace-local, so first replicate the token Secret from the control namespace + // into the minecraft namespace where the login pod runs. The control namespace is + // the platform default (there is no felis.toml override for it); a deployment that + // renamed it must replicate the Secret by hand. + controlNS := platform.DefaultControlNamespace + apiBaseURL := platform.InternalAPIBaseURL(controlNS) + tokenOutcome := ensureServiceTokenReplica(ctx, cl, controlNS, cfg.K8s.Namespace) + outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain) + outcomes = append([]systemServerOutcome{tokenOutcome}, outcomes...) + fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):") + for _, o := range outcomes { + switch { + case o.err != nil: + fmt.Fprintf(out, " - %s: ERROR %v\n", o.name, o.err) + case o.created: + fmt.Fprintf(out, " - %s: created (DesiredState=Running)\n", o.name) + default: + fmt.Fprintf(out, " - %s: skipped (%s)\n", o.name, o.skipped) + } + } + printVelocityWiringGuidance(out, cfg.Server.RootDomain) +} + +// printVelocityWiringGuidance emits the manual off-cluster Velocity config that +// enforces the login-first topology. Felis auto-registers login/lobby as dynamic +// backends via /api/v1/servers, but the proxy's DEFAULT landing and waiting-park +// target live in velocity.toml on the off-cluster Java host, which Felis never +// writes. The one invariant: the default landing and the initial wait-park are +// BOTH the login gate — never the lobby — so no connection reaches the lobby (or +// any backend) without passing authentication first. The Paper lobby is reached +// only when the login gate transfers an authenticated player onward. +func printVelocityWiringGuidance(out io.Writer, rootDomain string) { + fmt.Fprintln(out, "\nfelis setup: finish the login topology on the off-cluster Velocity host (velocity.toml):") + fmt.Fprintln(out, " 1. Set the DEFAULT landing server to \"login\" so every fresh connection hits the") + fmt.Fprintln(out, " auth gate first (try = [\"login\"] under [servers], and the default forced-host).") + fmt.Fprintln(out, " 2. Point the waiting-park target at the gate, NOT the lobby:") + fmt.Fprintln(out, " set FELIS_LOBBY_SERVER=login (or lobby-server=login). The limbo holds waiters") + fmt.Fprintln(out, " while their backend wakes, and a player is never parked past authentication.") + fmt.Fprintln(out, " 3. Leave the Paper \"lobby\" OUT of the default/fallback paths — it is reached only") + fmt.Fprintln(out, " when the login gate transfers an authenticated player onward.") + fmt.Fprintln(out, " Rationale: rather refuse a connection when login is down than route a player past") + fmt.Fprintln(out, " the gate. Felis already refuses to give any server a fallback of \"lobby\".") + if rootDomain != "" { + fmt.Fprintf(out, " (login is the front door for %s; per-server subdomains fall back to login while waking.)\n", rootDomain) + } +} + type configuredSetup struct { cfg *config.Config drv *store.PostgresDriver diff --git a/cmd/felis/systemservers.go b/cmd/felis/systemservers.go new file mode 100644 index 0000000..42233ab --- /dev/null +++ b/cmd/felis/systemservers.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "fmt" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// System services are the always-on backends Felis provisions for itself after +// setup: the login limbo (LOOHP/Limbo auth gate) and the lobby (Paper + the +// felis-paper /menu hub). Unlike a user server they are created Running, are +// exempt from the world reaper, and carry reserved names — so they take the +// ValidateSystemServerName admission path rather than the user ValidateServerName. +// +// The routing topology and the one security invariant they encode: +// +// connect → login (auth gate, front door) → lobby (/menu hub) → target backend +// +// A stopped/starting server's fallback must land on the LOGIN gate, never on the +// lobby: falling back to the lobby would drop an unauthenticated player past the +// gate. So login has NO fallback (if it is down we refuse the connection rather +// than route onward) and everything else — the lobby included — falls back to +// login. "Rather have login unreachable than abandon authentication." + +// systemServerSpec is the small, explicit shape a system service is built from. +// It is intentionally narrower than the user applyRequest: no autostart choice +// (always public), no RCON, no resource overrides — a system service is uniform +// by construction so the invariants above cannot be configured away. +type systemServerSpec struct { + name string + subdomain string + displayName string + image string + memory string // container memory limit == request (§22 ceiling) + storage string // world PVC size + fallbackServer string // "" = none (refuse when down); never the lobby + healthHTTPPort int32 // > 0 → gate readiness on an HTTP health endpoint + // env are extra plain (non-secret) environment variables baked into the pod. + // System-service configuration derived from the deployment (the internal API + // URL, root domain, lobby name) rides here; secrets never do — the service + // token is injected by the operator via secretKeyRef, not as a literal value. + env []v1alpha1.EnvVar +} + +// felisLimboHealthPort is the port the felis-limbo readiness plugin serves its +// HTTP health endpoint on. The login system service gates pod readiness on it so +// "the limbo has finished starting" — not merely "the game socket is bound" — +// is what marks it Ready. +const felisLimboHealthPort int32 = 8080 + +// The felis-limbo login plugin reads its deployment configuration from these +// environment variables (env wins over its felis-link.properties template). The +// non-secret three are baked into the login pod's Spec.Env here at provision time +// (they derive from the deployment: the internal API URL, the root domain, the +// lobby server name); the service-token secret is injected separately by the +// operator via secretKeyRef. Without the token the plugin fail-safes to +// readiness-only, so a login pod that has the URL/domain but not yet the token is +// safe (it simply does not authenticate) rather than broken. +const ( + envAPIBaseURL = "FELIS_API_BASE_URL" + envRootDomain = "FELIS_ROOT_DOMAIN" + envLobbyServer = "FELIS_LOBBY_SERVER" +) + +// buildSystemServer constructs an always-on, reaper-exempt MinecraftServer from +// a systemServerSpec. It is a pure function (no K8s, no I/O) so it is unit +// testable without a cluster. Unlike buildMinecraftServerFromApplyRequest it: +// - permits reserved names (login/lobby) via ValidateSystemServerName, +// - sets DesiredState=Running (the service is up the moment it exists), +// - sets ReaperExempt=true and AutostartPolicy=public, +// - leaves RCON disabled (LOOHP/Limbo has none; readiness is gated on pod +// TCP/HTTP health, not an RCON probe — see the operator reconciler). +func buildSystemServer(in systemServerSpec, namespace string) (*v1alpha1.MinecraftServer, error) { + if err := naming.ValidateSystemServerName(in.name); err != nil { + return nil, fmt.Errorf("invalid name: %w", err) + } + if err := naming.ValidateSystemServerName(in.subdomain); err != nil { + return nil, fmt.Errorf("invalid subdomain: %w", err) + } + if in.image == "" { + return nil, fmt.Errorf("image is required for system server %q", in.name) + } + // A system service must never fall back onto the lobby: that would route an + // unauthenticated player past the login gate. Refuse to build one that does, + // rather than silently ship the bypass. + if in.fallbackServer == naming.SystemLobbyServer { + return nil, fmt.Errorf("system server %q must not fall back to the lobby (%q) — it would bypass the login gate; fall back to %q or leave it empty", + in.name, naming.SystemLobbyServer, naming.SystemLoginServer) + } + + memQ, err := resource.ParseQuantity(in.memory) + if err != nil { + return nil, fmt.Errorf("invalid memory %q for %q: %w", in.memory, in.name, err) + } + if memQ.Sign() <= 0 { + return nil, fmt.Errorf("memory must be positive for %q", in.name) + } + storageQ, err := resource.ParseQuantity(in.storage) + if err != nil { + return nil, fmt.Errorf("invalid storage %q for %q: %w", in.storage, in.name, err) + } + if storageQ.Sign() <= 0 { + return nil, fmt.Errorf("storage must be positive for %q", in.name) + } + + limits := corev1.ResourceList{corev1.ResourceMemory: memQ} + requests := corev1.ResourceList{corev1.ResourceMemory: memQ} + + return &v1alpha1.MinecraftServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: in.name, + Namespace: namespace, + }, + Spec: v1alpha1.MinecraftServerSpec{ + Subdomain: in.subdomain, + DisplayName: in.displayName, + Image: in.image, + JavaMemory: deriveApplyJavaHeap(memQ), + DesiredState: v1alpha1.DesiredRunning, + AutostartPolicy: v1alpha1.AutostartPublic, + ReaperExempt: true, + FallbackServer: in.fallbackServer, + // Behind the Velocity proxy (which enforces online-mode and modern + // forwarding), backends run offline-mode; the proxy is the one place + // online-mode is true (spec §8, §11). + OnlineMode: false, + Rcon: v1alpha1.RconSpec{Enabled: false}, + Storage: v1alpha1.StorageSpec{Size: storageQ.String()}, + Resources: corev1.ResourceRequirements{Limits: limits, Requests: requests}, + Startup: v1alpha1.StartupSpec{HealthHTTPPort: in.healthHTTPPort}, + Env: in.env, + }, + }, nil +} + +// loginSystemServer is the LOOHP/Limbo auth gate. It is the front door and the +// only safe fallback, so it carries no fallback of its own: if it is down the +// proxy refuses the connection rather than routing onward past authentication. +// +// apiBaseURL is the felis-api internal face the login plugin authenticates to and +// rootDomain builds the console URL the plugin links players at; both are baked in +// as plain env. The service token is NOT passed here — the operator injects it via +// secretKeyRef so the credential never lands in the CRD. +func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alpha1.MinecraftServer, error) { + return buildSystemServer(systemServerSpec{ + name: naming.SystemLoginServer, + subdomain: naming.SystemLoginServer, + displayName: "Login", + image: image, + memory: "512Mi", + storage: "1Gi", + fallbackServer: "", // none — refuse if the gate is down + healthHTTPPort: felisLimboHealthPort, + env: []v1alpha1.EnvVar{ + {Name: envAPIBaseURL, Value: apiBaseURL}, + {Name: envRootDomain, Value: rootDomain}, + {Name: envLobbyServer, Value: naming.SystemLobbyServer}, + }, + }, namespace) +} + +// lobbySystemServer is the post-auth /menu hub (Paper + felis-paper). It falls +// back to the login gate — never to itself and never onward — so a lobby that is +// briefly down still routes players through authentication first. +func lobbySystemServer(image, namespace string) (*v1alpha1.MinecraftServer, error) { + return buildSystemServer(systemServerSpec{ + name: naming.SystemLobbyServer, + subdomain: naming.SystemLobbyServer, + displayName: "Lobby", + image: image, + memory: "1Gi", + storage: "2Gi", + fallbackServer: naming.SystemLoginServer, + }, namespace) +} + +// buildSystemServerClient builds a controller-runtime client for the setup-time +// system-service provisioner. It is deliberately best-effort and never calls +// ctrl.SetupSignalHandler (setup owns its own context): it first honours the +// standard resolution (in-cluster, then $KUBECONFIG / --kubeconfig, then +// ~/.kube/config) and, failing that, falls back to the k3s admin kubeconfig the +// host bootstrap writes at hostBootstrapKubeconfigPath — the common case when +// setup runs as root directly on a single-node control-plane host. A returned +// error is not fatal to setup; the caller degrades to printed guidance. +func buildSystemServerClient() (client.Client, error) { + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return nil, err + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + return nil, err + } + cfg, err := ctrl.GetConfig() + if err != nil { + cfg, err = clientcmd.BuildConfigFromFlags("", hostBootstrapKubeconfigPath) + if err != nil { + return nil, fmt.Errorf("no reachable kubeconfig (tried in-cluster/$KUBECONFIG/~/.kube and %s): %w", hostBootstrapKubeconfigPath, err) + } + } + return client.New(cfg, client.Options{Scheme: scheme}) +} + +// systemServerOutcome records what ensureSystemServers did with one service so +// setup can report it without the provisioner deciding on the output format. +type systemServerOutcome struct { + name string + created bool // true = we created it this run + skipped string // non-empty = why it was skipped (image unset / already exists) + err error // non-nil = create failed +} + +// ensureSystemServers idempotently creates the login and lobby system services. +// It create-if-absent per service: an existing CRD is left untouched (so an +// operator's later edits to a system service survive re-runs of setup), a +// service whose image is unset in config is skipped with a reason, and any other +// service is created. It never deletes or overwrites. The caller supplies the +// K8s client and namespace; this function performs no signal-handler or client +// setup of its own. +func ensureSystemServers(ctx context.Context, cl client.Client, namespace, loginImage, lobbyImage, apiBaseURL, rootDomain string) []systemServerOutcome { + type plan struct { + name string + image string + build func(image, namespace string) (*v1alpha1.MinecraftServer, error) + } + plans := []plan{ + {name: naming.SystemLoginServer, image: loginImage, build: func(image, ns string) (*v1alpha1.MinecraftServer, error) { + return loginSystemServer(image, ns, apiBaseURL, rootDomain) + }}, + {name: naming.SystemLobbyServer, image: lobbyImage, build: lobbySystemServer}, + } + + outcomes := make([]systemServerOutcome, 0, len(plans)) + for _, p := range plans { + if p.image == "" { + outcomes = append(outcomes, systemServerOutcome{name: p.name, skipped: "image not configured"}) + continue + } + ms, err := p.build(p.image, namespace) + if err != nil { + outcomes = append(outcomes, systemServerOutcome{name: p.name, err: err}) + continue + } + // Create-if-absent: check first so an existing service is reported as a + // deliberate skip rather than an AlreadyExists error. + var existing v1alpha1.MinecraftServer + getErr := cl.Get(ctx, client.ObjectKeyFromObject(ms), &existing) + if getErr == nil { + outcomes = append(outcomes, systemServerOutcome{name: p.name, skipped: "already exists"}) + continue + } + if !apierrors.IsNotFound(getErr) { + outcomes = append(outcomes, systemServerOutcome{name: p.name, err: getErr}) + continue + } + if err := cl.Create(ctx, ms); err != nil { + if apierrors.IsAlreadyExists(err) { + outcomes = append(outcomes, systemServerOutcome{name: p.name, skipped: "already exists"}) + continue + } + outcomes = append(outcomes, systemServerOutcome{name: p.name, err: err}) + continue + } + outcomes = append(outcomes, systemServerOutcome{name: p.name, created: true}) + } + return outcomes +} + +// ensureServiceTokenReplica copies the internal-API service-token Secret from the +// control namespace into the minecraft namespace so the login system server's pod +// can mount it via secretKeyRef. A secretKeyRef is namespace-local, but the login +// pod runs in the minecraft namespace while the source Secret lives beside the +// control plane — so without this replica the operator's injected secretKeyRef +// would dangle and wedge the login pod in CreateContainerConfigError. It is +// create-if-absent: an existing replica is left untouched so a hand-rotated token +// in the minecraft namespace is never clobbered (to rotate, delete the replica and +// re-run setup). Best-effort like the rest of the provisioner: a missing source or +// a create failure degrades to a reported outcome, never a hard setup failure. It +// copies only Type and Data — never labels/annotations/ownerRefs — so the replica +// carries no accidental GC owner or managed-by lineage. +func ensureServiceTokenReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace string) systemServerOutcome { + const name = "service-token (minecraft ns)" + if controlNamespace == minecraftNamespace { + // Same namespace — the operator's secretKeyRef already resolves in place. + return systemServerOutcome{name: name, skipped: "control and minecraft namespaces coincide"} + } + // Never overwrite an existing replica (it may hold a rotated token). + var existing corev1.Secret + getErr := cl.Get(ctx, client.ObjectKey{Namespace: minecraftNamespace, Name: naming.ServiceTokenSecretName}, &existing) + if getErr == nil { + return systemServerOutcome{name: name, skipped: "already exists"} + } + if !apierrors.IsNotFound(getErr) { + return systemServerOutcome{name: name, err: getErr} + } + // Read the source of truth from the control namespace. + var src corev1.Secret + if err := cl.Get(ctx, client.ObjectKey{Namespace: controlNamespace, Name: naming.ServiceTokenSecretName}, &src); err != nil { + if apierrors.IsNotFound(err) { + return systemServerOutcome{name: name, skipped: fmt.Sprintf( + "source Secret %s/%s not found — provision it (deploy/bootstrap.sh), then re-run setup", + controlNamespace, naming.ServiceTokenSecretName)} + } + return systemServerOutcome{name: name, err: err} + } + replica := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: minecraftNamespace}, + Type: src.Type, + Data: src.Data, + } + if err := cl.Create(ctx, replica); err != nil { + if apierrors.IsAlreadyExists(err) { + return systemServerOutcome{name: name, skipped: "already exists"} + } + return systemServerOutcome{name: name, err: err} + } + return systemServerOutcome{name: name, created: true} +} diff --git a/cmd/felis/systemservers_test.go b/cmd/felis/systemservers_test.go new file mode 100644 index 0000000..cac7709 --- /dev/null +++ b/cmd/felis/systemservers_test.go @@ -0,0 +1,278 @@ +package main + +import ( + "context" + "testing" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// A system service must be born up, reaper-exempt, publicly wakeable, and +// RCON-free — the uniform shape the topology invariants depend on. +func TestBuildSystemServerShape(t *testing.T) { + ms, err := buildSystemServer(systemServerSpec{ + name: "login", + subdomain: "login", + image: "reg/limbo:1", + memory: "512Mi", + storage: "1Gi", + }, "minecraft") + if err != nil { + t.Fatalf("buildSystemServer: %v", err) + } + if ms.Spec.DesiredState != v1alpha1.DesiredRunning { + t.Errorf("DesiredState = %q, want Running", ms.Spec.DesiredState) + } + if !ms.Spec.ReaperExempt { + t.Error("ReaperExempt = false, want true") + } + if ms.Spec.AutostartPolicy != v1alpha1.AutostartPublic { + t.Errorf("AutostartPolicy = %q, want public", ms.Spec.AutostartPolicy) + } + if ms.Spec.Rcon.Enabled { + t.Error("Rcon.Enabled = true, want false (LOOHP/Limbo has no RCON)") + } + if ms.Spec.OnlineMode { + t.Error("OnlineMode = true, want false (backend behind the proxy)") + } + if _, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory]; !ok { + t.Error("missing memory limit (§22 ceiling)") + } + if ms.Namespace != "minecraft" { + t.Errorf("namespace = %q, want minecraft", ms.Namespace) + } +} + +// The login gate is the front door and the only safe fallback, so it carries no +// fallback of its own; the lobby falls back to login. Neither may fall back to +// the lobby — that would route a player past authentication. +func TestSystemServerFallbackPolicy(t *testing.T) { + login, err := loginSystemServer("reg/limbo:1", "minecraft", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + if err != nil { + t.Fatalf("loginSystemServer: %v", err) + } + if login.Spec.FallbackServer != "" { + t.Errorf("login FallbackServer = %q, want empty (refuse when down)", login.Spec.FallbackServer) + } + + lobby, err := lobbySystemServer("reg/lobby:1", "minecraft") + if err != nil { + t.Fatalf("lobbySystemServer: %v", err) + } + if lobby.Spec.FallbackServer != naming.SystemLoginServer { + t.Errorf("lobby FallbackServer = %q, want %q", lobby.Spec.FallbackServer, naming.SystemLoginServer) + } +} + +// buildSystemServer must refuse a spec that falls back to the lobby rather than +// silently shipping the auth-bypass. +func TestBuildSystemServerRejectsLobbyFallback(t *testing.T) { + _, err := buildSystemServer(systemServerSpec{ + name: "survival", + subdomain: "survival", + image: "reg/paper:1", + memory: "1Gi", + storage: "1Gi", + fallbackServer: naming.SystemLobbyServer, + }, "minecraft") + if err == nil { + t.Fatal("expected error for fallback=lobby, got nil") + } +} + +func TestBuildSystemServerRejectsBadInput(t *testing.T) { + cases := []struct { + name string + in systemServerSpec + }{ + {"empty image", systemServerSpec{name: "login", subdomain: "login", memory: "512Mi", storage: "1Gi"}}, + {"bad name", systemServerSpec{name: "ab", subdomain: "login", image: "x", memory: "512Mi", storage: "1Gi"}}, + {"zero memory", systemServerSpec{name: "login", subdomain: "login", image: "x", memory: "0", storage: "1Gi"}}, + {"bad storage", systemServerSpec{name: "login", subdomain: "login", image: "x", memory: "512Mi", storage: "nonsense"}}, + } + for _, tc := range cases { + if _, err := buildSystemServer(tc.in, "minecraft"); err == nil { + t.Errorf("%s: expected error, got nil", tc.name) + } + } +} + +// The login gate needs its deployment config as plain env: the internal API URL, +// the root domain, and the lobby name — but NEVER the service token (that is +// injected by the operator via secretKeyRef, never a literal in the CRD). +func TestLoginSystemServerEnv(t *testing.T) { + login, err := loginSystemServer("reg/limbo:1", "minecraft", + "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + if err != nil { + t.Fatalf("loginSystemServer: %v", err) + } + got := map[string]string{} + for _, e := range login.Spec.Env { + got[e.Name] = e.Value + } + want := map[string]string{ + "FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081", + "FELIS_ROOT_DOMAIN": "mc.example.net", + "FELIS_LOBBY_SERVER": naming.SystemLobbyServer, + } + for k, v := range want { + if got[k] != v { + t.Errorf("env %s = %q, want %q", k, got[k], v) + } + } + // The CRD's EnvVar type has no valueFrom, so the token must NEVER appear here — + // it would have to be a plaintext value, which is the leak we refuse. + if _, leaked := got["FELIS_SERVICE_TOKEN"]; leaked { + t.Error("FELIS_SERVICE_TOKEN must not be baked into the CRD (operator injects it via secretKeyRef)") + } +} + +// ensureServiceTokenReplica copies the token Secret from the control namespace into +// the minecraft namespace (create-if-absent), so the operator's secretKeyRef on the +// login pod resolves. It must not overwrite an existing replica, and must degrade +// gracefully when the source is missing or the namespaces coincide. +func TestEnsureServiceTokenReplica(t *testing.T) { + scheme := newSystemServerScheme(t) + ctx := context.Background() + + srcSecret := func() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "felis"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte("s3cr3t")}, + } + } + + t.Run("replicates when absent", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret()).Build() + out := ensureServiceTokenReplica(ctx, cl, "felis", "minecraft") + if out.err != nil || !out.created { + t.Fatalf("outcome = %+v, want created", out) + } + var replica corev1.Secret + if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ServiceTokenSecretName}, &replica); err != nil { + t.Fatalf("get replica: %v", err) + } + if string(replica.Data[naming.ServiceTokenSecretKey]) != "s3cr3t" { + t.Errorf("replica token = %q, want s3cr3t", replica.Data[naming.ServiceTokenSecretKey]) + } + }) + + t.Run("does not overwrite existing replica", func(t *testing.T) { + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "minecraft"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte("rotated")}, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), existing).Build() + out := ensureServiceTokenReplica(ctx, cl, "felis", "minecraft") + if out.created || out.skipped == "" { + t.Fatalf("outcome = %+v, want skipped (not clobbered)", out) + } + var replica corev1.Secret + if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ServiceTokenSecretName}, &replica); err != nil { + t.Fatalf("get replica: %v", err) + } + if string(replica.Data[naming.ServiceTokenSecretKey]) != "rotated" { + t.Error("existing replica was overwritten — a rotated token must survive") + } + }) + + t.Run("skips when source missing", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + out := ensureServiceTokenReplica(ctx, cl, "felis", "minecraft") + if out.err != nil || out.created || out.skipped == "" { + t.Fatalf("outcome = %+v, want skipped (source absent)", out) + } + }) + + t.Run("no-op when namespaces coincide", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + out := ensureServiceTokenReplica(ctx, cl, "felis", "felis") + if out.err != nil || out.created { + t.Fatalf("outcome = %+v, want skipped no-op", out) + } + }) +} + +func newSystemServerScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("clientgo scheme: %v", err) + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("v1alpha1 scheme: %v", err) + } + return scheme +} + +// ensureSystemServers creates both services on a fresh cluster, then is a no-op +// on re-run (create-if-absent), and skips a service whose image is unset. +func TestEnsureSystemServersIdempotent(t *testing.T) { + scheme := newSystemServerScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx := context.Background() + + first := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + if len(first) != 2 { + t.Fatalf("first run outcomes = %d, want 2", len(first)) + } + for _, o := range first { + if o.err != nil { + t.Fatalf("%s: unexpected error: %v", o.name, o.err) + } + if !o.created { + t.Errorf("%s: created = false on fresh cluster (skipped=%q)", o.name, o.skipped) + } + } + + // The created login CRD must carry the always-on system-service shape. + var login v1alpha1.MinecraftServer + if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: "login"}, &login); err != nil { + t.Fatalf("get login after create: %v", err) + } + if login.Spec.DesiredState != v1alpha1.DesiredRunning || !login.Spec.ReaperExempt { + t.Errorf("login spec = {desired=%q exempt=%v}, want {Running true}", login.Spec.DesiredState, login.Spec.ReaperExempt) + } + + // Re-run: both already exist → skipped, nothing created, no error. + second := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + for _, o := range second { + if o.err != nil { + t.Fatalf("%s: unexpected error on re-run: %v", o.name, o.err) + } + if o.created { + t.Errorf("%s: created = true on re-run, want skipped", o.name) + } + if o.skipped == "" { + t.Errorf("%s: skipped reason empty on re-run", o.name) + } + } +} + +func TestEnsureSystemServersSkipsUnsetImage(t *testing.T) { + scheme := newSystemServerScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx := context.Background() + + // login image set, lobby image empty → login created, lobby skipped. + out := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + byName := map[string]systemServerOutcome{} + for _, o := range out { + byName[o.name] = o + } + if !byName[naming.SystemLoginServer].created { + t.Errorf("login: created = false, want true") + } + if byName[naming.SystemLobbyServer].skipped != "image not configured" { + t.Errorf("lobby: skipped = %q, want %q", byName[naming.SystemLobbyServer].skipped, "image not configured") + } +}