feat(reaper): 未配置世界目录时渲染只清理备份库存的 CronJob,默认安装也每日删除过期备份,安装器与清单生成器说明回收开关状态
This commit is contained in:
12 files changed
+300
-93
No files matched your search
+17
-3
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user