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.
243 lines
8.6 KiB
Go
243 lines
8.6 KiB
Go
package backupjob
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
)
|
|
|
|
// Label keys applied to backup objects, mirroring internal/restore so the two
|
|
// executors are observable the same way.
|
|
const (
|
|
LabelManagedBy = "app.kubernetes.io/managed-by"
|
|
LabelComponent = "app.kubernetes.io/component"
|
|
LabelServer = "felis.lolicon.best/server"
|
|
|
|
managedByValue = "felis-backup"
|
|
componentValue = "world-backup"
|
|
|
|
worldVolume = "world"
|
|
backupVolume = "backup"
|
|
configVolume = "config"
|
|
tmpVolume = "tmp"
|
|
felisBinaryPath = "/usr/local/bin/felis"
|
|
)
|
|
|
|
// JobParams are the rendered inputs to a backup Job, derived from a server +
|
|
// former owner + Config by the Backuper. jobspec is a pure function of them so
|
|
// the security-critical Job shape is unit-tested without a cluster.
|
|
type JobParams struct {
|
|
Server string
|
|
// JobName is the unique Job object name for this invocation. The Backuper sets a
|
|
// fresh per-call name (BackupJobName(server) + random suffix) so every on-demand
|
|
// request produces its own archive — a deterministic name would collide with a
|
|
// just-finished Job still inside its TTL window and silently no-op the retry.
|
|
// Empty falls back to the deterministic BackupJobName (tests, and any direct call).
|
|
JobName string
|
|
FormerOwner string // recorded on the world_backups row so the owner can later restore it
|
|
WorldPVC string
|
|
BackupPVC string
|
|
Namespace string
|
|
ServiceAccount string
|
|
Image string
|
|
ConfigSecret string // the felis config Secret mounted for the DB URL (self-recording)
|
|
ConfigMount string // where the config Secret is mounted (holds felis.toml)
|
|
BackupRoot string
|
|
WorldsRoot string
|
|
Deadline time.Duration
|
|
CPULimit string
|
|
MemLimit string
|
|
RunAsUser int64
|
|
RunAsGroup int64
|
|
FSGroup int64
|
|
|
|
TTLAfterFinished time.Duration
|
|
}
|
|
|
|
// BackupJobName is the base Job name for a server's on-demand backup — the prefix
|
|
// the Backuper extends with a per-invocation random suffix (JobParams.JobName) so
|
|
// repeat backups do not collide. It is distinct from the restore Job name so a
|
|
// backup and a restore of the same server never collide.
|
|
func BackupJobName(server string) string { return "backup-" + server }
|
|
|
|
func backupLabels(p JobParams) map[string]string {
|
|
return map[string]string{
|
|
LabelManagedBy: managedByValue,
|
|
LabelComponent: componentValue,
|
|
LabelServer: p.Server,
|
|
}
|
|
}
|
|
|
|
// BackupJob renders the on-demand world-backup Job (spec §18/§19 WorldArchiver,
|
|
// run on demand rather than on the reaper's daily schedule). Its isolation mirrors
|
|
// the restore Job (weak SA, non-root, read-only root fs, drop ALL, one-shot with a
|
|
// deadline) with ONE deliberate, security-reviewed departure asserted by
|
|
// jobspec_test.go:
|
|
//
|
|
// - It DOES mount the felis config Secret (read-only), because a backup must
|
|
// self-record its world_backups row — archive-then-insert atomically, exactly
|
|
// like the reaper, which is the only other component holding both a world mount
|
|
// and the database. A restore Pod must never touch the DB because it processes a
|
|
// potentially poisoned archive; a backup Pod only READS a world the operator
|
|
// already owns and tars it (bytes, never executed), so the poisoned-input threat
|
|
// that forbids restore's DB access does not apply. Its blast radius is the DB
|
|
// plus the two PVCs, matching the reaper's trust for a strict subset of the
|
|
// reaper's operations (it never deletes a PVC and never calls the K8s API — its
|
|
// SA token stays un-mounted).
|
|
// - The world PVC is mounted READ-ONLY (backup only reads it; the RWO volume must
|
|
// be free, which the handler's stopped-gate guarantees), and the backup PVC
|
|
// READ-WRITE (the archive is written into it) — the mirror image of restore.
|
|
//
|
|
// The container runs `/usr/local/bin/felis backup` (cmd/felis), which tars the
|
|
// world at WorldsRoot into BackupRoot and inserts the world_backups row. BackupRoot
|
|
// MUST equal the reaper's [archive] local_path so the recorded absolute ref
|
|
// resolves the same way a later restore Job mounts it.
|
|
func BackupJob(p JobParams) (*batchv1.Job, error) {
|
|
if p.Image == "" {
|
|
return nil, fmt.Errorf("backup: image is empty")
|
|
}
|
|
if p.WorldPVC == "" || p.BackupPVC == "" {
|
|
return nil, fmt.Errorf("backup: world and backup PVC names are required")
|
|
}
|
|
if p.ConfigSecret == "" {
|
|
return nil, fmt.Errorf("backup: config secret name is required")
|
|
}
|
|
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deadline := int64(p.Deadline / time.Second)
|
|
if deadline <= 0 {
|
|
deadline = int64(defaultDeadline / time.Second)
|
|
}
|
|
ttl := int32(p.TTLAfterFinished / time.Second)
|
|
if ttl <= 0 {
|
|
ttl = int32(defaultTTL / time.Second)
|
|
}
|
|
|
|
args := []string{
|
|
"--config", p.ConfigMount + "/felis.toml",
|
|
"--server", p.Server,
|
|
"--worlds-root", p.WorldsRoot,
|
|
}
|
|
// FormerOwner is optional: an admin backing up an unowned (released) server
|
|
// records an empty former_owner, exactly as the reaper does for an unowned reap.
|
|
if p.FormerOwner != "" {
|
|
args = append(args, "--former-owner", p.FormerOwner)
|
|
}
|
|
|
|
container := corev1.Container{
|
|
Name: "backup",
|
|
Image: p.Image,
|
|
Command: []string{felisBinaryPath, "backup"},
|
|
Args: args,
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
// The world is only read to tar it; mounting it read-only means the
|
|
// backup process can never mutate the world it snapshots.
|
|
{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: true},
|
|
{Name: backupVolume, MountPath: p.BackupRoot},
|
|
{Name: configVolume, MountPath: p.ConfigMount, ReadOnly: true},
|
|
{Name: tmpVolume, MountPath: "/tmp"},
|
|
},
|
|
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
|
SecurityContext: &corev1.SecurityContext{
|
|
Privileged: boolPtr(false),
|
|
AllowPrivilegeEscalation: boolPtr(false),
|
|
ReadOnlyRootFilesystem: boolPtr(true),
|
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
|
},
|
|
}
|
|
|
|
name := p.JobName
|
|
if name == "" {
|
|
name = BackupJobName(p.Server)
|
|
}
|
|
job := &batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: p.Namespace,
|
|
Labels: backupLabels(p),
|
|
},
|
|
Spec: batchv1.JobSpec{
|
|
// One shot: a wedged archive must not loop. The TTL GCs the finished Job
|
|
// so a later backup of the same server is not blocked forever by a stale
|
|
// completed Job.
|
|
BackoffLimit: int32Ptr(0),
|
|
ActiveDeadlineSeconds: int64Ptr(deadline),
|
|
TTLSecondsAfterFinished: int32Ptr(ttl),
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: backupLabels(p)},
|
|
Spec: corev1.PodSpec{
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
ServiceAccountName: p.ServiceAccount,
|
|
AutomountServiceAccountToken: boolPtr(false),
|
|
SecurityContext: &corev1.PodSecurityContext{
|
|
RunAsNonRoot: boolPtr(true),
|
|
RunAsUser: int64Ptr(p.RunAsUser),
|
|
RunAsGroup: int64Ptr(p.RunAsGroup),
|
|
FSGroup: int64Ptr(p.FSGroup),
|
|
},
|
|
Containers: []corev1.Container{container},
|
|
Volumes: []corev1.Volume{
|
|
{
|
|
Name: worldVolume,
|
|
VolumeSource: corev1.VolumeSource{
|
|
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
|
ClaimName: p.WorldPVC,
|
|
ReadOnly: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: backupVolume,
|
|
VolumeSource: corev1.VolumeSource{
|
|
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
|
ClaimName: p.BackupPVC,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: configVolume,
|
|
VolumeSource: corev1.VolumeSource{
|
|
Secret: &corev1.SecretVolumeSource{SecretName: p.ConfigSecret},
|
|
},
|
|
},
|
|
{Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
// resourceLimits parses the CPU/memory limits into a ResourceList.
|
|
func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
|
|
if cpu == "" {
|
|
cpu = defaultCPULimit
|
|
}
|
|
if mem == "" {
|
|
mem = defaultMemLimit
|
|
}
|
|
cpuQty, err := resource.ParseQuantity(cpu)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: invalid cpu limit %q: %w", cpu, err)
|
|
}
|
|
memQty, err := resource.ParseQuantity(mem)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: invalid memory limit %q: %w", mem, err)
|
|
}
|
|
return corev1.ResourceList{
|
|
corev1.ResourceCPU: cpuQty,
|
|
corev1.ResourceMemory: memQty,
|
|
}, nil
|
|
}
|
|
|
|
func boolPtr(b bool) *bool { return &b }
|
|
func int32Ptr(i int32) *int32 { return &i }
|
|
func int64Ptr(i int64) *int64 { return &i }
|