feat(backups): 主人和管理员可下载单个备份、导出停服世界,字节经一次性票据从导出 Job 流式转给浏览器,备份按 sha256 核对
This commit is contained in:
35 files changed
+4018
-70
No files matched your search
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user