diff --git a/README.md b/README.md index 21ad3d5..c0be761 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Kubernetes-driven Minecraft server hosting platform — one command to deploy, - **Web 控制面板**:浏览器中查看服务器状态、在线玩家与资源用量,管理备份与恢复。 - **备份与恢复**:一键把整服数据(世界、配置、插件/模组,即整个 /data 卷)打包进集群内的归档库,支持从任意备份点回滚;默认安装就已启用(归档 PVC 与路径由安装器一并生成)。 - **控制面数据库备份**:账号、服务器归属、配额与存档索引所在的数据库每天自动备份,每次升级迁移前先快照,出错可用 `felis db restore` 整库原子回滚;面板「维护与备份」页显示备份是否新鲜(见 [故障排查 §16](docs/troubleshooting.md))。 -- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。 +- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。过期备份无论是否开启都会每天清理。 - **多核心支持**:兼容 Paper、Fabric、Forge、NeoForge,经由 Velocity 代理统一入口。 - **模组自助提交**:玩家自行上传模组包,服主审批通过后自动构建;构建产物进入镜像白名单,可直接选用为服务器镜像完成部署。 - **Passkey 登录**:支持指纹、面容、硬件密钥等无密码认证方式。 diff --git a/cmd/felis/manifests.go b/cmd/felis/manifests.go index 1187eed..eeed8ee 100644 --- a/cmd/felis/manifests.go +++ b/cmd/felis/manifests.go @@ -48,7 +48,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry 2.8.3, pinned by digest)") backupPVC := fs.String("backup-pvc", "felis-backups", "name of the world-archive PVC this bundle renders in the Minecraft namespace and advertises to the backup/restore executors via FELIS_BACKUP_PVC (default: felis-backups; pass an empty value to render none, leaving backup/restore answering 503)") worldsHostPath := fs.String("worlds-host-path", "", "node directory the reaper reads worlds from: each world PVC resolves as /, or as the stock local-path directory /__ (k3s storage root: /var/lib/rancher/k3s/storage); enables the reaper CronJob (requires --archive-local-path and a non-empty --backup-pvc)") - archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [archive] local_path") + archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [archive] local_path. With the backup PVC alone it renders the retention-only CronJob, which deletes backups past their expiry and never touches a world") registryStorage := fs.String("registry-storage", "", "capacity the registry PVC requests (default 10Gi; k3s local-path does not enforce it)") uploadsStorage := fs.String("uploads-storage", "", "capacity the uploads PVC requests (default 5Gi; k3s local-path does not enforce it)") backupStorage := fs.String("backup-storage", "", "capacity the world-archive PVC requests (default 10Gi; k3s local-path does not enforce it)") @@ -138,8 +138,22 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { "/, or each candidate's archive fails and the world is preserved;\n"+ " - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin) } else { - fmt.Fprintln(stderr, "felis manifests: note: retention reaper CronJob not rendered "+ - "(pass --worlds-host-path and --archive-local-path — the archive PVC defaults to felis-backups — to enable it)") + switch { + case *archiveLocalPath != "" && *backupPVC == "": + fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+ + "but --backup-pvc is empty (no archive store renders); drop one or the other") + return 2 + case *archiveLocalPath != "": + fmt.Fprintln(stderr, "felis manifests: note: rendering the reaper CronJob retention-only: backups past "+ + "their expiry are deleted daily, idle worlds are never archived or deleted "+ + "(pass --worlds-host-path to reap them too)") + case *backupPVC != "": + fmt.Fprintln(stderr, "felis manifests: note: reaper CronJob not rendered: backups are never expired, so "+ + "the archive store only grows until the disk fills (pass --archive-local-path, equal to felis.toml "+ + "[archive] local_path, for the retention-only CronJob, and --worlds-host-path as well to reap idle worlds)") + default: + fmt.Fprintln(stderr, "felis manifests: note: reaper CronJob not rendered (backups are disabled)") + } } params := platform.Params{ diff --git a/cmd/felis/manifests_test.go b/cmd/felis/manifests_test.go index 6fd40d0..e93b774 100644 --- a/cmd/felis/manifests_test.go +++ b/cmd/felis/manifests_test.go @@ -90,12 +90,13 @@ func TestManifestsRendersBundle(t *testing.T) { t.Error("rendered bundle must not contain ClusterRole/ClusterRoleBinding") } // Without the retention flags, the reaper CronJob is not rendered and the - // generator says so on stderr. + // generator says so on stderr, naming what that leaves: backups that never + // expire. if strings.Contains(text, "kind: CronJob") { - t.Error("no reaper CronJob must render without --worlds-host-path") + t.Error("no reaper CronJob must render without --archive-local-path") } - if !strings.Contains(errBuf.String(), "not rendered") { - t.Errorf("expected a 'reaper not rendered' notice on stderr, got %q", errBuf.String()) + if !strings.Contains(errBuf.String(), "not rendered") || !strings.Contains(errBuf.String(), "never expired") { + t.Errorf("expected a 'reaper not rendered, backups never expired' notice on stderr, got %q", errBuf.String()) } } @@ -144,6 +145,40 @@ func TestManifestsBackupPVCOptOut(t *testing.T) { } } +// TestManifestsRendersRetentionOnly: the archive store without a worlds root +// still gets the daily CronJob, retention-only, so backups past their expiry +// leave the store on an install that never reaps a world; the operator is told +// which of the two it got. An archive path with the store switched off is a +// mistake and fails loud. +func TestManifestsRendersRetentionOnly(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", + "--archive-local-path", "/var/lib/felis/archives"}, &out, &errBuf) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, errBuf.String()) + } + text := out.String() + for _, want := range []string{"kind: CronJob", "name: felis-reaper", "--retention-only", "claimName: felis-backups"} { + if !strings.Contains(text, want) { + t.Errorf("retention-only bundle missing %q", want) + } + } + if strings.Contains(text, "kind: PersistentVolume\n") || strings.Contains(text, "--worlds-root") { + t.Error("a retention-only bundle must not reach for a worlds root") + } + if !strings.Contains(errBuf.String(), "retention-only") { + t.Errorf("stderr must say the CronJob is retention-only, got %q", errBuf.String()) + } + + out.Reset() + errBuf.Reset() + code = run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", + "--archive-local-path", "/var/lib/felis/archives", "--backup-pvc="}, &out, &errBuf) + if code != 2 || out.Len() != 0 || !strings.Contains(errBuf.String(), "--backup-pvc is empty") { + t.Errorf("archive path without an archive store: exit=%d out=%d bytes stderr=%q, want a fail-loud 2", code, out.Len(), errBuf.String()) + } +} + // TestManifestsRendersReaper proves the happy path with the full retention trio: // a batch/v1 CronJob is emitted, named felis-reaper, mounting the backup PVC at the // supplied archive path. diff --git a/cmd/felis/reaper.go b/cmd/felis/reaper.go index f9fde57..f0196ea 100644 --- a/cmd/felis/reaper.go +++ b/cmd/felis/reaper.go @@ -32,11 +32,17 @@ import ( // cadence, and RunOnce is idempotent and restart-safe, so a missed or retried // run simply converges. Only the tarLocal archive backend is wired in this // build; the snapshot backends (§19) are a later integration. +// +// --retention-only runs the archive-store half alone (reaper.RunRetention): +// the CronJob an install without a worlds root gets, so backups past their +// expiry still leave the store there. It builds no Kubernetes client and reads +// no world. func cmdReaper(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("reaper", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") worldsRoot := fs.String("worlds-root", "/worlds", "mount root under which world PVCs are visible (tarLocal: /, else the stock local-path /__)") + retentionOnly := fs.Bool("retention-only", false, "only expire, read back and sweep the archive store: no server is evaluated and no world is read or deleted, so neither the worlds root nor the Kubernetes API is needed") if err := fs.Parse(args); err != nil { return 2 } @@ -55,13 +61,16 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { ctx := ctrl.SetupSignalHandler() - scheme := runtime.NewScheme() - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha1.AddToScheme(scheme)) - cl, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme}) - if err != nil { - fmt.Fprintf(stderr, "felis reaper: build k8s client: %v\n", err) - return 1 + var cl client.Client + if !*retentionOnly { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + cl, err = client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme}) + if err != nil { + fmt.Fprintf(stderr, "felis reaper: build k8s client: %v\n", err) + return 1 + } } archiver, err := buildArchiver(ctx, cfg, *worldsRoot, cl) @@ -80,9 +89,13 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { r := &reaper.Reaper{ Cfg: rcfg, Store: reaper.NewPGStore(drv.DB()), - Cluster: reaper.NewK8sCluster(cl, cfg.K8s.Namespace), Archiver: archiver, } + if *retentionOnly { + fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released") + return reportReaperRun(r.RunRetention(ctx), stdout, stderr) + } + r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace) // Pre-reap warnings go out by email when [smtp] is configured (the same // relay and password_ref convention felis-api uses); without it the channel @@ -248,14 +261,20 @@ func reaperConfig(cfg *config.Config) (reaper.Config, error) { // buildArchiver constructs the WorldArchiver. Only tarLocal is implemented in // this build; the resolver maps each world PVC to its directory under worldsRoot -// (resolveWorldDir). +// (resolveWorldDir). A nil cl is the retention-only run, which never archives a +// world, so its archiver resolves none. func buildArchiver(ctx context.Context, cfg *config.Config, worldsRoot string, cl client.Client) (backup.WorldArchiver, error) { switch cfg.Archive.Store { case "tarLocal": - return &backup.TarLocal{ - BackupRoot: cfg.Archive.LocalPath, - Resolve: resolveWorldDir(ctx, cl, cfg.K8s.Namespace, worldsRoot), - }, nil + t := &backup.TarLocal{BackupRoot: cfg.Archive.LocalPath} + if cl != nil { + t.Resolve = resolveWorldDir(ctx, cl, cfg.K8s.Namespace, worldsRoot) + } else { + t.Resolve = func(pvc string) (string, error) { + return "", fmt.Errorf("resolve world PVC %s: this run has no worlds root (retention only)", pvc) + } + } + return t, nil default: return nil, fmt.Errorf("[archive] store %q is not implemented in this build (only tarLocal)", cfg.Archive.Store) } diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 3fbb066..8074204 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -95,10 +95,11 @@ # felis.toml [archive] local_path (default: /var/lib/felis/archives) # FELIS_WORLDS_HOST_PATH node directory holding the world volumes (on the k3s this # installer provisions: /var/lib/rancher/k3s/storage). Setting it -# enables the daily retention reaper, which archives and then deletes -# worlds idle beyond the retention window; the reaper reads that +# lets the daily reaper also archive and then delete worlds idle +# for 15 days (without it the reaper only deletes backups past +# their expiry and leaves every world); the reaper reads that # root as root, so it keeps k3s's own 0700 root:root -# (default: unset = no reaper) +# (default: unset = worlds are never reaped) # FELIS_REGISTRY_STORAGE / FELIS_UPLOADS_STORAGE / FELIS_BACKUP_STORAGE capacity the # registry, uploads and world-archive PVCs request on first install # (defaults: 10Gi, 5Gi, 10Gi). An existing claim keeps its size; on @@ -172,8 +173,8 @@ FELIS_BACKUP_STORAGE="${FELIS_BACKUP_STORAGE:-}" # Retention is opt-in because it DELETES worlds (after a verified archive): point this at the # node directory the world volumes live under. On the k3s this installer provisions that is # /var/lib/rancher/k3s/storage — the reaper resolves each PVC's local-path directory exactly -# from its volumeName. Left unset, no reaper CronJob renders and archives accumulate until -# the backup PVC fills (then backups fail loudly; nothing is deleted). +# from its volumeName. Left unset, the reaper CronJob renders retention-only: backups past +# their expiry are still deleted daily, and no world is ever archived or deleted. FELIS_WORLDS_HOST_PATH="${FELIS_WORLDS_HOST_PATH:-}" # k3s's local-path provisioner root. It appears with the first volume the provisioner # creates, which on a fresh install is after the reaper's PV has been applied. @@ -3462,8 +3463,15 @@ deploy_bundle() { else manifest_args+=(--backup-pvc=) fi - # Retention renders only when the operator names where the worlds live; the archive path - # always travels with it because it must equal the [archive] local_path written above. + # With an archive store the reaper always renders, since backups past their expiry have + # to leave it; it reaps idle worlds only when the operator names where the worlds live. + # The archive path must equal the [archive] local_path written above. + if [ -n "$FELIS_BACKUP_PVC" ]; then + manifest_args+=(--archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH") + if [ -z "$FELIS_WORLDS_HOST_PATH" ]; then + log "idle-world retention is off: the daily reaper deletes expired backups and keeps every world (set FELIS_WORLDS_HOST_PATH=${K3S_STORAGE_ROOT} to reap worlds idle for 15 days)" + fi + fi if [ -n "$FELIS_WORLDS_HOST_PATH" ]; then log "retention enabled: the daily reaper will read worlds from ${FELIS_WORLDS_HOST_PATH}" # The reaper reads this root as root with DAC_OVERRIDE (platform.reaperPodSecurityContext) @@ -3478,7 +3486,7 @@ deploy_bundle() { warn "worlds root ${FELIS_WORLDS_HOST_PATH} does not exist yet; the reaper CronJob cannot start until it does (hostPath type Directory)" fi fi - manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH" --archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH") + manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH") fi local size size="$(pvc_size "$CONTROL_NS" registry "$FELIS_REGISTRY_STORAGE" FELIS_REGISTRY_STORAGE)" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index bbdaba3..7a1ae62 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -949,8 +949,12 @@ run_bundle_flags() { # backup-pvc worlds-host-path out="$(run_bundle_flags felis-backups '')" expect "a default install asks the renderer for the archive PVC" "--backup-pvc felis-backups" "$out" +# data-durability-17: without a worlds root the reaper still renders, retention-only, +# so backups past their expiry leave the store; it needs the archive mount for that. +expect "a default install passes the archive mount so expired backups are deleted" "--archive-local-path +/var/lib/felis/archives" "$out" case "$out" in - *--worlds-host-path*) echo "FAIL: no reaper flags may render without FELIS_WORLDS_HOST_PATH"; fails=$((fails + 1)) ;; + *--worlds-host-path*) echo "FAIL: no worlds root may render without FELIS_WORLDS_HOST_PATH"; fails=$((fails + 1)) ;; esac expect "a known registry size reaches the renderer" "--registry-storage @@ -961,6 +965,9 @@ esac out="$(run_bundle_flags '' '')" expect "an emptied FELIS_BACKUP_PVC is the explicit no-backup shape" "--backup-pvc=" "$out" +case "$out" in + *--archive-local-path*) echo "FAIL: no archive mount may be passed without an archive PVC"; fails=$((fails + 1)) ;; +esac # Game server egress excludes every private range already; the node's own public # addresses must be excluded too or a server can dial the panel NodePort on them. expect "every global node address is denied to game server egress (v4)" "--server-egress-deny-cidr diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5dc3afa..cd41605 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -756,7 +756,16 @@ Registry manifest rendering is [GO-TESTED]; actual serving is ## 10. World reaper: false-deletes and skipped backups (spec §18) -The reaper is a **run-once daily CronJob batch**, not an operator controller. It +The reaper is a **run-once daily CronJob batch**, not an operator controller. +Every install with an archive store gets it. Without `FELIS_WORLDS_HOST_PATH` +it runs `felis reaper --retention-only`: it deletes backups past their expiry, +reads archives back and sweeps the store (the second half of "A failed reaper +Job" below), never looks at a server, and its summary shows `evaluated=0`. The +installer says so (`idle-world retention is off`). Setting the worlds root on a +re-run turns world reaping on. [GO-TESTED: `TestRunRetentionTouchesNoWorld`, +`TestReaperCronJob_Gating`, `TestReaperCronJob_RetentionOnlyShape`.] + +With a worlds root the reaper reaps a world only when `now - last_active_at > 15d` (`inactive_15d`); the 15-day deadline is **hard-fixed in code** (only `warn_before` / `retention` / `max_local_bytes` and the on-demand backup keys `manual_retention` / diff --git a/internal/platform/bundle.go b/internal/platform/bundle.go index b205409..0733b8f 100644 --- a/internal/platform/bundle.go +++ b/internal/platform/bundle.go @@ -39,9 +39,10 @@ type Object interface { // node-pressure eviction shield is the BUILT-IN system-cluster-critical // PriorityClass the pod templates reference (workloads.go controlPlanePriorityName), // not an object this bundle renders. -// The reaper CronJob is also part of Workloads, rendered only when the retention -// storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath — -// workloads.go documents the gate and the shape-asserted hostPath caveat). The +// The reaper CronJob is also part of Workloads: it reaps worlds when the full +// storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath) +// and only looks after the archive store when the worlds root is missing +// (workloads.go documents both gates and the shape-asserted hostPath caveat). The // per-server StatefulSet is never a static manifest — the operator renders it at // reconcile time (internal/operator). func Objects(p Params) []Object { diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 61911ff..10439c4 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -25,17 +25,20 @@ import ( // workloads_test.go evaluates those correspondences with the SAME selector // machinery K8s uses, because no cluster runs here. // -// The reaper CronJob (spec §18 three-clock retention) renders ONLY when the +// The reaper CronJob (spec §18 three-clock retention) reaps worlds ONLY when the // storage topology is supplied — WorldsHostPath + BackupPVC + ArchiveLocalPath, -// gated by reaperEnabled. It is opt-in-when-configured rather than always-on +// gated by reaperEnabled. World reaping is opt-in-when-configured rather than always-on // because archiving idle worlds means mounting where the worlds physically live, // and the spec keeps that open (§18/§19: tarLocal-on-local-path is the starter, // Longhorn/snapshot the documented evolution, and they do not share a mount // model). The starter model — a node-local worlds-root, exposed through a static // hostPath PV and mounted read-only — is the only one coherent with the operator's per-server // ReadWriteOnce world PVCs (a shared RWX worlds mount would contradict them), so -// that is what renders; when the trio is absent no CronJob is emitted, which is -// the fail-safe choice for a workload that deletes PVCs. The reaper resolver +// that is what renders; when the trio is absent no world is ever reaped, which is +// the fail-safe choice for a workload that deletes PVCs. With only the archive +// store configured (BackupPVC + ArchiveLocalPath) the CronJob still renders, in +// a retention-only shape that expires, reads back and sweeps backups and never +// touches a world (retentionEnabled). The reaper resolver // (cmd/felis/reaper.resolveWorldDir) finds worlds either as / // or in the stock local-path layout k3s writes under its storage root // (__, read from the live PVC), so pointing @@ -249,8 +252,11 @@ func Workloads(p Params) []Object { if p.BackupPVC != "" { objs = append(objs, backupPVC(p)) } - if reaperEnabled(p) { + switch { + case reaperEnabled(p): objs = append(objs, worldsRootPV(p), worldsRootPVC(p), reaperCronJob(p)) + case retentionEnabled(p): + objs = append(objs, reaperCronJob(p)) } return objs } @@ -289,7 +295,16 @@ const controlPlanePriorityName = "system-cluster-critical" // together so a partial configuration fails loudly rather than silently dropping // retention here. func reaperEnabled(p Params) bool { - return p.WorldsHostPath != "" && p.BackupPVC != "" && p.ArchiveLocalPath != "" + return p.WorldsHostPath != "" && retentionEnabled(p) +} + +// retentionEnabled reports whether the archive store can be looked after: the +// backup PVC and the path it must be mounted at. Without a worlds root the +// same CronJob renders in its retention-only shape (see reaperCronJob), so +// backups past their expiry still leave the store on an install that never +// reaps worlds; without it they would pile up until the node's disk filled. +func retentionEnabled(p Params) bool { + return p.BackupPVC != "" && p.ArchiveLocalPath != "" } // APIDeployment renders the felis-api Deployment (spec §7). It runs as the @@ -600,37 +615,26 @@ func OperatorDeployment(p Params) *appsv1.Deployment { // pod loud if the worlds-root is absent, rather than silently creating an empty dir // and archiving nothing. // -// Pre-conditions are the caller's: reaperCronJob assumes reaperEnabled(p) — it -// dereferences WorldsHostPath / BackupPVC / ArchiveLocalPath without re-checking. +// Retention only. With no WorldsHostPath the same CronJob runs `felis reaper +// --retention-only`: it expires, reads back and sweeps the archive store and +// never looks at a server. It then mounts no worlds root, reads no SMTP +// password (it sends no warnings) and gets no service account token, since it +// never calls the K8s API. It keeps the reaper's root + DAC_OVERRIDE identity: +// the archives it reads back and deletes were written by that identity, and by +// the backup Jobs. +// +// Pre-conditions are the caller's: reaperCronJob assumes retentionEnabled(p) — +// it dereferences BackupPVC / ArchiveLocalPath without re-checking. func reaperCronJob(p Params) *batchv1.CronJob { p = p.withDefaults() labels := controlPlanePodLabels(ComponentReaper) - container := corev1.Container{ Name: ComponentReaper, Image: p.FelisImage, Command: []string{felisBinaryPath, "reaper"}, - Args: []string{ - "--config", configFilePath, - "--worlds-root", worldsMountPath, - }, - // The [smtp] relay password for pre-reap warning emails — same optional - // Secret felis-api reads. Namespace caveat: a secretKeyRef is - // namespace-local, so this resolves against the minecraft-ns felis-smtp - // mirror that the "configure email" screen refreshes (the felis-config - // mirror it also refreshes is what puts [smtp] in this pod's config). - // Absent Secret ⇒ empty env ⇒ the reaper logs suppressed warnings - // instead of stamping them (never a failed pod). - Env: []corev1.EnvVar{ - {Name: SMTPPasswordEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: SMTPSecretName}, - Key: SMTPSecretPasswordKey, - Optional: boolPtr(true), - }}}, - }, + Args: []string{"--config", configFilePath, "--retention-only"}, VolumeMounts: []corev1.VolumeMount{ {Name: configVolume, MountPath: configMountPath, ReadOnly: true}, - {Name: worldsVolume, MountPath: worldsMountPath, ReadOnly: true}, {Name: backupVolume, MountPath: p.ArchiveLocalPath}, {Name: tmpVolume, MountPath: "/tmp"}, }, @@ -645,14 +649,6 @@ func reaperCronJob(p Params) *batchv1.CronJob { Secret: &corev1.SecretVolumeSource{SecretName: configSecretName}, }, }, - { - Name: worldsVolume, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: worldsRootName(p), ReadOnly: true, - }, - }, - }, { Name: backupVolume, VolumeSource: corev1.VolumeSource{ @@ -662,6 +658,34 @@ func reaperCronJob(p Params) *batchv1.CronJob { {Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, } + if p.WorldsHostPath != "" { + container.Args = []string{"--config", configFilePath, "--worlds-root", worldsMountPath} + // The [smtp] relay password for pre-reap warning emails — same optional + // Secret felis-api reads. Namespace caveat: a secretKeyRef is + // namespace-local, so this resolves against the minecraft-ns felis-smtp + // mirror that the "configure email" screen refreshes (the felis-config + // mirror it also refreshes is what puts [smtp] in this pod's config). + // Absent Secret ⇒ empty env ⇒ the reaper logs suppressed warnings + // instead of stamping them (never a failed pod). + container.Env = []corev1.EnvVar{ + {Name: SMTPPasswordEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: SMTPSecretName}, + Key: SMTPSecretPasswordKey, + Optional: boolPtr(true), + }}}, + } + container.VolumeMounts = append(container.VolumeMounts, + corev1.VolumeMount{Name: worldsVolume, MountPath: worldsMountPath, ReadOnly: true}) + volumes = append(volumes, corev1.Volume{ + Name: worldsVolume, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: worldsRootName(p), ReadOnly: true, + }, + }, + }) + } + return &batchv1.CronJob{ TypeMeta: metav1.TypeMeta{APIVersion: "batch/v1", Kind: "CronJob"}, // The CronJob lives in the MINECRAFT namespace: a Pod can only mount PVCs @@ -771,7 +795,8 @@ func worldsRootPVC(p Params) *corev1.PersistentVolumeClaim { // reaperPodSpec is the reaper Job's pod template. It lives apart from the CronJob // literal only so the optional node pin is one visible branch: with ReaperNode // set the pod carries a kubernetes.io/hostname selector, keeping the reaper on -// the node that actually holds the worlds hostPath on a multi-node cluster. +// the node that actually holds the worlds hostPath on a multi-node cluster. The +// retention-only shape gets no service account token: it never calls the API. func reaperPodSpec(p Params, container corev1.Container, volumes []corev1.Volume) corev1.PodSpec { spec := corev1.PodSpec{ ServiceAccountName: SAReaper, @@ -784,6 +809,9 @@ func reaperPodSpec(p Params, container corev1.Container, volumes []corev1.Volume if p.ReaperNode != "" { spec.NodeSelector = map[string]string{"kubernetes.io/hostname": p.ReaperNode} } + if p.WorldsHostPath == "" { + spec.AutomountServiceAccountToken = boolPtr(false) + } return spec } diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index e6a44d3..1fdc67f 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -2,6 +2,7 @@ package platform import ( "fmt" + "reflect" "strings" "testing" @@ -785,40 +786,83 @@ func reaperParams() Params { 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). +// TestReaperCronJob_Gating proves the reaper reaps iff all three storage +// coordinates are present, and that the archive store alone (backup PVC + its +// mount path) still renders the CronJob in its retention-only shape, with no +// worlds-root pair: backups must expire on an install that never reaps worlds. +// Anything less renders none (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 + name string + mutate func(p *Params) + reap, cron 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}, + {"none", func(p *Params) {}, false, false}, + {"worlds only", func(p *Params) { p.WorldsHostPath = "/w" }, false, false}, + {"worlds+backup", func(p *Params) { p.WorldsHostPath = "/w"; p.BackupPVC = "b" }, false, false}, + {"worlds+archive", func(p *Params) { p.WorldsHostPath = "/w"; p.ArchiveLocalPath = "/a" }, false, false}, + {"backup+archive (no worlds)", func(p *Params) { p.BackupPVC = "b"; p.ArchiveLocalPath = "/a" }, false, true}, + {"all three", func(p *Params) { p.WorldsHostPath = "/w"; p.BackupPVC = "b"; p.ArchiveLocalPath = "/a" }, true, 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) + if got := reaperEnabled(p); got != c.reap { + t.Errorf("reaperEnabled = %v, want %v", got, c.reap) } - cj := findCronJob(Workloads(p)) - if c.want && cj == nil { - t.Error("CronJob must be in Workloads when enabled") + objs := Workloads(p) + cj := findCronJob(objs) + if c.cron != (cj != nil) { + t.Fatalf("CronJob rendered = %v, want %v", cj != nil, c.cron) } - if !c.want && cj != nil { - t.Error("CronJob must NOT be in Workloads when disabled") + var pv bool + for _, o := range objs { + if _, ok := o.(*corev1.PersistentVolume); ok { + pv = true + } + } + if pv != c.reap { + t.Errorf("worlds-root PV rendered = %v, want %v", pv, c.reap) + } + if cj != nil { + _, ctr := cronPodSpec(t, cj) + if got := contains(ctr.Args, "--retention-only"); got == c.reap { + t.Errorf("args = %v: --retention-only must be passed exactly when no world is reaped", ctr.Args) + } } }) } } +// TestReaperCronJob_RetentionOnlyShape pins what the store-only run is left +// with: the config and the archive store, and nothing that reaches a world or +// the API — no worlds mount, no SMTP password, no service account token. +func TestReaperCronJob_RetentionOnlyShape(t *testing.T) { + p := reaperParams() + p.WorldsHostPath = "" + ps, c := cronPodSpec(t, reaperCronJob(p)) + if want := []string{"--config", configFilePath, "--retention-only"}; !reflect.DeepEqual(c.Args, want) { + t.Errorf("args = %v, want %v", c.Args, want) + } + if volumeByName(ps.Volumes, worldsVolume) != nil || mountByName(c.VolumeMounts, worldsVolume) != nil { + t.Error("a retention-only run must not mount a worlds root") + } + 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) + } + if len(c.Env) != 0 { + t.Errorf("env = %v, want none (it sends no warnings)", c.Env) + } + if ps.AutomountServiceAccountToken == nil || *ps.AutomountServiceAccountToken { + t.Error("a retention-only run never calls the API and must not mount a service account token") + } + if c.SecurityContext == nil || c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Add) != 1 || c.SecurityContext.Capabilities.Add[0] != "DAC_OVERRIDE" { + t.Error("it reads back and deletes archives other identities wrote, so it keeps DAC_OVERRIDE") + } +} + // TestReaperCronJob_NodePin proves the optional multi-node pin: no selector by // default (the single-node starter), and exactly the kubernetes.io/hostname // selector when ReaperNode names the node holding the worlds hostPath. diff --git a/internal/reaper/reaper.go b/internal/reaper/reaper.go index 4dd5633..dc1ed20 100644 --- a/internal/reaper/reaper.go +++ b/internal/reaper/reaper.go @@ -413,12 +413,27 @@ func (r *Reaper) RunOnce(ctx context.Context) (Summary, error) { } } - r.expireBackups(ctx, now, &sum) - r.verifyBackups(ctx, now, &sum) - r.sweepArchives(ctx, now, &sum) + r.retain(ctx, now, &sum) return sum, nil } +// RunRetention is the archive-store half of RunOnce on its own: backups past +// their expiry are deleted, archives are read back, and leftovers are swept, +// while no server is looked at and no world is touched. It needs no Cluster, +// which is what lets it run where the worlds are out of reach (an install with +// no worlds root): backups still leave the store when they expire there. +func (r *Reaper) RunRetention(ctx context.Context) Summary { + var sum Summary + r.retain(ctx, r.now(), &sum) + return sum +} + +func (r *Reaper) retain(ctx context.Context, now time.Time, sum *Summary) { + r.expireBackups(ctx, now, sum) + r.verifyBackups(ctx, now, sum) + r.sweepArchives(ctx, now, sum) +} + // evaluate handles one server: exemption, reap, or warning. A returned error // means the server was skipped (counted by the caller); nil covers the normal // outcomes including "exempt" and "warned". diff --git a/internal/reaper/reaper_test.go b/internal/reaper/reaper_test.go index dc2aa61..3493fe7 100644 --- a/internal/reaper/reaper_test.go +++ b/internal/reaper/reaper_test.go @@ -1222,3 +1222,30 @@ func TestReapFinishesInterruptedReap(t *testing.T) { t.Fatalf("owner %q audits %+v", st.byName["lambda"].OwnerID, st.audits) } } + +// data-durability-17: with no worlds root the store is still looked after. +// RunRetention expires, reads back and sweeps without listing a server or +// reaching the cluster (nil here, so any call would panic). +func TestRunRetentionTouchesNoWorld(t *testing.T) { + r, st, _, ar := newReaper(DefaultConfig(), + Candidate{Name: "idle", OwnerID: "user-1", LastActiveAt: idleBy(40 * Day)}) + r.Cluster = nil + st.listErr = errors.New("servers must not be listed") + ca := checking(r, ar) + ca.sweepRemoved = []string{"/archives/.x-1.tar.gz.partial"} + st.backups = []*fakeBackup{ + {id: "gone", server: "s1", ref: "ref-gone", size: 5, status: "present", createdAt: idleBy(120 * Day), expires: idleBy(1 * Day)}, + {id: "keep", server: "s2", ref: "ref-keep", size: 5, status: "present", createdAt: idleBy(10 * Day), expires: testNow.Add(80 * Day)}, + } + + sum := r.RunRetention(context.Background()) + if sum.Evaluated != 0 || sum.WorldsReaped != 0 || ar.archives != 0 { + t.Fatalf("retention looked at servers: %+v", sum) + } + if sum.BackupsExpired != 1 || sum.Verified != 1 || sum.Swept != 1 || sum.Failed() { + t.Fatalf("summary = %+v, want 1 expired, 1 read back, 1 swept", sum) + } + if len(ar.deletes) != 1 || ar.deletes[0] != "ref-gone" { + t.Fatalf("deleted archives = %v, want [ref-gone]", ar.deletes) + } +}