复用现有 k3s 调度和 Job 生命周期,增加 worker 接入与批准、受保护节点身份、归档传输、持久迁移锁及活动 PVC 切换;同步管理员 API、CLI、面板和隔离规则。分布式模式默认关闭,保持单机兼容。 验证:Go 全量测试与 vet;面板 874 个测试、lint/build;Linux VM 安装器测试、清单服务端 dry-run、网络命名空间防火墙实测。A/B/C 三机 WireGuard、Velocity 和迁移验收仍待完成。
158 lines
5.3 KiB
Go
158 lines
5.3 KiB
Go
// Package worldexport is the executor behind the world export routes (POST
|
|
// /servers/{name}/world/export and POST /servers/{name}/backups/{id}/export)
|
|
// and the file manager's download (POST /servers/{name}/files/download): a
|
|
// one-shot Job in the minecraft namespace that reads a stopped server's world,
|
|
// one of its stored archives, or one file or folder of the world, and PUTs it
|
|
// 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 what is sent 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"
|
|
"felis.lolicon.best/internal/placement"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
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, ModeBackup or ModeFiles
|
|
// BackupRef is the archive a ModeBackup export reads, and BackupSHA256 what
|
|
// it must hash to.
|
|
BackupRef string
|
|
BackupSHA256 string
|
|
// Path and Dir are the file or folder a ModeFiles export sends.
|
|
Path string
|
|
Dir bool
|
|
// 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 {
|
|
ResolveWorld placement.Resolver
|
|
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
|
|
claim := naming.WorldPVCName(r.Server)
|
|
node := ""
|
|
if c.ResolveWorld != nil && r.Mode != ModeBackup {
|
|
world, err := c.ResolveWorld(ctx, r.Server)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
claim, node = world.Claim, world.Node
|
|
}
|
|
job, err := ExportJob(JobParams{
|
|
Server: r.Server, ID: r.ID, Mode: r.Mode,
|
|
NodeName: node,
|
|
WorldPVC: claim, BackupPVC: c.BackupPVC, BackupRef: r.BackupRef,
|
|
BackupSHA256: r.BackupSHA256, Path: r.Path, Dir: r.Dir,
|
|
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
|
|
}
|
|
|
|
// Stop deletes an export Job felis-api has given up on, so a Pod that never got
|
|
// going stops holding the world. Its Pods go with it; one already gone is not
|
|
// an error.
|
|
func (e *Exporter) Stop(ctx context.Context, job string) error {
|
|
bg := metav1.DeletePropagationBackground
|
|
err := e.cs.BatchV1().Jobs(e.cfg.Namespace).Delete(ctx, job, metav1.DeleteOptions{PropagationPolicy: &bg})
|
|
if err != nil && !apierrors.IsNotFound(err) {
|
|
return fmt.Errorf("worldexport: delete export job: %w", err)
|
|
}
|
|
return nil
|
|
}
|