243 lines
9.4 KiB
Go
243 lines
9.4 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", Path: "plugins",
|
|
})
|
|
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},
|
|
{"files export", exportJob(t, "survival", worldexport.ModeFiles), KindExport, true},
|
|
} {
|
|
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)
|
|
}
|
|
}
|