Unverified Commit 43ab9215 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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

Archive-based world backup and restore, plus the reaper that enforces retention and reclaims idle servers.
parent 708cdfc5
Loading
Loading
Loading
Loading
+45 −0
Changes for internal/backup/archiver.go: 45 added lines, 0 removed lines.
Original line number Diff line number Diff line
// 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 −0
Changes for internal/backup/tarlocal.go: 286 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/backup/tarlocal_test.go: 224 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/reaper/k8scluster.go: 74 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading