feat(backup): add backup, restore, and reaper subsystems

Archive-based world backup and restore, plus the reaper that enforces retention and reclaims idle servers.
This commit is contained in:
flyemoji committed 2026-06-26 23:31:58 +09:00
1 parent 708cdfc5b8
commit 43ab92151f
12 files changed
+2749

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
// Package backup implements the WorldArchiver abstraction (spec §19). The
// reaper and the restore endpoint speak only to the interface and never learn
// whether the backend is a tar file, a VolumeSnapshot, or a Longhorn backup —
// ArchiveRef is deliberately opaque.
package backup
import "context"
// ArchiveRef is an opaque handle to a stored world archive. Depending on the
// backend it may be a tar path, a VolumeSnapshot name, or a Longhorn backup URL.
type ArchiveRef string
// WorldArchiver archives, restores, and deletes a server's world. The signature
// is intentionally "archive a world" rather than "write bytes": snapshot
// backends (VolumeSnapshot/Longhorn) cannot produce an io.Reader — they create
// K8s objects referencing the source PVC (spec §19).
type WorldArchiver interface {
// Archive captures the world living on pvc for server and returns an opaque
// ref plus the stored size in bytes.
Archive(ctx context.Context, server, pvc string) (ref ArchiveRef, size int64, err error)
// Restore writes a previously archived world into targetPVC.
Restore(ctx context.Context, ref ArchiveRef, targetPVC string) error
// Delete removes the archive identified by ref.
Delete(ctx context.Context, ref ArchiveRef) error
}
// PVCResolver maps a PVC name to the local filesystem path where it is mounted.
// In production the reaper Job mounts the source/backup PVCs and supplies a
// resolver over those mount points; tests supply temp dirs.
type PVCResolver func(pvc string) (string, error)
// StaticResolver resolves PVC names from a fixed map, erroring on unknown names.
func StaticResolver(paths map[string]string) PVCResolver {
return func(pvc string) (string, error) {
if p, ok := paths[pvc]; ok {
return p, nil
}
return "", &UnknownPVCError{PVC: pvc}
}
}
// UnknownPVCError is returned when a resolver cannot map a PVC name.
type UnknownPVCError struct{ PVC string }
func (e *UnknownPVCError) Error() string { return "backup: unknown pvc " + e.PVC }
+286
View File
@@ -0,0 +1,286 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// TarLocal is the zero-storageClass-requirement backend (spec §19): it mounts
// the source PVC, tars+gzips it, and writes the archive into the backup PVC.
// It runs on any StorageClass, including hostPath-style local-path, where the
// snapshot backends cannot.
type TarLocal struct {
// BackupRoot is the directory (backup PVC mount) archives are written into.
BackupRoot string
// Resolve maps a PVC name to its mounted filesystem path.
Resolve PVCResolver
// Now is injectable for deterministic archive names in tests.
Now func() time.Time
}
func (t *TarLocal) now() time.Time {
if t.Now != nil {
return t.Now()
}
return time.Now()
}
// Archive tars+gzips the world on pvc into BackupRoot and returns the archive
// path as the opaque ref plus its on-disk size.
func (t *TarLocal) Archive(ctx context.Context, server, pvc string) (ArchiveRef, int64, error) {
srcDir, err := t.Resolve(pvc)
if err != nil {
return "", 0, err
}
if err := os.MkdirAll(t.BackupRoot, 0o750); err != nil {
return "", 0, fmt.Errorf("backup: mkdir backup root: %w", err)
}
name := fmt.Sprintf("%s-%d.tar.gz", server, t.now().UTC().UnixNano())
dest := filepath.Join(t.BackupRoot, name)
f, err := os.Create(dest)
if err != nil {
return "", 0, fmt.Errorf("backup: create archive: %w", err)
}
if err := writeTarGz(ctx, f, srcDir); err != nil {
f.Close()
os.Remove(dest)
return "", 0, err
}
if err := f.Close(); err != nil {
os.Remove(dest)
return "", 0, fmt.Errorf("backup: close archive: %w", err)
}
info, err := os.Stat(dest)
if err != nil {
return "", 0, fmt.Errorf("backup: stat archive: %w", err)
}
return ArchiveRef(dest), info.Size(), nil
}
// Restore extracts the archive at ref into the world mount for targetPVC,
// replacing the target's contents so the world equals the archive (spec §466
// "restore PVC": a rollback must not leave stale files the backup lacks — e.g. a
// griefer's chunks). It extracts over the target, then removes any pre-existing
// entry the archive did not contain.
//
// The prune runs only after a fully successful extract: a corrupt or truncated
// archive fails before the prune, leaving the target as a (recoverable) partial
// overlay rather than a destroyed world. The archive is retained on restore, so
// such a failure is recoverable by re-running the Job.
//
// A top-level lost+found is never a prune target. It is a filesystem artifact
// (root-owned, mode 0700) that the non-root restore Pod cannot delete anyway,
// and writeTarGz includes it in the archive, so it is preserved on both axes.
func (t *TarLocal) Restore(ctx context.Context, ref ArchiveRef, targetPVC string) error {
dstDir, err := t.Resolve(targetPVC)
if err != nil {
return err
}
if err := os.MkdirAll(dstDir, 0o750); err != nil {
return fmt.Errorf("backup: mkdir restore target: %w", err)
}
f, err := os.Open(string(ref))
if err != nil {
return fmt.Errorf("backup: open archive: %w", err)
}
defer f.Close()
keep, err := readTarGz(ctx, f, dstDir)
if err != nil {
return err
}
return pruneToManifest(dstDir, keep)
}
// pruneToManifest removes every entry under dstDir whose archive-relative path
// is absent from keep, giving Restore replace semantics. keep holds cleaned,
// forward-slash relative paths (no trailing slash) for every archive entry plus
// all of their ancestor directories, so a kept file's parent dirs are never
// removed. A top-level lost+found is always kept. dstDir (the mount root) is
// never removed.
func pruneToManifest(dstDir string, keep map[string]struct{}) error {
cleanDst := filepath.Clean(dstDir)
return filepath.WalkDir(cleanDst, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == cleanDst {
return nil // never remove the mount root itself
}
relNative, err := filepath.Rel(cleanDst, p)
if err != nil {
return err
}
rel := filepath.ToSlash(relNative)
if rel == "lost+found" {
if d.IsDir() {
return filepath.SkipDir // filesystem artifact: keep and don't descend
}
return nil
}
if _, ok := keep[rel]; ok {
return nil // the archive contained this path: keep it
}
// Stale: present in the target but absent from the archive.
if err := os.RemoveAll(p); err != nil {
return fmt.Errorf("backup: prune stale entry %q: %w", rel, err)
}
if d.IsDir() {
return filepath.SkipDir // already removed; don't descend into it
}
return nil
})
}
// Delete removes the tar archive at ref.
func (t *TarLocal) Delete(_ context.Context, ref ArchiveRef) error {
if err := os.Remove(string(ref)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: delete archive: %w", err)
}
return nil
}
func writeTarGz(ctx context.Context, w io.Writer, srcDir string) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
root := filepath.Clean(srcDir)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil // don't archive the root entry itself
}
// Normalize to forward slashes so archives are portable.
name := filepath.ToSlash(rel)
switch {
case info.IsDir():
hdr := &tar.Header{Name: name + "/", Mode: 0o750, Typeflag: tar.TypeDir}
return tw.WriteHeader(hdr)
case info.Mode().IsRegular():
hdr := &tar.Header{Name: name, Mode: 0o640, Size: info.Size(), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(tw, src)
return err
default:
// Skip symlinks/devices/sockets: a world directory should be plain
// files, and refusing the rest avoids surprising archive contents.
return nil
}
})
if err != nil {
return fmt.Errorf("backup: tar walk: %w", err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("backup: close tar: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("backup: close gzip: %w", err)
}
return nil
}
// readTarGz extracts the gzip+tar stream into dstDir and returns the keep-set:
// the cleaned, forward-slash relative path of every entry the archive contained
// plus all of their ancestor directories. The caller uses it to prune stale
// target files for replace semantics. On any error the keep-set is incomplete
// and must not be used to prune (a partial manifest would delete live files the
// stream had not yet reached).
func readTarGz(ctx context.Context, r io.Reader, dstDir string) (map[string]struct{}, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("backup: open gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
keep := make(map[string]struct{})
cleanDst := filepath.Clean(dstDir)
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
hdr, err := tr.Next()
if err == io.EOF {
return keep, nil
}
if err != nil {
return nil, fmt.Errorf("backup: read tar: %w", err)
}
// Guard against path traversal (zip-slip): the resolved target must stay
// within dstDir.
target := filepath.Join(cleanDst, filepath.FromSlash(hdr.Name))
if target != cleanDst && !strings.HasPrefix(target, cleanDst+string(os.PathSeparator)) {
return nil, fmt.Errorf("backup: archive entry escapes target: %q", hdr.Name)
}
// Record this entry and its ancestors in the keep-set. Names are stored
// in archive form (forward slash, no trailing slash) to match the
// relative paths pruneToManifest derives from the on-disk walk.
rememberKept(keep, hdr.Name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o750); err != nil {
return nil, err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return nil, err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return nil, err
}
if err := out.Close(); err != nil {
return nil, err
}
default:
// Ignore entry types tarLocal never writes.
}
}
}
// rememberKept adds an archive entry name and every ancestor directory to keep,
// normalized to a cleaned forward-slash path with no trailing slash. Adding
// ancestors guards against archives that list a file without an explicit entry
// for its parent dir: the dir must still survive the prune.
func rememberKept(keep map[string]struct{}, name string) {
rel := path.Clean(strings.TrimSuffix(name, "/"))
for rel != "." && rel != "/" && rel != "" {
keep[rel] = struct{}{}
rel = path.Dir(rel)
}
}
+224
View File
@@ -0,0 +1,224 @@
package backup_test
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
"felis.lolicon.best/internal/backup"
)
// writeTree creates files (path->content) under root.
func writeTree(t *testing.T, root string, files map[string]string) {
t.Helper()
for rel, content := range files {
full := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
}
func TestTarLocalRoundTrip(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
backupRoot := t.TempDir()
want := map[string]string{
"level.dat": "world-seed-and-spawn",
"region/r.0.0.mca": "chunk-bytes-aaaa",
"data/scoreboard.dat": "{}",
"playerdata/uuid.dat": "player-state",
}
writeTree(t, src, want)
archiver := &backup.TarLocal{
BackupRoot: backupRoot,
Resolve: backup.StaticResolver(map[string]string{
"src-pvc": src,
"dst-pvc": dst,
}),
}
ctx := context.Background()
ref, size, err := archiver.Archive(ctx, "survival", "src-pvc")
if err != nil {
t.Fatalf("Archive: %v", err)
}
if size <= 0 {
t.Errorf("archive size = %d, want > 0", size)
}
if _, err := os.Stat(string(ref)); err != nil {
t.Fatalf("archive file missing: %v", err)
}
if err := archiver.Restore(ctx, ref, "dst-pvc"); err != nil {
t.Fatalf("Restore: %v", err)
}
for rel, content := range want {
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
if err != nil {
t.Errorf("restored file %s missing: %v", rel, err)
continue
}
if string(got) != content {
t.Errorf("restored %s = %q, want %q", rel, got, content)
}
}
if err := archiver.Delete(ctx, ref); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(string(ref)); !os.IsNotExist(err) {
t.Errorf("archive still present after Delete: %v", err)
}
// Delete of an already-gone archive is a no-op.
if err := archiver.Delete(ctx, ref); err != nil {
t.Errorf("second Delete should be a no-op, got %v", err)
}
}
// TestTarLocalRestoreReplacesTarget pins replace semantics (spec §466): after a
// restore the world must equal the archive, not be merged onto whatever the
// target already held. It restores over a populated target and asserts that
//
// (a) archive files are present with the archive's content (overwriting stale
// copies),
// (b) files the archive did not contain are gone — including a stale chunk
// inside a directory the archive *does* keep, which proves per-file prune
// within a surviving dir and exercises the rel-path normalization, and
// (c) a pre-existing lost+found/ with a file inside survives untouched — the
// never-delete invariant for the filesystem artifact a non-root restore
// Pod cannot remove.
//
// Honesty: this runs as the test user (which *can* delete anything), so it
// proves the prune logic and the lost+found skip but does NOT exercise the
// non-root / FSGroup runtime path. "Restore works as a non-root Pod on a real
// ext4 PVC" remains code-complete-but-unverified (same bucket as the K8s E2E).
func TestTarLocalRestoreReplacesTarget(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
backupRoot := t.TempDir()
archived := map[string]string{
"level.dat": "new-seed",
"region/r.0.0.mca": "good-chunk-00",
"region/nested/a.mca": "good-chunk-nested",
"playerdata/uuid.dat": "player-state",
}
writeTree(t, src, archived)
// The target already holds an older, divergent world: a stale copy of a file
// the archive also has, a griefer chunk inside a kept dir, and a whole stale
// directory the archive never mentions.
writeTree(t, dst, map[string]string{
"level.dat": "OLD-seed-overwrite-me",
"region/r.9.9.mca": "griefer-chunk-must-vanish",
"oldworld/junk.dat": "whole-stale-dir-must-vanish",
})
// A pre-existing lost+found with content the prune must never touch.
if err := os.MkdirAll(filepath.Join(dst, "lost+found"), 0o700); err != nil {
t.Fatalf("mkdir lost+found: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "lost+found", "0001"), []byte("fsck-recovered"), 0o600); err != nil {
t.Fatalf("seed lost+found: %v", err)
}
archiver := &backup.TarLocal{
BackupRoot: backupRoot,
Resolve: backup.StaticResolver(map[string]string{
"src-pvc": src,
"dst-pvc": dst,
}),
}
ctx := context.Background()
ref, _, err := archiver.Archive(ctx, "survival", "src-pvc")
if err != nil {
t.Fatalf("Archive: %v", err)
}
if err := archiver.Restore(ctx, ref, "dst-pvc"); err != nil {
t.Fatalf("Restore: %v", err)
}
// (a) Every archive file present with the archive's content.
for rel, want := range archived {
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
if err != nil {
t.Errorf("archive file %s missing after restore: %v", rel, err)
continue
}
if string(got) != want {
t.Errorf("restored %s = %q, want %q", rel, got, want)
}
}
// (b) Files absent from the archive are gone — both the stale chunk inside the
// kept region/ dir and the whole stale directory.
for _, gone := range []string{"region/r.9.9.mca", "oldworld/junk.dat", "oldworld"} {
if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(gone))); !os.IsNotExist(err) {
t.Errorf("stale entry %s survived the restore (err=%v); replace semantics broken", gone, err)
}
}
// (c) lost+found and its contents survive untouched.
lf, err := os.ReadFile(filepath.Join(dst, "lost+found", "0001"))
if err != nil {
t.Errorf("lost+found content was removed: %v", err)
} else if string(lf) != "fsck-recovered" {
t.Errorf("lost+found content = %q, want %q", lf, "fsck-recovered")
}
}
func TestTarLocalUnknownPVC(t *testing.T) {
archiver := &backup.TarLocal{
BackupRoot: t.TempDir(),
Resolve: backup.StaticResolver(map[string]string{}),
}
if _, _, err := archiver.Archive(context.Background(), "x", "missing"); err == nil {
t.Fatal("expected error for unknown pvc")
}
}
// TestTarLocalRejectsZipSlip crafts a malicious archive whose entry escapes the
// target directory and asserts Restore refuses it.
func TestTarLocalRejectsZipSlip(t *testing.T) {
backupRoot := t.TempDir()
dst := t.TempDir()
evil := filepath.Join(backupRoot, "evil.tar.gz")
f, err := os.Create(evil)
if err != nil {
t.Fatalf("create evil archive: %v", err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
body := []byte("pwned")
if err := tw.WriteHeader(&tar.Header{Name: "../escape.txt", Mode: 0o640, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("write header: %v", err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write body: %v", err)
}
tw.Close()
gz.Close()
f.Close()
archiver := &backup.TarLocal{
BackupRoot: backupRoot,
Resolve: backup.StaticResolver(map[string]string{"dst-pvc": dst}),
}
if err := archiver.Restore(context.Background(), backup.ArchiveRef(evil), "dst-pvc"); err == nil {
t.Fatal("Restore must reject a path-traversal archive")
}
// Ensure nothing was written outside the target.
if _, err := os.Stat(filepath.Join(filepath.Dir(dst), "escape.txt")); !os.IsNotExist(err) {
t.Errorf("zip-slip wrote outside target: %v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package reaper
import (
"context"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// WorldPVCName returns the world PVC name for a server. The convention
// ("world-<name>-0") is owned by internal/naming because the operator, the
// reaper, and restore all depend on it; this is a thin alias kept so existing
// reaper call sites read naturally.
func WorldPVCName(server string) string {
return naming.WorldPVCName(server)
}
// K8sCluster is the production Cluster backed by a controller-runtime client
// (spec §4, §18). It reads spec.reaperExempt, deletes the world PVC, and flips
// spec.desiredState to Stopped — nothing else. It is integration-tested against
// a live cluster, not the hermetic reaper_test.go suite.
type K8sCluster struct {
c client.Client
namespace string
}
// NewK8sCluster builds a Cluster over c, scoped to namespace.
func NewK8sCluster(c client.Client, namespace string) *K8sCluster {
return &K8sCluster{c: c, namespace: namespace}
}
func (k *K8sCluster) Inspect(ctx context.Context, name string) (ServerCRD, error) {
var ms v1alpha1.MinecraftServer
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
if apierrors.IsNotFound(err) {
return ServerCRD{}, ErrNotFound
}
return ServerCRD{}, err
}
return ServerCRD{Exempt: ms.Spec.ReaperExempt, PVC: WorldPVCName(name)}, nil
}
// DeletePVC deletes the world PersistentVolumeClaim. A missing PVC is not an
// error: the reap is idempotent and a re-run after a partial failure must still
// converge.
func (k *K8sCluster) DeletePVC(ctx context.Context, pvc string) error {
obj := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Namespace: k.namespace, Name: pvc},
}
if err := k.c.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
}
// Stop sets spec.desiredState=Stopped with a merge patch so a concurrent status
// write by the operator is never clobbered (spec §9.1).
func (k *K8sCluster) Stop(ctx context.Context, name string) error {
var ms v1alpha1.MinecraftServer
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
if apierrors.IsNotFound(err) {
return ErrNotFound
}
return err
}
patch := client.MergeFrom(ms.DeepCopy())
ms.Spec.DesiredState = v1alpha1.DesiredStopped
return k.c.Patch(ctx, &ms, patch)
}
+155
View File
@@ -0,0 +1,155 @@
package reaper
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// PGStore is the production Store backed by Postgres (spec §6, §18). The SQL
// here is exercised by integration tests against a live database, not the
// hermetic reaper_test.go suite. Every statement is the narrow operation §18
// requires; there is no generic UPDATE escape hatch.
type PGStore struct {
db *sql.DB
}
// NewPGStore wraps an existing pool (from store.PostgresDriver.DB()).
func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) {
const q = `SELECT name, owner_id, last_active_at, warned_3d_at, warned_1d_at
FROM servers WHERE deleted_at IS NULL ORDER BY name`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Candidate
for rows.Next() {
var (
c Candidate
owner sql.NullString
w3, w1 sql.NullTime
)
if err := rows.Scan(&c.Name, &owner, &c.LastActiveAt, &w3, &w1); err != nil {
return nil, err
}
c.OwnerID = owner.String
if w3.Valid {
c.Warned3dAt = w3.Time
}
if w1.Valid {
c.Warned1dAt = w1.Time
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *PGStore) FreshBackup(ctx context.Context, server string, since time.Time) (string, bool, error) {
const q = `SELECT backup_ref FROM world_backups
WHERE server_name = $1 AND status = 'present' AND created_at >= $2
ORDER BY created_at DESC LIMIT 1`
var ref string
switch err := s.db.QueryRowContext(ctx, q, server, since).Scan(&ref); {
case err == sql.ErrNoRows:
return "", false, nil
case err != nil:
return "", false, err
}
return ref, true, nil
}
func (s *PGStore) InsertBackup(ctx context.Context, rec BackupRecord) error {
const q = `INSERT INTO world_backups
(id, server_name, former_owner, backup_ref, size_bytes, reason, status, created_at, expires_at)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, 'present', now(), $7)`
_, err := s.db.ExecContext(ctx, q,
rec.ID, rec.ServerName, rec.FormerOwner, rec.BackupRef, rec.SizeBytes, rec.Reason, rec.ExpiresAt)
return err
}
// ReleaseWorld releases ownership and resets the activity clock and warnings —
// without deleting the row (red line ②).
func (s *PGStore) ReleaseWorld(ctx context.Context, name string, at time.Time) error {
const q = `UPDATE servers
SET owner_id = NULL, last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL
WHERE name = $1 AND deleted_at IS NULL`
_, err := s.db.ExecContext(ctx, q, name, at)
return err
}
func (s *PGStore) MarkWarned(ctx context.Context, name string, tier Tier, at time.Time) error {
// The column is one of two fixed identifiers, never user input.
col := "warned_3d_at"
if tier == Tier1d {
col = "warned_1d_at"
}
q := fmt.Sprintf(`UPDATE servers SET %s = $2 WHERE name = $1 AND deleted_at IS NULL`, col)
_, err := s.db.ExecContext(ctx, q, name, at)
return err
}
func (s *PGStore) PresentBackupBytes(ctx context.Context) (int64, error) {
var n int64
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(size_bytes), 0) FROM world_backups WHERE status = 'present'`).Scan(&n)
return n, err
}
func (s *PGStore) OldestPresentBackups(ctx context.Context) ([]StoredBackup, error) {
const q = `SELECT id, server_name, backup_ref, size_bytes FROM world_backups
WHERE status = 'present' ORDER BY created_at ASC`
return s.queryBackups(ctx, q)
}
func (s *PGStore) ListExpiredBackups(ctx context.Context, now time.Time) ([]StoredBackup, error) {
const q = `SELECT id, server_name, backup_ref, size_bytes FROM world_backups
WHERE status = 'present' AND expires_at < $1 ORDER BY expires_at ASC`
return s.queryBackups(ctx, q, now)
}
func (s *PGStore) queryBackups(ctx context.Context, q string, args ...any) ([]StoredBackup, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StoredBackup
for rows.Next() {
var b StoredBackup
if err := rows.Scan(&b.ID, &b.ServerName, &b.BackupRef, &b.SizeBytes); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *PGStore) MarkBackupDeleted(ctx context.Context, id string, at time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE world_backups SET status = 'deleted', deleted_at = $2 WHERE id = $1`, id, at)
return err
}
// Audit writes a reaper-sourced row. The actor/source are the system identity
// "reaper" (no human Access email applies, spec §14); former_owner has no
// dedicated column so it goes into the payload jsonb.
func (s *PGStore) Audit(ctx context.Context, rec AuditRecord) error {
payload := []byte("{}")
if rec.FormerOwner != "" {
p, err := json.Marshal(map[string]string{"former_owner": rec.FormerOwner})
if err != nil {
return err
}
payload = p
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_logs (actor, source, action, server_name, payload)
VALUES ('reaper', 'reaper', $1, NULLIF($2, ''), $3)`,
rec.Action, rec.ServerName, string(payload))
return err
}
+491
View File
@@ -0,0 +1,491 @@
// Package reaper implements the world reaper / three-clock retention batch
// (spec §18). It is the only component that deletes a player's world data, so
// every step is ordered and gated to honor §18's six red lines:
//
// ① system-server exemption — spec.reaperExempt servers are never touched
// (losing the lobby would be total ingress loss).
// ② delete PVC ≠ delete the servers row — the row stays so the subdomain
// remains reserved and a re-claim yields the same-named empty world.
// ③ world_backups is NOT FK'd to servers — a backup must outlive the world
// it came from (3-month retention from deletion).
// ④ back up BEFORE deleting — the PVC is only deleted after the archive is
// both written and recorded; an archive failure preserves the world.
// ⑤ warnings are best-effort — a delivery failure never blocks a reap, and a
// server with no linked owner is still reaped on time.
// ⑥ a real join resets the clock — RecordJoin (spec §7) refreshes
// last_active_at and clears warned_*, so renewal restarts the countdown.
//
// The logic here is pure and hermetically testable: all I/O is behind the
// Store, Cluster, and Warner interfaces plus the backup.WorldArchiver, with an
// injectable clock and id generator. The Postgres and Kubernetes bindings live
// in pgstore.go / k8scluster.go and are integration-tested, not unit-tested.
package reaper
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"time"
"felis.lolicon.best/internal/backup"
)
// Day is a calendar day; the retention windows in §18 are expressed in days.
const Day = 24 * time.Hour
// ReasonInactive is the canonical world_backups.reason for an idle-reaped world
// (spec §18). It is a stable label, not a literal restatement of the deadline.
const ReasonInactive = "inactive_15d"
// Audit actions emitted by the reaper. The actor/source are a system identity
// ("reaper") because no human Access email is in play here (spec §14).
const (
ActionReapWorld = "reap_world"
ActionEvictBackup = "evict_backup_early"
)
// ErrNotFound is returned by Cluster.Inspect when the MinecraftServer CRD for a
// servers row no longer exists. The reaper treats it as "skip" — it will not
// delete world data it cannot first inspect for the exemption flag.
var ErrNotFound = errors.New("reaper: server not found")
// errStoreFull is an internal sentinel: the backup store is at capacity and
// could not be freed, so the world is preserved rather than deleted without a
// backup (red line ④). It is never returned to callers.
var errStoreFull = errors.New("reaper: backup store full, world preserved")
// Tier identifies which warning column a notice corresponds to. The tiers are
// positional: Tier3d is the first configured WarnBefore offset (the earlier,
// larger one) and maps to servers.warned_3d_at; Tier1d is the second and maps
// to warned_1d_at. The names follow the schema columns, not fixed durations —
// the actual thresholds are derived from Config.IdleBeforeReap.
type Tier int
const (
Tier3d Tier = iota
Tier1d
)
// Config holds the retention windows. The warning thresholds are derived from
// the deadline (threshold = IdleBeforeReap - offset) rather than hardcoded, so
// changing IdleBeforeReap moves the warnings with it and the toml's
// warn_before=["3d","1d"] maps 1:1 onto WarnBefore.
type Config struct {
IdleBeforeReap time.Duration // §18: reap after this much per-server idle (default 15d)
WarnBefore []time.Duration // §24: warn these long before the deadline (default 3d, 1d)
Retention time.Duration // §18: keep a backup this long after deletion (default 3mo≈90d)
MaxLocalBytes int64 // §26: backup store soft cap; 0 = unlimited
}
// DefaultConfig is the spec's §24 default window set.
func DefaultConfig() Config {
return Config{
IdleBeforeReap: 15 * Day,
WarnBefore: []time.Duration{3 * Day, 1 * Day},
Retention: 90 * Day,
MaxLocalBytes: 0,
}
}
// Candidate is a servers row in the reaper's view (only deleted_at IS NULL rows
// are listed). Activity is per-server: LastActiveAt = max(last human join,
// created_at); stop/idle/wake do NOT move it (spec §18).
type Candidate struct {
Name string
OwnerID string // "" when unowned — still reaped, but never warned (red line ⑤)
LastActiveAt time.Time
Warned3dAt time.Time // zero = not yet sent
Warned1dAt time.Time // zero = not yet sent
}
func (c Candidate) warnedAt(t Tier) time.Time {
if t == Tier1d {
return c.Warned1dAt
}
return c.Warned3dAt
}
// ServerCRD is the slice of the MinecraftServer CRD the reaper needs: the
// exemption flag (red line ①) and the world PVC to archive then delete.
type ServerCRD struct {
Exempt bool
PVC string
}
// BackupRecord is a world_backups insert. FormerOwner is captured so the
// backup, which outlives the server row, still records who it belonged to
// (red line ③). It is NOT a foreign key.
type BackupRecord struct {
ID string
ServerName string
FormerOwner string
BackupRef string
SizeBytes int64
Reason string
ExpiresAt time.Time
}
// StoredBackup is an existing world_backups row, used by both the expiry pass
// and the capacity-eviction path.
type StoredBackup struct {
ID string
ServerName string
BackupRef string
SizeBytes int64
}
// AuditRecord is a reaper-sourced audit_logs entry. The PG binding fills
// actor="reaper", source="reaper", and puts FormerOwner into the payload jsonb
// (audit_logs has no former_owner column).
type AuditRecord struct {
Action string
ServerName string
FormerOwner string
}
// Store is the business-layer (Postgres) face the reaper needs. It deliberately
// exposes only the narrow operations §18 performs, never a generic UPDATE.
type Store interface {
// ListActiveServers returns every servers row with deleted_at IS NULL.
ListActiveServers(ctx context.Context) ([]Candidate, error)
// FreshBackup reports an existing present backup for server whose world is
// still current — created at or after since (the world's last_active_at).
// It makes a reap idempotent across a DeletePVC failure: the retry reuses
// the archive instead of writing a duplicate.
FreshBackup(ctx context.Context, server string, since time.Time) (ref string, ok bool, err error)
// InsertBackup records a world_backups row (status=present).
InsertBackup(ctx context.Context, rec BackupRecord) error
// ReleaseWorld is the post-delete business mutation: owner_id→NULL,
// last_active_at→at (clock reset), warned_*→NULL. It does NOT delete the
// row (red line ②).
ReleaseWorld(ctx context.Context, name string, at time.Time) error
// MarkWarned stamps the warned_3d_at / warned_1d_at column for tier.
MarkWarned(ctx context.Context, name string, tier Tier, at time.Time) error
// PresentBackupBytes is the total size of status=present backups (§26 cap).
PresentBackupBytes(ctx context.Context) (int64, error)
// OldestPresentBackups lists status=present backups oldest-first, for
// early eviction when the store is full.
OldestPresentBackups(ctx context.Context) ([]StoredBackup, error)
// ListExpiredBackups lists status=present backups whose expires_at < now.
ListExpiredBackups(ctx context.Context, now time.Time) ([]StoredBackup, error)
// MarkBackupDeleted flips a backup to status=deleted, deleted_at=at.
MarkBackupDeleted(ctx context.Context, id string, at time.Time) error
// Audit appends a reaper-sourced audit_logs row.
Audit(ctx context.Context, rec AuditRecord) error
}
// Cluster is the lifecycle (Kubernetes) face: read the CRD, delete the world
// PVC, and flip desiredState to Stopped. These are the only cluster operations
// §18 performs.
type Cluster interface {
// Inspect returns the exemption flag and world PVC name for a server, or
// ErrNotFound if the CRD is gone.
Inspect(ctx context.Context, name string) (ServerCRD, error)
// DeletePVC deletes the world PersistentVolumeClaim.
DeletePVC(ctx context.Context, pvc string) error
// Stop sets spec.desiredState=Stopped.
Stop(ctx context.Context, name string) error
}
// Warner delivers an impending-reap notice. It is optional and best-effort: a
// nil Warner or a delivery error never blocks a reap (red line ⑤).
type Warner interface {
Warn(ctx context.Context, ownerID, server, remaining string) error
}
// Reaper runs the §18 batch. Now and IDGen are injectable for hermetic tests;
// Log defaults to slog.Default(); Warner may be nil.
type Reaper struct {
Cfg Config
Store Store
Cluster Cluster
Archiver backup.WorldArchiver
Warner Warner
Log *slog.Logger
Now func() time.Time
IDGen func() string
}
// Summary is the per-run tally (feeds §23 metrics).
type Summary struct {
Evaluated int
WorldsReaped int
Warned int
Skipped int // exempt, CRD gone, or could not back up
EvictedEarly int
BackupsExpired int
}
func (r *Reaper) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func (r *Reaper) log() *slog.Logger {
if r.Log != nil {
return r.Log
}
return slog.Default()
}
func (r *Reaper) id() string {
if r.IDGen != nil {
return r.IDGen()
}
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "bk-" + strconv.FormatInt(r.now().UnixNano(), 16)
}
return "bk-" + hex.EncodeToString(b[:])
}
// RunOnce executes one full batch: a world pass over active servers, then a
// retention pass over expired backups. It is idempotent and restart-safe, so a
// Kubernetes CronJob can drive the daily cadence (spec §18). Per-server
// failures are logged and counted as Skipped without aborting the batch; only
// an inability to list servers is a hard error.
func (r *Reaper) RunOnce(ctx context.Context) (Summary, error) {
var sum Summary
cands, err := r.Store.ListActiveServers(ctx)
if err != nil {
return sum, fmt.Errorf("reaper: list active servers: %w", err)
}
// Warning thresholds derive from the deadline, so the offsets must be
// largest-first (earliest warning first) to honor §18's elif precedence.
offs := append([]time.Duration(nil), r.Cfg.WarnBefore...)
sort.Slice(offs, func(i, j int) bool { return offs[i] > offs[j] })
now := r.now()
for _, c := range cands {
sum.Evaluated++
if err := r.evaluate(ctx, now, offs, c, &sum); err != nil {
r.log().Error("reaper: skipping server", "server", c.Name, "err", err)
sum.Skipped++
}
}
r.expireBackups(ctx, now, &sum)
return sum, nil
}
// 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".
func (r *Reaper) evaluate(ctx context.Context, now time.Time, offs []time.Duration, c Candidate, sum *Summary) error {
crd, err := r.Cluster.Inspect(ctx, c.Name)
if err != nil {
if errors.Is(err, ErrNotFound) {
// CRD gone but the row lingers — nothing safe to do; not a failure.
r.log().Warn("reaper: CRD missing, skipping", "server", c.Name)
return nil
}
return fmt.Errorf("inspect: %w", err)
}
if crd.Exempt {
// Red line ①: system servers (lobby/proxy) are never reaped.
return nil
}
idle := now.Sub(c.LastActiveAt)
if idle > r.Cfg.IdleBeforeReap {
return r.reap(ctx, now, c, crd, sum)
}
r.maybeWarn(ctx, now, idle, offs, c, sum)
return nil
}
// reap archives the world, records the backup, and only then deletes the PVC,
// releases ownership, and stops the server — the strict ordering of red line ④.
func (r *Reaper) reap(ctx context.Context, now time.Time, c Candidate, crd ServerCRD, sum *Summary) error {
// §26 soft cap: free space before adding a backup. If the store cannot be
// brought under cap, preserve the world rather than delete it unbacked.
if r.Cfg.MaxLocalBytes > 0 {
ok, err := r.ensureCapacity(ctx, now, sum)
if err != nil {
return fmt.Errorf("ensure capacity: %w", err)
}
if !ok {
r.log().Error("reaper: backup store full, world preserved", "server", c.Name)
return errStoreFull
}
}
// Idempotent archive: if a prior run already archived this (unchanged)
// world but failed before deleting the PVC, reuse that backup rather than
// writing a duplicate. The world has not changed since last_active_at, so
// any present backup created after it still describes the current world.
ref, ok, err := r.Store.FreshBackup(ctx, c.Name, c.LastActiveAt)
if err != nil {
return fmt.Errorf("lookup fresh backup: %w", err)
}
if !ok {
aref, size, err := r.Archiver.Archive(ctx, c.Name, crd.PVC)
if err != nil {
// Red line ④: archive failed → the PVC is untouched, the world
// survives, and this server is retried next run.
return fmt.Errorf("archive: %w", err)
}
rec := BackupRecord{
ID: r.id(),
ServerName: c.Name,
FormerOwner: c.OwnerID,
BackupRef: string(aref),
SizeBytes: size,
Reason: ReasonInactive,
ExpiresAt: now.Add(r.Cfg.Retention),
}
if err := r.Store.InsertBackup(ctx, rec); err != nil {
// The archive exists but is untracked. Delete the orphan so it does
// not leak, then fail without touching the PVC.
if derr := r.Archiver.Delete(ctx, aref); derr != nil {
r.log().Error("reaper: orphan archive cleanup failed", "server", c.Name, "ref", aref, "err", derr)
}
return fmt.Errorf("insert backup: %w", err)
}
ref = string(aref)
}
// World is safely archived and recorded — now (and only now) delete it.
if err := r.Cluster.DeletePVC(ctx, crd.PVC); err != nil {
// The backup row persists; next run's FreshBackup reuses it and retries
// the delete, so no duplicate archive is created.
return fmt.Errorf("delete pvc: %w", err)
}
if err := r.Store.ReleaseWorld(ctx, c.Name, now); err != nil {
return fmt.Errorf("release world: %w", err)
}
if err := r.Cluster.Stop(ctx, c.Name); err != nil {
// The world is already deleted and ownership released; the desiredState
// flip is cosmetic by comparison. Log, but the reap stands.
r.log().Error("reaper: set desiredState=Stopped failed", "server", c.Name, "err", err)
}
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionReapWorld, ServerName: c.Name, FormerOwner: c.OwnerID}); err != nil {
r.log().Error("reaper: audit reap_world failed", "server", c.Name, "err", err)
}
sum.WorldsReaped++
r.log().Info("reaper: world reaped", "server", c.Name, "former_owner", c.OwnerID, "backup_ref", ref)
return nil
}
// ensureCapacity frees the backup store down under MaxLocalBytes by evicting the
// oldest present backups early. Early eviction is destructive (it removes
// not-yet-expired backups), so each eviction is alerted and audited. It returns
// whether the store is now under cap.
func (r *Reaper) ensureCapacity(ctx context.Context, now time.Time, sum *Summary) (bool, error) {
used, err := r.Store.PresentBackupBytes(ctx)
if err != nil {
return false, err
}
if used < r.Cfg.MaxLocalBytes {
return true, nil
}
r.log().Warn("reaper: backup store at capacity, evicting oldest backups early",
"used", used, "max", r.Cfg.MaxLocalBytes)
old, err := r.Store.OldestPresentBackups(ctx)
if err != nil {
return false, err
}
for _, b := range old {
if used < r.Cfg.MaxLocalBytes {
break
}
if err := r.Archiver.Delete(ctx, backup.ArchiveRef(b.BackupRef)); err != nil {
r.log().Error("reaper: early-evict delete failed", "id", b.ID, "err", err)
continue
}
if err := r.Store.MarkBackupDeleted(ctx, b.ID, now); err != nil {
r.log().Error("reaper: early-evict mark failed", "id", b.ID, "err", err)
continue
}
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionEvictBackup, ServerName: b.ServerName}); err != nil {
r.log().Error("reaper: audit evict failed", "id", b.ID, "err", err)
}
used -= b.SizeBytes
sum.EvictedEarly++
}
return used < r.Cfg.MaxLocalBytes, nil
}
// maybeWarn sends at most one impending-reap notice per run, honoring §18's
// elif precedence (earliest unsent warning first). Unowned servers are never
// warned but are still reaped at the deadline (red line ⑤). A warner delivery
// failure is logged but the warned_* stamp still advances so the notice is not
// retried forever; a real join (RecordJoin) is what clears the stamps.
func (r *Reaper) maybeWarn(ctx context.Context, now time.Time, idle time.Duration, offs []time.Duration, c Candidate, sum *Summary) {
if c.OwnerID == "" {
return
}
for i := 0; i < len(offs) && i < 2; i++ {
threshold := r.Cfg.IdleBeforeReap - offs[i]
if idle <= threshold {
continue
}
tier := Tier(i)
if !c.warnedAt(tier).IsZero() {
continue // already sent this tier
}
if r.Warner != nil {
if err := r.Warner.Warn(ctx, c.OwnerID, c.Name, formatRemaining(offs[i])); err != nil {
r.log().Warn("reaper: warn delivery failed (best-effort)", "server", c.Name, "err", err)
}
}
if err := r.Store.MarkWarned(ctx, c.Name, tier, now); err != nil {
r.log().Error("reaper: mark warned failed", "server", c.Name, "err", err)
return
}
sum.Warned++
return // one warning per run
}
}
// expireBackups is the retention pass: delete archives whose expires_at has
// passed and mark them deleted. Per-backup failures are logged, not fatal.
func (r *Reaper) expireBackups(ctx context.Context, now time.Time, sum *Summary) {
exp, err := r.Store.ListExpiredBackups(ctx, now)
if err != nil {
r.log().Error("reaper: list expired backups", "err", err)
return
}
for _, b := range exp {
if err := r.Archiver.Delete(ctx, backup.ArchiveRef(b.BackupRef)); err != nil {
r.log().Error("reaper: delete expired archive", "id", b.ID, "err", err)
continue
}
if err := r.Store.MarkBackupDeleted(ctx, b.ID, now); err != nil {
r.log().Error("reaper: mark expired deleted", "id", b.ID, "err", err)
continue
}
sum.BackupsExpired++
}
}
// formatRemaining renders an offset as the human-facing time left before reap.
func formatRemaining(d time.Duration) string {
if d%Day == 0 {
return strconv.FormatInt(int64(d/Day), 10) + "d"
}
if d%time.Hour == 0 {
return strconv.FormatInt(int64(d/time.Hour), 10) + "h"
}
return d.String()
}
+615
View File
@@ -0,0 +1,615 @@
package reaper
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"sort"
"testing"
"time"
"felis.lolicon.best/internal/backup"
)
// testNow is the frozen clock for every hermetic case. Idle is expressed as an
// offset back from here.
var testNow = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
func idleBy(d time.Duration) time.Time { return testNow.Add(-d) }
// recorder captures the cross-fake call order so a test can assert the strict
// archive→insert→deletePVC→release→stop→audit sequence of red line ④.
type recorder struct{ events []string }
func (r *recorder) add(e string) { r.events = append(r.events, e) }
// ---- fake backup.WorldArchiver -------------------------------------------
type fakeArchiver struct {
rec *recorder
archiveErr error
deleteErr error
archives int
deletes []backup.ArchiveRef
seq int
}
func (f *fakeArchiver) Archive(_ context.Context, server, _ string) (backup.ArchiveRef, int64, error) {
if f.archiveErr != nil {
return "", 0, f.archiveErr
}
f.archives++
f.seq++
f.rec.add("archive")
return backup.ArchiveRef(fmt.Sprintf("ref-%s-%d", server, f.seq)), 10, nil
}
func (f *fakeArchiver) Restore(context.Context, backup.ArchiveRef, string) error { return nil }
func (f *fakeArchiver) Delete(_ context.Context, ref backup.ArchiveRef) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deletes = append(f.deletes, ref)
f.rec.add("delete")
return nil
}
// ---- fake Cluster ---------------------------------------------------------
type fakeCluster struct {
rec *recorder
crds map[string]ServerCRD
inspectErr map[string]error
deletePVCErr error
deletePVCCalls int
deletedPVCs []string
stopped []string
}
func (c *fakeCluster) Inspect(_ context.Context, name string) (ServerCRD, error) {
if e := c.inspectErr[name]; e != nil {
return ServerCRD{}, e
}
crd, ok := c.crds[name]
if !ok {
return ServerCRD{}, ErrNotFound
}
return crd, nil
}
func (c *fakeCluster) DeletePVC(_ context.Context, pvc string) error {
c.deletePVCCalls++
if c.deletePVCErr != nil {
return c.deletePVCErr
}
c.deletedPVCs = append(c.deletedPVCs, pvc)
c.rec.add("deletePVC")
return nil
}
func (c *fakeCluster) Stop(_ context.Context, name string) error {
c.stopped = append(c.stopped, name)
c.rec.add("stop")
return nil
}
// ---- fake Store -----------------------------------------------------------
type fakeBackup struct {
id, server, ref string
size int64
status string // present | deleted
createdAt, expires time.Time
}
type fakeStore struct {
rec *recorder
clock time.Time
order []string
byName map[string]*Candidate
backups []*fakeBackup
audits []AuditRecord
released []string
listErr error
insertErr error
idn int
}
func (s *fakeStore) ListActiveServers(context.Context) ([]Candidate, error) {
if s.listErr != nil {
return nil, s.listErr
}
out := make([]Candidate, 0, len(s.order))
for _, n := range s.order {
out = append(out, *s.byName[n])
}
return out, nil
}
func (s *fakeStore) FreshBackup(_ context.Context, server string, since time.Time) (string, bool, error) {
for _, b := range s.backups {
if b.server == server && b.status == "present" && !b.createdAt.Before(since) {
return b.ref, true, nil
}
}
return "", false, nil
}
func (s *fakeStore) InsertBackup(_ context.Context, rec BackupRecord) error {
if s.insertErr != nil {
return s.insertErr
}
s.backups = append(s.backups, &fakeBackup{
id: rec.ID, server: rec.ServerName, ref: rec.BackupRef, size: rec.SizeBytes,
status: "present", createdAt: s.clock, expires: rec.ExpiresAt,
})
s.rec.add("insert")
return nil
}
func (s *fakeStore) ReleaseWorld(_ context.Context, name string, at time.Time) error {
c := s.byName[name]
c.OwnerID = ""
c.LastActiveAt = at
c.Warned3dAt = time.Time{}
c.Warned1dAt = time.Time{}
s.released = append(s.released, name)
s.rec.add("release")
return nil
}
func (s *fakeStore) MarkWarned(_ context.Context, name string, tier Tier, at time.Time) error {
c := s.byName[name]
if tier == Tier1d {
c.Warned1dAt = at
} else {
c.Warned3dAt = at
}
return nil
}
func (s *fakeStore) PresentBackupBytes(context.Context) (int64, error) {
var total int64
for _, b := range s.backups {
if b.status == "present" {
total += b.size
}
}
return total, nil
}
func (s *fakeStore) OldestPresentBackups(context.Context) ([]StoredBackup, error) {
var ps []*fakeBackup
for _, b := range s.backups {
if b.status == "present" {
ps = append(ps, b)
}
}
sort.Slice(ps, func(i, j int) bool { return ps[i].createdAt.Before(ps[j].createdAt) })
out := make([]StoredBackup, 0, len(ps))
for _, b := range ps {
out = append(out, StoredBackup{ID: b.id, ServerName: b.server, BackupRef: b.ref, SizeBytes: b.size})
}
return out, nil
}
func (s *fakeStore) ListExpiredBackups(_ context.Context, now time.Time) ([]StoredBackup, error) {
var out []StoredBackup
for _, b := range s.backups {
if b.status == "present" && b.expires.Before(now) {
out = append(out, StoredBackup{ID: b.id, ServerName: b.server, BackupRef: b.ref, SizeBytes: b.size})
}
}
return out, nil
}
func (s *fakeStore) MarkBackupDeleted(_ context.Context, id string, at time.Time) error {
for _, b := range s.backups {
if b.id == id {
b.status = "deleted"
return nil
}
}
return fmt.Errorf("no backup %s", id)
}
func (s *fakeStore) Audit(_ context.Context, rec AuditRecord) error {
s.audits = append(s.audits, rec)
s.rec.add("audit:" + rec.Action)
return nil
}
// ---- fixture --------------------------------------------------------------
func newReaper(cfg Config, cands ...Candidate) (*Reaper, *fakeStore, *fakeCluster, *fakeArchiver) {
rec := &recorder{}
st := &fakeStore{rec: rec, clock: testNow, byName: map[string]*Candidate{}}
cl := &fakeCluster{rec: rec, crds: map[string]ServerCRD{}, inspectErr: map[string]error{}}
for i := range cands {
cc := cands[i]
st.byName[cc.Name] = &cc
st.order = append(st.order, cc.Name)
cl.crds[cc.Name] = ServerCRD{PVC: "world-" + cc.Name + "-0"}
}
ar := &fakeArchiver{rec: rec}
r := &Reaper{
Cfg: cfg, Store: st, Cluster: cl, Archiver: ar,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
Now: func() time.Time { return testNow },
IDGen: func() string { st.idn++; return fmt.Sprintf("bk-%d", st.idn) },
}
return r, st, cl, ar
}
func mustRun(t *testing.T, r *Reaper) Summary {
t.Helper()
sum, err := r.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
return sum
}
// ---- tests ----------------------------------------------------------------
// Red line ①: a reaperExempt server is never archived, deleted, or warned no
// matter how idle it is — losing the lobby would be total ingress loss.
func TestReapExemptServerNeverTouched(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "lobby", OwnerID: "", LastActiveAt: idleBy(100 * Day)})
cl.crds["lobby"] = ServerCRD{Exempt: true, PVC: "world-lobby-0"}
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 0 || ar.archives != 0 || cl.deletePVCCalls != 0 {
t.Fatalf("exempt server was touched: %+v archives=%d deletePVC=%d", sum, ar.archives, cl.deletePVCCalls)
}
if st.byName["lobby"].OwnerID != "" || len(st.released) != 0 {
t.Fatalf("exempt server ownership mutated")
}
}
// The happy path, asserting the strict ordering of red line ④ and that the
// reap audit carries former_owner (spec §18 audit(reap_world, s, former_owner)).
func TestReapIdleWorldFullSequence(t *testing.T) {
r, st, cl, _ := newReaper(DefaultConfig(),
Candidate{Name: "alpha", OwnerID: "user-7", LastActiveAt: idleBy(20 * Day)})
sum := mustRun(t, r)
if sum.WorldsReaped != 1 {
t.Fatalf("WorldsReaped = %d, want 1", sum.WorldsReaped)
}
want := []string{"archive", "insert", "deletePVC", "release", "stop", "audit:" + ActionReapWorld}
if !reflect.DeepEqual(st.rec.events, want) {
t.Fatalf("call order = %v, want %v", st.rec.events, want)
}
if got := cl.deletedPVCs; len(got) != 1 || got[0] != "world-alpha-0" {
t.Fatalf("deleted PVCs = %v, want [world-alpha-0]", got)
}
// Backup carries the former owner and a retention deadline 3mo out.
if len(st.backups) != 1 || st.backups[0].server != "alpha" {
t.Fatalf("backup not recorded: %+v", st.backups)
}
if want := testNow.Add(DefaultConfig().Retention); !st.backups[0].expires.Equal(want) {
t.Fatalf("backup expires = %v, want %v", st.backups[0].expires, want)
}
if len(st.audits) != 1 || st.audits[0].FormerOwner != "user-7" || st.audits[0].Action != ActionReapWorld {
t.Fatalf("audit = %+v, want reap_world former_owner=user-7", st.audits)
}
// Red line ②: the row survives (ownership released, not deleted).
c := st.byName["alpha"]
if c.OwnerID != "" || !c.LastActiveAt.Equal(testNow) || !c.Warned3dAt.IsZero() {
t.Fatalf("post-reap state wrong: %+v", c)
}
}
// CENTERPIECE — red line ④: when the archive fails, the PVC is never deleted,
// ownership is untouched, no backup row is written, and the same server is
// retried (state unchanged) on the next run.
func TestReapArchiveFailurePreservesWorld(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "beta", OwnerID: "user-1", LastActiveAt: idleBy(20 * Day)})
ar.archiveErr = errors.New("backend offline")
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 1 {
t.Fatalf("summary = %+v, want 0 reaped / 1 skipped", sum)
}
if cl.deletePVCCalls != 0 {
t.Fatalf("DeletePVC was called %d times despite archive failure", cl.deletePVCCalls)
}
if len(st.backups) != 0 {
t.Fatalf("a backup row was written despite archive failure: %+v", st.backups)
}
if len(st.released) != 0 {
t.Fatalf("ReleaseWorld ran despite archive failure")
}
c := st.byName["beta"]
if c.OwnerID != "user-1" || !c.LastActiveAt.Equal(idleBy(20*Day)) {
t.Fatalf("server state changed despite archive failure: %+v", c)
}
// Recovery: backend returns, next run reaps cleanly.
ar.archiveErr = nil
sum2 := mustRun(t, r)
if sum2.WorldsReaped != 1 || cl.deletePVCCalls != 1 {
t.Fatalf("recovery run: summary=%+v deletePVC=%d, want 1 reaped / 1 delete", sum2, cl.deletePVCCalls)
}
}
// Red line ④ (recording arm): if the archive succeeds but recording it fails,
// the orphan archive is cleaned up and the PVC is still never deleted.
func TestReapInsertBackupFailurePreservesWorld(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "gamma", OwnerID: "user-2", LastActiveAt: idleBy(20 * Day)})
st.insertErr = errors.New("db down")
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 1 {
t.Fatalf("summary = %+v, want 0 reaped / 1 skipped", sum)
}
if cl.deletePVCCalls != 0 {
t.Fatalf("DeletePVC called despite insert failure")
}
if ar.archives != 1 || len(ar.deletes) != 1 {
t.Fatalf("orphan archive not cleaned up: archives=%d deletes=%d", ar.archives, len(ar.deletes))
}
if len(st.backups) != 0 {
t.Fatalf("backup row present despite insert failure")
}
}
// Idempotency (the deliberate disk-growth choice): a DeletePVC failure leaves a
// recorded backup; the retry reuses it via FreshBackup instead of writing a
// duplicate archive.
func TestReapDeletePVCFailureIsIdempotent(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "delta", OwnerID: "user-3", LastActiveAt: idleBy(20 * Day)})
cl.deletePVCErr = errors.New("apiserver timeout")
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 1 {
t.Fatalf("run1 summary = %+v, want 0 reaped / 1 skipped", sum)
}
if ar.archives != 1 || len(st.backups) != 1 {
t.Fatalf("run1: archives=%d backups=%d, want 1/1", ar.archives, len(st.backups))
}
// apiserver recovers; the retry must NOT re-archive.
cl.deletePVCErr = nil
sum2 := mustRun(t, r)
if sum2.WorldsReaped != 1 {
t.Fatalf("run2 WorldsReaped = %d, want 1", sum2.WorldsReaped)
}
if ar.archives != 1 {
t.Fatalf("retry re-archived: archives=%d, want 1 (reuse via FreshBackup)", ar.archives)
}
if len(st.backups) != 1 {
t.Fatalf("retry duplicated the backup row: %d rows, want 1", len(st.backups))
}
}
// Red line ⑤ (reap arm): an unowned server is still reaped on time; the audit
// records an empty former_owner.
func TestReapUnownedServerStillReaped(t *testing.T) {
r, st, cl, _ := newReaper(DefaultConfig(),
Candidate{Name: "orphan", OwnerID: "", LastActiveAt: idleBy(20 * Day)})
sum := mustRun(t, r)
if sum.WorldsReaped != 1 || cl.deletePVCCalls != 1 {
t.Fatalf("unowned server not reaped: %+v deletePVC=%d", sum, cl.deletePVCCalls)
}
if len(st.audits) != 1 || st.audits[0].FormerOwner != "" {
t.Fatalf("audit former_owner = %q, want empty", st.audits[0].FormerOwner)
}
}
func TestNoReapBeforeDeadline(t *testing.T) {
r, _, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "fresh", OwnerID: "user-4", LastActiveAt: idleBy(10 * Day)})
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Warned != 0 || ar.archives != 0 || cl.deletePVCCalls != 0 {
t.Fatalf("acted on a 10d-idle server with a 15d deadline: %+v", sum)
}
}
// Warning thresholds are DERIVED from the deadline, not hardcoded. Using a
// non-default 10d deadline, warnings must fire at 10d-3d=7d and 10d-1d=9d, in
// elif precedence, deduped per tier, and never for an unowned server.
func TestWarningsDerivedFromNonDefaultDeadline(t *testing.T) {
cfg := Config{
IdleBeforeReap: 10 * Day,
WarnBefore: []time.Duration{1 * Day, 3 * Day}, // intentionally unsorted
Retention: 90 * Day,
}
r, st, cl, _ := newReaper(cfg,
// past 7d, under 9d, nothing sent -> 3d (Tier3d) warning
Candidate{Name: "a", OwnerID: "u-a", LastActiveAt: idleBy(8 * Day)},
// past 9d, 3d already sent -> 1d (Tier1d) warning
Candidate{Name: "b", OwnerID: "u-b", LastActiveAt: idleBy(95 * Day / 10), Warned3dAt: idleBy(2 * Day)},
// past 7d, 3d already sent, under 9d -> no new warning (dedup)
Candidate{Name: "c", OwnerID: "u-c", LastActiveAt: idleBy(8 * Day), Warned3dAt: idleBy(2 * Day)},
// under 7d -> no warning yet
Candidate{Name: "d", OwnerID: "u-d", LastActiveAt: idleBy(6 * Day)},
// past 7d but unowned -> never warned (red line ⑤)
Candidate{Name: "e", OwnerID: "", LastActiveAt: idleBy(8 * Day)},
)
sum := mustRun(t, r)
if sum.WorldsReaped != 0 {
t.Fatalf("nothing should be reaped under a 10d deadline at <=9.5d idle: %+v", sum)
}
if sum.Warned != 2 {
t.Fatalf("Warned = %d, want 2 (a:3d, b:1d)", sum.Warned)
}
if cl.deletePVCCalls != 0 {
t.Fatalf("a warning path deleted a PVC")
}
if st.byName["a"].Warned3dAt.IsZero() || !st.byName["a"].Warned1dAt.IsZero() {
t.Fatalf("server a: expected only a 3d warning, got %+v", st.byName["a"])
}
if st.byName["b"].Warned1dAt.IsZero() {
t.Fatalf("server b: expected a 1d warning, got %+v", st.byName["b"])
}
if !st.byName["e"].Warned3dAt.IsZero() {
t.Fatalf("unowned server e was warned")
}
}
// Red line ⑤ (best-effort): a Warner delivery error does not abort the run, and
// the warned_* stamp still advances (a real join, not a failed warn, is what
// resets the clock).
func TestWarningBestEffortOnDeliveryFailure(t *testing.T) {
r, st, _, _ := newReaper(DefaultConfig(),
Candidate{Name: "h", OwnerID: "u-h", LastActiveAt: idleBy(13 * Day)})
r.Warner = failWarner{}
sum := mustRun(t, r)
if sum.Warned != 1 {
t.Fatalf("Warned = %d, want 1 despite delivery failure", sum.Warned)
}
if st.byName["h"].Warned3dAt.IsZero() {
t.Fatalf("warned_3d_at not stamped after best-effort warn")
}
}
type failWarner struct{}
func (failWarner) Warn(context.Context, string, string, string) error {
return errors.New("smtp unavailable")
}
// §26 capacity: when the store is over its cap, the oldest backup is evicted
// early (destructive — audited) to make room, then the reap proceeds.
func TestCapacityEvictsOldestThenReaps(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxLocalBytes = 100
r, st, _, ar := newReaper(cfg,
Candidate{Name: "epsilon", OwnerID: "user-5", LastActiveAt: idleBy(20 * Day)})
// Two present backups of 75 each = 150 > 100. Oldest must be evicted first.
st.backups = []*fakeBackup{
{id: "old", server: "zzz", ref: "ref-old", size: 75, status: "present", createdAt: idleBy(40 * Day), expires: testNow.Add(30 * Day)},
{id: "new", server: "yyy", ref: "ref-new", size: 75, status: "present", createdAt: idleBy(5 * Day), expires: testNow.Add(60 * Day)},
}
sum := mustRun(t, r)
if sum.EvictedEarly != 1 {
t.Fatalf("EvictedEarly = %d, want 1", sum.EvictedEarly)
}
if sum.WorldsReaped != 1 {
t.Fatalf("WorldsReaped = %d, want 1 after eviction freed room", sum.WorldsReaped)
}
var oldStatus, newStatus string
for _, b := range st.backups {
switch b.id {
case "old":
oldStatus = b.status
case "new":
newStatus = b.status
}
}
if oldStatus != "deleted" || newStatus != "present" {
t.Fatalf("eviction hit wrong backup: old=%s new=%s, want deleted/present", oldStatus, newStatus)
}
// The destructive eviction must be audited.
var evicted bool
for _, a := range st.audits {
if a.Action == ActionEvictBackup {
evicted = true
}
}
if !evicted {
t.Fatalf("early eviction was not audited")
}
if ar.archives != 1 {
t.Fatalf("reap did not archive after eviction: archives=%d", ar.archives)
}
}
// §26 capacity + red line ④: if eviction cannot free enough space, the reap is
// skipped and the world is preserved rather than deleted unbacked.
func TestCapacityStillFullSkipsReap(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxLocalBytes = 100
r, st, cl, ar := newReaper(cfg,
Candidate{Name: "zeta", OwnerID: "user-6", LastActiveAt: idleBy(20 * Day)})
st.backups = []*fakeBackup{
{id: "stuck", server: "zzz", ref: "ref-stuck", size: 150, status: "present", createdAt: idleBy(40 * Day), expires: testNow.Add(30 * Day)},
}
// The archive backend can't delete, so eviction cannot free space.
r.Archiver.(*fakeArchiver).deleteErr = errors.New("evict unavailable")
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 1 {
t.Fatalf("summary = %+v, want 0 reaped / 1 skipped (store full)", sum)
}
if cl.deletePVCCalls != 0 {
t.Fatalf("world was deleted while the store was full")
}
if ar.archives != 0 {
t.Fatalf("archived into a full store")
}
if st.byName["zeta"].OwnerID != "user-6" {
t.Fatalf("ownership changed while store full")
}
}
// Retention pass: backups past expires_at are deleted from the backend and
// marked deleted; unexpired backups are untouched.
func TestExpiredBackupsDeleted(t *testing.T) {
r, st, _, ar := newReaper(DefaultConfig())
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 := mustRun(t, r)
if sum.BackupsExpired != 1 {
t.Fatalf("BackupsExpired = %d, want 1", sum.BackupsExpired)
}
if len(ar.deletes) != 1 || ar.deletes[0] != "ref-gone" {
t.Fatalf("deleted archives = %v, want [ref-gone]", ar.deletes)
}
byID := map[string]string{}
for _, b := range st.backups {
byID[b.id] = b.status
}
if byID["gone"] != "deleted" || byID["keep"] != "present" {
t.Fatalf("expiry hit wrong rows: %v", byID)
}
}
// A servers row whose CRD has been deleted is skipped (not a failure) — the
// reaper never deletes world data it cannot first inspect for the exemption.
func TestMissingCRDSkipped(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(),
Candidate{Name: "ghost", OwnerID: "u", LastActiveAt: idleBy(20 * Day)})
delete(cl.crds, "ghost") // CRD gone, servers row lingers
sum := mustRun(t, r)
if sum.WorldsReaped != 0 || sum.Skipped != 0 {
t.Fatalf("summary = %+v, want 0/0 (skipped without error)", sum)
}
if ar.archives != 0 || cl.deletePVCCalls != 0 {
t.Fatalf("acted on a server with no CRD")
}
if st.byName["ghost"].OwnerID != "u" {
t.Fatalf("mutated a server with no CRD")
}
}
// A failure to list servers is the one hard error that aborts the batch.
func TestListErrorAbortsBatch(t *testing.T) {
r, st, _, _ := newReaper(DefaultConfig())
st.listErr = errors.New("db unreachable")
if _, err := r.RunOnce(context.Background()); err == nil {
t.Fatal("expected a hard error when listing servers fails")
}
}
+225
View File
@@ -0,0 +1,225 @@
package restore
import (
"fmt"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Label keys applied to restore objects, mirroring internal/build so the two
// executors are observable the same way.
const (
LabelManagedBy = "app.kubernetes.io/managed-by"
LabelComponent = "app.kubernetes.io/component"
LabelServer = "felis.lolicon.best/server"
managedByValue = "felis-restore"
componentValue = "world-restore"
worldVolume = "world"
backupVolume = "backup"
)
// JobParams are the rendered inputs to a restore Job, derived from a server +
// archive ref + Config by the Restorer. jobspec is a pure function of them so
// the security-critical Job shape is unit-tested without a cluster.
type JobParams struct {
Server string
WorldPVC string
BackupPVC string
BackupRef string
ArchiveStore string
Namespace string
ServiceAccount string
Image string
BackupRoot string
WorldsRoot string
Deadline time.Duration
CPULimit string
MemLimit string
RunAsUser int64
RunAsGroup int64
FSGroup int64
TTLAfterFinished time.Duration
}
// RestoreJobName is the deterministic Job name for a server's restore. It is a
// pure function of the server name, which is how CreateRestoreJob detects a
// restore already in flight (AlreadyExists) and how the orchestrator's
// idempotency holds.
func RestoreJobName(server string) string { return "restore-" + server }
func restoreLabels(p JobParams) map[string]string {
return map[string]string{
LabelManagedBy: managedByValue,
LabelComponent: componentValue,
LabelServer: p.Server,
}
}
// RestoreJob renders the world-restore Job (spec §7, §16, §22). Every isolation
// guarantee lives here and is asserted by jobspec_test.go, because no cluster
// runs in this environment:
//
// - runs under the weak felis-restore SA (never the felis-api SA) with its
// token auto-mount disabled, so it cannot reach the K8s API (spec §16, §21);
// - mounts EXACTLY two volumes — the world PVC read-write and the backup PVC
// read-only — and NO Secret/ConfigMap, so a poisoned archive cannot reach
// the felis database or any credential (the four-power red line, spec §22);
// - runs as a non-root, fixed uid/gid with an fsGroup so the files it writes
// are owned by the same identity the minecraft server later runs as;
// - no privilege, no privilege escalation, read-only root filesystem, drop ALL
// capabilities — all writes go to the mounted world PVC, nothing else;
// - activeDeadlineSeconds + backoffLimit=0 so a wedged or malicious archive
// cannot loop or run forever; ttlSecondsAfterFinished GCs the finished Job.
//
// The container runs `felis restore` (cmd/felis), which extracts the archive at
// BackupRef from the backup mount into the world mount. BackupRef is an absolute
// path, so the backup PVC MUST be mounted at BackupRoot — the same path the
// reaper wrote it under — for the ref to resolve.
func RestoreJob(p JobParams) (*batchv1.Job, error) {
if p.Image == "" {
return nil, fmt.Errorf("restore: image is empty")
}
if p.WorldPVC == "" || p.BackupPVC == "" {
return nil, fmt.Errorf("restore: world and backup PVC names are required")
}
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
if err != nil {
return nil, err
}
deadline := int64(p.Deadline / time.Second)
if deadline <= 0 {
deadline = int64(defaultDeadline / time.Second)
}
ttl := int32(p.TTLAfterFinished / time.Second)
if ttl <= 0 {
ttl = int32(defaultTTL / time.Second)
}
container := corev1.Container{
Name: "restore",
Image: p.Image,
Command: []string{"felis", "restore"},
Args: []string{
"--server", p.Server,
"--ref", p.BackupRef,
"--archive-store", p.ArchiveStore,
"--backup-root", p.BackupRoot,
"--worlds-root", p.WorldsRoot,
},
VolumeMounts: []corev1.VolumeMount{
{Name: worldVolume, MountPath: p.WorldsRoot},
// The archive is only ever read; mounting it read-only means a
// compromised restore process cannot mutate other servers' backups.
{Name: backupVolume, MountPath: p.BackupRoot, ReadOnly: true},
},
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
SecurityContext: &corev1.SecurityContext{
Privileged: boolPtr(false),
AllowPrivilegeEscalation: boolPtr(false),
ReadOnlyRootFilesystem: boolPtr(true),
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
},
}
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: RestoreJobName(p.Server),
Namespace: p.Namespace,
Labels: restoreLabels(p),
},
Spec: batchv1.JobSpec{
// One shot: a bad archive must not loop. The TTL GCs the finished Job
// so a later restore of the same server is not blocked forever by a
// stale completed Job.
BackoffLimit: int32Ptr(0),
ActiveDeadlineSeconds: int64Ptr(deadline),
TTLSecondsAfterFinished: int32Ptr(ttl),
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: restoreLabels(p)},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false),
SecurityContext: &corev1.PodSecurityContext{
RunAsNonRoot: boolPtr(true),
RunAsUser: int64Ptr(p.RunAsUser),
RunAsGroup: int64Ptr(p.RunAsGroup),
FSGroup: int64Ptr(p.FSGroup),
},
Containers: []corev1.Container{container},
Volumes: []corev1.Volume{
{
Name: worldVolume,
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: p.WorldPVC,
},
},
},
{
Name: backupVolume,
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: p.BackupPVC,
ReadOnly: true,
},
},
},
},
},
},
},
}
return job, nil
}
// RestoreServiceAccount renders the weak restore SA (spec §16, §21). Like the
// build SA it is created bare: no secrets, token auto-mounting disabled, and —
// by having no Role or RoleBinding anywhere — zero K8s API permissions. Its only
// capability is filesystem access to the two PVCs the Job mounts.
func RestoreServiceAccount(namespace, name string) *corev1.ServiceAccount {
return &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{
LabelManagedBy: managedByValue,
LabelComponent: componentValue,
},
},
AutomountServiceAccountToken: boolPtr(false),
}
}
// resourceLimits parses the CPU/memory limits into a ResourceList.
func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
if cpu == "" {
cpu = defaultCPULimit
}
if mem == "" {
mem = defaultMemLimit
}
cpuQty, err := resource.ParseQuantity(cpu)
if err != nil {
return nil, fmt.Errorf("restore: invalid cpu limit %q: %w", cpu, err)
}
memQty, err := resource.ParseQuantity(mem)
if err != nil {
return nil, fmt.Errorf("restore: invalid memory limit %q: %w", mem, err)
}
return corev1.ResourceList{
corev1.ResourceCPU: cpuQty,
corev1.ResourceMemory: memQty,
}, nil
}
func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i }
+282
View File
@@ -0,0 +1,282 @@
package restore
import (
"testing"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
)
func sampleJobParams() JobParams {
return JobParams{
Server: "survival",
WorldPVC: "world-survival-0",
BackupPVC: "felis-backups",
BackupRef: "/backups/survival/2026-06-25.tar.gz",
ArchiveStore: "tarLocal",
Namespace: defaultNamespace,
ServiceAccount: defaultServiceAccount,
Image: "registry.felis.svc:5000/felis:1.0",
BackupRoot: "/backups",
WorldsRoot: "/world",
Deadline: 30 * time.Minute,
CPULimit: "1",
MemLimit: "1Gi",
RunAsUser: 1000,
RunAsGroup: 1000,
FSGroup: 1000,
TTLAfterFinished: 10 * time.Minute,
}
}
// The restore Pod must run under the weak felis-restore SA — never the
// felis-api identity — with its token un-mounted, so it cannot reach the K8s
// API. This is the §16/§22 red line asserted on the rendered spec because no
// cluster runs here.
func TestRestoreJobRunsUnderWeakSA(t *testing.T) {
job, err := RestoreJob(sampleJobParams())
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
sa := job.Spec.Template.Spec.ServiceAccountName
if sa != defaultServiceAccount {
t.Errorf("service account = %q, want %q", sa, defaultServiceAccount)
}
if sa == "felis-api" {
t.Fatal("restore Pod must NOT run as the felis-api SA")
}
if amt := job.Spec.Template.Spec.AutomountServiceAccountToken; amt == nil || *amt {
t.Error("AutomountServiceAccountToken must be explicitly false")
}
}
// The four-power red line: a restore Pod handles a (potentially poisoned)
// archive, so it must mount EXACTLY the two PVCs — world read-write, backup
// read-only — and NO Secret or ConfigMap, so it can never reach the felis
// database or any credential.
func TestRestoreJobMountsOnlyTheTwoPVCsAndNoSecrets(t *testing.T) {
job, err := RestoreJob(sampleJobParams())
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
vols := job.Spec.Template.Spec.Volumes
if len(vols) != 2 {
t.Fatalf("expected exactly 2 volumes (world + backup), got %d: %+v", len(vols), vols)
}
var world, backup *corev1.Volume
for i := range vols {
v := &vols[i]
// The forbidden volume kinds: anything that could carry DB creds or
// reach the API.
if v.Secret != nil {
t.Errorf("volume %q is a Secret — a restore Pod must never mount a Secret", v.Name)
}
if v.ConfigMap != nil {
t.Errorf("volume %q is a ConfigMap — no config/credential injection allowed", v.Name)
}
if v.Projected != nil || v.DownwardAPI != nil {
t.Errorf("volume %q is a projected/downward volume — could surface the SA token", v.Name)
}
if v.HostPath != nil {
t.Errorf("volume %q is a hostPath — no node filesystem access allowed", v.Name)
}
if v.PersistentVolumeClaim == nil {
t.Errorf("volume %q is not a PVC; only the world and backup PVCs are permitted", v.Name)
continue
}
switch v.PersistentVolumeClaim.ClaimName {
case "world-survival-0":
world = v
case "felis-backups":
backup = v
default:
t.Errorf("unexpected PVC %q mounted", v.PersistentVolumeClaim.ClaimName)
}
}
if world == nil {
t.Fatal("world PVC not mounted")
}
if backup == nil {
t.Fatal("backup PVC not mounted")
}
// The backup PVC must be read-only at the volume source: a restore must not
// be able to mutate the archive store.
if !backup.PersistentVolumeClaim.ReadOnly {
t.Error("backup PVC volume source must be ReadOnly")
}
// And the container's mounts must agree: backup read-only, world writable.
c := singleContainer(t, job)
var backupMount, worldMount *corev1.VolumeMount
for i := range c.VolumeMounts {
m := &c.VolumeMounts[i]
switch m.Name {
case backupVolume:
backupMount = m
case worldVolume:
worldMount = m
}
}
if backupMount == nil || !backupMount.ReadOnly {
t.Error("backup mount must be ReadOnly")
}
if worldMount == nil || worldMount.ReadOnly {
t.Error("world mount must be writable (the archive extracts into it)")
}
if backupMount != nil && backupMount.MountPath != "/backups" {
t.Errorf("backup mount path = %q, want /backups (absolute refs resolve here)", backupMount.MountPath)
}
// Volumes are only half the red line: a single Env var (e.g. a DATABASE_URL)
// or an EnvFrom pulling a whole Secret/ConfigMap into the environment would
// hand the restore Pod a credential without ever mounting one. The container
// gets ALL of its input from the command flags, so both must be empty.
if len(c.Env) != 0 {
t.Errorf("restore container must carry no env vars, got %+v", c.Env)
}
if len(c.EnvFrom) != 0 {
t.Errorf("restore container must carry no envFrom sources (no Secret/ConfigMap injection), got %+v", c.EnvFrom)
}
}
// A poisoned archive must terminate and not loop or run unbounded; the finished
// Job must self-GC.
func TestRestoreJobIsBoundedOneShotAndSelfCleaning(t *testing.T) {
job, err := RestoreJob(sampleJobParams())
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 {
t.Error("BackoffLimit must be 0 — a bad archive must not retry")
}
if job.Spec.ActiveDeadlineSeconds == nil || *job.Spec.ActiveDeadlineSeconds != 1800 {
t.Errorf("ActiveDeadlineSeconds must be 1800, got %v", job.Spec.ActiveDeadlineSeconds)
}
if job.Spec.TTLSecondsAfterFinished == nil || *job.Spec.TTLSecondsAfterFinished != 600 {
t.Errorf("TTLSecondsAfterFinished must be 600, got %v", job.Spec.TTLSecondsAfterFinished)
}
if job.Spec.Template.Spec.RestartPolicy != corev1.RestartPolicyNever {
t.Error("RestartPolicy must be Never")
}
}
// The container must be non-root, non-privileged, escalation-proof, read-only
// root, drop ALL caps, and carry resource limits.
func TestRestoreJobContainerIsHardened(t *testing.T) {
job, err := RestoreJob(sampleJobParams())
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
pod := job.Spec.Template.Spec
if pod.SecurityContext == nil || pod.SecurityContext.RunAsNonRoot == nil || !*pod.SecurityContext.RunAsNonRoot {
t.Error("pod must set runAsNonRoot=true")
}
if pod.SecurityContext == nil || pod.SecurityContext.FSGroup == nil || *pod.SecurityContext.FSGroup != 1000 {
t.Error("pod must set an fsGroup so restored files are group-owned by the server identity")
}
c := singleContainer(t, job)
sc := c.SecurityContext
if sc == nil {
t.Fatal("container has no security context")
}
if sc.Privileged == nil || *sc.Privileged {
t.Error("container must not be privileged")
}
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
t.Error("container must set allowPrivilegeEscalation=false")
}
if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
t.Error("container must set readOnlyRootFilesystem=true (writes go only to the world PVC)")
}
if sc.Capabilities == nil || len(sc.Capabilities.Drop) == 0 || string(sc.Capabilities.Drop[0]) != "ALL" {
t.Errorf("container must drop ALL capabilities, got %v", sc.Capabilities)
}
if c.Resources.Limits.Cpu().IsZero() || c.Resources.Limits.Memory().IsZero() {
t.Error("container must carry CPU+memory limits")
}
}
// The container must invoke `felis restore` with the archive parameters as
// plain flags — and crucially the world PVC name the operator/reaper agree on.
func TestRestoreJobInvokesFelisRestoreWithParams(t *testing.T) {
p := sampleJobParams()
job, err := RestoreJob(p)
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
c := singleContainer(t, job)
if len(c.Command) < 2 || c.Command[0] != "felis" || c.Command[1] != "restore" {
t.Errorf("command = %v, want [felis restore ...]", c.Command)
}
if !argPairPresent(c.Args, "--server", p.Server) {
t.Errorf("args must carry --server %q, got %v", p.Server, c.Args)
}
if !argPairPresent(c.Args, "--ref", p.BackupRef) {
t.Errorf("args must carry --ref %q, got %v", p.BackupRef, c.Args)
}
if !argPairPresent(c.Args, "--archive-store", p.ArchiveStore) {
t.Errorf("args must carry --archive-store %q, got %v", p.ArchiveStore, c.Args)
}
if !argPairPresent(c.Args, "--backup-root", p.BackupRoot) {
t.Errorf("args must carry --backup-root %q, got %v", p.BackupRoot, c.Args)
}
if !argPairPresent(c.Args, "--worlds-root", p.WorldsRoot) {
t.Errorf("args must carry --worlds-root %q, got %v", p.WorldsRoot, c.Args)
}
if job.Name != "restore-survival" {
t.Errorf("job name = %q, want restore-survival (deterministic for idempotency)", job.Name)
}
}
// An empty image must be rejected rather than render an unrunnable Job; this is
// what lets cmd/felis fall back to a 503 instead of enqueuing junk.
func TestRestoreJobRequiresImage(t *testing.T) {
p := sampleJobParams()
p.Image = ""
if _, err := RestoreJob(p); err == nil {
t.Error("expected error for an empty image")
}
}
func TestRestoreJobRejectsBadResourceLimit(t *testing.T) {
p := sampleJobParams()
p.MemLimit = "not-a-quantity"
if _, err := RestoreJob(p); err == nil {
t.Error("expected error for an unparseable memory limit")
}
}
// The restore SA must be bare: no secrets, token automount disabled.
func TestRestoreServiceAccountIsBare(t *testing.T) {
sa := RestoreServiceAccount(defaultNamespace, defaultServiceAccount)
if sa.AutomountServiceAccountToken == nil || *sa.AutomountServiceAccountToken {
t.Error("SA must disable token automounting")
}
if len(sa.Secrets) != 0 {
t.Errorf("SA must carry no secrets, got %d", len(sa.Secrets))
}
if len(sa.ImagePullSecrets) != 0 {
t.Errorf("SA must carry no image-pull secrets, got %d", len(sa.ImagePullSecrets))
}
}
// ---- helpers ----
func singleContainer(t *testing.T, job *batchv1.Job) corev1.Container {
t.Helper()
cs := job.Spec.Template.Spec.Containers
if len(cs) != 1 {
t.Fatalf("expected exactly one restore container, got %d", len(cs))
}
return cs[0]
}
func argPairPresent(args []string, flag, val string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag && args[i+1] == val {
return true
}
}
return false
}
+46
View File
@@ -0,0 +1,46 @@
package restore
import (
"context"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// K8sJobs is the production Jobs backed by a controller-runtime client (spec
// §7, §16). It creates the world-restore Job — nothing more: the restore Job is
// one-shot and self-cleaning (ttlSecondsAfterFinished), so there is no phase or
// cancel seam, and thus no config to hold (unlike build.K8sJobs, which needs the
// namespace to read and delete its Job). Every restore parameter arrives in the
// JobParams the Restorer builds from its own (defaulted) Config. The
// cluster-bootstrap objects (the weak felis-restore SA) are installed once by
// the deployment manifests (spec §21), not per restore, so this binding never
// creates them. It is integration-tested against a live cluster, not the
// hermetic restore_test.go suite.
type K8sJobs struct {
c client.Client
}
// NewK8sJobs builds a Jobs over c. The restore Job's parameters all travel in
// JobParams, so there is no Config to retain here.
func NewK8sJobs(c client.Client) *K8sJobs {
return &K8sJobs{c: c}
}
// CreateRestoreJob renders and applies the restore Job. Its name is a
// deterministic function of the server (RestoreJobName), so a concurrent restore
// of the same server collides on Create; that collision is mapped to
// ErrAlreadyExists, which the Restorer treats as success (idempotent enqueue).
func (k *K8sJobs) CreateRestoreJob(ctx context.Context, p JobParams) error {
job, err := RestoreJob(p)
if err != nil {
return err
}
if err := k.c.Create(ctx, job); err != nil {
if apierrors.IsAlreadyExists(err) {
return ErrAlreadyExists
}
return err
}
return nil
}
+222
View File
@@ -0,0 +1,222 @@
// Package restore implements the world-restore executor (spec §7
// POST /servers/{name}/restore-backup, spec §466: a former owner who re-claims a
// released server within the retention window restores their archived world).
//
// felis-api cannot restore a world in-process: the world PVC is RWO and owned by
// the operator's StatefulSet, so the API has nothing to mount at request time
// (see internal/api.Restorer). This package is the production executor it hands
// off to — a one-shot Kubernetes Job in the minecraft namespace that mounts the
// target world PVC and the backup store, then runs `felis restore` (cmd/felis)
// to extract the archive into the world volume.
//
// Trust model, mirroring internal/build's weak-SA isolation (spec §16, §21, §22):
// the restore Pod runs under a deliberately weak service account with its token
// auto-mount disabled, so it cannot reach the K8s API; it is handed ONLY the two
// PVCs and the archive parameters as plain flags, never a database URL or any
// Secret — the four-power red line that a build/restore Pod must not touch the
// felis database or the K8s API. felis-api owns the database and the
// authorization decision (handlers_backups.go); this Pod only moves bytes from
// the backup PVC onto the world PVC. Every isolation guarantee lives in the pure
// jobspec (jobspec.go) and is asserted by jobspec_test.go, because no cluster
// runs in this environment.
//
// The Restorer depends on the Jobs interface, so the orchestration (idempotent
// enqueue, error mapping) is unit-tested against an in-memory fake; the
// controller-runtime implementation (k8sjobs.go) compiles here but is exercised
// only by integration tests against a live cluster.
package restore
import (
"context"
"errors"
"time"
"felis.lolicon.best/internal/naming"
)
// ErrAlreadyExists is returned by a Jobs implementation when a restore Job for a
// server already exists (a restore is already in flight). The Restorer treats it
// as success — see Restore.
var ErrAlreadyExists = errors.New("restore: job already exists")
// Jobs is the cluster-side restore lifecycle the Restorer depends on. It is an
// interface so the orchestration is tested against a fake; the controller-runtime
// implementation (K8sJobs) is integration-tested only — it requires a live
// cluster. The restore Job is one-shot and self-cleaning (TTL), so unlike the
// build subsystem there is no phase-polling or cancel seam: kicking it off is the
// whole contract, exactly matching the asynchronous 202 the handler answers.
type Jobs interface {
// CreateRestoreJob renders and applies the restore Job for p. It returns
// ErrAlreadyExists if a Job of the same (deterministic) name already exists.
CreateRestoreJob(ctx context.Context, p JobParams) error
}
// Config parameterises the restore executor. Deployment-specific values that
// have no safe default — the felis Image to run and the BackupPVC to mount — are
// supplied by the caller (cmd/felis sources them from the environment); when
// either is empty the caller leaves the API's Restorer nil so the endpoint
// reports 503 rather than enqueuing a Job that cannot run.
type Config struct {
// Namespace is where the world PVCs live and the restore Job runs (the
// minecraft namespace). The Job is intentionally co-located with the world it
// restores; it never runs in the felis control-plane namespace.
Namespace string
// ServiceAccount is the weak SA the restore Pod runs as. Like felis-build it
// MUST NOT be the felis-api SA and has no Role/RoleBinding anywhere.
ServiceAccount string
// Image is the felis binary image; the Job runs `felis restore` from it.
Image string
// ArchiveStore selects the backup backend. Only "tarLocal" is implemented in
// this build, mirroring the reaper (cmd/felis buildArchiver).
ArchiveStore string
// BackupPVC is the name of the backup PVC the archives live on. It must be
// RWX so the reaper and concurrent restores can mount it (a helm-slice
// contract); restore mounts it read-only.
BackupPVC string
// BackupRoot is the in-Pod mount path of BackupPVC. It MUST equal the path
// the reaper wrote archives under (cfg.Archive.LocalPath), because tarLocal
// archive refs are absolute paths — mounting the PVC anywhere else would make
// the stored ref unresolvable inside the Pod.
BackupRoot string
// WorldsRoot is the in-Pod mount path of the world PVC the archive extracts
// into.
WorldsRoot string
// Deadline caps the restore Pod's wall-clock (activeDeadlineSeconds).
Deadline time.Duration
// CPULimit / MemLimit cap the restore container.
CPULimit string
MemLimit string
// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. FSGroup in
// particular MUST match the operator StatefulSet's runtime group so the files
// the restore Pod writes are readable by the minecraft server that later
// mounts the same world PVC. The default matches the conventional minecraft
// container uid; a deployment that runs minecraft as another id overrides it.
RunAsUser int64
RunAsGroup int64
FSGroup int64
// TTLAfterFinished is how long a finished restore Job lingers before the Job
// controller garbage-collects it. There is no cancel path, so the TTL is the
// only cleanup; it also bounds the window in which a re-restore sees a stale
// completed Job as ErrAlreadyExists.
TTLAfterFinished time.Duration
}
// defaults applied when a Config field is left zero. Image and BackupPVC have no
// default on purpose — see Config.
const (
defaultNamespace = "minecraft"
defaultServiceAccount = "felis-restore"
defaultArchiveStore = "tarLocal"
defaultBackupRoot = "/backups"
defaultWorldsRoot = "/world"
defaultDeadline = 30 * time.Minute
defaultCPULimit = "1"
defaultMemLimit = "1Gi"
defaultRunAsID = int64(1000)
defaultTTL = 10 * time.Minute
)
// withDefaults returns a copy of c with zero fields filled, so a partially
// configured Config (or the zero value, in tests) is always usable.
func (c Config) withDefaults() Config {
if c.Namespace == "" {
c.Namespace = defaultNamespace
}
if c.ServiceAccount == "" {
c.ServiceAccount = defaultServiceAccount
}
if c.ArchiveStore == "" {
c.ArchiveStore = defaultArchiveStore
}
if c.BackupRoot == "" {
c.BackupRoot = defaultBackupRoot
}
if c.WorldsRoot == "" {
c.WorldsRoot = defaultWorldsRoot
}
if c.Deadline <= 0 {
c.Deadline = defaultDeadline
}
if c.CPULimit == "" {
c.CPULimit = defaultCPULimit
}
if c.MemLimit == "" {
c.MemLimit = defaultMemLimit
}
if c.RunAsUser == 0 {
c.RunAsUser = defaultRunAsID
}
if c.RunAsGroup == 0 {
c.RunAsGroup = defaultRunAsID
}
if c.FSGroup == 0 {
c.FSGroup = defaultRunAsID
}
if c.TTLAfterFinished <= 0 {
c.TTLAfterFinished = defaultTTL
}
return c
}
// Restorer is the production internal/api.Restorer (the compile-time proof of
// that is in internal/api's test, which imports this package; this package never
// imports api). It holds no mutable state.
type Restorer struct {
Jobs Jobs
Config Config
}
// Restore enqueues a restore Job that extracts the archive at backupRef into
// serverName's world PVC. It returns once the Job is created — the extraction
// runs in the Pod — so the handler's 202 ("restoring") is honest.
//
// It is idempotent: if a restore Job for this server already exists (a restore
// is already in flight, or a just-finished one has not yet hit its TTL), the
// duplicate enqueue is treated as success rather than surfaced as an error.
//
// The coalescing key is the Job name (RestoreJobName), which depends only on the
// server, NOT on backupRef — so a second request that arrives while one is in
// flight is absorbed regardless of the ref it carries, and if the two refs
// differ the second is silently dropped (the in-flight restore wins). That is
// acceptable here: restore runs only for a Stopped server (handler gate ⑥) and
// the handler always passes the latest backup, which for a stopped server does
// not change, so concurrent requests carry the same ref in practice. A caller
// that genuinely needs a different archive can re-request after the Job clears
// its TTL. This keeps the handler's 202 honest without it having to map "already
// in progress" onto a 500.
func (r *Restorer) Restore(ctx context.Context, serverName, backupRef string) error {
if err := r.Jobs.CreateRestoreJob(ctx, r.jobParams(serverName, backupRef)); err != nil {
if errors.Is(err, ErrAlreadyExists) {
return nil // already enqueued — idempotent
}
return err
}
return nil
}
// jobParams projects the server, archive ref, and config onto the inputs
// jobspec.go renders. The world PVC name is derived from the single shared
// naming convention (naming.WorldPVCName), the same one the operator created it
// under and the reaper deletes it by.
func (r *Restorer) jobParams(serverName, backupRef string) JobParams {
cfg := r.Config.withDefaults()
return JobParams{
Server: serverName,
WorldPVC: naming.WorldPVCName(serverName),
BackupPVC: cfg.BackupPVC,
BackupRef: backupRef,
ArchiveStore: cfg.ArchiveStore,
Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount,
Image: cfg.Image,
BackupRoot: cfg.BackupRoot,
WorldsRoot: cfg.WorldsRoot,
Deadline: cfg.Deadline,
CPULimit: cfg.CPULimit,
MemLimit: cfg.MemLimit,
RunAsUser: cfg.RunAsUser,
RunAsGroup: cfg.RunAsGroup,
FSGroup: cfg.FSGroup,
TTLAfterFinished: cfg.TTLAfterFinished,
}
}
+84
View File
@@ -0,0 +1,84 @@
package restore_test
import (
"context"
"errors"
"testing"
"felis.lolicon.best/internal/restore"
)
// fakeJobs is an in-memory Jobs that records the params it was handed and
// returns a programmable error, so the orchestration is tested without a
// cluster.
type fakeJobs struct {
calls []restore.JobParams
err error
}
func (f *fakeJobs) CreateRestoreJob(_ context.Context, p restore.JobParams) error {
f.calls = append(f.calls, p)
return f.err
}
func TestRestoreEnqueuesJobWithDerivedParams(t *testing.T) {
jobs := &fakeJobs{}
r := &restore.Restorer{
Jobs: jobs,
Config: restore.Config{
Image: "registry.internal/felis:test",
BackupPVC: "felis-backups",
},
}
if err := r.Restore(context.Background(), "survival", "/backups/survival/2026.tar.gz"); err != nil {
t.Fatalf("Restore: %v", err)
}
if len(jobs.calls) != 1 {
t.Fatalf("CreateRestoreJob called %d times, want 1", len(jobs.calls))
}
got := jobs.calls[0]
if got.Server != "survival" {
t.Errorf("Server = %q, want survival", got.Server)
}
// The world PVC must come from the shared naming convention, not be invented
// here: it is the same name the operator created and the reaper deletes.
if got.WorldPVC != "world-survival-0" {
t.Errorf("WorldPVC = %q, want world-survival-0", got.WorldPVC)
}
if got.BackupRef != "/backups/survival/2026.tar.gz" {
t.Errorf("BackupRef = %q, want the passed ref", got.BackupRef)
}
if got.BackupPVC != "felis-backups" {
t.Errorf("BackupPVC = %q, want felis-backups", got.BackupPVC)
}
if got.Image != "registry.internal/felis:test" {
t.Errorf("Image = %q, want the configured image", got.Image)
}
// withDefaults must have filled the unset fields.
if got.Namespace == "" || got.ServiceAccount == "" || got.WorldsRoot == "" || got.BackupRoot == "" {
t.Errorf("defaults not applied: %+v", got)
}
}
func TestRestoreIsIdempotentOnAlreadyExists(t *testing.T) {
jobs := &fakeJobs{err: restore.ErrAlreadyExists}
r := &restore.Restorer{Jobs: jobs, Config: restore.Config{Image: "img", BackupPVC: "pvc"}}
// A restore already in flight is success, not an error: the handler must be
// able to answer 202 for a coalesced duplicate request.
if err := r.Restore(context.Background(), "survival", "ref"); err != nil {
t.Fatalf("Restore on AlreadyExists = %v, want nil (idempotent)", err)
}
}
func TestRestorePropagatesGenericError(t *testing.T) {
sentinel := errors.New("apiserver exploded")
jobs := &fakeJobs{err: sentinel}
r := &restore.Restorer{Jobs: jobs, Config: restore.Config{Image: "img", BackupPVC: "pvc"}}
err := r.Restore(context.Background(), "survival", "ref")
if !errors.Is(err, sentinel) {
t.Fatalf("Restore error = %v, want the underlying error propagated", err)
}
}