feat(backups): 主人和管理员可下载单个备份、导出停服世界,字节经一次性票据从导出 Job 流式转给浏览器,备份按 sha256 核对

This commit is contained in:
Lemon-miaow committed 2026-09-28 00:58:42 +08:00
1 parent 34b81ee8fe
commit 5dffadb40d
35 files changed
+4018 -70

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
package worldexport
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 export objects, the same ones the other executors use
// (internal/maintenance and internal/api keep their own copies;
// maintenance_test pins them against these).
const (
LabelManagedBy = "app.kubernetes.io/managed-by"
LabelComponent = "app.kubernetes.io/component"
LabelServer = "felis.lolicon.best/server"
// LabelMode is what the Job archives (ModeWorld or ModeBackup). A world
// export holds the world volume; a backup export does not.
LabelMode = "felis.lolicon.best/export-mode"
managedByValue = "felis-export"
componentValue = "world-export"
worldVolume = "world"
backupVolume = "backup"
containerName = "export"
felisBinaryPath = "/usr/local/bin/felis"
)
// The two things an export can archive.
const (
ModeWorld = "world"
ModeBackup = "backup"
)
// TokenEnv carries the one-time upload token into the Pod. It is the only
// secret the Pod holds, so it rides the environment and not argv, where a
// process listing on the node would show it.
const TokenEnv = "FELIS_EXPORT_TOKEN"
// JobParams are the rendered inputs to an export Job. ExportJob is a pure
// function of them, so the Job shape is unit-tested without a cluster.
type JobParams struct {
Server string
// ID names this export: it is the tail of the Job name and of the internal
// upload path, so two exports of one server never collide.
ID string
Mode string
// WorldPVC is mounted for ModeWorld, BackupPVC and BackupRef for ModeBackup.
WorldPVC string
BackupPVC string
BackupRef string
// TargetURL is where the Pod PUTs the archive (felis-api's internal face),
// and Token the one-time bearer token that opens it.
TargetURL string
Token string
Namespace string
ServiceAccount string
Image string
BackupRoot string
WorldsRoot string
Deadline time.Duration
CPULimit string
MemLimit string
RunAsUser int64
RunAsGroup int64
FSGroup int64
TTLAfterFinished time.Duration
}
// JobName is the Job an export runs as.
func JobName(server, id string) string { return "export-" + server + "-" + id }
func exportLabels(p JobParams) map[string]string {
return map[string]string{
LabelManagedBy: managedByValue,
LabelComponent: componentValue,
LabelServer: p.Server,
LabelMode: p.Mode,
}
}
// ExportJob renders the export Job. Every isolation guarantee lives here and is
// asserted by jobspec_test.go:
//
// - the weak felis-restore SA with its token auto-mount disabled, so the Pod
// cannot reach the K8s API;
// - EXACTLY one volume, read-only in the claim and in the mount: the world PVC
// for a world export, the backup PVC for a backup export. No Secret or
// ConfigMap: the one credential in the Pod is the upload token, which opens
// this one export and nothing else;
// - root with DAC_OVERRIDE and nothing more: the world is the game uid's
// mode-0600 files, and Pod Security baseline, which the minecraft namespace
// enforces, admits DAC_OVERRIDE but not the narrower DAC_READ_SEARCH. No
// privilege or escalation, a read-only root filesystem;
// - backoffLimit 0 (the token is spent by the first attempt, a retry could
// only fail) and activeDeadlineSeconds, so a download left hanging cannot
// hold the world forever; ttlSecondsAfterFinished GCs the finished Job.
//
// The container runs `/usr/local/bin/felis export` (cmd/felis). The backup ref is
// an absolute path, so the backup PVC is mounted at BackupRoot, the path the
// archives were written under, as the restore Job mounts it.
func ExportJob(p JobParams) (*batchv1.Job, error) {
if p.Image == "" {
return nil, fmt.Errorf("worldexport: image is empty")
}
if p.ID == "" || p.TargetURL == "" || p.Token == "" {
return nil, fmt.Errorf("worldexport: an export needs an id, a target URL and a token")
}
var (
args = []string{"--mode", p.Mode, "--server", p.Server, "--target-url", p.TargetURL}
volume corev1.Volume
mount corev1.VolumeMount
)
switch p.Mode {
case ModeWorld:
if p.WorldPVC == "" {
return nil, fmt.Errorf("worldexport: world PVC name is required")
}
args = append(args, "--worlds-root", p.WorldsRoot)
volume = readOnlyClaim(worldVolume, p.WorldPVC)
mount = corev1.VolumeMount{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: true}
case ModeBackup:
if p.BackupPVC == "" || p.BackupRef == "" {
return nil, fmt.Errorf("worldexport: backup PVC name and archive ref are required")
}
args = append(args, "--ref", p.BackupRef, "--backup-root", p.BackupRoot)
volume = readOnlyClaim(backupVolume, p.BackupPVC)
mount = corev1.VolumeMount{Name: backupVolume, MountPath: p.BackupRoot, ReadOnly: true}
default:
return nil, fmt.Errorf("worldexport: unknown mode %q", p.Mode)
}
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)
}
container := corev1.Container{
Name: containerName,
Image: p.Image,
Command: []string{felisBinaryPath, "export"},
Args: args,
Env: []corev1.EnvVar{{Name: TokenEnv, Value: p.Token}},
VolumeMounts: []corev1.VolumeMount{mount},
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"},
Add: []corev1.Capability{"DAC_OVERRIDE"},
},
},
// The exit error reaches the export's status and GET
// /servers/{name}/jobs through the terminated state.
TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
}
sc := &corev1.PodSecurityContext{
RunAsNonRoot: boolPtr(false),
RunAsUser: int64Ptr(p.RunAsUser),
RunAsGroup: int64Ptr(p.RunAsGroup),
}
if p.FSGroup > 0 {
sc.FSGroup = int64Ptr(p.FSGroup)
}
return &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: JobName(p.Server, p.ID),
Namespace: p.Namespace,
Labels: exportLabels(p),
},
Spec: batchv1.JobSpec{
BackoffLimit: int32Ptr(0),
ActiveDeadlineSeconds: int64Ptr(deadline),
TTLSecondsAfterFinished: int32Ptr(ttl),
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: exportLabels(p)},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false),
SecurityContext: sc,
Containers: []corev1.Container{container},
Volumes: []corev1.Volume{volume},
},
},
},
}, nil
}
func readOnlyClaim(name, claim string) corev1.Volume {
return corev1.Volume{
Name: name,
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: claim, ReadOnly: true},
},
}
}
// 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("worldexport: invalid cpu limit %q: %w", cpu, err)
}
memQty, err := resource.ParseQuantity(mem)
if err != nil {
return nil, fmt.Errorf("worldexport: 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 }
+178
View File
@@ -0,0 +1,178 @@
package worldexport
import (
"context"
"slices"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
const secretToken = "5ecret5ecret5ecret5ecret5ecret5ecret5ecret5ecret5ecret5ecret5ecr"
func params(mode string) JobParams {
return JobParams{
Server: "survival", ID: "0011223344556677", Mode: mode,
WorldPVC: "world-survival-0", BackupPVC: "felis-backups",
BackupRef: "/backups/survival-1.tar.gz",
TargetURL: "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/exports/0011223344556677",
Token: secretToken,
Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1",
BackupRoot: "/backups", WorldsRoot: "/world", Deadline: time.Hour,
CPULimit: "1", MemLimit: "256Mi", TTLAfterFinished: 5 * time.Minute,
}
}
// The export Pod reads a world or an archive and hands it to felis-api. It gets
// exactly the one volume it reads, read-only at both ends, no Secret, no API
// token, and no capability beyond DAC_OVERRIDE: a compromised export can read
// that one volume and talk to that one upload URL, nothing more.
func TestExportJobIsolation(t *testing.T) {
for _, tc := range []struct {
mode, volume, claim, mountPath string
args []string
}{
{ModeWorld, worldVolume, "world-survival-0", "/world", []string{"--worlds-root", "/world"}},
{ModeBackup, backupVolume, "felis-backups", "/backups", []string{"--ref", "/backups/survival-1.tar.gz", "--backup-root", "/backups"}},
} {
job, err := ExportJob(params(tc.mode))
if err != nil {
t.Fatalf("%s: ExportJob: %v", tc.mode, err)
}
if job.Name != "export-survival-0011223344556677" || job.Namespace != "minecraft" {
t.Errorf("%s: job = %s/%s", tc.mode, job.Namespace, job.Name)
}
for _, labels := range []map[string]string{job.Labels, job.Spec.Template.Labels} {
if labels[LabelManagedBy] != "felis-export" || labels[LabelServer] != "survival" || labels[LabelMode] != tc.mode {
t.Errorf("%s: labels = %v", tc.mode, labels)
}
}
spec := job.Spec.Template.Spec
if spec.ServiceAccountName != "felis-restore" {
t.Errorf("%s: service account = %q", tc.mode, spec.ServiceAccountName)
}
if spec.AutomountServiceAccountToken == nil || *spec.AutomountServiceAccountToken {
t.Errorf("%s: the SA token is mounted", tc.mode)
}
if spec.RestartPolicy != corev1.RestartPolicyNever {
t.Errorf("%s: restart policy = %q", tc.mode, spec.RestartPolicy)
}
if len(spec.Volumes) != 1 {
t.Fatalf("%s: volumes = %+v, want exactly one", tc.mode, spec.Volumes)
}
v := spec.Volumes[0]
if v.Name != tc.volume || v.PersistentVolumeClaim == nil || v.PersistentVolumeClaim.ClaimName != tc.claim || !v.PersistentVolumeClaim.ReadOnly {
t.Errorf("%s: volume = %+v, want %s read-only", tc.mode, v, tc.claim)
}
if len(spec.Containers) != 1 || len(spec.InitContainers) != 0 {
t.Fatalf("%s: containers = %d, init = %d", tc.mode, len(spec.Containers), len(spec.InitContainers))
}
c := spec.Containers[0]
if len(c.VolumeMounts) != 1 || c.VolumeMounts[0] != (corev1.VolumeMount{Name: tc.volume, MountPath: tc.mountPath, ReadOnly: true}) {
t.Errorf("%s: mounts = %+v", tc.mode, c.VolumeMounts)
}
if len(c.EnvFrom) != 0 || len(c.Env) != 1 || c.Env[0] != (corev1.EnvVar{Name: TokenEnv, Value: secretToken}) {
t.Errorf("%s: env = %+v, envFrom = %+v; want only the token", tc.mode, c.Env, c.EnvFrom)
}
if strings.Contains(strings.Join(c.Args, " "), secretToken) {
t.Errorf("%s: the token rides argv: %v", tc.mode, c.Args)
}
wantArgs := append([]string{"--mode", tc.mode, "--server", "survival", "--target-url", params(tc.mode).TargetURL}, tc.args...)
if !slices.Equal(c.Command, []string{felisBinaryPath, "export"}) || !slices.Equal(c.Args, wantArgs) {
t.Errorf("%s: command = %v %v", tc.mode, c.Command, c.Args)
}
sc := c.SecurityContext
if sc == nil || sc.Privileged == nil || *sc.Privileged || sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation ||
sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
t.Errorf("%s: container security context = %+v", tc.mode, sc)
}
if sc.Capabilities == nil || !slices.Equal(sc.Capabilities.Drop, []corev1.Capability{"ALL"}) ||
!slices.Equal(sc.Capabilities.Add, []corev1.Capability{"DAC_OVERRIDE"}) {
t.Errorf("%s: capabilities = %+v", tc.mode, sc.Capabilities)
}
if c.TerminationMessagePolicy != corev1.TerminationMessageFallbackToLogsOnError {
t.Errorf("%s: termination message policy = %q", tc.mode, c.TerminationMessagePolicy)
}
if psc := spec.SecurityContext; psc == nil || psc.RunAsUser == nil || *psc.RunAsUser != 0 || psc.FSGroup != nil {
t.Errorf("%s: pod security context = %+v", tc.mode, psc)
}
if b := job.Spec.BackoffLimit; b == nil || *b != 0 {
t.Errorf("%s: backoffLimit = %v, want 0", tc.mode, b)
}
if d := job.Spec.ActiveDeadlineSeconds; d == nil || *d != 3600 {
t.Errorf("%s: activeDeadlineSeconds = %v, want 3600", tc.mode, d)
}
if ttl := job.Spec.TTLSecondsAfterFinished; ttl == nil || *ttl != 300 {
t.Errorf("%s: ttlSecondsAfterFinished = %v, want 300", tc.mode, ttl)
}
}
}
func TestExportJobRefusesIncompleteParams(t *testing.T) {
for name, edit := range map[string]func(*JobParams){
"no image": func(p *JobParams) { p.Image = "" },
"no token": func(p *JobParams) { p.Token = "" },
"no target": func(p *JobParams) { p.TargetURL = "" },
"no id": func(p *JobParams) { p.ID = "" },
"unknown mode": func(p *JobParams) { p.Mode = "both" },
"world without claim": func(p *JobParams) { p.WorldPVC = "" },
} {
p := params(ModeWorld)
edit(&p)
if _, err := ExportJob(p); err == nil {
t.Errorf("%s: ExportJob accepted %+v", name, p)
}
}
p := params(ModeBackup)
p.BackupRef = ""
if _, err := ExportJob(p); err == nil {
t.Error("a backup export without a ref was accepted")
}
}
// TestExportJobDefaults: a caller that leaves the deadline and the TTL unset
// still gets a Job that ends and is collected.
func TestExportJobDefaults(t *testing.T) {
p := params(ModeWorld)
p.Deadline, p.TTLAfterFinished = 0, 0
job, err := ExportJob(p)
if err != nil {
t.Fatal(err)
}
if d := job.Spec.ActiveDeadlineSeconds; d == nil || *d != 7200 {
t.Errorf("activeDeadlineSeconds = %v, want 7200", d)
}
if ttl := job.Spec.TTLSecondsAfterFinished; ttl == nil || *ttl != 600 {
t.Errorf("ttlSecondsAfterFinished = %v, want 600", ttl)
}
}
func TestStartCreatesTheJob(t *testing.T) {
cs := fake.NewSimpleClientset()
e := New(cs, Config{Image: "felis:1", BackupPVC: "felis-backups"})
name, err := e.Start(context.Background(), Request{
Server: "survival", Mode: ModeWorld, ID: "0011223344556677",
TargetURL: "http://api:8081/api/v1/internal/exports/0011223344556677", Token: secretToken,
})
if err != nil || name != "export-survival-0011223344556677" {
t.Fatalf("Start = %q, %v", name, err)
}
job, err := cs.BatchV1().Jobs("minecraft").Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
t.Fatalf("the Job is not in the minecraft namespace: %v", err)
}
if claim := job.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim.ClaimName; claim != "world-survival-0" {
t.Errorf("world claim = %q, want world-survival-0", claim)
}
if d := *job.Spec.ActiveDeadlineSeconds; d != int64(defaultDeadline/time.Second) {
t.Errorf("deadline = %d, want the %s default", d, defaultDeadline)
}
if _, err := e.Start(context.Background(), Request{Server: "survival", Mode: ModeWorld, ID: "0011223344556677",
TargetURL: "http://api:8081/x", Token: secretToken}); err == nil {
t.Error("a second Job of the same name was reported as created")
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package worldexport is the executor behind the world export routes (POST
// /servers/{name}/world/export and POST /servers/{name}/backups/{id}/export): a
// one-shot Job in the minecraft namespace that reads a stopped server's world,
// or one of its stored archives, and PUTs the tar.gz to felis-api's internal
// face, which streams it on to the owner's browser as it arrives
// (internal/api/exports.go). Nothing is staged on the way: felis-api never
// mounts a world or the backup store, and the archive never lands on a disk it
// owns.
//
// Trust model as in internal/restore: the Pod runs under the weak felis-restore
// SA with no API token, mounts one volume read-only, and holds no database URL
// or Secret. The only credential it gets is the one-time token of this one
// upload. felis-api makes every authorization decision before the Job exists.
package worldexport
import (
"context"
"fmt"
"time"
"felis.lolicon.best/internal/naming"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// Request is one export as felis-api admitted it.
type Request struct {
Server string
Mode string // ModeWorld or ModeBackup
// BackupRef is the archive a ModeBackup export reads.
BackupRef string
// ID names the export (JobName) and TargetURL/Token are where and how the
// Pod hands the archive over.
ID string
TargetURL string
Token string
}
// Config parameterises the executor. Image and BackupPVC have no safe default:
// cmd/felis leaves the API's Exporter nil when either is missing, and the
// routes answer 503.
type Config struct {
Namespace string
ServiceAccount string
Image string
BackupPVC string
// BackupRoot MUST be the path the archives were written under
// (cfg.Archive.LocalPath): the stored refs are absolute paths.
BackupRoot string
WorldsRoot string
// Deadline caps the Pod's wall-clock. The archive moves at the browser's
// pace, so it is longer than a backup's, and it is also the longest a world
// export can keep the server from starting.
Deadline time.Duration
CPULimit string
MemLimit string
RunAsUser int64
RunAsGroup int64
FSGroup int64
TTLAfterFinished time.Duration
}
const (
defaultNamespace = "minecraft"
defaultServiceAccount = "felis-restore"
defaultBackupRoot = "/backups"
defaultWorldsRoot = "/world"
defaultDeadline = 2 * time.Hour
defaultCPULimit = "1"
defaultMemLimit = "256Mi"
defaultTTL = 10 * time.Minute
)
func (c Config) withDefaults() Config {
if c.Namespace == "" {
c.Namespace = defaultNamespace
}
if c.ServiceAccount == "" {
c.ServiceAccount = defaultServiceAccount
}
if c.BackupRoot == "" {
c.BackupRoot = defaultBackupRoot
}
if c.WorldsRoot == "" {
c.WorldsRoot = defaultWorldsRoot
}
if c.Deadline <= 0 {
c.Deadline = defaultDeadline
}
return c
}
// Exporter is the production internal/api.Exporter. It creates the Job with
// jobs:create, which felis-api already holds in the minecraft namespace.
type Exporter struct {
cs kubernetes.Interface
cfg Config
}
// New builds an Exporter over the typed clientset.
func New(cs kubernetes.Interface, cfg Config) *Exporter {
return &Exporter{cs: cs, cfg: cfg.withDefaults()}
}
// Start creates the export Job for r and returns its name.
func (e *Exporter) Start(ctx context.Context, r Request) (string, error) {
c := e.cfg
job, err := ExportJob(JobParams{
Server: r.Server, ID: r.ID, Mode: r.Mode,
WorldPVC: naming.WorldPVCName(r.Server), BackupPVC: c.BackupPVC, BackupRef: r.BackupRef,
TargetURL: r.TargetURL, Token: r.Token,
Namespace: c.Namespace, ServiceAccount: c.ServiceAccount, Image: c.Image,
BackupRoot: c.BackupRoot, WorldsRoot: c.WorldsRoot, Deadline: c.Deadline,
CPULimit: c.CPULimit, MemLimit: c.MemLimit,
RunAsUser: c.RunAsUser, RunAsGroup: c.RunAsGroup, FSGroup: c.FSGroup,
TTLAfterFinished: c.TTLAfterFinished,
})
if err != nil {
return "", err
}
if _, err := e.cs.BatchV1().Jobs(c.Namespace).Create(ctx, job, metav1.CreateOptions{}); err != nil {
return "", fmt.Errorf("worldexport: create export job: %w", err)
}
return job.Name, nil
}