Files
Felis/cmd/felis/systemservers_test.go
T
flyemoji f554d525d4 feat(cli): provision login/lobby system servers with login env and token replica
setup builds the always-on, reaper-exempt login/lobby MinecraftServers (create-if-absent), bakes the login limbo's non-secret config (internal API URL, root domain, lobby name) into spec.env, and replicates the felis-service-token Secret from the control namespace into the minecraft namespace so the operator's namespace-local secretKeyRef on the login pod resolves.
2026-07-02 19:38:37 +09:00

279 lines
10 KiB
Go

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")
}
}