Felis never actually sent mail: OTP codes for onboarding, email login and
op-login were only written to the felis-api log behind a "demo has no SMTP"
limitation, and the Settings/SMTP flow those comments promised was never
built. Combined with the bootstrap Owner's address being recorded unverified
(87279a1), op-login start always took the anti-enumeration neutral branch and
minted a fake request_id, so the in-game approve inevitably answered "No
pending operator sign-in with that code".
Give the codes a real delivery path, configured in felis.toml rather than a
web settings page so config keeps a single source of truth:
- config: new [smtp] table (host, port defaulting to 587, from, username,
password_ref). Validation requires a plausible from address and a sane
port; the password itself never enters the config file.
- internal/mail (new): stdlib net/smtp mailer implementing the api.OTPMailer
seam. Port 465 dials implicit TLS, other ports upgrade via STARTTLS when
advertised; AUTH only when a username is configured (PlainAuth itself
refuses plaintext, so the password cannot leak to a TLS-less relay).
Ping() proves reachability and credentials without sending mail. The
message shape (CRLF, Q-encoded bilingual subject) is pinned by test.
- platform: felis-smtp Secret constants and an optional FELIS_SMTP_PASSWORD
env var on the felis-api Deployment, mirroring felis-uploads-s3.
- cmd/felis api: construct the real mailer when [smtp] is configured; keep
the log fallback otherwise and say so at startup. Warn when a username is
set but the credentials env is empty.
- setup TUI: "e" on the summary/status screen opens the email form (host,
port, from, optional auth). Apply order: Ping preflight, [smtp] into both
host and pod config files, felis-smtp Secret piped to kubectl via stdin,
config Secret, felis-api rollout. A failed preflight leaves the install
untouched. SMTP is deliberately not a wizard rail step: first-run stays
mail-less by design, and the passkey minted at onboarding is the pre-SMTP
owner credential.
Also make PGRepo.UserByEmail match case-insensitively (lower(email) =
lower($1)), honoring the interface contract and the users_verified_email_
unique partial index; the fake repo already matched with EqualFold.
Existing installs need the felis-api Deployment manifest re-applied (e.g. a
bootstrap re-run) before the new env var exists; a rollout restart alone
cannot add it.
673 lines
27 KiB
Go
673 lines
27 KiB
Go
package platform
|
|
|
|
import (
|
|
"testing"
|
|
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
)
|
|
|
|
// podSpec returns the single container and the pod template of a Deployment,
|
|
// failing if the shape is not the expected single-container pod.
|
|
func podSpec(t *testing.T, d *appsv1.Deployment) (corev1.PodSpec, corev1.Container) {
|
|
t.Helper()
|
|
ps := d.Spec.Template.Spec
|
|
if len(ps.Containers) != 1 {
|
|
t.Fatalf("%s: want exactly 1 container, got %d", d.Name, len(ps.Containers))
|
|
}
|
|
return ps, ps.Containers[0]
|
|
}
|
|
|
|
// rconPeerSelector returns the podSelector of the allow-rcon NetworkPolicy peer,
|
|
// compiled into the same labels.Selector K8s evaluates at runtime. This is the
|
|
// real gate: a pod reaches server RCON iff its labels Match this selector.
|
|
func rconPeerSelector(t *testing.T, p Params) labels.Selector {
|
|
t.Helper()
|
|
rcon := npByName(t, MinecraftNetworkPolicies(p), "felis-allow-rcon-from-control-plane")
|
|
if len(rcon.Spec.Ingress) != 1 || len(rcon.Spec.Ingress[0].From) != 1 {
|
|
t.Fatalf("rcon policy shape changed; want 1 ingress / 1 peer")
|
|
}
|
|
sel, err := metav1.LabelSelectorAsSelector(rcon.Spec.Ingress[0].From[0].PodSelector)
|
|
if err != nil {
|
|
t.Fatalf("compiling rcon podSelector: %v", err)
|
|
}
|
|
return sel
|
|
}
|
|
|
|
// mapSelectorMatches evaluates a Service-style equality selector (a plain label
|
|
// map: every entry must be present and equal) against a pod's labels.
|
|
func mapSelectorMatches(selector, podLabels map[string]string) bool {
|
|
if len(selector) == 0 {
|
|
return false // an empty Service selector selects nothing useful here
|
|
}
|
|
for k, v := range selector {
|
|
if podLabels[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestControlPlaneDeployments_RunAsMatchingSA is the SA↔workload binding: the
|
|
// namespaced Roles only mean something if a workload actually runs as each SA.
|
|
// The Deployment is also NAMED for its SA (the tree convention), so a rename
|
|
// can't silently detach the labels from the identity.
|
|
func TestControlPlaneDeployments_RunAsMatchingSA(t *testing.T) {
|
|
p := testParams()
|
|
cases := []struct {
|
|
name string
|
|
dep *appsv1.Deployment
|
|
sa string
|
|
}{
|
|
{"api", APIDeployment(p), SAAPI},
|
|
{"operator", OperatorDeployment(p), SAOperator},
|
|
}
|
|
for _, c := range cases {
|
|
ps, _ := podSpec(t, c.dep)
|
|
if ps.ServiceAccountName != c.sa {
|
|
t.Errorf("%s pod serviceAccountName = %q, want %q", c.name, ps.ServiceAccountName, c.sa)
|
|
}
|
|
if c.dep.Name != c.sa {
|
|
t.Errorf("%s Deployment name = %q, want %q (named for its SA)", c.name, c.dep.Name, c.sa)
|
|
}
|
|
if c.dep.Namespace != p.ControlNamespace {
|
|
t.Errorf("%s Deployment namespace = %q, want control ns %q", c.name, c.dep.Namespace, p.ControlNamespace)
|
|
}
|
|
// Selector, template labels, and object labels must agree (a mismatch
|
|
// orphans the pods).
|
|
if !labels.Equals(c.dep.Spec.Selector.MatchLabels, c.dep.Spec.Template.Labels) {
|
|
t.Errorf("%s selector %v != template labels %v", c.name, c.dep.Spec.Selector.MatchLabels, c.dep.Spec.Template.Labels)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestControlPlanePods_SatisfyRConPeer is the second correspondence: the api and
|
|
// operator pods (which legitimately open RCON — console writes, readiness probes)
|
|
// carry labels that SATISFY the allow-rcon peer, while the registry and the reaper
|
|
// do NOT. Evaluated with the live selector, so it proves the labels and the policy
|
|
// agree rather than re-asserting the policy's shape.
|
|
func TestControlPlanePods_SatisfyRConPeer(t *testing.T) {
|
|
p := testParams()
|
|
sel := rconPeerSelector(t, p)
|
|
|
|
apiPod := APIDeployment(p).Spec.Template.Labels
|
|
opPod := OperatorDeployment(p).Spec.Template.Labels
|
|
regPod := registryDeployment(p).Spec.Template.Labels
|
|
reaperPod := controlPlanePodLabels(ComponentReaper)
|
|
|
|
if !sel.Matches(labels.Set(apiPod)) {
|
|
t.Errorf("api pod labels %v must satisfy the rcon peer", apiPod)
|
|
}
|
|
if !sel.Matches(labels.Set(opPod)) {
|
|
t.Errorf("operator pod labels %v must satisfy the rcon peer", opPod)
|
|
}
|
|
if sel.Matches(labels.Set(regPod)) {
|
|
t.Errorf("registry pod labels %v must NOT satisfy the rcon peer (no part-of=control-plane)", regPod)
|
|
}
|
|
if sel.Matches(labels.Set(reaperPod)) {
|
|
t.Errorf("reaper pod labels %v must NOT satisfy the rcon peer (component not in {api,operator})", reaperPod)
|
|
}
|
|
}
|
|
|
|
// TestControlPlanePods_Hardened asserts the pod/container SecurityContext on every
|
|
// workload here mirrors the build/restore Job hardening: non-root, no privilege,
|
|
// no escalation, read-only root fs, all caps dropped. (Shape-asserted: no cluster
|
|
// proves the images actually start under these constraints.)
|
|
func TestControlPlanePods_Hardened(t *testing.T) {
|
|
p := testParams()
|
|
for _, d := range []*appsv1.Deployment{APIDeployment(p), OperatorDeployment(p), registryDeployment(p)} {
|
|
ps, c := podSpec(t, d)
|
|
|
|
if ps.SecurityContext == nil || ps.SecurityContext.RunAsNonRoot == nil || !*ps.SecurityContext.RunAsNonRoot {
|
|
t.Errorf("%s: pod must set runAsNonRoot=true", d.Name)
|
|
}
|
|
if ps.SecurityContext == nil || ps.SecurityContext.RunAsUser == nil || *ps.SecurityContext.RunAsUser != nonRootUID {
|
|
t.Errorf("%s: pod runAsUser must be %d", d.Name, nonRootUID)
|
|
}
|
|
sc := c.SecurityContext
|
|
if sc == nil {
|
|
t.Fatalf("%s: container has no SecurityContext", d.Name)
|
|
}
|
|
if sc.Privileged == nil || *sc.Privileged {
|
|
t.Errorf("%s: container must not be privileged", d.Name)
|
|
}
|
|
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
|
|
t.Errorf("%s: container must set allowPrivilegeEscalation=false", d.Name)
|
|
}
|
|
if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
|
|
t.Errorf("%s: container must set readOnlyRootFilesystem=true", d.Name)
|
|
}
|
|
if sc.Capabilities == nil || len(sc.Capabilities.Drop) == 0 || sc.Capabilities.Drop[0] != "ALL" {
|
|
t.Errorf("%s: container must drop ALL capabilities", d.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAPIDeployment_Wiring pins the api entrypoint, the credential plumbing, and
|
|
// the FELIS_IMAGE passthrough.
|
|
func TestAPIDeployment_Wiring(t *testing.T) {
|
|
p := testParams()
|
|
d := APIDeployment(p)
|
|
ps, c := podSpec(t, d)
|
|
|
|
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "api"}) {
|
|
t.Errorf("api command/args = %v, want it to start `%s api`", got, felisBinaryPath)
|
|
}
|
|
if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) {
|
|
t.Errorf("api args must mount config at %s, got %v", configFilePath, c.Args)
|
|
}
|
|
if !contains(c.Args, "--internal-addr") {
|
|
t.Errorf("api args must set --internal-addr, got %v", c.Args)
|
|
}
|
|
if !contains(c.Args, "--https-addr") || !contains(c.Args, ":8443") {
|
|
t.Errorf("api args must set HTTPS listener, got %v", c.Args)
|
|
}
|
|
if !contains(c.Args, "--tls-cert") || !contains(c.Args, apiTLSMountPath+"/tls.crt") ||
|
|
!contains(c.Args, "--tls-key") || !contains(c.Args, apiTLSMountPath+"/tls.key") {
|
|
t.Errorf("api args must point at mounted TLS secret, got %v", c.Args)
|
|
}
|
|
if c.Image != p.FelisImage {
|
|
t.Errorf("api image = %q, want FelisImage %q", c.Image, p.FelisImage)
|
|
}
|
|
|
|
// FELIS_IMAGE passthrough (used to launch the restore Job with the same image).
|
|
if v := envValue(c.Env, "FELIS_IMAGE"); v != p.FelisImage {
|
|
t.Errorf("FELIS_IMAGE = %q, want %q", v, p.FelisImage)
|
|
}
|
|
// FELIS_SERVICE_TOKEN must come from a Secret, never a literal value.
|
|
tok := envVar(c.Env, "FELIS_SERVICE_TOKEN")
|
|
if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
|
|
t.Fatal("FELIS_SERVICE_TOKEN must be sourced from a secretKeyRef")
|
|
}
|
|
if tok.Value != "" {
|
|
t.Error("FELIS_SERVICE_TOKEN must not carry a literal value")
|
|
}
|
|
|
|
// felis.toml carries the DB URL, so its volume must be a Secret (NOT a
|
|
// ConfigMap), mounted read-only.
|
|
cfgVol := volumeByName(ps.Volumes, configVolume)
|
|
if cfgVol == nil || cfgVol.Secret == nil {
|
|
t.Fatal("config volume must be sourced from a Secret")
|
|
}
|
|
if cfgVol.ConfigMap != nil {
|
|
t.Error("config volume must NOT be a ConfigMap (felis.toml holds the DB credential)")
|
|
}
|
|
if cfgVol.Secret.SecretName != configSecretName {
|
|
t.Errorf("config Secret name = %q, want %q", cfgVol.Secret.SecretName, configSecretName)
|
|
}
|
|
if m := mountByName(c.VolumeMounts, configVolume); m == nil || !m.ReadOnly {
|
|
t.Error("config volume must be mounted read-only")
|
|
}
|
|
tlsVol := volumeByName(ps.Volumes, "tls")
|
|
if tlsVol == nil || tlsVol.Secret == nil || tlsVol.Secret.SecretName != apiTLSSecretName {
|
|
t.Fatalf("tls volume must mount Secret %q, got %#v", apiTLSSecretName, tlsVol)
|
|
}
|
|
if m := mountByName(c.VolumeMounts, "tls"); m == nil || !m.ReadOnly || m.MountPath != apiTLSMountPath {
|
|
t.Errorf("tls volume mount = %#v, want read-only at %s", m, apiTLSMountPath)
|
|
}
|
|
|
|
// No backup PVC in testParams ⇒ no FELIS_BACKUP_PVC env (restore degrades to 503).
|
|
if envVar(c.Env, "FELIS_BACKUP_PVC") != nil {
|
|
t.Error("FELIS_BACKUP_PVC must be absent when no backup PVC is configured")
|
|
}
|
|
}
|
|
|
|
// TestAPIDeployment_UploadsStorage pins both storage backends' wiring: the local
|
|
// uploads PVC mounted read-write, and the two S3 credential env vars sourced
|
|
// optionally from the felis-uploads-s3 Secret (so a local install still starts).
|
|
func TestAPIDeployment_UploadsStorage(t *testing.T) {
|
|
ps, c := podSpec(t, APIDeployment(testParams()))
|
|
|
|
// Local backend: uploads PVC mounted read-WRITE at UploadsLocalPath.
|
|
vol := volumeByName(ps.Volumes, uploadsVolume)
|
|
if vol == nil || vol.PersistentVolumeClaim == nil || vol.PersistentVolumeClaim.ClaimName != uploadsPVCName {
|
|
t.Fatalf("uploads volume must mount PVC %q, got %#v", uploadsPVCName, vol)
|
|
}
|
|
if m := mountByName(c.VolumeMounts, uploadsVolume); m == nil || m.MountPath != UploadsLocalPath || m.ReadOnly {
|
|
t.Errorf("uploads mount = %#v, want read-write at %s", m, UploadsLocalPath)
|
|
}
|
|
|
|
// Credential env vars (S3 backend + SMTP relay) come from their Secrets (never
|
|
// literals) and are OPTIONAL, so an install without them still starts.
|
|
for _, ev := range []struct{ name, secret, key string }{
|
|
{UploadsS3AccessKeyEnv, UploadsS3SecretName, UploadsS3SecretAccessKey},
|
|
{UploadsS3SecretKeyEnv, UploadsS3SecretName, UploadsS3SecretSecretKey},
|
|
{SMTPPasswordEnv, SMTPSecretName, SMTPSecretPasswordKey},
|
|
} {
|
|
e := envVar(c.Env, ev.name)
|
|
if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil {
|
|
t.Fatalf("%s must be sourced from a secretKeyRef", ev.name)
|
|
}
|
|
ref := e.ValueFrom.SecretKeyRef
|
|
if ref.Name != ev.secret || ref.Key != ev.key {
|
|
t.Errorf("%s ref = %s/%s, want %s/%s", ev.name, ref.Name, ref.Key, ev.secret, ev.key)
|
|
}
|
|
if ref.Optional == nil || !*ref.Optional {
|
|
t.Errorf("%s secretKeyRef must be optional (an install without it has no such Secret)", ev.name)
|
|
}
|
|
if e.Value != "" {
|
|
t.Errorf("%s must not carry a literal value", ev.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIService_NodePort(t *testing.T) {
|
|
p := testParams()
|
|
p.PanelNodePort = 30445
|
|
svc := apiService(p)
|
|
dep := APIDeployment(p)
|
|
|
|
if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace {
|
|
t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI)
|
|
}
|
|
if svc.Spec.Type != corev1.ServiceTypeNodePort {
|
|
t.Errorf("api Service type = %s, want NodePort", svc.Spec.Type)
|
|
}
|
|
if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) {
|
|
t.Errorf("api Service selector %v does not select api pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels)
|
|
}
|
|
if len(svc.Spec.Ports) != 1 {
|
|
t.Fatalf("api Service ports = %v, want one", svc.Spec.Ports)
|
|
}
|
|
port := svc.Spec.Ports[0]
|
|
if port.Port != 443 || port.TargetPort.StrVal != "https" || port.NodePort != p.PanelNodePort {
|
|
t.Errorf("api Service port = %#v, want 443 -> https NodePort %d", port, p.PanelNodePort)
|
|
}
|
|
}
|
|
|
|
// TestAPIInternalService_ClusterIP pins the separate internal-face Service: it must
|
|
// be ClusterIP (never NodePort — the internal face is service-token-only and must not
|
|
// be published on a node's external IP), expose 8081 -> the api pod's "internal"
|
|
// port, carry NO nodePort, and select the same api pods as the external Service. It
|
|
// is what makes the felis-api DNS name actually answer on 8081 (the login pod path)
|
|
// and gives the on-node console a ClusterIP to dial.
|
|
func TestAPIInternalService_ClusterIP(t *testing.T) {
|
|
p := testParams()
|
|
svc := apiInternalService(p)
|
|
dep := APIDeployment(p)
|
|
|
|
if svc.Name != APIInternalServiceName || svc.Namespace != p.ControlNamespace {
|
|
t.Errorf("internal Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, APIInternalServiceName)
|
|
}
|
|
if svc.Name == SAAPI {
|
|
t.Errorf("internal Service must not collide with the external Service name %q", SAAPI)
|
|
}
|
|
if svc.Spec.Type != corev1.ServiceTypeClusterIP {
|
|
t.Errorf("internal Service type = %s, want ClusterIP (never expose the no-Zero-Trust face on a node)", svc.Spec.Type)
|
|
}
|
|
if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) {
|
|
t.Errorf("internal Service selector %v does not select api pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels)
|
|
}
|
|
if len(svc.Spec.Ports) != 1 {
|
|
t.Fatalf("internal Service ports = %v, want one", svc.Spec.Ports)
|
|
}
|
|
port := svc.Spec.Ports[0]
|
|
if port.Port != apiInternalPort || port.TargetPort.StrVal != "internal" || port.NodePort != 0 {
|
|
t.Errorf("internal Service port = %#v, want %d -> internal with no nodePort", port, apiInternalPort)
|
|
}
|
|
}
|
|
|
|
// TestAPIDeployment_BackupPVC proves the FELIS_BACKUP_PVC env appears only when a
|
|
// backup PVC is named.
|
|
func TestAPIDeployment_BackupPVC(t *testing.T) {
|
|
p := testParams()
|
|
p.BackupPVC = "felis-backups"
|
|
_, c := podSpec(t, APIDeployment(p))
|
|
if v := envValue(c.Env, "FELIS_BACKUP_PVC"); v != "felis-backups" {
|
|
t.Errorf("FELIS_BACKUP_PVC = %q, want %q", v, "felis-backups")
|
|
}
|
|
}
|
|
|
|
// TestOperatorDeployment_Wiring pins the operator entrypoint, its namespace split,
|
|
// and its deliberately smaller surface (NO config Secret — it holds no DB URL).
|
|
func TestOperatorDeployment_Wiring(t *testing.T) {
|
|
p := testParams()
|
|
d := OperatorDeployment(p)
|
|
ps, c := podSpec(t, d)
|
|
|
|
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "operator"}) {
|
|
t.Errorf("operator command/args = %v, want it to start `%s operator`", got, felisBinaryPath)
|
|
}
|
|
if !contains(c.Args, "--namespace") || !contains(c.Args, p.MinecraftNamespace) {
|
|
t.Errorf("operator must watch --namespace %s, got %v", p.MinecraftNamespace, c.Args)
|
|
}
|
|
if c.Image != p.FelisImage {
|
|
t.Errorf("operator image = %q, want FelisImage %q", c.Image, p.FelisImage)
|
|
}
|
|
// No config Secret volume: the operator reads config from flags + the API only.
|
|
for _, v := range ps.Volumes {
|
|
if v.Secret != nil {
|
|
t.Errorf("operator must mount NO Secret volume, found %q", v.Name)
|
|
}
|
|
}
|
|
// And it must hold no credential env at all.
|
|
if len(c.Env) != 0 {
|
|
t.Errorf("operator must carry no env (flags-only), got %v", c.Env)
|
|
}
|
|
}
|
|
|
|
// TestRegistry_DeploymentServicePVC pins the in-cluster registry: its pinned
|
|
// listen port, token-mount hygiene, the Service that gives it its DNS name, and
|
|
// the backing PVC — the trio the build egress policy targets.
|
|
func TestRegistry_DeploymentServicePVC(t *testing.T) {
|
|
// testParams leaves RegistryNamespace/RegistryPort zero; the renderers fill them
|
|
// via withDefaults, so compare against the defaulted Params.
|
|
p := testParams().withDefaults()
|
|
dep := registryDeployment(p)
|
|
svc := registryService(p)
|
|
pvc := registryPVC(p)
|
|
|
|
ps, c := podSpec(t, dep)
|
|
if c.Image != defaultRegistryImage {
|
|
t.Errorf("registry image = %q, want default %q", c.Image, defaultRegistryImage)
|
|
}
|
|
// REGISTRY_HTTP_ADDR pins the listen port to the Service port rather than
|
|
// trusting the image default.
|
|
if v := envValue(c.Env, "REGISTRY_HTTP_ADDR"); v != ":5000" {
|
|
t.Errorf("REGISTRY_HTTP_ADDR = %q, want :5000", v)
|
|
}
|
|
// Registry never calls the K8s API ⇒ no auto-mounted token.
|
|
if ps.AutomountServiceAccountToken == nil || *ps.AutomountServiceAccountToken {
|
|
t.Error("registry pod must set automountServiceAccountToken=false")
|
|
}
|
|
// Data is on the PVC named "registry".
|
|
dataVol := volumeByName(ps.Volumes, registryVolume)
|
|
if dataVol == nil || dataVol.PersistentVolumeClaim == nil || dataVol.PersistentVolumeClaim.ClaimName != registryName {
|
|
t.Errorf("registry data volume must be PVC %q", registryName)
|
|
}
|
|
|
|
// Service: gives the pinned DNS name registry.<ns>.svc:5000.
|
|
if svc.Name != registryName || svc.Namespace != p.RegistryNamespace {
|
|
t.Errorf("registry Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.RegistryNamespace, registryName)
|
|
}
|
|
if len(svc.Spec.Ports) != 1 || svc.Spec.Ports[0].Port != p.RegistryPort {
|
|
t.Errorf("registry Service port = %v, want %d", svc.Spec.Ports, p.RegistryPort)
|
|
}
|
|
// The Service selector must select the registry pods...
|
|
if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) {
|
|
t.Errorf("registry Service selector %v does not select registry pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels)
|
|
}
|
|
// ...and must NOT select the api pods (distinct component).
|
|
if mapSelectorMatches(svc.Spec.Selector, APIDeployment(p).Spec.Template.Labels) {
|
|
t.Error("registry Service selector must not select the api pod")
|
|
}
|
|
|
|
// PVC: RWO with a concrete request, no pinned storage class.
|
|
if pvc.Name != registryName || pvc.Namespace != p.RegistryNamespace {
|
|
t.Errorf("registry PVC = %s/%s, want %s/%s", pvc.Namespace, pvc.Name, p.RegistryNamespace, registryName)
|
|
}
|
|
if !contains(accessModeStrings(pvc.Spec.AccessModes), string(corev1.ReadWriteOnce)) {
|
|
t.Errorf("registry PVC access modes = %v, want ReadWriteOnce", pvc.Spec.AccessModes)
|
|
}
|
|
if pvc.Spec.Resources.Requests.Storage().IsZero() {
|
|
t.Error("registry PVC must request a non-zero storage size")
|
|
}
|
|
}
|
|
|
|
// TestWorkloads_BundleContents sanity-checks the slice Workloads returns: the two
|
|
// control-plane Deployments, the api external+internal Services, and the registry
|
|
// Deployment/Service/PVC, every one with TypeMeta (so its YAML header renders). The
|
|
// internal Service must be present or the login pod's felis-api:8081 path is dead.
|
|
func TestWorkloads_BundleContents(t *testing.T) {
|
|
objs := Workloads(testParams())
|
|
if len(objs) != 8 {
|
|
t.Fatalf("Workloads returned %d objects, want 8", len(objs))
|
|
}
|
|
var haveInternalSvc bool
|
|
for _, o := range objs {
|
|
gvk := o.GetObjectKind().GroupVersionKind()
|
|
if gvk.Kind == "" || gvk.Version == "" {
|
|
t.Errorf("%T missing TypeMeta (kind=%q version=%q)", o, gvk.Kind, gvk.Version)
|
|
}
|
|
if svc, ok := o.(*corev1.Service); ok && svc.Name == APIInternalServiceName {
|
|
haveInternalSvc = true
|
|
}
|
|
}
|
|
if !haveInternalSvc {
|
|
t.Errorf("Workloads bundle is missing the internal-face Service %q", APIInternalServiceName)
|
|
}
|
|
}
|
|
|
|
// cronPodSpec returns the single container and pod template of a CronJob's Job
|
|
// template, failing if the shape is not a single-container pod (the reaper's shape).
|
|
func cronPodSpec(t *testing.T, cj *batchv1.CronJob) (corev1.PodSpec, corev1.Container) {
|
|
t.Helper()
|
|
ps := cj.Spec.JobTemplate.Spec.Template.Spec
|
|
if len(ps.Containers) != 1 {
|
|
t.Fatalf("%s: want exactly 1 container, got %d", cj.Name, len(ps.Containers))
|
|
}
|
|
return ps, ps.Containers[0]
|
|
}
|
|
|
|
// findCronJob returns the first CronJob in objs, or nil — used to assert the
|
|
// reaper's presence/absence in the rendered Workloads slice.
|
|
func findCronJob(objs []Object) *batchv1.CronJob {
|
|
for _, o := range objs {
|
|
if cj, ok := o.(*batchv1.CronJob); ok {
|
|
return cj
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reaperParams is testParams with the retention storage trio supplied, so the
|
|
// reaper CronJob renders. The paths are illustrative (no cluster runs here).
|
|
func reaperParams() Params {
|
|
p := testParams()
|
|
p.WorldsHostPath = "/var/lib/felis/worlds"
|
|
p.BackupPVC = "felis-backups"
|
|
p.ArchiveLocalPath = "/backups"
|
|
return p
|
|
}
|
|
|
|
// TestReaperCronJob_Gating proves the reaper renders iff all three storage
|
|
// coordinates are present: an incomplete configuration must produce NO CronJob
|
|
// (the partial-flag mistake is rejected at the CLI; here the renderer fails safe).
|
|
func TestReaperCronJob_Gating(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(p *Params)
|
|
want bool
|
|
}{
|
|
{"none", func(p *Params) {}, false},
|
|
{"worlds only", func(p *Params) { p.WorldsHostPath = "/w" }, false},
|
|
{"worlds+backup", func(p *Params) { p.WorldsHostPath = "/w"; p.BackupPVC = "b" }, false},
|
|
{"worlds+archive", func(p *Params) { p.WorldsHostPath = "/w"; p.ArchiveLocalPath = "/a" }, false},
|
|
{"backup+archive (no worlds)", func(p *Params) { p.BackupPVC = "b"; p.ArchiveLocalPath = "/a" }, false},
|
|
{"all three", func(p *Params) { p.WorldsHostPath = "/w"; p.BackupPVC = "b"; p.ArchiveLocalPath = "/a" }, true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
p := testParams()
|
|
c.mutate(&p)
|
|
if got := reaperEnabled(p); got != c.want {
|
|
t.Errorf("reaperEnabled = %v, want %v", got, c.want)
|
|
}
|
|
cj := findCronJob(Workloads(p))
|
|
if c.want && cj == nil {
|
|
t.Error("CronJob must be in Workloads when enabled")
|
|
}
|
|
if !c.want && cj != nil {
|
|
t.Error("CronJob must NOT be in Workloads when disabled")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReaperCronJob_Shape pins the rendered CronJob: its scheduling guards, its
|
|
// run-as identity (felis-reaper WITH an auto-mounted token, because it legitimately
|
|
// calls the K8s API — unlike the weak Job/registry pods), the hardening, the
|
|
// entrypoint, and the three-mount storage crux (config RO, worlds hostPath RO at
|
|
// /worlds, backup PVC RW at ArchiveLocalPath). Shape-asserted, runtime-unverified.
|
|
func TestReaperCronJob_Shape(t *testing.T) {
|
|
p := reaperParams()
|
|
cj := reaperCronJob(p)
|
|
|
|
if cj.Kind != "CronJob" || cj.APIVersion != "batch/v1" {
|
|
t.Errorf("CronJob TypeMeta = %s/%s, want batch/v1 CronJob", cj.APIVersion, cj.Kind)
|
|
}
|
|
if cj.Name != SAReaper {
|
|
t.Errorf("CronJob name = %q, want %q", cj.Name, SAReaper)
|
|
}
|
|
if cj.Namespace != p.ControlNamespace {
|
|
t.Errorf("CronJob namespace = %q, want control ns %q", cj.Namespace, p.ControlNamespace)
|
|
}
|
|
|
|
spec := cj.Spec
|
|
if spec.Schedule == "" {
|
|
t.Error("CronJob must set a schedule")
|
|
}
|
|
if spec.ConcurrencyPolicy != batchv1.ForbidConcurrent {
|
|
t.Errorf("concurrencyPolicy = %q, want Forbid (retention runs must not overlap)", spec.ConcurrencyPolicy)
|
|
}
|
|
if spec.StartingDeadlineSeconds == nil {
|
|
t.Error("CronJob must set startingDeadlineSeconds (a missed run should still start, bounded)")
|
|
}
|
|
if spec.SuccessfulJobsHistoryLimit == nil || spec.FailedJobsHistoryLimit == nil {
|
|
t.Error("CronJob must bound job history")
|
|
}
|
|
js := spec.JobTemplate.Spec
|
|
if js.BackoffLimit == nil {
|
|
t.Error("Job must set backoffLimit")
|
|
}
|
|
if js.ActiveDeadlineSeconds == nil {
|
|
t.Error("Job must set activeDeadlineSeconds (a wedged run must not hold the Forbid lock forever)")
|
|
}
|
|
|
|
ps, c := cronPodSpec(t, cj)
|
|
if ps.RestartPolicy != corev1.RestartPolicyNever {
|
|
t.Errorf("pod restartPolicy = %q, want Never", ps.RestartPolicy)
|
|
}
|
|
if ps.ServiceAccountName != SAReaper {
|
|
t.Errorf("pod serviceAccountName = %q, want %q (it patches MinecraftServers and deletes PVCs)", ps.ServiceAccountName, SAReaper)
|
|
}
|
|
// The reaper LEGITIMATELY calls the K8s API, so — unlike the build/restore/
|
|
// registry pods — it must NOT disable the SA-token auto-mount.
|
|
if ps.AutomountServiceAccountToken != nil {
|
|
t.Errorf("reaper pod must auto-mount its SA token (got AutomountServiceAccountToken=%v); it needs the API", *ps.AutomountServiceAccountToken)
|
|
}
|
|
|
|
// Hardening mirrors the other control-plane pods.
|
|
if ps.SecurityContext == nil || ps.SecurityContext.RunAsNonRoot == nil || !*ps.SecurityContext.RunAsNonRoot {
|
|
t.Error("reaper pod must set runAsNonRoot=true")
|
|
}
|
|
if c.SecurityContext == nil || c.SecurityContext.ReadOnlyRootFilesystem == nil || !*c.SecurityContext.ReadOnlyRootFilesystem {
|
|
t.Error("reaper container must set readOnlyRootFilesystem=true")
|
|
}
|
|
if c.SecurityContext == nil || c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Drop) == 0 || c.SecurityContext.Capabilities.Drop[0] != "ALL" {
|
|
t.Error("reaper container must drop ALL capabilities")
|
|
}
|
|
|
|
// Entrypoint: `/usr/local/bin/felis reaper --config <cfg> --worlds-root /worlds`.
|
|
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "reaper"}) {
|
|
t.Errorf("reaper command/args = %v, want it to start `%s reaper`", got, felisBinaryPath)
|
|
}
|
|
if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) {
|
|
t.Errorf("reaper must read config at %s, got %v", configFilePath, c.Args)
|
|
}
|
|
if !contains(c.Args, "--worlds-root") || !contains(c.Args, worldsMountPath) {
|
|
t.Errorf("reaper must read worlds at %s, got %v", worldsMountPath, c.Args)
|
|
}
|
|
if c.Image != p.FelisImage {
|
|
t.Errorf("reaper image = %q, want FelisImage %q", c.Image, p.FelisImage)
|
|
}
|
|
|
|
// config: Secret, mounted read-only (it carries the DB URL).
|
|
cfgVol := volumeByName(ps.Volumes, configVolume)
|
|
if cfgVol == nil || cfgVol.Secret == nil || cfgVol.Secret.SecretName != configSecretName {
|
|
t.Errorf("config volume must be Secret %q", configSecretName)
|
|
}
|
|
if m := mountByName(c.VolumeMounts, configVolume); m == nil || !m.ReadOnly {
|
|
t.Error("config must be mounted read-only")
|
|
}
|
|
|
|
// worlds: node hostPath at WorldsHostPath, type Directory, mounted READ-ONLY at
|
|
// /worlds — the reaper only reads worlds to tar them (deletion is a PVC API call).
|
|
wVol := volumeByName(ps.Volumes, worldsVolume)
|
|
if wVol == nil || wVol.HostPath == nil || wVol.HostPath.Path != p.WorldsHostPath {
|
|
t.Errorf("worlds volume must be hostPath %q, got %+v", p.WorldsHostPath, wVol)
|
|
}
|
|
if wVol != nil && (wVol.HostPath == nil || wVol.HostPath.Type == nil || *wVol.HostPath.Type != corev1.HostPathDirectory) {
|
|
t.Error("worlds hostPath must be type Directory (fail loud if the dir is absent)")
|
|
}
|
|
if m := mountByName(c.VolumeMounts, worldsVolume); m == nil || m.MountPath != worldsMountPath || !m.ReadOnly {
|
|
t.Errorf("worlds must be mounted read-only at %s, got %+v", worldsMountPath, m)
|
|
}
|
|
|
|
// backup: PVC, mounted READ-WRITE at ArchiveLocalPath (== felis.toml [archive]
|
|
// local_path, so tarLocal's absolute archive refs resolve under it).
|
|
bVol := volumeByName(ps.Volumes, backupVolume)
|
|
if bVol == nil || bVol.PersistentVolumeClaim == nil || bVol.PersistentVolumeClaim.ClaimName != p.BackupPVC {
|
|
t.Errorf("backup volume must be PVC %q, got %+v", p.BackupPVC, bVol)
|
|
}
|
|
if m := mountByName(c.VolumeMounts, backupVolume); m == nil || m.MountPath != p.ArchiveLocalPath || m.ReadOnly {
|
|
t.Errorf("backup must be mounted read-write at ArchiveLocalPath %q, got %+v", p.ArchiveLocalPath, m)
|
|
}
|
|
|
|
// The reaper never opens RCON, so its pod labels must NOT satisfy the RCON peer.
|
|
if sel := rconPeerSelector(t, p); sel.Matches(labels.Set(cj.Spec.JobTemplate.Spec.Template.Labels)) {
|
|
t.Error("reaper pod labels must NOT satisfy the rcon peer (component not in {api,operator})")
|
|
}
|
|
}
|
|
|
|
// --- small env/volume helpers (test-local) ---
|
|
|
|
func envVar(env []corev1.EnvVar, name string) *corev1.EnvVar {
|
|
for i := range env {
|
|
if env[i].Name == name {
|
|
return &env[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envValue(env []corev1.EnvVar, name string) string {
|
|
if v := envVar(env, name); v != nil {
|
|
return v.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func volumeByName(vols []corev1.Volume, name string) *corev1.Volume {
|
|
for i := range vols {
|
|
if vols[i].Name == name {
|
|
return &vols[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mountByName(mounts []corev1.VolumeMount, name string) *corev1.VolumeMount {
|
|
for i := range mounts {
|
|
if mounts[i].Name == name {
|
|
return &mounts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func accessModeStrings(modes []corev1.PersistentVolumeAccessMode) []string {
|
|
out := make([]string, len(modes))
|
|
for i, m := range modes {
|
|
out[i] = string(m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// containsSeq reports whether sub appears as a contiguous prefix-anchored run at
|
|
// the START of seq (command then args), which is what we want for an entrypoint.
|
|
func containsSeq(seq, sub []string) bool {
|
|
if len(sub) > len(seq) {
|
|
return false
|
|
}
|
|
for i := range sub {
|
|
if seq[i] != sub[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|