feat(reaper): 未配置世界目录时渲染只清理备份库存的 CronJob,默认安装也每日删除过期备份,安装器与清单生成器说明回收开关状态

This commit is contained in:
Lemon-miaow committed 2026-09-25 03:42:55 +08:00
1 parent fe15b56074
commit 0770a4d676
12 files changed
+300 -93

No files matched your search

+1 -1
View File
@@ -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 登录**:支持指纹、面容、硬件密钥等无密码认证方式。
+17 -3
View File
@@ -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 <path>/<pvc>, or as the stock local-path directory <path>/<pv-name>_<ns>_<pvc-name> (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 {
"<path>/<pvc>, 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{
+39 -4
View File
@@ -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.
+32 -13
View File
@@ -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: <root>/<pvc>, else the stock local-path <root>/<pv-name>_<ns>_<pvc-name>)")
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)
}
+16 -8
View File
@@ -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)"
+8 -1
View File
@@ -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
+10 -1
View File
@@ -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` /
+4 -3
View File
@@ -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 {
+65 -37
View File
@@ -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 <WorldsHostPath>/<pvc>
// or in the stock local-path layout k3s writes under its storage root
// (<pv-name>_<ns>_<pvc-name>, 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
}
+63 -19
View File
@@ -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.
+18 -3
View File
@@ -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".
+27
View File
@@ -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)
}
}