The platform package that places servers across nodes and wires the operator, build, restore, and reaper subsystems, plus cmd/felis, the single binary that runs them.
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/backup"
|
|
)
|
|
|
|
// archiveTempWorld tars a freshly populated world directory with the real
|
|
// TarLocal and returns the absolute archive ref plus the backup root it lives
|
|
// under, so the restore round-trip below exercises production code end to end
|
|
// without a cluster.
|
|
func archiveTempWorld(t *testing.T, files map[string]string) (ref string, backupRoot string) {
|
|
t.Helper()
|
|
srcDir := t.TempDir()
|
|
for name, content := range files {
|
|
full := filepath.Join(srcDir, filepath.FromSlash(name))
|
|
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: %v", err)
|
|
}
|
|
}
|
|
backupRoot = t.TempDir()
|
|
ar := &backup.TarLocal{
|
|
BackupRoot: backupRoot,
|
|
Resolve: func(string) (string, error) { return srcDir, nil },
|
|
}
|
|
got, _, err := ar.Archive(context.Background(), "survival", "world-survival-0")
|
|
if err != nil {
|
|
t.Fatalf("Archive: %v", err)
|
|
}
|
|
return string(got), backupRoot
|
|
}
|
|
|
|
// The restore subcommand must extract the archived world into the target world
|
|
// mount — the real reverse of what the reaper wrote.
|
|
func TestRestoreSubcommandRoundTrips(t *testing.T) {
|
|
files := map[string]string{
|
|
"level.dat": "world-seed",
|
|
"region/r.0.0.mca": "chunk-bytes",
|
|
"playerdata/uuid.json": "{}",
|
|
}
|
|
ref, backupRoot := archiveTempWorld(t, files)
|
|
|
|
// Seed the target with a stale file the archive does not contain: the restore
|
|
// must prune it (replace semantics), not leave it behind. This is the path the
|
|
// admin/owner rollback-onto-a-stopped-populated-PVC case actually takes.
|
|
targetDir := t.TempDir()
|
|
stale := filepath.Join(targetDir, "region", "r.9.9.mca")
|
|
if err := os.MkdirAll(filepath.Dir(stale), 0o750); err != nil {
|
|
t.Fatalf("mkdir stale: %v", err)
|
|
}
|
|
if err := os.WriteFile(stale, []byte("griefer-chunk"), 0o640); err != nil {
|
|
t.Fatalf("seed stale: %v", err)
|
|
}
|
|
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{
|
|
"restore",
|
|
"--server", "survival",
|
|
"--ref", ref,
|
|
"--archive-store", "tarLocal",
|
|
"--backup-root", backupRoot,
|
|
"--worlds-root", targetDir,
|
|
}, &out, &errBuf)
|
|
if code != 0 {
|
|
t.Fatalf("restore exit = %d, stderr=%q", code, errBuf.String())
|
|
}
|
|
|
|
for name, want := range files {
|
|
full := filepath.Join(targetDir, filepath.FromSlash(name))
|
|
got, err := os.ReadFile(full)
|
|
if err != nil {
|
|
t.Errorf("restored file %q missing: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("restored %q = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
|
|
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
|
t.Errorf("stale file survived restore (err=%v); replace semantics broken", err)
|
|
}
|
|
}
|
|
|
|
// A ref outside the backup mount must be rejected before any file is opened —
|
|
// the restore Pod must never read an arbitrary host path.
|
|
func TestRestoreSubcommandRejectsRefOutsideBackupRoot(t *testing.T) {
|
|
backupRoot := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "evil.tar.gz")
|
|
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{
|
|
"restore",
|
|
"--server", "survival",
|
|
"--ref", outside,
|
|
"--backup-root", backupRoot,
|
|
"--worlds-root", t.TempDir(),
|
|
}, &out, &errBuf)
|
|
if code != 1 {
|
|
t.Fatalf("exit = %d, want 1 for an out-of-root ref", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "not under backup root") {
|
|
t.Errorf("expected containment error, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
// An unimplemented archive store must fail loudly, not silently no-op.
|
|
func TestRestoreSubcommandRejectsUnknownStore(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{
|
|
"restore",
|
|
"--ref", "/backups/x.tar.gz",
|
|
"--archive-store", "s3snapshot",
|
|
"--backup-root", "/backups",
|
|
}, &out, &errBuf)
|
|
if code != 1 {
|
|
t.Fatalf("exit = %d, want 1 for an unknown store", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "not implemented") {
|
|
t.Errorf("expected not-implemented error, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
// --ref is mandatory: a missing ref is a usage error, not a panic.
|
|
func TestRestoreSubcommandRequiresRef(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{"restore", "--backup-root", "/backups"}, &out, &errBuf)
|
|
if code != 2 {
|
|
t.Fatalf("exit = %d, want 2 for a missing --ref", code)
|
|
}
|
|
}
|