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.
This commit is contained in:
flyemoji committed 2026-07-07 10:04:30 +09:00
1 parent fad48ff21d
commit 7a7c0d53ab
16 files changed
+1396 -1

No files matched your search

+30
View File
@@ -13,6 +13,7 @@ import (
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/panel"
@@ -162,6 +163,19 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
}
// On-demand backup subsystem (spec §18/§19 WorldArchiver, run on demand). Its
// backup Job mirrors the restore Job's weak-SA isolation but additionally mounts
// the config Secret so it self-records the world_backups row (see internal/
// backupjob). It needs the same deployment-specific values as restore, so it is
// wired under the same gate; otherwise the Backuper is left nil and the backup
// endpoint honestly returns 503.
var backuper api.Backuper
if felisImage != "" && backupPVC != "" {
backuper = &backupjob.Backuper{Jobs: backupjob.NewK8sJobs(cl), Config: backupConfig(cfg, felisImage, backupPVC)}
} else {
fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503")
}
// One PGRepo instance backs both the handlers and the session verifier: the
// SessionAuth that fronts the external face reads sessions/users/settings from
// the same store the auth handlers write to, so a login and the next request
@@ -179,6 +193,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Internal: api.BearerTokenAuth{Token: token},
Builder: builder,
Restorer: restorer,
Backuper: backuper,
Submissions: submissions,
// The external face is fronted by SessionAuth: it prefers a local-password
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
@@ -359,6 +374,21 @@ func restoreConfig(cfg *config.Config, image, backupPVC string) restore.Config {
}
}
// backupConfig builds the on-demand backup executor's config from felis.toml plus
// the deployment-supplied image and backup PVC. BackupRoot mirrors restoreConfig —
// it MUST equal [archive] local_path so the recorded ref resolves the same way a
// later restore Job mounts it. ConfigSecret/ConfigMount are left to backupjob's
// defaults (the control-plane manifest names), which is the Secret this backup Job
// mounts to self-record its world_backups row.
func backupConfig(cfg *config.Config, image, backupPVC string) backupjob.Config {
return backupjob.Config{
Namespace: cfg.K8s.Namespace,
Image: image,
BackupPVC: backupPVC,
BackupRoot: cfg.Archive.LocalPath,
}
}
// reconcileBuilds polls unfinished builds on an interval and advances any whose
// Job has reached a terminal phase. It exits when ctx is cancelled.
func reconcileBuilds(ctx context.Context, b *build.Builder, stderr io.Writer) {
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"io"
"time"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/reaper"
"felis.lolicon.best/internal/store"
ctrl "sigs.k8s.io/controller-runtime"
)
// cmdBackup is the in-Pod entrypoint the on-demand backup Job runs. internal/backupjob
// renders a Pod whose command is `/usr/local/bin/felis backup`. It tars the mounted
// world into the archive store AND records the world_backups row, then exits — it is
// NOT a user-facing command and is never invoked by hand.
//
// Unlike `felis restore`, this command DOES hold database credentials (via the mounted
// config Secret) and calls config.Load: a backup must record its row atomically with
// the archive, exactly like the reaper — otherwise a completed archive would leak as an
// orphan file the retention pass never expires. The security review for that departure
// lives in internal/backupjob/jobspec.go. The world is mounted directly at --worlds-root
// (single-PVC mount, like restore), so the archiver's resolver returns that root for any
// PVC; the archive is written into the backup PVC at cfg.Archive.LocalPath.
func cmdBackup(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
server := fs.String("server", "", "server name whose world is being backed up")
formerOwner := fs.String("former-owner", "", "owner recorded on the backup row (empty for an unowned server)")
worldsRoot := fs.String("worlds-root", "/world", "mount path of the world PVC being archived")
if err := fs.Parse(args); err != nil {
return 2
}
if *server == "" {
fmt.Fprintln(stderr, "felis backup: --server is required")
return 2
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis backup: %v\n", err)
return 1
}
if cfg.Archive.Store != "tarLocal" {
fmt.Fprintf(stderr, "felis backup: archive store %q is not implemented in this build (only tarLocal)\n", cfg.Archive.Store)
return 1
}
// Reuse the reaper's retention derivation so an on-demand backup expires on the
// same clock as an inactivity backup — one retention policy, not two.
rcfg, err := reaperConfig(cfg)
if err != nil {
fmt.Fprintf(stderr, "felis backup: %v\n", err)
return 1
}
// The world PVC is mounted directly at worldsRoot; the resolver returns it for
// any target, exactly as in cmdRestore. This is the same TarLocal the reaper
// writes archives with.
archiver := &backup.TarLocal{
BackupRoot: cfg.Archive.LocalPath,
Resolve: func(string) (string, error) {
return *worldsRoot, nil
},
}
ctx := ctrl.SetupSignalHandler()
ref, size, err := archiver.Archive(ctx, *server, naming.WorldPVCName(*server))
if err != nil {
fmt.Fprintf(stderr, "felis backup: archive: %v\n", err)
return 1
}
drv, err := store.Open(ctx, cfg.Database.URL)
if err != nil {
fmt.Fprintf(stderr, "felis backup: open database: %v\n", err)
return 1
}
defer drv.Close()
rec := reaper.BackupRecord{
ID: newBackupID(),
ServerName: *server,
FormerOwner: *formerOwner,
BackupRef: string(ref),
SizeBytes: size,
Reason: "manual",
ExpiresAt: time.Now().Add(rcfg.Retention),
}
if err := reaper.NewPGStore(drv.DB()).InsertBackup(ctx, rec); err != nil {
// The archive is written but unrecorded — an orphan the retention pass would
// never expire. Delete it so a failed backup leaves no leaked bytes, mirroring
// the reaper's archive-then-record atomicity.
if delErr := archiver.Delete(ctx, ref); delErr != nil {
fmt.Fprintf(stderr, "felis backup: record failed (%v) AND orphan archive %s could not be removed: %v\n", err, ref, delErr)
return 1
}
fmt.Fprintf(stderr, "felis backup: record failed, orphan archive removed: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "felis backup: server=%s archived %d bytes to %s (backup %s)\n", *server, size, ref, rec.ID)
return 0
}
// newBackupID mints a world_backups primary key, matching the reaper's "bk-"+hex
// scheme so a manual and an inactivity backup are indistinguishable downstream.
func newBackupID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failure is fatal and unrecoverable; a time-based fallback would
// be a weaker ID for no benefit. ponytail: panic is the honest failure here.
panic("felis backup: crypto/rand: " + err.Error())
}
return "bk-" + hex.EncodeToString(b[:])
}
+3
View File
@@ -16,6 +16,7 @@ Commands:
api Run the felis-api HTTP server
reaper Run the world reaper / backup batch
restore Extract a world archive into a world volume (internal Job entrypoint)
backup Archive a world into the backup store and record it (internal Job entrypoint)
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
@@ -43,6 +44,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdReaper(rest, stdout, stderr)
case "restore":
return cmdRestore(rest, stdout, stderr)
case "backup":
return cmdBackup(rest, stdout, stderr)
case "manifests":
return cmdManifests(rest, stdout, stderr)
case "apply":