Files
Felis/internal/backupjob/backup_test.go
T
flyemoji 7a7c0d53ab feat(api): add on-demand world backup endpoint and Job executor (§B4 Sync)
Add POST /api/v1/servers/{name}/backup: an owner or admin snapshots a
stopped server's world into the archive store on demand, recorded as a
first-class world_backups row (reason `manual`) — restorable by the
existing restore path and expired by the reaper's retention pass, so it
never leaks as an orphan archive. This is the break-glass "Sync" op,
resolved as immediate/on-demand backup.

felis-api cannot archive in-process (the world PVC is RWO, held by the
operator StatefulSet), so the work hands off to a one-shot Kubernetes Job
(new internal/backupjob) that mounts the world PVC read-only and the
backup PVC read-write, plus the felis config Secret so it self-records
its row atomically like the reaper. The Pod mirrors restore's weak-SA
isolation (SA token un-mounted, non-root, read-only rootfs, drop ALL);
the one reviewed departure is that config-Secret mount, frozen by
jobspec_test.go. Handler answers 202 backing_up; gated on the server
being Stopped (RWO world PVC), owner-or-admin, and FELIS_IMAGE +
FELIS_BACKUP_PVC being wired (else 503 backup_unavailable).

Each request mints a unique Job name (backup-<server>-<rand>) so a repeat
on-demand backup produces a fresh archive rather than colliding with a
just-finished Job still inside its TTL window and silently no-op'ing the
retry.
2026-07-07 10:04:30 +09:00

44 lines
1.3 KiB
Go

package backupjob
import (
"context"
"strings"
"testing"
)
// captureJobs records every JobParams it is handed so the test can inspect the
// names the Backuper minted.
type captureJobs struct{ got []JobParams }
func (c *captureJobs) CreateBackupJob(_ context.Context, p JobParams) error {
c.got = append(c.got, p)
return nil
}
// The core fix: a repeat on-demand backup of the same server must not collide with
// the previous Job's name (which would silently no-op the retry within the TTL
// window). Two Backup calls must mint two distinct Job names, both under the
// server's base prefix.
func TestBackupMintsUniqueJobNamePerCall(t *testing.T) {
jobs := &captureJobs{}
b := &Backuper{Jobs: jobs, Config: Config{Image: "img", BackupPVC: "pvc"}}
for i := 0; i < 2; i++ {
if err := b.Backup(context.Background(), "survival", "usr-1"); err != nil {
t.Fatalf("Backup: %v", err)
}
}
if len(jobs.got) != 2 {
t.Fatalf("created %d jobs, want 2", len(jobs.got))
}
base := BackupJobName("survival")
for _, p := range jobs.got {
if !strings.HasPrefix(p.JobName, base+"-") {
t.Errorf("JobName %q must extend the base %q", p.JobName, base)
}
}
if jobs.got[0].JobName == jobs.got[1].JobName {
t.Errorf("two backups reused the Job name %q — retry would silently no-op", jobs.got[0].JobName)
}
}