feat(distributed): 支持单主控多节点部署和停服迁移

复用现有 k3s 调度和 Job 生命周期,增加 worker 接入与批准、受保护节点身份、归档传输、持久迁移锁及活动 PVC 切换;同步管理员 API、CLI、面板和隔离规则。分布式模式默认关闭,保持单机兼容。

验证:Go 全量测试与 vet;面板 874 个测试、lint/build;Linux VM 安装器测试、清单服务端 dry-run、网络命名空间防火墙实测。A/B/C 三机 WireGuard、Velocity 和迁移验收仍待完成。
This commit is contained in:
Lemon-miaow committed 2026-10-01 19:48:37 +08:00
1 parent 26e817f2c9
commit adf563ffe3
77 files changed
+5224 -73

No files matched your search

+31 -17
View File
@@ -1,7 +1,9 @@
package restore
import (
"felis.lolicon.best/internal/archivetransfer"
"fmt"
"strconv"
"time"
batchv1 "k8s.io/api/batch/v1"
@@ -34,22 +36,24 @@ const (
// archive ref + Config by the Restorer. jobspec is a pure function of them so
// the security-critical Job shape is unit-tested without a cluster.
type JobParams struct {
Server string
WorldPVC string
BackupPVC string
BackupRef string
ArchiveStore string
Namespace string
ServiceAccount string
Image string
BackupRoot string
WorldsRoot string
Deadline time.Duration
CPULimit string
MemLimit string
RunAsUser int64
RunAsGroup int64
FSGroup int64
SourceURL, Token, SHA256 string
MaxBytes int64
Server string
WorldPVC string
BackupPVC string
BackupRef string
ArchiveStore 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
}
@@ -92,7 +96,7 @@ func RestoreJob(p JobParams) (*batchv1.Job, error) {
if p.Image == "" {
return nil, fmt.Errorf("restore: image is empty")
}
if p.WorldPVC == "" || p.BackupPVC == "" {
if p.WorldPVC == "" || (p.BackupPVC == "" && p.SourceURL == "") {
return nil, fmt.Errorf("restore: world and backup PVC names are required")
}
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
@@ -192,6 +196,16 @@ func RestoreJob(p JobParams) (*batchv1.Job, error) {
},
},
}
if p.SourceURL != "" {
if p.Token == "" || p.SHA256 == "" || p.MaxBytes <= 0 {
return nil, fmt.Errorf("restore: remote archive credentials and bounds required")
}
c := &job.Spec.Template.Spec.Containers[0]
c.Args = append(c.Args, "--source-url", p.SourceURL, "--sha256", p.SHA256, "--max-bytes", strconv.FormatInt(p.MaxBytes, 10))
c.Env = []corev1.EnvVar{{Name: archivetransfer.TokenEnv, Value: p.Token}}
c.VolumeMounts[1] = corev1.VolumeMount{Name: "tmp", MountPath: "/tmp"}
job.Spec.Template.Spec.Volumes[1] = corev1.Volume{Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}
}
return job, nil
}
+11 -1
View File
@@ -32,6 +32,7 @@ import (
"time"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
)
// ErrAlreadyExists is returned by a Jobs implementation when a restore Job for a
@@ -75,6 +76,7 @@ type Jobs interface {
// either is empty the caller leaves the API's Restorer nil so the endpoint
// reports 503 rather than enqueuing a Job that cannot run.
type Config struct {
ResolveWorld placement.Resolver
// Namespace is where the world PVCs live and the restore Job runs (the
// minecraft namespace). The Job is intentionally co-located with the world it
// restores; it never runs in the felis control-plane namespace.
@@ -199,7 +201,15 @@ type Restorer struct {
// keeps the handler's 202 honest in both directions — not a 500 for a genuine
// duplicate, and not a false "restoring" for a retry after a failure.
func (r *Restorer) Restore(ctx context.Context, serverName, backupRef string) error {
if err := r.Jobs.CreateRestoreJob(ctx, r.jobParams(serverName, backupRef)); err != nil {
p := r.jobParams(serverName, backupRef)
if r.Config.ResolveWorld != nil {
w, err := r.Config.ResolveWorld(ctx, serverName)
if err != nil {
return err
}
p.WorldPVC = w.Claim
}
if err := r.Jobs.CreateRestoreJob(ctx, p); err != nil {
if errors.Is(err, ErrAlreadyExists) {
return nil // already enqueued — idempotent
}