Files
Felis/internal/maintenance/maintenance_test.go
T

242 lines
9.3 KiB
Go

package maintenance
import (
"testing"
"time"
"felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/restore"
"felis.lolicon.best/internal/worldexport"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
)
var now = time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
func restoreJob(t *testing.T, server string) batchv1.Job {
t.Helper()
j, err := restore.RestoreJob(restore.JobParams{
Server: server, WorldPVC: "world-" + server + "-0", BackupPVC: "felis-backups",
BackupRef: "/backups/" + server + "/a.tar.gz", ArchiveStore: "tarLocal",
Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1",
BackupRoot: "/backups", WorldsRoot: "/world", Deadline: time.Minute,
CPULimit: "1", MemLimit: "1Gi", TTLAfterFinished: time.Minute,
})
if err != nil {
t.Fatalf("RestoreJob: %v", err)
}
return *j
}
func backupJob(t *testing.T, server string) batchv1.Job {
t.Helper()
j, err := backupjob.BackupJob(backupjob.JobParams{
Server: server, FormerOwner: "u", WorldPVC: "world-" + server + "-0", BackupPVC: "felis-backups",
Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1",
ConfigSecret: "felis-config", ConfigMount: "/etc/felis", BackupRoot: "/backups",
WorldsRoot: "/world", Deadline: time.Minute, CPULimit: "1", MemLimit: "1Gi",
TTLAfterFinished: time.Minute,
})
if err != nil {
t.Fatalf("BackupJob: %v", err)
}
return *j
}
func filesJob(t *testing.T, server, op string) batchv1.Job {
t.Helper()
p := fileedit.JobParams{
Server: server, OpID: "0011223344556677", Op: op, Path: "server.properties",
WorldPVC: "world-" + server + "-0", Namespace: "minecraft", ServiceAccount: "felis-restore",
Image: "felis:1", WorldsRoot: "/data", Deadline: time.Minute, CPULimit: "500m",
MemLimit: "256Mi", TTLAfterFinished: time.Minute,
}
switch op {
case fileedit.OpWrite:
p.Content = []byte("motd=hi\n")
case fileedit.OpRename:
p.To = "server.properties.bak"
case fileedit.OpUpload:
p.SourceURL, p.UploadToken = "http://felis-api-internal.felis.svc.cluster.local:8081/x", "t"
}
j, err := fileedit.FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
return *j
}
func exportJob(t *testing.T, server, mode string) batchv1.Job {
t.Helper()
j, err := worldexport.ExportJob(worldexport.JobParams{
Server: server, ID: "0011223344556677", Mode: mode, WorldPVC: "world-" + server + "-0",
BackupPVC: "felis-backups", BackupRef: "/backups/a.tar.gz", TargetURL: "http://api/x", Token: "t",
Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1",
BackupRoot: "/backups", WorldsRoot: "/world",
})
if err != nil {
t.Fatalf("ExportJob: %v", err)
}
return *j
}
func finished(j batchv1.Job, cond batchv1.JobConditionType) batchv1.Job {
j.Status.Conditions = append(j.Status.Conditions, batchv1.JobCondition{Type: cond, Status: corev1.ConditionTrue})
return j
}
// The executors keep their own label copies (they must not import each other or
// this package's callers); this pins what they actually render against JobKind.
func TestJobKindMatchesTheExecutors(t *testing.T) {
for _, tc := range []struct {
name string
job batchv1.Job
kind string
ok bool
}{
{"restore", restoreJob(t, "survival"), KindRestore, true},
{"backup", backupJob(t, "survival"), KindBackup, true},
{"file write", filesJob(t, "survival", fileedit.OpWrite), KindFileWrite, true},
{"file mkdir", filesJob(t, "survival", fileedit.OpMkdir), KindFileWrite, true},
{"file delete", filesJob(t, "survival", fileedit.OpDelete), KindFileWrite, true},
{"file rename", filesJob(t, "survival", fileedit.OpRename), KindFileWrite, true},
{"file upload", filesJob(t, "survival", fileedit.OpUpload), KindFileWrite, true},
{"file read", filesJob(t, "survival", fileedit.OpRead), "", false},
{"file list", filesJob(t, "survival", fileedit.OpList), "", false},
{"world export", exportJob(t, "survival", worldexport.ModeWorld), KindExport, true},
{"backup export", exportJob(t, "survival", worldexport.ModeBackup), "", false},
} {
kind, ok := JobKind(&tc.job)
if kind != tc.kind || ok != tc.ok {
t.Errorf("%s: JobKind = %q, %v; want %q, %v", tc.name, kind, ok, tc.kind, tc.ok)
}
if tc.job.Labels[LabelServer] != "survival" {
t.Errorf("%s: server label = %q", tc.name, tc.job.Labels[LabelServer])
}
}
}
func TestUnlabelledFilesJobCountsAsWrite(t *testing.T) {
j := filesJob(t, "survival", fileedit.OpRead)
delete(j.Labels, LabelFilesMode)
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job from before the mode label = %q, %v; want file-write", kind, ok)
}
// An operation a later build adds holds too, until this build learns it.
j.Labels[LabelFilesMode] = "chmod"
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job with an unknown mode = %q, %v; want file-write", kind, ok)
}
}
func TestHolderFromJobs(t *testing.T) {
restoreRunning := restoreJob(t, "survival")
if kind, ok := Holder("survival", nil, []batchv1.Job{restoreRunning}, now); !ok || kind != KindRestore {
t.Fatalf("running restore: Holder = %q, %v", kind, ok)
}
for _, cond := range []batchv1.JobConditionType{
batchv1.JobComplete, batchv1.JobFailed, batchv1.JobSuccessCriteriaMet, batchv1.JobFailureTarget,
} {
if kind, ok := Holder("survival", nil, []batchv1.Job{finished(restoreRunning, cond)}, now); ok {
t.Errorf("restore with %s still holds as %q", cond, kind)
}
}
other := backupJob(t, "creative")
if kind, ok := Holder("survival", nil, []batchv1.Job{other}, now); ok {
t.Fatalf("another server's backup holds survival as %q", kind)
}
read := filesJob(t, "survival", fileedit.OpRead)
if kind, ok := Holder("survival", nil, []batchv1.Job{read}, now); ok {
t.Fatalf("a running file read holds the volume as %q", kind)
}
write := filesJob(t, "survival", fileedit.OpWrite)
if kind, ok := Holder("survival", nil, []batchv1.Job{read, write}, now); !ok || kind != KindFileWrite {
t.Fatalf("running file write: Holder = %q, %v", kind, ok)
}
}
// A safety snapshot holds the volume as a restore from its creation until
// felis-api settles the chain, finished or not, so nothing wakes the server
// between the backup Job and the restore Job it is followed by.
func TestHolderFromRestoreChain(t *testing.T) {
j, err := backupjob.BackupJob(backupjob.JobParams{
Server: "survival", WorldPVC: "world-survival-0", BackupPVC: "felis-backups",
Namespace: "minecraft", Image: "felis:1", ConfigSecret: "felis-config", ConfigMount: "/etc/felis",
RestoreRef: "/backups/survival/a.tar.gz", RestoreBackupID: "bk-1",
})
if err != nil {
t.Fatalf("BackupJob: %v", err)
}
if j.Annotations[AnnotationRestoreRef] != "/backups/survival/a.tar.gz" || j.Annotations[AnnotationRestoreBackupID] != "bk-1" {
t.Fatalf("chain annotations = %v", j.Annotations)
}
if !RestorePending(j) {
t.Fatalf("a fresh safety snapshot is not a pending chain: labels %v", j.Labels)
}
for _, tc := range []struct {
name string
job batchv1.Job
held bool
kind string
}{
{"running", *j, true, KindRestore},
{"succeeded, restore not started yet", finished(*j, batchv1.JobComplete), true, KindRestore},
{"failed, not settled yet", finished(*j, batchv1.JobFailed), true, KindRestore},
{"restore started", settled(finished(*j, batchv1.JobComplete), ThenRestoreStarted), false, ""},
{"abandoned", settled(finished(*j, batchv1.JobFailed), ThenRestoreAbandoned), false, ""},
{"abandoned while running", settled(*j, ThenRestoreAbandoned), true, KindBackup},
} {
kind, held := Holder("survival", nil, []batchv1.Job{tc.job}, now)
if held != tc.held || kind != tc.kind {
t.Errorf("%s: Holder = %q, %v; want %q, %v", tc.name, kind, held, tc.kind, tc.held)
}
}
if plain := backupJob(t, "survival"); RestorePending(&plain) {
t.Fatal("a plain backup is a pending chain")
}
}
func settled(j batchv1.Job, state string) batchv1.Job {
j.Labels = map[string]string{LabelServer: j.Labels[LabelServer], LabelManagedBy: j.Labels[LabelManagedBy], LabelThenRestore: state}
return j
}
func TestHolderFromLock(t *testing.T) {
for _, tc := range []struct {
name string
value string
held bool
}{
{"fresh", LockValue(KindBackup, now.Add(-10*time.Second)), true},
{"just inside grace", LockValue(KindBackup, now.Add(-Grace+time.Second)), true},
{"stale", LockValue(KindBackup, now.Add(-Grace)), false},
{"far future", LockValue(KindBackup, now.Add(time.Hour)), false},
{"garbage", "yes", false},
{"no kind", "@" + now.Format(time.RFC3339), false},
{"bad time", "restore@yesterday", false},
} {
kind, held := Holder("survival", map[string]string{Annotation: tc.value}, nil, now)
if held != tc.held {
t.Errorf("%s (%q): held = %v, want %v", tc.name, tc.value, held, tc.held)
}
if held && kind != KindBackup {
t.Errorf("%s: kind = %q", tc.name, kind)
}
}
}
// An export Job that does not say it reads a backup holds, like a files Job
// with an unknown mode: a world export a newer build labels differently must
// not let the server start under it.
func TestExportJobWithoutModeHolds(t *testing.T) {
j := exportJob(t, "survival", worldexport.ModeBackup)
if j.Labels[LabelExportMode] != ExportModeBackup {
t.Fatalf("export mode label = %q", j.Labels[LabelExportMode])
}
delete(j.Labels, LabelExportMode)
if kind, ok := JobKind(&j); !ok || kind != KindExport {
t.Fatalf("an export Job without a mode = %q, %v; want export", kind, ok)
}
}