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

+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.