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
+5196 -45

No files matched your search

+50 -6
View File
@@ -20,6 +20,7 @@ import (
"felis.lolicon.best/internal/backupjob" "felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/imagepin" "felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/mail"
@@ -27,6 +28,7 @@ import (
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
"felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/passkey"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/reaper"
"felis.lolicon.best/internal/registryprune" "felis.lolicon.best/internal/registryprune"
@@ -273,9 +275,23 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// store at load, so by this point cfg.Archive.Store is guaranteed tarLocal.) // store at load, so by this point cfg.Archive.Store is guaranteed tarLocal.)
var restorer api.Restorer var restorer api.Restorer
felisImage, backupPVC := os.Getenv("FELIS_IMAGE"), os.Getenv("FELIS_BACKUP_PVC") felisImage, backupPVC := os.Getenv("FELIS_IMAGE"), os.Getenv("FELIS_BACKUP_PVC")
worldResolver := placement.Resolve(cl, cfg.K8s.Namespace)
distribution, err := distributionManager(cl, cfg, felisImage)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if distribution != nil {
worldResolver = distribution.Resolve
}
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
rcfg := restoreConfig(cfg, felisImage, backupPVC) rcfg := restoreConfig(cfg, felisImage, backupPVC)
restorer = &restore.Restorer{Jobs: restore.NewK8sJobs(cl), Config: rcfg} rcfg.ResolveWorld = worldResolver
var jobs restore.Jobs = restore.NewK8sJobs(cl)
if distribution != nil {
jobs = distribution
}
restorer = &restore.Restorer{Jobs: jobs, Config: rcfg}
} else { } else {
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503") fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
} }
@@ -288,7 +304,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// endpoint honestly returns 503. // endpoint honestly returns 503.
var backuper api.Backuper var backuper api.Backuper
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
backuper = &backupjob.Backuper{Jobs: backupjob.NewK8sJobs(cl), Config: backupConfig(cfg, felisImage, backupPVC)} bcfg := backupConfig(cfg, felisImage, backupPVC)
bcfg.ResolveWorld = worldResolver
var jobs backupjob.Jobs = backupjob.NewK8sJobs(cl)
if distribution != nil {
jobs = distribution
}
backuper = &backupjob.Backuper{Jobs: jobs, Config: bcfg}
} else { } else {
fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503") fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503")
} }
@@ -299,7 +321,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// is wired under the restore gate; otherwise the export routes return 503. // is wired under the restore gate; otherwise the export routes return 503.
var exporter api.Exporter var exporter api.Exporter
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
exporter = worldexport.New(clientset, exportConfig(cfg, felisImage, backupPVC)) ecfg := exportConfig(cfg, felisImage, backupPVC)
ecfg.ResolveWorld = worldResolver
exporter = worldexport.New(clientset, ecfg)
} else { } else {
fmt.Fprintln(stderr, "felis api: world export disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — export endpoints return 503") fmt.Fprintln(stderr, "felis api: world export disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — export endpoints return 503")
} }
@@ -319,9 +343,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
var files api.FileEditor var files api.FileEditor
var fileStage *fileedit.Stage var fileStage *fileedit.Stage
if felisImage != "" { if felisImage != "" {
fcfg := fileEditConfig(cfg, felisImage)
fcfg.ResolveWorld = worldResolver
files = &fileedit.Editor{ files = &fileedit.Editor{
Runner: fileedit.NewK8sRunner(clientset), Runner: fileedit.NewK8sRunner(clientset),
Config: fileEditConfig(cfg, felisImage), Config: fcfg,
} }
fileStage = &fileedit.Stage{Dir: fileStagingDir()} fileStage = &fileedit.Stage{Dir: fileStagingDir()}
if err := fileStage.Sweep(); err != nil { if err := fileStage.Sweep(); err != nil {
@@ -355,7 +381,21 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis api: MinecraftServer cache: %v\n", err) fmt.Fprintf(stderr, "felis api: MinecraftServer cache: %v\n", err)
return 1 return 1
} }
cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace).WithServerCache(serverCache, serversSynced) cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace).WithServerCache(serverCache, serversSynced).WithDistributed(distribution != nil, os.Getenv("FELIS_CONTROLLER_NODE"))
if distribution != nil {
distribution.Record = func(ctx context.Context, b distributed.Backup) error {
keep, retention, ok := backupPolicy(b.Reason, rcfg)
if !ok {
return fmt.Errorf("unknown backup reason %s", b.Reason)
}
st := reaper.NewPGStore(drv.DB())
if err := st.InsertBackup(ctx, reaper.BackupRecord{ID: "bk-" + b.ID, ServerName: b.Server, FormerOwner: b.Owner, BackupRef: b.Receipt.Ref, SizeBytes: b.Receipt.Size, SHA256: b.Receipt.SHA256, Reason: b.Reason, ExpiresAt: time.Now().Add(retention)}); err != nil {
return err
}
pruneBackups(ctx, st, distribution.Archive, b.Server, b.Owner, b.Reason, keep, b.Protect, stdout, stderr)
return nil
}
}
jobStatus := api.NewK8sJobStatus(cl, cfg.K8s.Namespace) jobStatus := api.NewK8sJobStatus(cl, cfg.K8s.Namespace)
a := &api.API{ a := &api.API{
Repo: repo, Repo: repo,
@@ -455,7 +495,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname), defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname), defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
cfg.Velocity.GamePort, resolvedVersion()) cfg.Velocity.GamePort, resolvedVersion(), distribution != nil)
internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
@@ -499,6 +539,10 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
} }
go retention.Loop(ctx, drv.DB(), retention.Policy{Audit: auditRetention}, retentionInterval, slog.Default()) go retention.Loop(ctx, drv.DB(), retention.Policy{Audit: auditRetention}, retentionInterval, slog.Default())
if distribution != nil {
a.Distribution = distribution
go reconcileDistribution(ctx, distribution, stderr)
}
servers := []*http.Server{internalSrv, externalSrv} servers := []*http.Server{internalSrv, externalSrv}
if httpsSrv != nil { if httpsSrv != nil {
servers = append(servers, httpsSrv) servers = append(servers, httpsSrv)
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
"felis.lolicon.best/internal/archivetransfer"
)
func cmdArchiveServe(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("archive-serve", flag.ContinueOnError)
fs.SetOutput(stderr)
root := fs.String("root", "/backups", "archive PVC mount (must match archive.local_path)")
addr := fs.String("listen", ":8090", "private archive listener")
limit := fs.Int64("max-bytes", archivetransfer.DefaultLimit, "maximum compressed archive bytes")
if err := fs.Parse(args); err != nil {
return 2
}
key := os.Getenv(archivetransfer.KeyEnv)
if len(key) < 32 || *limit <= 0 {
fmt.Fprintln(stderr, "archive-serve: signing key and positive size limit required")
return 2
}
if err := os.MkdirAll(*root, 0750); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
limitHeapToCgroup()
transport := &archivetransfer.Server{Root: *root, Key: key, Limit: *limit}
done := make(chan struct{})
defer close(done)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
if err := transport.Sweep(time.Now()); err != nil {
fmt.Fprintln(stderr, "archive journal cleanup:", err)
}
select {
case <-done:
return
case <-ticker.C:
}
}
}()
srv := &http.Server{Addr: *addr, Handler: transport, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Hour, WriteTimeout: 2 * time.Hour, MaxHeaderBytes: 16 << 10}
fmt.Fprintln(stdout, "archive transport listening", *addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
+3 -1
View File
@@ -168,7 +168,9 @@ func backupPolicy(reason string, rcfg reaper.Config) (keep int, retention time.D
// previous owner's. protect is never removed: it is the backup a chained restore // previous owner's. protect is never removed: it is the backup a chained restore
// is about to extract. The new backup is already recorded; a removal that fails // is about to extract. The new backup is already recorded; a removal that fails
// is reported and retried after the next backup. // is reported and retried after the next backup.
func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver backup.WorldArchiver, server, owner, reason string, keep int, protect string, stdout, stderr io.Writer) { func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver interface {
Delete(context.Context, backup.ArchiveRef) error
}, server, owner, reason string, keep int, protect string, stdout, stderr io.Writer) {
excess, err := st.ExcessBackups(ctx, server, owner, reason, keep, protect) excess, err := st.ExcessBackups(ctx, server, owner, reason, keep, protect)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis backup: list older backups of %s: %v\n", server, err) fmt.Fprintf(stderr, "felis backup: list older backups of %s: %v\n", server, err)
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"context"
"fmt"
"io"
"os"
"time"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/placement"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func distributionManager(cl client.Client, cfg *config.Config, image string) (*distributed.Manager, error) {
if os.Getenv("FELIS_DISTRIBUTED") != "true" {
return nil, nil
}
controller, url, key := os.Getenv("FELIS_CONTROLLER_NODE"), os.Getenv("FELIS_ARCHIVE_URL"), os.Getenv(archivetransfer.KeyEnv)
if controller == "" || url == "" || len(key) < 32 || image == "" || cfg.Archive.Store != "tarLocal" {
return nil, fmt.Errorf("distributed mode requires controller identity, archive service/key, Felis image and tarLocal")
}
return &distributed.Manager{Client: cl, Namespace: cfg.K8s.Namespace, Image: image, Controller: controller, Archive: archivetransfer.Client{URL: url, Root: cfg.Archive.LocalPath, Key: key}, Resolve: placement.Resolve(cl, cfg.K8s.Namespace, controller)}, nil
}
func reconcileDistribution(ctx context.Context, m *distributed.Manager, stderr io.Writer) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
if err := m.SettleBackups(ctx); err != nil {
fmt.Fprintln(stderr, "distributed backup:", err)
}
if err := m.ReconcileMigrations(ctx); err != nil {
fmt.Fprintln(stderr, "migration:", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
+13
View File
@@ -37,6 +37,7 @@ func cmdEgressGate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("egress-gate", flag.ContinueOnError) fs := flag.NewFlagSet("egress-gate", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
probe := fs.String("probe", "", "host:port the pod's NetworkPolicy denies (default: the Kubernetes API Service from KUBERNETES_SERVICE_HOST/PORT)") probe := fs.String("probe", "", "host:port the pod's NetworkPolicy denies (default: the Kubernetes API Service from KUBERNETES_SERVICE_HOST/PORT)")
positive := fs.String("positive-probe", "", "allowed host:port that must remain reachable during denial checks")
wait := fs.Duration("wait", 2*time.Minute, "how long the probe may keep answering before the gate gives up") wait := fs.Duration("wait", 2*time.Minute, "how long the probe may keep answering before the gate gives up")
failOpen := fs.Bool("fail-open", false, "when --wait runs out, warn and let the pod go on instead of refusing it") failOpen := fs.Bool("fail-open", false, "when --wait runs out, warn and let the pod go on instead of refusing it")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -53,6 +54,18 @@ func cmdEgressGate(args []string, stdout, stderr io.Writer) int {
start := time.Now() start := time.Now()
for { for {
if *positive != "" {
allowed, err := net.DialTimeout("tcp", *positive, egressDialTimeout)
if err != nil {
if time.Since(start) >= *wait {
fmt.Fprintln(stderr, "felis egress-gate: positive probe unavailable; refusing to start", err)
return 1
}
time.Sleep(egressPollInterval)
continue
}
allowed.Close()
}
conn, err := net.DialTimeout("tcp", *probe, egressDialTimeout) conn, err := net.DialTimeout("tcp", *probe, egressDialTimeout)
if err != nil { if err != nil {
fmt.Fprintf(stdout, "felis egress-gate: %s is unreachable after %s (%v); the egress lock is in effect\n", fmt.Fprintf(stdout, "felis egress-gate: %s is unreachable after %s (%v); the egress lock is in effect\n",
+14
View File
@@ -15,6 +15,20 @@ func shrinkEgressGate(t *testing.T) {
t.Cleanup(func() { egressDialTimeout, egressPollInterval = dial, poll }) t.Cleanup(func() { egressDialTimeout, egressPollInterval = dial, poll })
} }
func TestEgressGateRequiresPositiveReachability(t *testing.T) {
shrinkEgressGate(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
closed := ln.Addr().String()
ln.Close()
var out, errb bytes.Buffer
if code := cmdEgressGate([]string{"--positive-probe", closed, "--probe", closed, "--wait", "20ms"}, &out, &errb); code != 1 {
t.Fatal("unavailable probes allowed startup", code)
}
}
// The gate holds while the probe answers and lets the pod go on once the policy // The gate holds while the probe answers and lets the pod go on once the policy
// lands, which the test plays by closing the listener. // lands, which the test plays by closing the listener.
func TestEgressGateWaitsForTheLock(t *testing.T) { func TestEgressGateWaitsForTheLock(t *testing.T) {
+5
View File
@@ -60,6 +60,7 @@ func cmdExport(args []string, stdout, stderr io.Writer) int {
sum := fs.String("sha256", "", "backup only: the sha256 recorded when the archive was written; a mismatch fails the export before its end is sent") sum := fs.String("sha256", "", "backup only: the sha256 recorded when the archive was written; a mismatch fails the export before its end is sent")
worldsRoot := fs.String("worlds-root", "/world", "world and files: mount path of the world PVC") worldsRoot := fs.String("worlds-root", "/world", "world and files: mount path of the world PVC")
path := fs.String("path", "", "files only: the file or folder to send, relative to the world root") path := fs.String("path", "", "files only: the file or folder to send, relative to the world root")
rawArchive := fs.Bool("archive-raw", false, "internal archive transfer: preserve the complete world")
dir := fs.Bool("dir", false, "files only: the path is a folder, sent as a zip") dir := fs.Bool("dir", false, "files only: the path is a folder, sent as a zip")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
@@ -82,7 +83,11 @@ func cmdExport(args []string, stdout, stderr io.Writer) int {
} }
err = exportBackup(ctx, *target, token, *ref, *backupRoot, *sum, stdout) err = exportBackup(ctx, *target, token, *ref, *backupRoot, *sum, stdout)
case worldexport.ModeWorld: case worldexport.ModeWorld:
if *rawArchive {
err = streamExport(ctx, *target, token, archiveType, -1, func(w io.Writer) error { _, _, err := backup.WriteTarGz(ctx, w, *worldsRoot, nil); return err })
} else {
err = exportWorld(ctx, *target, token, *worldsRoot, stdout) err = exportWorld(ctx, *target, token, *worldsRoot, stdout)
}
case worldexport.ModeFiles: case worldexport.ModeFiles:
if *path == "" { if *path == "" {
fmt.Fprintln(stderr, "felis export: --path is required for files") fmt.Fprintln(stderr, "felis export: --path is required for files")
+1 -1
View File
@@ -526,7 +526,7 @@ func TestCmdExportWiring(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
cfg.K8s.Namespace, cfg.Archive.LocalPath = "games", "/srv/felis-backups" cfg.K8s.Namespace, cfg.Archive.LocalPath = "games", "/srv/felis-backups"
want := worldexport.Config{Namespace: "games", Image: "felis:1", BackupPVC: "felis-backups", BackupRoot: "/srv/felis-backups"} want := worldexport.Config{Namespace: "games", Image: "felis:1", BackupPVC: "felis-backups", BackupRoot: "/srv/felis-backups"}
if got := exportConfig(cfg, "felis:1", "felis-backups"); got != want { if got := exportConfig(cfg, "felis:1", "felis-backups"); !reflect.DeepEqual(got, want) {
t.Fatalf("exportConfig = %+v, want %+v", got, want) t.Fatalf("exportConfig = %+v, want %+v", got, want)
} }
} }
+9 -2
View File
@@ -43,6 +43,11 @@ func (m *multiFlag) Set(v string) error {
func cmdManifests(args []string, stdout, stderr io.Writer) int { func cmdManifests(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("manifests", flag.ContinueOnError) fs := flag.NewFlagSet("manifests", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
distributed := fs.Bool("distributed", false, "enable approved workers and archive transport")
controller := fs.String("controller-node", "", "protected controller identity for A")
probe := fs.String("egress-probe", "", "reachable controller host:port denied to game Pods")
var registryNodes multiFlag
fs.Var(&registryNodes, "registry-node-cidr", "exact node pull source for the registry (repeatable)")
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace the control plane (api/operator/reaper) runs in") controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace the control plane (api/operator/reaper) runs in")
minecraftNS := fs.String("minecraft-namespace", platform.DefaultMinecraftNamespace, "namespace MinecraftServer workloads run in") minecraftNS := fs.String("minecraft-namespace", platform.DefaultMinecraftNamespace, "namespace MinecraftServer workloads run in")
buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in") buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in")
@@ -75,6 +80,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
case "": case "":
case "postgres": case "postgres":
return renderManifests(stdout, stderr, platform.PostgresObjects(platform.Params{ return renderManifests(stdout, stderr, platform.PostgresObjects(platform.Params{
ControllerNode: *controller,
ControlNamespace: *controlNS, ControlNamespace: *controlNS,
MinecraftNamespace: *minecraftNS, MinecraftNamespace: *minecraftNS,
PostgresImage: *postgresImage, PostgresImage: *postgresImage,
@@ -115,7 +121,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
// The node pin exists only for the reaper's hostPath: naming a node without the // The node pin exists only for the reaper's hostPath: naming a node without the
// worlds root would be silently dropped (no CronJob renders), so fail loud like // worlds root would be silently dropped (no CronJob renders), so fail loud like
// the storage-trio check below. // the storage-trio check below.
if *reaperNode != "" && *worldsHostPath == "" { if *reaperNode != "" && *worldsHostPath == "" && !*distributed {
fmt.Fprintln(stderr, "felis manifests: --reaper-node requires --worlds-host-path "+ fmt.Fprintln(stderr, "felis manifests: --reaper-node requires --worlds-host-path "+
"(it pins the reaper CronJob, which renders only with the retention storage trio)") "(it pins the reaper CronJob, which renders only with the retention storage trio)")
return 2 return 2
@@ -156,7 +162,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
"writes under /var/lib/rancher/k3s/storage). Any other provisioner needs its volumes exposed as "+ "writes under /var/lib/rancher/k3s/storage). Any other provisioner needs its volumes exposed as "+
"<path>/<pvc>, or each candidate's archive fails and the world is preserved;\n"+ "<path>/<pvc>, or each candidate's archive fails and the world is preserved;\n"+
" - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin) " - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin)
} else { } else if !*distributed {
switch { switch {
case *archiveLocalPath != "" && *backupPVC == "": case *archiveLocalPath != "" && *backupPVC == "":
fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+ fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+
@@ -176,6 +182,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
} }
params := platform.Params{ params := platform.Params{
Distributed: *distributed, ControllerNode: *controller, EgressProbe: *probe, RegistryNodeCIDRs: registryNodes,
ControlNamespace: *controlNS, ControlNamespace: *controlNS,
MinecraftNamespace: *minecraftNS, MinecraftNamespace: *minecraftNS,
BuildNamespace: *buildNS, BuildNamespace: *buildNS,
+466
View File
@@ -0,0 +1,466 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os/exec"
"strconv"
"strings"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/operator"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func approveNode(ctx context.Context, cl client.Client, cs kubernetes.Interface, ns, controlNS, name, image, remote string, stdout io.Writer) error {
var n corev1.Node
if err := cl.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil {
return err
}
_, controlPlane := n.Labels["node-role.kubernetes.io/control-plane"]
if !placement.Online(&n) || controlPlane || n.Labels[placement.LabelRole] == placement.RoleController {
return fmt.Errorf("candidate must be an online agent")
}
var nodes corev1.NodeList
if err := cl.List(ctx, &nodes); err != nil {
return err
}
var controller *corev1.Node
var peers []string
var denied []string
for i := range nodes.Items {
node := &nodes.Items[i]
if node.Labels[placement.LabelRole] == placement.RoleController {
if controller != nil {
return fmt.Errorf("multiple controllers found")
}
controller = node
}
for _, addr := range node.Status.Addresses {
if addr.Type == corev1.NodeInternalIP || addr.Type == corev1.NodeExternalIP {
cidr, err := exactCIDR(addr.Address)
if err != nil {
return err
}
peers = append(peers, cidr)
for _, p := range []string{"6443", "10250", "5000", "30443"} {
denied = append(denied, net.JoinHostPort(addr.Address, p))
}
}
}
}
if controller == nil || controller.Name == n.Name || controller.Status.NodeInfo.Architecture != n.Status.NodeInfo.Architecture {
return fmt.Errorf("candidate architecture must match the sole controller")
}
if n.Status.NodeInfo.KubeletVersion != controller.Status.NodeInfo.KubeletVersion {
return fmt.Errorf("candidate k3s version must match A")
}
var apiSvc, registrySvc, archiveSvc, kubernetesSvc corev1.Service
for _, svc := range []struct {
obj *corev1.Service
ns, name string
}{{&kubernetesSvc, "default", "kubernetes"}, {&apiSvc, controlNS, platform.SAAPI}, {&registrySvc, controlNS, "registry"}, {&archiveSvc, ns, platform.ArchiveName}} {
if err := cl.Get(ctx, types.NamespacedName{Namespace: svc.ns, Name: svc.name}, svc.obj); err != nil {
return err
}
}
if len(apiSvc.Spec.Ports) == 0 || apiSvc.Spec.Ports[0].NodePort == 0 {
return fmt.Errorf("controller API NodePort is absent")
}
// A's exact interface addresses let the probe observe DNAT/SNAT before the production policy is adjusted.
var aCIDRs []string
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
ip, _, err := net.ParseCIDR(a.String())
if err == nil && !ip.IsLoopback() {
cidr, _ := exactCIDR(ip.String())
aCIDRs = append(aCIDRs, cidr)
}
}
}
if len(aCIDRs) == 0 {
return fmt.Errorf("cannot determine A's exact interface addresses")
}
onController := false
for _, addr := range controller.Status.Addresses {
cidr, err := exactCIDR(addr.Address)
if err != nil {
continue
}
for _, local := range aCIDRs {
if cidr == local {
onController = true
}
}
}
if !onController || !placement.Online(controller) {
return fmt.Errorf("run admission on the online controller A")
}
// Quarantine first. Approval is the final write, after all checks have succeeded.
before := n.DeepCopy()
if n.Labels == nil {
n.Labels = map[string]string{}
}
delete(n.Labels, placement.LabelApproved)
found := false
for _, t := range n.Spec.Taints {
if t.Key == "felis.lolicon.best/unapproved" {
found = true
}
}
if !found {
n.Spec.Taints = append(n.Spec.Taints, corev1.Taint{Key: "felis.lolicon.best/unapproved", Value: "true", Effect: corev1.TaintEffectNoSchedule})
}
if err := cl.Patch(ctx, &n, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); err != nil {
return err
}
if err := denyNodeAddresses(ctx, cl, ns, nodes.Items); err != nil {
return err
}
// The host's Service dial may be masqueraded to its bridge gateway. Permit exact host addresses only.
pullSources := append([]string{}, peers...)
if ip, network, err := net.ParseCIDR(n.Spec.PodCIDR); err == nil && ip.To4() != nil {
v := append(net.IP(nil), network.IP.To4()...)
pullSources = append(pullSources, v.String()+"/32")
v[3]++
pullSources = append(pullSources, v.String()+"/32")
}
var registryNP networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNS, Name: "felis-registry-ingress"}, &registryNP); err != nil {
return err
}
if len(registryNP.Spec.Ingress) == 0 {
return fmt.Errorf("registry ingress policy is not configured")
}
prev := registryNP.DeepCopy()
for _, cidr := range pullSources {
registryNP.Spec.Ingress[0].From = append(registryNP.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := cl.Patch(ctx, &registryNP, client.MergeFrom(prev)); err != nil {
return err
}
ctrlIP := ""
for _, a := range controller.Status.Addresses {
if a.Type == corev1.NodeInternalIP {
ctrlIP = a.Address
break
}
}
if ctrlIP == "" {
return fmt.Errorf("controller has no address")
}
script := "set -euo pipefail\numask 077\n" +
"systemctl is-active --quiet k3s-agent\n! systemctl is-active --quiet k3s\ntest ! -f /etc/rancher/k3s/k3s.yaml\n" +
"ip -d link show flannel-wg | grep -q wireguard\n" +
"/usr/local/bin/felis node firewall --peers " + shellQuote(strings.Join(peers, ",")) + " --controller-ip " + shellQuote(ctrlIP) + " --api-service-ip " + shellQuote(kubernetesSvc.Spec.ClusterIP) + " --node-port " + strconv.Itoa(int(apiSvc.Spec.Ports[0].NodePort)) + " --namespace " + shellQuote(ns) + " --control-namespace " + shellQuote(controlNS) + "\n" +
"kubeconfig=/var/lib/rancher/k3s/agent/kubelet.kubeconfig\n" +
"/usr/local/bin/k3s kubectl --kubeconfig \"$kubeconfig\" get node " + shellQuote(name) + " -o name >/dev/null\n" +
"proof=$(mktemp); trap 'rm -f \"$proof\"' EXIT\n" +
"if /usr/local/bin/k3s kubectl --kubeconfig \"$kubeconfig\" label node " + shellQuote(name) + " felis.node-restriction.kubernetes.io/probe=controller --overwrite 2>\"$proof\"; then echo 'NodeRestriction failed' >&2;exit 1;fi\ngrep -qi forbidden \"$proof\"\n" +
"image=" + shellQuote(image) + "\nif [ -n \"$(/usr/local/bin/k3s crictl images -q \"$image\")\" ]; then /usr/local/bin/k3s crictl rmi \"$image\" >/dev/null; fi\ntest -z \"$(/usr/local/bin/k3s crictl images -q \"$image\")\"\n/usr/local/bin/k3s crictl pull \"$image\" >/dev/null\n"
cmd := exec.CommandContext(ctx, "ssh", "--", remote, "if [ \"$(id -u)\" = 0 ]; then bash -s; else sudo -n bash -s; fi")
cmd.Stdin = strings.NewReader(script)
cmd.Stdout = stdout
cmd.Stderr = stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("worker host, NodeRestriction or uncached registry pull check failed: %w", err)
}
id := "felis-probe-" + archivetransfer.ID()[:12]
var objects []client.Object
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for i := len(objects) - 1; i >= 0; i-- {
cl.Delete(cleanup, objects[i])
}
}()
create := func(o client.Object) error {
if err := cl.Create(ctx, o); err != nil {
return err
}
objects = append(objects, o)
return nil
}
var endpoints []string
var echoPods []*corev1.Pod
for i := range nodes.Items {
node := &nodes.Items[i]
if !placement.Online(node) || (node.Name != name && node.Name != controller.Name && node.Labels[placement.LabelApproved] != "true") {
continue
}
echoName := fmt.Sprintf("%s-%d", id, i)
p := probePod(echoName, ns, node.Name, image, []string{"--listen", ":25565"}, true)
p.Labels["felis.lolicon.best/probe-echo"] = id
if err := create(p); err != nil {
return err
}
echoPods = append(echoPods, p)
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: echoName, Namespace: ns}, Spec: corev1.ServiceSpec{Selector: map[string]string{"felis.lolicon.best/probe-name": echoName}, Ports: []corev1.ServicePort{{Port: 25565, TargetPort: intstr.FromInt32(25565)}}}}
if err := create(svc); err != nil {
return err
}
endpoints = append(endpoints, net.JoinHostPort(svc.Spec.ClusterIP, "25565"))
}
tcp := corev1.ProtocolTCP
port := intstr.FromInt32(25565)
policy := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: id, Namespace: ns}, Spec: networkingv1.NetworkPolicySpec{PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-echo": id}}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, Ingress: []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{{PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-source": id}}}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}}}}}}
for _, cidr := range aCIDRs {
policy.Spec.Ingress[0].From = append(policy.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := create(policy); err != nil {
return err
}
for _, p := range echoPods {
if err := waitProbePod(ctx, cl, p); err != nil {
return err
}
}
open := append([]string{}, endpoints...)
open = append(open, net.JoinHostPort(apiSvc.Spec.ClusterIP, "443"), net.JoinHostPort(registrySvc.Spec.ClusterIP, "5000"), net.JoinHostPort(archiveSvc.Spec.ClusterIP, "8090"))
// Positive probes need temporary, narrowly scoped ingress grants where the
// production fence admits only the controller or archive-transfer jobs.
for _, svc := range []*corev1.Service{&apiSvc, &registrySvc, &archiveSvc} {
if len(svc.Spec.Selector) == 0 || len(svc.Spec.Ports) == 0 {
return fmt.Errorf("probe Service %s has no backend", svc.Name)
}
targetPort := svc.Spec.Ports[0].TargetPort
if targetPort.IntVal == 0 && targetPort.StrVal == "" {
targetPort = intstr.FromInt32(svc.Spec.Ports[0].Port)
}
allow := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: id + "-" + svc.Name, Namespace: svc.Namespace}, Spec: networkingv1.NetworkPolicySpec{
PodSelector: metav1.LabelSelector{MatchLabels: svc.Spec.Selector}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress},
Ingress: []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{{
NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": ns}},
PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-source": id}},
}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &targetPort}}}},
}}
if err := create(allow); err != nil {
return err
}
}
positive := probeJob(id+"-positive", ns, name, image, probeArgs("--open", open), false)
positive.Spec.Template.Labels["felis.lolicon.best/probe-source"] = id
if err := create(positive); err != nil {
return err
}
if err := waitProbeJob(ctx, cl, positive); err != nil {
return err
}
denied = append(denied, open...)
denied = append(denied, "169.254.169.254:80", "169.254.170.2:80")
negative := probeJob(id+"-negative", ns, name, image, probeArgs("--closed", denied), true)
negative.Spec.Template.Spec.InitContainers = []corev1.Container{{Name: "egress-gate", Image: image, Command: []string{"/usr/local/bin/felis", "egress-gate", "--probe", net.JoinHostPort(apiSvc.Spec.ClusterIP, "443"), "--wait", "2m"}, SecurityContext: negative.Spec.Template.Spec.Containers[0].SecurityContext}}
if err := create(negative); err != nil {
return err
}
if err := waitProbeJob(ctx, cl, negative); err != nil {
return err
}
// Dial from Velocity's host namespace and record the address actually observed inside each backend.
for _, endpoint := range endpoints {
c, err := net.DialTimeout("tcp", endpoint, 5*time.Second)
if err != nil {
return fmt.Errorf("Velocity host cannot dial backend Service %s: %w", endpoint, err)
}
c.Close()
}
var observed []string
time.Sleep(500 * time.Millisecond)
for _, p := range echoPods {
foundA := false
stream, err := cs.CoreV1().Pods(ns).GetLogs(p.Name, &corev1.PodLogOptions{}).Stream(ctx)
if err != nil {
return err
}
scan := bufio.NewScanner(stream)
for scan.Scan() {
line := scan.Text()
if strings.HasPrefix(line, "felis-probe-source ") {
host, _, err := net.SplitHostPort(strings.TrimPrefix(line, "felis-probe-source "))
if err == nil {
cidr, _ := exactCIDR(host)
for _, a := range aCIDRs {
if cidr == a {
observed = append(observed, cidr)
foundA = true
}
}
}
}
}
if !foundA {
stream.Close()
return fmt.Errorf("backend %s did not observe A as an exact source", p.Name)
}
err = scan.Err()
stream.Close()
if err != nil {
return err
}
}
if len(observed) < len(echoPods) {
return fmt.Errorf("backend observed a source outside A's exact interfaces")
}
var game networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: "felis-allow-game-from-velocity"}, &game); err != nil {
return err
}
if len(game.Spec.Ingress) == 0 {
return fmt.Errorf("Velocity ingress policy is not configured")
}
prevGame := game.DeepCopy()
for _, cidr := range observed {
exists := false
for _, peer := range game.Spec.Ingress[0].From {
if peer.IPBlock != nil && peer.IPBlock.CIDR == cidr {
exists = true
break
}
}
if exists {
continue
}
game.Spec.Ingress[0].From = append(game.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := cl.Patch(ctx, &game, client.MergeFrom(prevGame)); err != nil {
return err
}
// Read fresh resourceVersion so concurrent node health writes cannot be overwritten.
if err := cl.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil {
return err
}
if !placement.Online(&n) {
return fmt.Errorf("worker became offline during validation")
}
before = n.DeepCopy()
n.Labels[placement.LabelIdentity] = name
n.Labels[placement.LabelRole] = placement.RoleWorker
n.Labels[placement.LabelApproved] = "true"
taints := n.Spec.Taints[:0]
for _, t := range n.Spec.Taints {
if t.Key != "felis.lolicon.best/unapproved" {
taints = append(taints, t)
}
}
n.Spec.Taints = taints
if err := cl.Patch(ctx, &n, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); err != nil {
return err
}
fmt.Fprintln(stdout, "approved worker", name, "Velocity sources", strings.Join(observed, ","))
return nil
}
func probeArgs(flag string, addresses []string) []string {
var args []string
for _, a := range addresses {
args = append(args, flag, a)
}
return args
}
func probePod(name, ns, node, image string, args []string, game bool) *corev1.Pod {
no, yes := false, true
uid := int64(1000)
labels := map[string]string{"felis.lolicon.best/probe-name": name}
if game {
labels[v1alpha1.LabelManagedBy] = operator.ManagedByValue
labels[v1alpha1.LabelComponent] = operator.ComponentValue
labels[v1alpha1.LabelServer] = name
}
return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: labels}, Spec: corev1.PodSpec{NodeName: node, RestartPolicy: corev1.RestartPolicyNever, AutomountServiceAccountToken: &no, SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: &yes, RunAsUser: &uid, SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}}, Containers: []corev1.Container{{Name: "probe", Image: image, ImagePullPolicy: corev1.PullAlways, Command: []string{"/usr/local/bin/felis", "node-probe"}, Args: args, Resources: corev1.ResourceRequirements{Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("256Mi"), corev1.ResourceCPU: resource.MustParse("200m")}}, SecurityContext: &corev1.SecurityContext{AllowPrivilegeEscalation: &no, ReadOnlyRootFilesystem: &yes, Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}}}}}
}
func probeJob(name, ns, node, image string, args []string, game bool) *batchv1.Job {
p := probePod(name, ns, node, image, args, game)
zero := int32(0)
deadline := int64(600)
return &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: batchv1.JobSpec{BackoffLimit: &zero, ActiveDeadlineSeconds: &deadline, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: p.Labels}, Spec: p.Spec}}}
}
func waitProbePod(ctx context.Context, cl client.Client, p *corev1.Pod) error {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
if err := cl.Get(ctx, client.ObjectKeyFromObject(p), p); err != nil {
return err
}
for _, c := range p.Status.Conditions {
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
return nil
}
}
if p.Status.Phase == corev1.PodFailed {
return fmt.Errorf("probe Pod %s failed", p.Name)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
func waitProbeJob(ctx context.Context, cl client.Client, j *batchv1.Job) error {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
if err := cl.Get(ctx, client.ObjectKeyFromObject(j), j); err != nil {
return err
}
for _, c := range j.Status.Conditions {
if c.Status != corev1.ConditionTrue {
continue
}
if c.Type == batchv1.JobComplete {
return nil
}
if c.Type == batchv1.JobFailed {
return fmt.Errorf("admission probe Job %s failed; inspect its Pod log", j.Name)
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
func denyNodeAddresses(ctx context.Context, cl client.Client, ns string, nodes []corev1.Node) error {
var np networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: "felis-server-egress"}, &np); err != nil {
return err
}
before := np.DeepCopy()
for _, n := range nodes {
for _, a := range n.Status.Addresses {
if a.Type != corev1.NodeInternalIP && a.Type != corev1.NodeExternalIP {
continue
}
cidr, err := exactCIDR(a.Address)
if err != nil {
return err
}
for i := range np.Spec.Egress {
for k := range np.Spec.Egress[i].To {
block := np.Spec.Egress[i].To[k].IPBlock
if block != nil && ((strings.Contains(cidr, ":") && block.CIDR == "::/0") || (!strings.Contains(cidr, ":") && block.CIDR == "0.0.0.0/0")) {
block.Except = append(block.Except, cidr)
}
}
}
}
}
return cl.Patch(ctx, &np, client.MergeFrom(before))
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"strconv"
"strings"
)
func nodeFirewall(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("node firewall", flag.ContinueOnError)
fs.SetOutput(stderr)
peers := fs.String("peers", "", "comma-separated exact node IP CIDRs")
controller := fs.String("controller-ip", "", "controller address (optionally :6443)")
main := fs.Bool("controller", false, "A hosts the public API NodePort")
pod := fs.String("pod-cidr", "10.42.0.0/16", "cluster Pod CIDR")
dryRun := fs.Bool("dry-run", false, "print firewall scripts without installing")
controlNS := fs.String("control-namespace", "felis", "controller namespace")
minecraftNS := fs.String("namespace", "minecraft", "game namespace")
apiIP := fs.String("api-service-ip", "10.43.0.1", "Kubernetes API Service IP")
port := fs.Int("node-port", 30443, "API NodePort to block on workers before DNAT")
if err := fs.Parse(args); err != nil {
return 2
}
if host, _, err := net.SplitHostPort(*controller); err == nil {
*controller = host
}
if net.ParseIP(*apiIP) == nil || net.ParseIP(*controller) == nil || *port < 30000 || *port > 32767 {
fmt.Fprintln(stderr, "node firewall: controller IP and NodePort required")
return 2
}
if _, _, err := net.ParseCIDR(*pod); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
var v4, v6 []string
for _, p := range strings.Split(*peers, ",") {
ip, n, err := net.ParseCIDR(p)
if err != nil {
fmt.Fprintln(stderr, "node firewall: invalid peer CIDR")
return 2
}
ones, bits := n.Mask.Size()
if ones != bits {
fmt.Fprintln(stderr, "node firewall: only exact peer addresses accepted")
return 2
}
if ip.To4() != nil {
v4 = append(v4, n.String())
} else {
v6 = append(v6, n.String())
}
}
script := "#!/bin/bash\nset -euo pipefail\n"
for _, f := range []struct {
bin string
peers []string
metadata string
}{{"iptables", v4, "169.254.0.0/16"}, {"ip6tables", v6, "fe80::/10"}} {
if f.bin == "ip6tables" && len(f.peers) == 0 {
continue
}
b := f.bin + " -w 10"
script += b + " -N FELIS-HOST 2>/dev/null || true\n" + b + " -F FELIS-HOST\n"
script += b + " -N FELIS-FORWARD 2>/dev/null || true\n" + b + " -F FELIS-FORWARD\n"
script += b + " -t raw -N FELIS-NODEPORT 2>/dev/null || true\n" + b + " -t raw -F FELIS-NODEPORT\n"
for _, c := range []struct{ table, parent, chain string }{{"filter", "INPUT", "FELIS-HOST"}, {"filter", "FORWARD", "FELIS-FORWARD"}, {"raw", "PREROUTING", "FELIS-NODEPORT"}} {
script += "while " + b + " -t " + c.table + " -D " + c.parent + " -j " + c.chain + " 2>/dev/null; do :; done\n" + b + " -t " + c.table + " -I " + c.parent + " 1 -j " + c.chain + "\n"
}
script += b + " -A FELIS-HOST -i lo -j RETURN\n"
if *main {
script += b + " -N FELIS-CONTROL 2>/dev/null || true\n" + b + " -A FELIS-HOST -j FELIS-CONTROL\n"
script += b + " -t raw -N FELIS-CONTROL 2>/dev/null || true\n" + b + " -t raw -A FELIS-NODEPORT -j FELIS-CONTROL\n"
}
script += b + " -A FELIS-HOST -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n" + b + " -A FELIS-FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n"
family := 4
if f.bin == "ip6tables" {
family = 6
}
podIP, _, _ := net.ParseCIDR(*pod)
podFamily := 6
if podIP.To4() != nil {
podFamily = 4
}
if family == podFamily {
script += b + " -A FELIS-HOST -s " + *pod + " -j DROP\n"
// raw precedes DNAT and kube-router's filter ACCEPT rules. Block new
// host connections while preserving established Velocity/RCON replies.
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p tcp --syn -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p udp -j DROP\n"
if (net.ParseIP(*apiIP).To4() != nil) == (family == 4) {
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + *apiIP + " -p tcp --dport 443 --syn -j DROP\n"
}
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p tcp --dport " + strconv.Itoa(*port) + " -j DROP\n"
script += b + " -A FELIS-FORWARD -s " + *pod + " -d " + f.metadata + " -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + f.metadata + " -j DROP\n"
for _, p := range f.peers {
script += b + " -A FELIS-FORWARD -s " + *pod + " -d " + p + " -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + p + " -p tcp --syn -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + p + " -p udp -j DROP\n"
}
}
if !*main {
script += b + " -t raw -A FELIS-NODEPORT -m addrtype --dst-type LOCAL -p tcp --dport " + strconv.Itoa(*port) + " -j DROP\n"
}
for _, p := range f.peers {
script += b + " -A FELIS-HOST -s " + p + " -p udp --dport 51820:51821 -j RETURN\n"
}
script += b + " -A FELIS-HOST -p udp --dport 51820:51821 -j DROP\n"
ctrlIP := net.ParseIP(*controller)
ctrlFamily := 6
if ctrlIP.To4() != nil {
ctrlFamily = 4
}
if *main {
for _, p := range f.peers {
script += b + " -A FELIS-HOST -s " + p + " -p tcp --dport 6443 -j RETURN\n"
}
}
if ctrlFamily == family {
script += b + " -A FELIS-HOST -s " + ctrlIP.String() + " -p tcp --dport 10250 -j RETURN\n"
}
script += b + " -A FELIS-HOST -p tcp -m multiport --dports 6443,6444,10250,10255,2379,2380,5000,5001,15432 -j DROP\n"
}
if *dryRun {
fmt.Fprint(stdout, script)
if *main {
fmt.Fprint(stdout, controlFirewallScript(*controlNS, *minecraftNS))
}
return 0
}
if err := os.MkdirAll("/etc/felis", 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err := os.WriteFile("/etc/felis/node-firewall.sh", []byte(script), 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
unit := "[Unit]\nDescription=Felis host and NodePort isolation\nAfter=network-online.target firewalld.service ufw.service\nBefore=k3s.service k3s-agent.service\n[Service]\nType=oneshot\nExecStart=/etc/felis/node-firewall.sh\nRemainAfterExit=yes\n[Install]\nWantedBy=multi-user.target\n"
if err := os.WriteFile("/etc/systemd/system/felis-node-firewall.service", []byte(unit), 0644); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if *main {
refresh := controlFirewallScript(*controlNS, *minecraftNS)
if err := os.WriteFile("/etc/felis/control-firewall.sh", []byte(refresh), 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
svc := "[Unit]\nDescription=Refresh exact controller Pod access to the apiserver\nAfter=k3s.service\n[Service]\nType=oneshot\nExecStart=/etc/felis/control-firewall.sh\n"
timer := "[Unit]\nDescription=Track trusted controller Pods after rescheduling\n[Timer]\nOnBootSec=5s\nOnUnitActiveSec=5s\n[Install]\nWantedBy=timers.target\n"
if err := os.WriteFile("/etc/systemd/system/felis-control-firewall.service", []byte(svc), 0644); err != nil {
return 1
}
if err := os.WriteFile("/etc/systemd/system/felis-control-firewall.timer", []byte(timer), 0644); err != nil {
return 1
}
}
for _, cmd := range []*exec.Cmd{exec.Command("systemctl", "daemon-reload"), exec.Command("systemctl", "enable", "felis-node-firewall.service"), exec.Command("bash", "/etc/felis/node-firewall.sh")} {
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
if *main {
cmd := exec.Command("systemctl", "enable", "--now", "felis-control-firewall.timer")
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
return 0
}
func controlFirewallScript(controlNS, minecraftNS string) string {
return "#!/bin/bash\nset -euo pipefail\niptables -w 10 -F FELIS-CONTROL\niptables -w 10 -t raw -F FELIS-CONTROL\n/usr/local/bin/k3s kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{\" \"}{.spec.serviceAccountName}{\" \"}{.status.podIP}{\"\\n\"}{end}' | while read -r ns sa ip; do\ncase \"$ns/$sa\" in " + shellQuote(controlNS+"/felis-api") + "|" + shellQuote(controlNS+"/felis-operator") + "|" + shellQuote(minecraftNS+"/felis-reaper") + "|kube-system/*) ;; *) continue;; esac\n[[ $ip =~ ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ ]] || continue\niptables -w 10 -A FELIS-CONTROL -s \"$ip/32\" -p tcp --dport 6443 -j ACCEPT\niptables -w 10 -t raw -A FELIS-CONTROL -s \"$ip/32\" -p tcp -m multiport --dports 443,6443 -j ACCEPT\niptables -w 10 -t raw -A FELIS-CONTROL -s \"$ip/32\" -p udp --dport 53 -j ACCEPT\niptables -w 10 -A FELIS-CONTROL -s \"$ip/32\" -p udp --dport 53 -j ACCEPT\ndone\n"
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"bytes"
"os/exec"
"strings"
"testing"
)
func TestDistributedFirewallPathsAndSyntax(t *testing.T) {
for _, controller := range []bool{false, true} {
args := []string{"--dry-run", "--peers", "192.0.2.1/32,192.0.2.2/32", "--controller-ip", "192.0.2.1"}
if controller {
args = append(args, "--controller")
}
var out, err bytes.Buffer
if code := nodeFirewall(args, &out, &err); code != 0 {
t.Fatal(code, err.String())
}
script := out.String()
for _, must := range []string{"-I INPUT 1 -j FELIS-HOST", "-I FORWARD 1 -j FELIS-FORWARD", "-t raw -I PREROUTING 1 -j FELIS-NODEPORT", "-A FELIS-HOST -s 10.42.0.0/16 -j DROP", "--dst-type LOCAL -p tcp --dport 30443 -j DROP", "-d 169.254.0.0/16 -j DROP", "--dst-type LOCAL -p tcp --syn -j DROP", "-d 10.43.0.1 -p tcp --dport 443 --syn -j DROP"} {
if !strings.Contains(script, must) {
t.Fatal("missing protection", must)
}
}
if !controller && strings.Contains(script, " -p tcp --dport 6443 -j RETURN") {
t.Fatal("worker exposed apiserver")
}
check := exec.Command("bash", "-n")
check.Stdin = strings.NewReader(script)
if result, e := check.CombinedOutput(); e != nil {
t.Fatalf("invalid firewall script: %s %v", result, e)
}
}
var out, err bytes.Buffer
if code := nodeFirewall([]string{"--dry-run", "--peers", "10.0.0.0/8", "--controller-ip", "10.0.0.1"}, &out, &err); code != 2 {
t.Fatal("broad node range accepted")
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"flag"
"fmt"
"io"
"net"
"time"
)
// A trusted probe runs with the same server labels and security context before node admission.
func cmdNodeProbe(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("node-probe", flag.ContinueOnError)
fs.SetOutput(stderr)
listen := fs.String("listen", "", "listen and print observed source addresses (admission only)")
var open, closed multiFlag
fs.Var(&open, "open", "required reachable host:port")
fs.Var(&closed, "closed", "required blocked host:port")
if err := fs.Parse(args); err != nil {
return 2
}
if *listen != "" {
l, err := net.Listen("tcp", *listen)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer l.Close()
for {
c, err := l.Accept()
if err != nil {
return 1
}
fmt.Fprintln(stdout, "felis-probe-source", c.RemoteAddr().String())
c.Write([]byte("felis-probe\n"))
c.Close()
}
}
time.Sleep(3 * time.Second)
for _, check := range []struct {
addresses []string
wantOpen bool
}{{open, true}, {closed, false}} {
for _, addr := range check.addresses {
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
if c != nil {
c.Close()
}
if (err == nil) != check.wantOpen {
fmt.Fprintf(stderr, "node-probe: %s open=%t, expected %t\n", addr, err == nil, check.wantOpen)
return 1
}
}
}
return 0
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"time"
felis "felis.lolicon.best"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/platform"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func cmdNode(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "felis node: list | token | join | approve | firewall")
return 2
}
if os.Geteuid() != 0 {
fmt.Fprintln(stderr, "felis node requires root/sudo")
return 1
}
if args[0] == "firewall" {
return nodeFirewall(args[1:], stdout, stderr)
}
fs := flag.NewFlagSet("node "+args[0], flag.ContinueOnError)
fs.SetOutput(stderr)
name := fs.String("name", "", "stable worker node name")
kubeconfig := fs.String("kubeconfig", "/etc/rancher/k3s/k3s.yaml", "A's local administrator kubeconfig")
ns := fs.String("namespace", platform.DefaultMinecraftNamespace, "world namespace")
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "controller namespace")
image := fs.String("image", "", "Felis image to re-pull and use for admission probes")
remote := fs.String("ssh-target", "", "SSH target of the trusted worker (normal SSH host verification applies)")
out := fs.String("out", "", "token output file; never printed to stdout")
ttl := fs.Duration("ttl", 10*time.Minute, "bootstrap token lifetime (maximum 1h)")
server := fs.String("server", "", "A's https://address:6443 endpoint for join")
tokenFile := fs.String("token-file", "", "CA-pinned bootstrap token file for join")
registry := fs.String("registry-ip", "", "registry ClusterIP for join")
peers := fs.String("peers", "", "comma-separated exact peer CIDRs for host firewall")
external := fs.String("external-ip", "", "this worker's fixed external IP")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
switch args[0] {
case "token":
if *name == "" || *out == "" || *ttl <= 0 || *ttl > time.Hour {
fmt.Fprintln(stderr, "node token: name, output file and lifetime <=1h required")
return 2
}
cmd := exec.CommandContext(ctx, "k3s", "token", "create", "--ttl", ttl.String(), "--description", "Felis worker "+*name)
cmd.Stderr = stderr
raw, err := cmd.Output()
if err != nil {
fmt.Fprintln(stderr, "node token: creation failed:", err)
return 1
}
f, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
_, err = f.Write(raw)
if err == nil {
err = f.Sync()
}
f.Close()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
fmt.Fprintln(stdout, "limited bootstrap token written to", *out)
return 0
case "join":
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Stdin = strings.NewReader(felis.BootstrapScript())
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Env = append(os.Environ(), "FELIS_INSTALL_MODE=worker", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_BOOTSTRAP_BINARY="+exe, "FELIS_NODE_NAME="+*name, "FELIS_SERVER_URL="+*server, "FELIS_BOOTSTRAP_TOKEN_FILE="+*tokenFile, "FELIS_REGISTRY_CLUSTER_IP="+*registry, "FELIS_PEER_CIDRS="+*peers, "FELIS_NODE_EXTERNAL_IP="+*external)
if err = cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
case "list", "approve":
default:
fmt.Fprintln(stderr, "unknown node operation")
return 2
}
cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
scheme := runtime.NewScheme()
clientgoscheme.AddToScheme(scheme)
v1alpha1.AddToScheme(scheme)
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
m := &distributed.Manager{Client: cl, Namespace: *ns}
if args[0] == "list" {
nodes, err := m.Nodes(ctx)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
json.NewEncoder(stdout).Encode(nodes)
return 0
}
if *name == "" || *image == "" || *remote == "" || strings.HasPrefix(*remote, "-") {
fmt.Fprintln(stderr, "node approve requires name, image and SSH target")
return 2
}
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err = approveNode(ctx, cl, cs, *ns, *controlNS, *name, *image, *remote, stdout); err != nil {
fmt.Fprintln(stderr, "node remains quarantined:", err)
return 1
}
return 0
}
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
func exactCIDR(ip string) (string, error) {
parsed := net.ParseIP(ip)
if parsed == nil {
return "", fmt.Errorf("invalid node address %q", ip)
}
if parsed.To4() != nil {
return parsed.String() + "/32", nil
}
return parsed.String() + "/128", nil
}
+6
View File
@@ -115,6 +115,9 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
// injects into user servers. The Deployment passes it as FELIS_IMAGE (see // injects into user servers. The Deployment passes it as FELIS_IMAGE (see
// platform.OperatorDeployment); absent, that injection is simply skipped. // platform.OperatorDeployment); absent, that injection is simply skipped.
FelisImage: os.Getenv("FELIS_IMAGE"), FelisImage: os.Getenv("FELIS_IMAGE"),
Nodes: nil,
EgressProbe: os.Getenv("FELIS_EGRESS_PROBE"),
ControllerNode: os.Getenv("FELIS_CONTROLLER_NODE"),
// Uncached: the maintenance-lock check lists Jobs only when a server is // Uncached: the maintenance-lock check lists Jobs only when a server is
// about to start, which does not justify a namespace-wide Job informer. // about to start, which does not justify a namespace-wide Job informer.
Jobs: mgr.GetAPIReader(), Jobs: mgr.GetAPIReader(),
@@ -127,6 +130,9 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
Recorder: mgr.GetEventRecorderFor("felis-operator"), Recorder: mgr.GetEventRecorderFor("felis-operator"),
Watch: watch, Watch: watch,
} }
if os.Getenv("FELIS_DISTRIBUTED") == "true" {
r.Nodes = mgr.GetAPIReader()
}
if err := r.SetupWithManager(mgr); err != nil { if err := r.SetupWithManager(mgr); err != nil {
fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err) fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err)
return 1 return 1
+15 -1
View File
@@ -16,6 +16,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/reaper"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -78,6 +79,19 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
if os.Getenv("FELIS_DISTRIBUTED") == "true" && !*retentionOnly {
m, err := distributionManager(cl, cfg, os.Getenv("FELIS_IMAGE"))
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
local, ok := archiver.(*backup.TarLocal)
if !ok {
fmt.Fprintln(stderr, "remote reaper requires tarLocal")
return 1
}
archiver = &distributed.RemoteArchiver{TarLocal: local, Manager: m}
}
drv, err := openPodStore(ctx, cfg.Database.URL, "reaper", stderr) drv, err := openPodStore(ctx, cfg.Database.URL, "reaper", stderr)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err) fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err)
@@ -94,7 +108,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released") fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released")
return reportReaperRun(r.RunRetention(ctx), stdout, stderr) return reportReaperRun(r.RunRetention(ctx), stdout, stderr)
} }
r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace) r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace).WithDistributed(os.Getenv("FELIS_DISTRIBUTED") == "true")
// Pre-reap warnings go out by email when [smtp] is configured (the same // Pre-reap warnings go out by email when [smtp] is configured (the same
// relay and password_ref convention felis-api uses); without it the channel // relay and password_ref convention felis-api uses); without it the channel
+30 -2
View File
@@ -1,14 +1,18 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os"
"os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
ctrl "sigs.k8s.io/controller-runtime"
) )
// cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore // cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore
@@ -26,6 +30,9 @@ import (
func cmdRestore(args []string, stdout, stderr io.Writer) int { func cmdRestore(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
source := fs.String("source-url", "", "one-use archive download URL")
sum := fs.String("sha256", "", "required digest for remote archive")
limit := fs.Int64("max-bytes", archivetransfer.DefaultLimit, "maximum download size")
server := fs.String("server", "", "server name being restored (for logging)") server := fs.String("server", "", "server name being restored (for logging)")
ref := fs.String("ref", "", "absolute path to the archive on the backup mount") ref := fs.String("ref", "", "absolute path to the archive on the backup mount")
store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)") store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)")
@@ -35,6 +42,22 @@ func cmdRestore(args []string, stdout, stderr io.Writer) int {
return 2 return 2
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if *source != "" {
dir, err := os.MkdirTemp("/tmp", "felis-restore-")
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer os.RemoveAll(dir)
*backupRoot = dir
*ref = filepath.Join(dir, "world.tar.gz")
if err := archivetransfer.Fetch(ctx, *source, os.Getenv(archivetransfer.TokenEnv), *ref, *sum, *limit); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
if *ref == "" { if *ref == "" {
fmt.Fprintln(stderr, "felis restore: --ref is required") fmt.Fprintln(stderr, "felis restore: --ref is required")
return 2 return 2
@@ -62,11 +85,16 @@ func cmdRestore(args []string, stdout, stderr io.Writer) int {
}, },
} }
ctx := ctrl.SetupSignalHandler()
if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil { if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil {
fmt.Fprintf(stderr, "felis restore: %v\n", err) fmt.Fprintf(stderr, "felis restore: %v\n", err)
return 1 return 1
} }
if *source != "" {
if err := backup.VerifyRestored(ctx, *ref, *worldsRoot); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot) fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot)
return 0 return 0
} }
+8
View File
@@ -28,6 +28,10 @@ Commands:
scan-gate Apply the scan policy to a build's Trivy report and hand felis-api the report and SBOM (internal Job entrypoint) scan-gate Apply the scan policy to a build's Trivy report and hand felis-api the report and SBOM (internal Job entrypoint)
push-image Push a scanned image tarball to the registry (internal Job entrypoint) push-image Push a scanned image tarball to the registry (internal Job entrypoint)
mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer) mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer)
server-migrate Move a stopped world between approved nodes (start|status|retry; requires root)
node Join and approve trusted daemon nodes (list|token|join|approve|firewall; requires root)
node-probe Verify reachability and observed sources (internal admission probe)
archive-serve Serve scoped one-use archive transfers on the controller (internal entrypoint)
registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint) registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint)
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
@@ -74,6 +78,10 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"push-image": cmdPushImage, "push-image": cmdPushImage,
"mirror-build-tools": cmdMirrorBuildTools, "mirror-build-tools": cmdMirrorBuildTools,
"registry-gate": cmdRegistryGate, "registry-gate": cmdRegistryGate,
"archive-serve": cmdArchiveServe,
"node": cmdNode,
"server-migrate": cmdServerMigrate,
"node-probe": cmdNodeProbe,
"manifests": cmdManifests, "manifests": cmdManifests,
"apply": cmdApply, "apply": cmdApply,
"setup": cmdSetup, "setup": cmdSetup,
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func cmdServerMigrate(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "server-migrate: start | status | retry (separate from database migrate)")
return 2
}
fs := flag.NewFlagSet("server-migrate "+args[0], flag.ContinueOnError)
fs.SetOutput(stderr)
name := fs.String("name", "", "stopped user server")
target := fs.String("target-node", "", "approved target worker")
id := fs.String("id", "", "operation id (required for retry)")
kube := fs.String("kubeconfig", "/etc/rancher/k3s/k3s.yaml", "A's local kubeconfig")
ns := fs.String("namespace", platform.DefaultMinecraftNamespace, "world namespace")
cfgPath := fs.String("config", "/var/lib/felis/felis.host.toml", "host config used to record the current owner on the safety backup")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
if os.Geteuid() != 0 {
fmt.Fprintln(stderr, "server-migrate requires root/sudo")
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := clientcmd.BuildConfigFromFlags("", *kube)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
scheme := runtime.NewScheme()
clientgoscheme.AddToScheme(scheme)
v1alpha1.AddToScheme(scheme)
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
m := &distributed.Manager{Client: cl, Namespace: *ns, Resolve: placement.Resolve(cl, *ns)}
var nodes corev1.NodeList
if err = cl.List(ctx, &nodes); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
for _, n := range nodes.Items {
if n.Labels[placement.LabelRole] == placement.RoleController {
m.Controller = n.Name
}
}
if m.Controller == "" {
fmt.Fprintln(stderr, "distributed controller identity is not configured")
return 1
}
m.Resolve = placement.Resolve(cl, *ns, m.Controller)
var op distributed.Operation
switch args[0] {
case "start":
host, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
drv, err := openPodStore(ctx, host.Database.URL, "migration", stderr)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer drv.Close()
var owner sql.NullString
if err = drv.DB().QueryRowContext(ctx, "SELECT owner_id FROM servers WHERE name=$1 AND deleted_at IS NULL AND retire_requested_at IS NULL", *name).Scan(&owner); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
op, err = m.BeginMigration(ctx, *name, *target, owner.String)
case "status":
op, err = m.Migration(ctx, *name, *id)
case "retry":
if *id == "" {
fmt.Fprintln(stderr, "retry requires --id")
return 2
}
op, err = m.RetryMigration(ctx, *name, *id)
default:
fmt.Fprintln(stderr, "unknown migration operation")
return 2
}
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
json.NewEncoder(stdout).Encode(op)
return 0
}
+128 -2
View File
@@ -274,6 +274,13 @@ FELIS_OFFSITE_DB_KEEP="${FELIS_OFFSITE_DB_KEEP:-}"
# the key that pings the check; off removes it, and a re-run without it keeps it. # the key that pings the check; off removes it, and a re-run without it keeps it.
FELIS_WATCHDOG_HEARTBEAT_URL="${FELIS_WATCHDOG_HEARTBEAT_URL:-}" FELIS_WATCHDOG_HEARTBEAT_URL="${FELIS_WATCHDOG_HEARTBEAT_URL:-}"
INSTALL_MODE="${FELIS_INSTALL_MODE:-}" INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
DISTRIBUTED="${FELIS_DISTRIBUTED:-0}"
WORKER_NAME="${FELIS_NODE_NAME:-}"
WORKER_SERVER="${FELIS_SERVER_URL:-}"
WORKER_TOKEN_FILE="${FELIS_BOOTSTRAP_TOKEN_FILE:-}"
WORKER_REGISTRY_IP="${FELIS_REGISTRY_CLUSTER_IP:-}"
NODE_EXTERNAL_IP="${FELIS_NODE_EXTERNAL_IP:-}"
WORKER_PEERS="${FELIS_PEER_CIDRS:-}"
# strict stops the install on any preflight problem (preflight below); warn reports them # strict stops the install on any preflight problem (preflight below); warn reports them
# and goes on, for a host the checks misjudge. # and goes on, for a host the checks misjudge.
FELIS_PREFLIGHT="${FELIS_PREFLIGHT:-strict}" FELIS_PREFLIGHT="${FELIS_PREFLIGHT:-strict}"
@@ -2024,6 +2031,19 @@ write_k3s_config() {
{ {
echo "# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it." echo "# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it."
echo 'write-kubeconfig-mode: "0600"' echo 'write-kubeconfig-mode: "0600"'
if [ "${DISTRIBUTED:-0}" = 1 ]; then
[ -n "$NODE_EXTERNAL_IP" ] || NODE_EXTERNAL_IP="$NODE_IP"
printf 'node-external-ip: "%s"\n' "$NODE_EXTERNAL_IP"
echo 'flannel-backend: "wireguard-native"'
echo 'flannel-external-ip: true'
echo 'agent-token-file: "/etc/rancher/k3s/felis-agent-token"'
# Append to existing API-server hardening arguments in earlier config files.
echo 'kube-apiserver-arg+:'
echo ' - "enable-admission-plugins=NodeRestriction"'
if [ ! -s /etc/rancher/k3s/felis-agent-token ]; then
(umask 077; openssl rand -hex 32 > /etc/rancher/k3s/felis-agent-token)
fi
fi
if [ -n "$name" ]; then if [ -n "$name" ]; then
printf 'node-name: "%s"\n' "$name" printf 'node-name: "%s"\n' "$name"
fi fi
@@ -5259,6 +5279,24 @@ deploy_bundle() {
revoke_worlds_root_grant revoke_worlds_root_grant
if [ "${DISTRIBUTED:-0}" = 1 ]; then
local controller archive_key_file="${STATE_DIR}/archive-transfer.key"
controller="$(k3s_node_name)"
[ -n "$controller" ] || die "distributed deployment needs a stable controller node name"
kube label node "$controller" "felis.node-restriction.kubernetes.io/role=controller" "felis.node-restriction.kubernetes.io/identity=$controller" --overwrite
local system_deployment
for system_deployment in coredns local-path-provisioner; do
if kube -n kube-system get deployment "$system_deployment" >/dev/null 2>&1; then
kube -n kube-system patch deployment "$system_deployment" --type merge \
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"felis.node-restriction.kubernetes.io/identity\":\"$controller\"}}}}}"
fi
done
if [ ! -s "$archive_key_file" ]; then (umask 077; openssl rand -hex 32 > "$archive_key_file"); fi
local archive_key
archive_key="$(cat "$archive_key_file")"
apply_literal_secret "$CONTROL_NS" felis-archive-key key "$archive_key"
apply_literal_secret "$MINECRAFT_NS" felis-archive-key key "$archive_key"
fi
log "rendering + applying the control-plane bundle" log "rendering + applying the control-plane bundle"
local -a manifest_args=( local -a manifest_args=(
--felis-image "$FELIS_IMAGE" --felis-image "$FELIS_IMAGE"
@@ -5266,6 +5304,13 @@ deploy_bundle() {
--panel-node-port "$FELIS_PANEL_NODEPORT" --panel-node-port "$FELIS_PANEL_NODEPORT"
--velocity-cidr "${NODE_IP}/32" --velocity-cidr "${NODE_IP}/32"
) )
if [ "${DISTRIBUTED:-0}" = 1 ]; then
manifest_args+=(--distributed --controller-node "$controller" --egress-probe "felis-api.${CONTROL_NS}.svc:443")
# Every node address, including global addresses, must be excluded from game egress.
while read -r cidr; do
[ -z "$cidr" ] || manifest_args+=(--server-egress-deny-cidr "$cidr")
done < <(kube get nodes -o jsonpath='{range .items[*]}{range .status.addresses[*]}{.address}{"\n"}{end}{end}' | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $0"/32"}')
fi
local cidr local cidr
while read -r cidr; do while read -r cidr; do
[ -n "$cidr" ] && manifest_args+=(--server-egress-deny-cidr "$cidr") [ -n "$cidr" ] && manifest_args+=(--server-egress-deny-cidr "$cidr")
@@ -5660,6 +5705,7 @@ summary() {
# prompt (or FELIS_INSTALL_MODE=nano). # prompt (or FELIS_INSTALL_MODE=nano).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
prompt_install_mode() { prompt_install_mode() {
if [ "$INSTALL_MODE" = worker ]; then log "install mode: worker";return; fi
# felis setup carries on to the Owner and edge setup, which needs the control plane, so # felis setup carries on to the Owner and edge setup, which needs the control plane, so
# a nano install under it could only end in a setup error. # a nano install under it could only end in a setup error.
if bootstrap_from_tui; then if bootstrap_from_tui; then
@@ -5669,9 +5715,9 @@ prompt_install_mode() {
return 0 return 0
fi fi
case "$INSTALL_MODE" in case "$INSTALL_MODE" in
full|nano) log "install mode: ${INSTALL_MODE} (from FELIS_INSTALL_MODE)"; return 0 ;; full|nano|worker) log "install mode: ${INSTALL_MODE} (from FELIS_INSTALL_MODE)"; return 0 ;;
"") ;; "") ;;
*) die "FELIS_INSTALL_MODE must be 'full' or 'nano', got: ${INSTALL_MODE}" ;; *) die "FELIS_INSTALL_MODE must be 'full', 'nano' or 'worker', got: ${INSTALL_MODE}" ;;
esac esac
# A felis-nano unit with no full install beside it makes this re-run a nano update; # A felis-nano unit with no full install beside it makes this re-run a nano update;
@@ -5997,6 +6043,80 @@ ensure_k3s_on_path() {
esac esac
} }
# Worker is a daemon-only branch. It neither generates Felis service credentials nor applies a controller bundle.
main_worker() {
[ -n "$WORKER_NAME" ] && [[ "$WORKER_NAME" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "FELIS_NODE_NAME is required and must be a DNS node name"
[[ "$WORKER_SERVER" =~ ^https://([a-zA-Z0-9.:-]+|\[[0-9a-fA-F:]+\]):6443$ ]] || die "FELIS_SERVER_URL must be an HTTPS k3s endpoint on port 6443"
[[ "$WORKER_REGISTRY_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "FELIS_REGISTRY_CLUSTER_IP is required"
[ -n "$WORKER_PEERS" ] || die "FELIS_PEER_CIDRS must list exact cluster peer addresses"
[ -s "$WORKER_TOKEN_FILE" ] || die "FELIS_BOOTSTRAP_TOKEN_FILE must name a secure limited bootstrap token file"
local token saved mode
[ -f "$WORKER_TOKEN_FILE" ] && [ ! -L "$WORKER_TOKEN_FILE" ] || die "bootstrap token must be a regular file"
mode="$(stat -c %a "$WORKER_TOKEN_FILE")"
(( (8#$mode & 077) == 0 )) || die "bootstrap token must not be readable by group or others (use chmod 600)"
token="$(cat "$WORKER_TOKEN_FILE")"
[[ "$token" =~ ^K10[0-9a-f]{64}::[a-z0-9]{6}\.[a-z0-9]{16}$ ]] || die "worker accepts only CA-pinned bootstrap tokens, never server or static agent tokens"
[ ! -e /var/lib/rancher/k3s/server ] || die "this host has a k3s server; refusing to turn a controller into a worker"
if [ -d /var/lib/rancher/k3s/agent ]; then
saved="$(k3s_node_name)"
[ -n "$saved" ] && [ "$saved" = "$WORKER_NAME" ] || die "cannot change or guess an installed worker identity"
fi
if [ -f "$K3S_CONFIG_DROPIN" ]; then
saved="$(awk -F'"' '/^node-name:/ {print $2;exit}' "$K3S_CONFIG_DROPIN")"
[ -z "$saved" ] || [ "$saved" = "$WORKER_NAME" ] || die "existing node identity is $saved; refusing to rename it"
saved="$(awk -F'"' '/^server:/ {print $2;exit}' "$K3S_CONFIG_DROPIN")"
[ -z "$saved" ] || [ "$saved" = "$WORKER_SERVER" ] || die "existing worker belongs to another controller"
fi
detect_node_ip
[ -n "$NODE_EXTERNAL_IP" ] || NODE_EXTERNAL_IP="$NODE_IP"
PREFLIGHT_PROBLEMS=()
preflight_platform; preflight_memory; preflight_disk; preflight_networks; preflight_outbound
local unit
for unit in rke2-server rke2-agent k0scontroller k0sworker snap.microk8s.daemon-kubelite kubelet k3s; do
if systemctl is-active --quiet "$unit.service"; then preflight_fail "conflicting Kubernetes service: $unit"; fi
done
[ "${#PREFLIGHT_PROBLEMS[@]}" = 0 ] || die "worker preflight failed: ${PREFLIGHT_PROBLEMS[*]}"
install_base
ensure_time_sync
ensure_persistent_journal
# Reuse the release binary path for the local admission checks, without importing game/platform images.
bootstrap_from_tui || [ -n "$FELIS_ARTIFACT_DIR" ] || [ -n "${FELIS_SKIP_FETCH:-}" ] || resolve_install_ref
acquire_felis_binary
if [ ! -x "$HOST_BIN" ]; then install_go_toolchain; build_nano_binary; fi
mkdir -p /etc/rancher/k3s/config.yaml.d
(umask 077; printf '%s\n' "$token" > /etc/rancher/k3s/felis-bootstrap-token)
unset token
cat > "$K3S_CONFIG_DROPIN" <<EOF_WORKER
server: "$WORKER_SERVER"
node-name: "$WORKER_NAME"
node-external-ip: "$NODE_EXTERNAL_IP"
token-file: "/etc/rancher/k3s/felis-bootstrap-token"
disable-default-registry-endpoint: true
node-taint:
- "felis.lolicon.best/unapproved=true:NoSchedule"
EOF_WORKER
chmod 0600 "$K3S_CONFIG_DROPIN"
cat > "$K3S_REGISTRIES_FILE" <<EOF_MIRROR
mirrors:
"$REGISTRY_URL":
endpoint:
- "http://${WORKER_REGISTRY_IP}:5000"
EOF_MIRROR
chmod 0600 "$K3S_REGISTRIES_FILE"
HOST_BIN_IN_USE=1
"$HOST_BIN" node firewall --peers "$WORKER_PEERS" --controller-ip "${WORKER_SERVER#https://}" --pod-cidr "$POD_CIDR" --node-port "$FELIS_PANEL_NODEPORT"
if [ ! -x "$K3S_BIN" ]; then
stage_k3s_airgap_images
curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \
INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" INSTALL_K3S_EXEC=agent sh -
else
[ "$("$K3S_BIN" --version | awk 'NR==1 {print $3}')" = "$FELIS_K3S_VERSION" ] || die "worker k3s version differs from pinned controller version; upgrade in a maintenance window"
systemctl enable --now k3s-agent
systemctl restart k3s-agent
fi
ok "worker $WORKER_NAME joined under quarantine; run felis node approve on A"
}
main() { main() {
ensure_k3s_on_path ensure_k3s_on_path
resolve_nano_listen resolve_nano_listen
@@ -6007,6 +6127,7 @@ main() {
main_nano main_nano
return return
fi fi
if [ "$INSTALL_MODE" = worker ]; then main_worker; return; fi
detect_node_ip detect_node_ip
# Before the first change to the host: a problem found here costs a rerun, one found # Before the first change to the host: a problem found here costs a rerun, one found
# halfway through costs an install to unwind. # halfway through costs an install to unwind.
@@ -6033,11 +6154,16 @@ main() {
ensure_panel_tls_cert ensure_panel_tls_cert
# No install_docker here: Docker comes in only for an image this run has to build # No install_docker here: Docker comes in only for an image this run has to build
# (ensure_docker), and an install from a release's assets builds none. # (ensure_docker), and an install from a release's assets builds none.
if [ -z "${FELIS_DISTRIBUTED+x}" ] && [ -f "$K3S_CONFIG_DROPIN" ] && grep -q 'flannel-backend: "wireguard-native"' "$K3S_CONFIG_DROPIN"; then DISTRIBUTED=1; fi
install_k3s install_k3s
# The registry mirror must exist before the bundle's pods start pulling (and # The registry mirror must exist before the bundle's pods start pulling (and
# before any re-run's rollouts). # before any re-run's rollouts).
configure_registry_mirror configure_registry_mirror
acquire_felis_binary acquire_felis_binary
if [ "${DISTRIBUTED:-0}" = 1 ]; then
[ -n "$WORKER_PEERS" ] || WORKER_PEERS="${NODE_EXTERNAL_IP:-$NODE_IP}/32"
"$HOST_BIN" node firewall --controller --controller-ip "${NODE_EXTERNAL_IP:-$NODE_IP}" --peers "$WORKER_PEERS" --pod-cidr "$POD_CIDR" --node-port "$FELIS_PANEL_NODEPORT" --control-namespace "$CONTROL_NS" --namespace "$MINECRAFT_NS"
fi
select_release_artifacts select_release_artifacts
resolve_felis_image resolve_felis_image
# The registry's and the database's own images must be in containerd before their # The registry's and the database's own images must be in containerd before their
+31 -1
View File
@@ -1334,7 +1334,7 @@ expect "a failed fetch into an existing checkout names the token" "set FELIS_GIT
mblock="$(awk '/^ log "rendering \+ applying the control-plane bundle"/,/kube apply -f -/' "$BS")" mblock="$(awk '/^ log "rendering \+ applying the control-plane bundle"/,/kube apply -f -/' "$BS")"
[ -n "$mblock" ] || { echo "FAIL: no manifest_args block found in $BS"; exit 1; } [ -n "$mblock" ] || { echo "FAIL: no manifest_args block found in $BS"; exit 1; }
[ "$(printf '%s\n' "$mblock" | wc -l)" -lt 60 ] \ [ "$(printf '%s\n' "$mblock" | wc -l)" -lt 100 ] \
|| { echo "FAIL: the extracted block is not the manifest_args block -- did it move?"; exit 1; } || { echo "FAIL: the extracted block is not the manifest_args block -- did it move?"; exit 1; }
run_bundle_flags() { # backup-pvc worlds-host-path run_bundle_flags() { # backup-pvc worlds-host-path
@@ -4910,6 +4910,36 @@ case "$out" in
esac esac
rm -rf "$credir" "$credcalls" rm -rf "$credir" "$credcalls"
# Worker admission reuses the installer but must never enter host control-plane setup.
expect "distributed admission preserves existing API-server arguments" \
"echo 'kube-apiserver-arg+:'" "$(bsfn write_k3s_config)"
worker="$(bsfn main_worker)"
before "worker identity is checked before the agent config is written" \
'refusing to rename it' 'cat > "$K3S_CONFIG_DROPIN"' "$worker"
expect "worker rejects server tokens and verifies the CA-pinned bootstrap shape" \
'K10[0-9a-f]{64}::[a-z0-9]{6}\.[a-z0-9]{16}' "$worker"
expect "worker cannot replace a controller" 'refusing to turn a controller into a worker' "$worker"
expect "worker is quarantined" 'felis.lolicon.best/unapproved=true:NoSchedule' "$worker"
expect "worker mirror preserves logical references and points at the cluster service" \
'http://${WORKER_REGISTRY_IP}:5000' "$worker"
expect "worker disables registry endpoint fallback" 'disable-default-registry-endpoint: true' "$worker"
for forbidden in deploy_bundle install_cloudflared install_velocity run_migrations load_or_make_secrets; do
case "$worker" in
*"$forbidden"*) echo "FAIL worker invokes $forbidden"; fails=$((fails + 1));;
*) echo "PASS worker does not invoke $forbidden";;
esac
done
badtoken="$(mktemp)"
printf 'server-token-not-bootstrap' > "$badtoken"
out="$(WORKER_TOKEN_FILE="$badtoken" bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; }
WORKER_NAME=b WORKER_SERVER=https://192.0.2.1:6443 WORKER_REGISTRY_IP=10.43.0.10 WORKER_PEERS=192.0.2.1/32
'"$worker"'
main_worker
')"
expect "worker refuses a copied server token before changing the machine" 'worker accepts only CA-pinned bootstrap tokens' "$out"
rm -f "$badtoken"
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
@@ -281,6 +281,8 @@ spec:
storage: storage:
description: Storage configures the world PVC. description: Storage configures the world PVC.
properties: properties:
claimName:
type: string
size: size:
description: Size is the requested PVC capacity (e.g. "10Gi"). description: Size is the requested PVC capacity (e.g. "10Gi").
type: string type: string
@@ -289,6 +291,8 @@ spec:
uses the default. uses the default.
type: string type: string
type: object type: object
nodeName:
type: string
subdomain: subdomain:
description: |- description: |-
Subdomain is the per-server label under the deployment zone. It is the Subdomain is the per-server label under the deployment zone. It is the
@@ -301,6 +305,8 @@ spec:
status: status:
description: MinecraftServerStatus is the observed state (spec §4 status.*). description: MinecraftServerStatus is the observed state (spec §4 status.*).
properties: properties:
nodeName:
type: string
autoRestarts: autoRestarts:
description: |- description: |-
AutoRestarts counts how often the operator recreated the pod of a start AutoRestarts counts how often the operator recreated the pod of a start
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Linux/root acceptance of resident-node and pre-DNAT paths. All packet rules,
# listeners and links live in disposable network namespaces, never the live host.
set -euo pipefail
bin="${1:?usage: test-node-firewall.sh /absolute/path/to/felis}"
[[ $bin = /* && -x $bin ]] || exit 2
[[ $(id -u) = 0 ]] || { echo 'requires root on Linux' >&2; exit 2; }
work=$(mktemp -d)
suffix="$$"
node="felis-fw-node-$suffix"
game="felis-fw-game-$suffix"
peer="felis-fw-peer-$suffix"
listener=''
cleanup() {
if [[ -n $listener ]]; then kill "$listener" 2>/dev/null || true; wait "$listener" 2>/dev/null || true; fi
for ns in "$game" "$peer" "$node"; do ip netns del "$ns" 2>/dev/null || true; done
rm -rf "$work"
}
trap cleanup EXIT
for ns in "$node" "$game" "$peer"; do ip netns add "$ns"; ip -n "$ns" link set lo up; done
ip link add fg-node type veth peer name fg-game
ip link set fg-node netns "$node"
ip link set fg-game netns "$game"
ip link add fp-node type veth peer name fp-peer
ip link set fp-node netns "$node"
ip link set fp-peer netns "$peer"
ip -n "$node" addr add 10.42.250.1/24 dev fg-node
ip -n "$game" addr add 10.42.250.2/24 dev fg-game
ip -n "$node" addr add 192.0.2.2/24 dev fp-node
ip -n "$peer" addr add 192.0.2.1/24 dev fp-peer
ip -n "$node" link set fg-node up
ip -n "$node" link set fp-node up
ip -n "$game" link set fg-game up
ip -n "$peer" link set fp-peer up
ip -n "$game" route add 192.0.2.0/24 via 10.42.250.1
ip -n "$game" route add 10.43.0.1/32 via 10.42.250.1
ip netns exec "$node" "$bin" node-probe --listen :18083 >"$work/listener.log" 2>&1 &
listener=$!
ip netns exec "$game" "$bin" node-probe --open 192.0.2.2:18083
ip netns exec "$peer" "$bin" node-probe --open 192.0.2.2:18083
"$bin" node firewall --dry-run --peers 192.0.2.1/32,192.0.2.2/32 \
--controller-ip 192.0.2.1 --pod-cidr 10.42.0.0/16 \
--node-port 30443 --api-service-ip 10.43.0.1 >"$work/firewall.sh"
ip netns exec "$node" bash "$work/firewall.sh"
# Simulate later kube-router insertion ahead of Felis filter hooks.
ip netns exec "$node" iptables -I INPUT 1 -j ACCEPT
ip netns exec "$node" iptables -t nat -A PREROUTING -p tcp --dport 30443 -j REDIRECT --to-ports 18083
ip netns exec "$node" iptables -t nat -A PREROUTING -d 10.43.0.1 -p tcp --dport 443 -j REDIRECT --to-ports 18083
ip netns exec "$game" "$bin" node-probe \
--closed 192.0.2.2:18083 --closed 192.0.2.2:30443 --closed 10.43.0.1:443
ip netns exec "$peer" "$bin" node-probe --closed 192.0.2.2:30443
# The listener remains healthy and the allowed peer still reaches it.
ip netns exec "$peer" "$bin" node-probe --open 192.0.2.2:18083
ip netns exec "$node" "$bin" node-probe --open 127.0.0.1:18083
echo 'PASS resident-node isolation and public NodePort denial survive pre-DNAT and early filter ACCEPT'
+109
View File
@@ -0,0 +1,109 @@
# A 主控与多机 worker
分布式模式默认关闭。A 运行唯一 Felis API/operator、k3s server、PostgreSQL、Registry、归档服务和系统服;Velocity 继续使用 A 的 systemd 服务。B、C 等节点只运行 k3s-agent/containerd、游戏 Pod 和 A 创建的维护 Job。节点必须与 A 同架构、同 k3s 版本,宿主机由管理员信任并维护。
## 先准备 A
在维护窗口停服,使用现有数据库备份流程备份 PostgreSQL,并离线保存 k3s 状态、server token 和当前安装配置。SQLite k3s 的状态目录是 `/var/lib/rancher/k3s/server/db`;使用其他 datastore 时按对应备份流程操作。不要把这些文件复制到 worker。
记录 A 的现有节点名称,后续安装必须沿用。先把当前控制工作负载固定到 A,再启用 WireGuard;已有游戏 PVC 不迁移。
```bash
# 在 A,以 root 执行;替换节点名和所有固定节点地址。
A_NODE=existing-node-name
PEERS=192.0.2.10/32,192.0.2.11/32,192.0.2.12/32
k3s kubectl label node "$A_NODE" \
felis.node-restriction.kubernetes.io/identity="$A_NODE" \
felis.node-restriction.kubernetes.io/role=controller --overwrite
# 按实际部署名称执行,均使用受保护身份标签。
for d in felis-api felis-operator felis-postgres registry; do
k3s kubectl -n felis patch deployment "$d" --type merge \
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"felis.node-restriction.kubernetes.io/identity\":\"$A_NODE\"}}}}}"
done
```
用包含此功能的 Felis 安装器在 A 重跑安装:`FELIS_DISTRIBUTED=1`、`FELIS_NODE_EXTERNAL_IP=<A 固定公网 IP>`、`FELIS_PEER_CIDRS="$PEERS"`,保留现有安装参数。该步骤会启用 `wireguard-native`、`flannel-external-ip`、NodeRestriction 和独立 agent token,并安装归档服务、最小 RBAC 和宿主机隔离规则。WireGuard 更换需要停服维护窗口。已有 worker 的对等地址列表也必须提前更新。
直接生成部署清单时,增加:
```bash
felis manifests --felis-image <现有逻辑镜像引用> \
--distributed --controller-node "$A_NODE" \
--egress-probe felis-api.felis.svc:443 \
--velocity-cidr <A精确来源IP/32> \
--archive-local-path <原archive.local_path> --backup-pvc felis-backups
```
其他已有参数继续保留。安装器也把 CoreDNS、local-path-provisioner 固定在 A;手动部署时同样给这些 Deployment 设置 A 的受保护节点选择器。手动部署须把同一随机密钥保存到 `felis` 和 `minecraft` 命名空间的 `felis-archive-key` Secret(字段 `key`,至少 32 字符);只有 API、Reaper 和归档服务获得密钥,operator 不获得。归档 PVC、Registry 和数据库均留在 A。
## 接入 B,再接入 C
先在所有现有节点执行 `felis node firewall --peers "$PEERS" --controller-ip <A地址>` 更新完整对等地址列表;A 增加 `--controller`。只允许精确 `/32` 或 `/128` 地址,不能用整个节点/Pod 网段充当 Velocity 或 Registry 来源。固定公网节点之间允许 WireGuard UDP 51820–51821,k3s 6443 只允许已知对等节点。
```bash
# A:每台 worker 独立创建,默认 10 分钟;输出文件 root-only,令牌不打印。
felis node token --name b --ttl 10m --out /root/b.bootstrap
k3s kubectl -n felis get svc registry
# 通过可信 SSH/SCP 把该文件和同版本 felis 二进制交给 B。
# B:不需要 server token、管理员 kubeconfig、数据库或 Registry 写入凭据。
felis node join --name b --server https://<A公网IP>:6443 \
--external-ip <B公网IP> --token-file /root/b.bootstrap \
--registry-ip <Registry ClusterIP> --peers "$PEERS"
# A:SSH 使用既有主机密钥校验;目标账号须能 sudo -n。
felis node approve --name b --ssh-target <B的SSH别名> \
--image <Felis逻辑镜像引用>
felis node list
```
安装 worker 不安装数据库、Velocity、API 或 operator,不删除本地卷或 node-password;重复安装会拒绝改名或更换集群。mirror 使用 Registry ClusterIP,关闭默认镜像源回退,保留原镜像引用。
批准前 worker 带 `NoSchedule` 隔离 taint,且没有受保护的 approved 标签。批准命令检查架构、版本、节点在线状态、WireGuard、宿主机隔离、kubelet 修改受保护标签被拒绝,删除指定镜像后执行真实 `crictl pull`。随后创建临时探针,检查跨节点 Service、控制服务的正向可达性和游戏标签 Pod 的拒绝路径。A 从宿主机连接每个测试 Service,读取后端实际观察到的源地址,只将属于 A 的精确地址写入游戏策略。任何检查失败都保留隔离状态。批准过程中会删除指定缓存镜像,须在该节点尚无游戏任务时执行。
节点批准后,管理员可在面板创建服务器时选择它,或在创建请求中传 `nodeName`。普通服主不能选节点或指定 PVC。失联节点拒绝新任务;operator 撤销该服务器 Service 的后端和 Ready 状态,Velocity 进入原有 fallback 流程,不换机。
## 停服迁移
先在面板停服并等待 `Stopped` 和游戏 Pod 退出。打开“停服迁移”,选择在线且已批准的目标 worker。面板从 CR 的持久化记录读取最新操作,刷新页面或重启 A 后仍可查进度。
```bash
# A 的 root 运维入口,区别于数据库 felis migrate:
felis server-migrate start --name survival --target-node c
felis server-migrate status --name survival
felis server-migrate retry --name survival --id <操作ID>
```
管理员 API:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/v1/nodes` | 执行节点列表 |
| POST | `/api/v1/servers/{name}/migrations` | `{ "targetNode": "c" }`,返回操作及 ID |
| GET | `/api/v1/servers/{name}/migrations` | 最新迁移记录 |
| GET | `/api/v1/servers/{name}/migrations/{id}` | 当前操作进度 |
| POST | `/api/v1/servers/{name}/migrations/{id}/retry` | 重试失败阶段 |
阶段为 `backing_up` → `restoring` → `switching` → `succeeded`。锁是 `migration@<开始时间>`,不按临时维护锁的两分钟规则过期。失败保存阶段与原因,保持锁和停服状态;相同目标的重复请求返回已有操作,其他迁移被拒绝。恢复 Job 校验下载 SHA-256,恢复后逐文件读回校验;成功后才在一次乐观锁 CR 更新中切换节点、活动 PVC 和进度。停服 StatefulSet 会重建以使用新的 PVC,CR、Service、ClusterIP、域名及归属保持不变。
迁移成功仍不自动启动。检查目标世界后手动启动。记录中的 `sourcePVC` 保留,不被回收流程顺带删除;确认不再需要后由管理员显式清理。失败不要手动删除迁移注解或锁;排除源节点失联、目标磁盘不足、传输/校验错误后使用重试。已提交的切换不会自动回退到源世界。
## 隔离与验收
游戏进程沿用非 root、禁止提权、drop ALL、无 SA token 和宿主机命名空间的限制。维护 Job 只挂一个世界 PVC:传输 Job 仅访问归档服务和 DNS,文件/导出 Job 仅额外访问既有 API 传输入口。归档服务无数据库配置和 Kubernetes 身份;只有完整归档原子落盘后 A 才记录成功,现有 tarLocal 路径、保留与 offsite 流程继续使用。
宿主机 INPUT/FORWARD 规则封闭 resident-node 路径,raw PREROUTING 在 DNAT 前封闭 worker NodePort;A 的受信控制 Pod 精确地址可以访问 apiserver。raw 规则也封闭游戏 Pod 向宿主机发起的新连接,避免 kube-router 的提前 ACCEPT 绕过 filter 规则;Velocity/RCON 的已建立连接回复保留。规则由 systemd 安装,控制 Pod 地址定期更新。节点地址、防火墙或 CNI 配置变动后,先停服并重新执行批准检查,再运行不可信代码。游戏 egress gate 同时检查允许的 DNS TCP 路径和拒绝路径,分布式模式超时拒绝启动。
必须在 A/B/C 三台 Linux 机器完成上线验收,不能用单机单元测试代替:
- Velocity `ClusterIP:25565` 跨节点连接及实际源地址、RCON、休眠唤醒、缓存清除后镜像拉取。
- B 上备份恢复,B→C 迁移,确认 Service IP/归属不变、源 PVC 保留、目标仍停服。
- 迁移期间唤醒、文件写入、导出、回收及重复请求互斥;A 重启继续协调。
- 源节点失联、传输中断、目标/归档磁盘满和读回校验失败,确认源世界可恢复。
- 游戏 Pod 对各服、主控、Registry、宿主机端口、kubelet、元数据的请求均拒绝;各拒绝目标在受信正向探针中确实可达。
- 过期、重放、跨服和错误操作的归档令牌拒绝;kubelet 伪造受保护标签拒绝。
当前本地验证使用一台已有 Felis 的 ARM64 CentOS Stream 9 VM,通过临时测试程序验证归档和迁移逻辑;`deploy/test-node-firewall.sh` 在独立网络命名空间中实测 resident-node、NodePort DNAT 和提前 ACCEPT 的防护,不改变其现有集群网络。三机网络验收仍是上线前必要步骤。A 继续是控制面和公网入口单点,首版没有主控 HA 或自动故障迁移。
相关上游说明:[k3s 跨公网组网](https://docs.k3s.io/networking/distributed-multicloud)、[限时 bootstrap token](https://docs.k3s.io/cli/token)、[NodeRestriction 标签](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-isolationrestriction)、[NetworkPolicy 的节点边界](https://kubernetes.io/docs/concepts/services-networking/network-policies/)。
+199
View File
@@ -468,11 +468,47 @@ components:
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1). description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
enum: [Unknown, Stopped, Starting, Running, Stopping, Failed] enum: [Unknown, Stopped, Starting, Running, Stopping, Failed]
ExecutionNode:
type: object
required: [name, role, ready, approved, addresses, architecture]
properties:
name: { type: string }
role: { type: string }
ready: { type: boolean }
approved: { type: boolean }
addresses: { type: array, items: { type: string } }
architecture: { type: string }
WorldMigration:
type: object
required: [id, server, state, stage, sourceNode, targetNode, sourcePVC, targetPVC, backup, started, updated, switched, attempt]
properties:
id: { type: string }
server: { type: string }
state: { type: string, enum: [backing_up, restoring, switching, succeeded, failed] }
stage: { type: string }
sourceNode: { type: string }
targetNode: { type: string }
sourcePVC: { type: string }
targetPVC: { type: string }
backup:
type: object
required: [ref, size, sha256]
properties:
ref: { type: string }
size: { type: integer, format: int64 }
sha256: { type: string }
started: { type: string, format: date-time }
updated: { type: string, format: date-time }
switched: { type: boolean }
attempt: { type: integer }
error: { type: string }
ServerInfo: ServerInfo:
type: object type: object
description: Status projection of one server (internal/api/cluster.go ServerInfo). description: Status projection of one server (internal/api/cluster.go ServerInfo).
required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds] required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
properties: properties:
nodeName: { type: string, description: Execution node; legacy servers report the observed node. }
name: { type: string } name: { type: string }
subdomain: { type: string } subdomain: { type: string }
phase: { $ref: '#/components/schemas/Phase' } phase: { $ref: '#/components/schemas/Phase' }
@@ -1106,6 +1142,168 @@ components:
on. Absent otherwise. on. Absent otherwise.
paths: paths:
/api/v1/nodes:
get:
operationId: executionNodes
tags: [admin-servers]
summary: List execution nodes (administrator).
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
responses:
'200':
description: Accepted operation or current state.
content:
application/json:
schema:
type: object
required: [nodes]
properties:
nodes: { type: array, items: { $ref: '#/components/schemas/ExecutionNode' } }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/migrations:
get:
operationId: latestWorldMigrationStatus
tags: [admin-servers]
summary: Read durable migration progress.
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Accepted operation or current state.
content:
application/json:
schema:
$ref: '#/components/schemas/WorldMigration'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
post:
operationId: startWorldMigration
tags: [admin-servers]
summary: Migrate an already stopped world; persistent lock survives controller restart.
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [targetNode]
properties:
targetNode: { type: string }
responses:
'202':
description: Accepted operation or current state.
content:
application/json:
schema:
$ref: '#/components/schemas/WorldMigration'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/migrations/{id}:
get:
operationId: worldMigrationStatus
tags: [admin-servers]
summary: Read durable migration progress.
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Accepted operation or current state.
content:
application/json:
schema:
$ref: '#/components/schemas/WorldMigration'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/migrations/{id}/retry:
post:
operationId: retryWorldMigration
tags: [admin-servers]
summary: Retry a failed migration, retaining the source and stopped state.
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'202':
description: Accepted operation or current state.
content:
application/json:
schema:
$ref: '#/components/schemas/WorldMigration'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
# ----------------------------------------------------------------- health --- # ----------------------------------------------------------------- health ---
/healthz: /healthz:
get: get:
@@ -1272,6 +1470,7 @@ paths:
type: object type: object
required: [name, subdomain] required: [name, subdomain]
properties: properties:
nodeName: { type: string, description: Required approved worker in distributed mode; administrator only. }
name: { type: string } name: { type: string }
subdomain: { type: string } subdomain: { type: string }
displayName: displayName:
+1 -1
View File
@@ -11,7 +11,7 @@ Evidence tags follow troubleshooting.md: **[VM-VERIFIED]** was run on a real hos
## 1. Supported hosts ## 1. Supported hosts
`deploy/bootstrap.sh` provisions a single node. It needs systemd, root, and one of the `deploy/bootstrap.sh` defaults to a single node. For the opt-in A controller / worker deployment, see [distributed.md](distributed.md). It needs systemd, root, and one of the
package managers below; everything else (k3s, the JRE, cloudflared, and Docker when an image package managers below; everything else (k3s, the JRE, cloudflared, and Docker when an image
has to be built on the host; see "Where the binary and the images come from" below) it has to be built on the host; see "Where the binary and the images come from" below) it
installs. installs.
+6
View File
@@ -29,6 +29,7 @@ import (
// API holds the dependencies shared by every handler. // API holds the dependencies shared by every handler.
type API struct { type API struct {
Distribution Distribution
Repo Repo Repo Repo
Cluster Cluster Cluster Cluster
Internal InternalAuth Internal InternalAuth
@@ -721,6 +722,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// Zero-Trust path, unlike the app-tier /me/servers. A path distinct from the // Zero-Trust path, unlike the app-tier /me/servers. A path distinct from the
// internal velocity GET /api/v1/servers on purpose: the parity test forbids one // internal velocity GET /api/v1/servers on purpose: the parity test forbids one
// {method, path} from carrying both the service and admin tiers. // {method, path} from carrying both the service and admin tiers.
{Method: "GET", Pattern: "/api/v1/nodes", Admin: true, h: a.handleNodes},
{Method: "GET", Pattern: "/api/v1/servers/{name}/migrations", Admin: true, h: a.handleMigrationStatus},
{Method: "POST", Pattern: "/api/v1/servers/{name}/migrations", Admin: true, h: a.handleMigration},
{Method: "GET", Pattern: "/api/v1/servers/{name}/migrations/{id}", Admin: true, h: a.handleMigrationStatus},
{Method: "POST", Pattern: "/api/v1/servers/{name}/migrations/{id}/retry", Admin: true, h: a.handleMigrationRetry},
{Method: "GET", Pattern: "/api/v1/fleet", Admin: true, h: a.handleFleet}, {Method: "GET", Pattern: "/api/v1/fleet", Admin: true, h: a.handleFleet},
// Image build + whitelist (spec §16, §15). Every route is admin-tier: a build // Image build + whitelist (spec §16, §15). Every route is admin-tier: a build
// is build-time RCE against the cluster, so submission requires the admin // is build-time RCE against the cluster, so submission requires the admin
+2
View File
@@ -13,6 +13,7 @@ import (
// patch (spec §7 PATCH /servers/{name}) — never the business-layer fields, which // patch (spec §7 PATCH /servers/{name}) — never the business-layer fields, which
// live in Postgres (spec §22). // live in Postgres (spec §22).
type ServerInfo struct { type ServerInfo struct {
NodeName string `json:"nodeName,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Subdomain string `json:"subdomain"` Subdomain string `json:"subdomain"`
Phase string `json:"phase"` Phase string `json:"phase"`
@@ -64,6 +65,7 @@ type ServerInfo struct {
// no free-form YAML path — every field is a typed, validated value. A created // no free-form YAML path — every field is a typed, validated value. A created
// server starts DesiredState=Stopped and unowned (claimed later, spec §9.3). // server starts DesiredState=Stopped and unowned (claimed later, spec §9.3).
type CreateServerInput struct { type CreateServerInput struct {
NodeName string
Name string Name string
Subdomain string Subdomain string
DisplayName string DisplayName string
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"context"
"errors"
"net/http"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/naming"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
type Distribution interface {
Nodes(context.Context) ([]distributed.Node, error)
ValidateNode(context.Context, string) error
BeginMigration(context.Context, string, string, string) (distributed.Operation, error)
Migration(context.Context, string, string) (distributed.Operation, error)
RetryMigration(context.Context, string, string) (distributed.Operation, error)
}
func (a *API) distributedReady(w http.ResponseWriter, r *http.Request) bool {
if a.Distribution == nil {
writeError(w, r, newError(503, "distributed_unavailable", "distributed deployment is not configured"))
return false
}
return true
}
func (a *API) handleNodes(w http.ResponseWriter, r *http.Request) {
if !a.distributedReady(w, r) {
return
}
nodes, err := a.Distribution.Nodes(r.Context())
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"nodes": nodes})
}
func (a *API) handleMigration(w http.ResponseWriter, r *http.Request) {
if !a.distributedReady(w, r) {
return
}
server := r.PathValue("name")
if err := naming.ValidateServerName(server); err != nil {
writeError(w, r, newError(400, "bad_name", "%v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), server)
if err != nil {
a.writeLookupError(w, r, err)
return
}
if rec.Retire != nil {
writeError(w, r, errServerRetiring)
return
}
var body struct {
TargetNode string `json:"targetNode"`
}
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if err := a.Distribution.ValidateNode(r.Context(), body.TargetNode); err != nil {
writeError(w, r, newError(400, "bad_node", "%v", err))
return
}
op, err := a.Distribution.BeginMigration(r.Context(), server, body.TargetNode, rec.OwnerID)
if err != nil {
a.writeMigrationError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, op)
}
func (a *API) handleMigrationStatus(w http.ResponseWriter, r *http.Request) {
if !a.distributedReady(w, r) {
return
}
op, err := a.Distribution.Migration(r.Context(), r.PathValue("name"), r.PathValue("id"))
if err != nil {
a.writeMigrationError(w, r, err)
return
}
writeJSON(w, http.StatusOK, op)
}
func (a *API) handleMigrationRetry(w http.ResponseWriter, r *http.Request) {
if !a.distributedReady(w, r) {
return
}
rec, err := a.Repo.ServerByName(r.Context(), r.PathValue("name"))
if err != nil {
a.writeLookupError(w, r, err)
return
}
if rec.Retire != nil {
writeError(w, r, errServerRetiring)
return
}
op, err := a.Distribution.RetryMigration(r.Context(), r.PathValue("name"), r.PathValue("id"))
if err != nil {
a.writeMigrationError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, op)
}
func (a *API) writeMigrationError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, distributed.ErrBusy):
writeError(w, r, newError(409, "migration_busy", "%v", err))
case errors.Is(err, distributed.ErrNotFound), apierrors.IsNotFound(err):
writeError(w, r, newError(404, "not_found", "%v", err))
case apierrors.IsConflict(err):
writeError(w, r, newError(409, "conflict", "retry after refreshing migration status"))
default:
writeError(w, r, err)
}
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"context"
"errors"
"testing"
"felis.lolicon.best/internal/distributed"
)
type fakeDistribution struct {
calls int
target, owner string
}
func (d *fakeDistribution) Nodes(context.Context) ([]distributed.Node, error) {
d.calls++
return []distributed.Node{{Name: "b", Ready: true, Approved: true, Addresses: []string{}}}, nil
}
func (d *fakeDistribution) ValidateNode(_ context.Context, name string) error {
if name != "b" {
return errors.New("not approved")
}
return nil
}
func (d *fakeDistribution) BeginMigration(_ context.Context, name, target, owner string) (distributed.Operation, error) {
d.calls++
d.target = target
d.owner = owner
return distributed.Operation{ID: "op", Server: name, State: "backing_up"}, nil
}
func (d *fakeDistribution) Migration(context.Context, string, string) (distributed.Operation, error) {
d.calls++
return distributed.Operation{ID: "op", State: "failed"}, nil
}
func (d *fakeDistribution) RetryMigration(context.Context, string, string) (distributed.Operation, error) {
d.calls++
return distributed.Operation{ID: "op", State: "restoring"}, nil
}
func TestDistributedRoutesAreAdministratorOnly(t *testing.T) {
for _, role := range []string{"user", "admin"} {
t.Run(role, func(t *testing.T) {
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner"}
a := newTestAPI(repo, newFakeCluster())
a.External = staticExternal{p: &Principal{UserID: "owner", Role: role, ViaAdminAccess: role == "admin"}}
d := &fakeDistribution{}
a.Distribution = d
for _, tc := range []struct {
method, path, body string
status int
}{
{"GET", "/api/v1/nodes", "", 200},
{"POST", "/api/v1/servers/survival/migrations", `{"targetNode":"b"}`, 202},
{"GET", "/api/v1/servers/survival/migrations", "", 200},
{"GET", "/api/v1/servers/survival/migrations/op", "", 200},
{"POST", "/api/v1/servers/survival/migrations/op/retry", "", 202},
} {
w := do(a.ExternalHandler(), tc.method, tc.path, tc.body, jsonHeader)
want := tc.status
if role == "user" {
want = 403
}
if w.Code != want {
t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
}
}
if role == "user" && d.calls != 0 {
t.Fatal("owner reached cluster-wide operations")
}
if role == "admin" && (d.target != "b" || d.owner != "owner") {
t.Fatal("wrong migration scope")
}
})
}
}
+11
View File
@@ -406,6 +406,7 @@ type fleetServerView struct {
// value and decodeJSON rejects unknown fields, so a caller can never smuggle // value and decodeJSON rejects unknown fields, so a caller can never smuggle
// free-form YAML or raw CRD fields through this endpoint. // free-form YAML or raw CRD fields through this endpoint.
type createServerRequest struct { type createServerRequest struct {
NodeName string `json:"nodeName,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Subdomain string `json:"subdomain"` Subdomain string `json:"subdomain"`
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
@@ -445,6 +446,15 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
return return
} }
if a.Distribution != nil {
if err := a.Distribution.ValidateNode(r.Context(), body.NodeName); err != nil {
writeError(w, r, newError(400, "bad_node", "select an approved worker: %v", err))
return
}
} else if body.NodeName != "" {
writeError(w, r, newError(400, "bad_node", "node selection requires distributed deployment"))
return
}
// Server name and subdomain both obey the §22 portability rule and the // Server name and subdomain both obey the §22 portability rule and the
// reservation list. // reservation list.
if err := naming.ValidateServerName(body.Name); err != nil { if err := naming.ValidateServerName(body.Name); err != nil {
@@ -572,6 +582,7 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
} }
in := CreateServerInput{ in := CreateServerInput{
NodeName: body.NodeName,
Name: body.Name, Name: body.Name,
Subdomain: body.Subdomain, Subdomain: body.Subdomain,
DisplayName: displayName, DisplayName: displayName,
+56 -6
View File
@@ -3,11 +3,13 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -32,6 +34,8 @@ import (
// the direct client c, so a write never works from a copy the watch has not caught // the direct client c, so a write never works from a copy the watch has not caught
// up with yet. // up with yet.
type K8sCluster struct { type K8sCluster struct {
distributed bool
controller string
c client.Client c client.Client
namespace string namespace string
// servers serves the fleet-wide reads; nil means c. // servers serves the fleet-wide reads; nil means c.
@@ -101,15 +105,28 @@ func (k *K8sCluster) GetServer(ctx context.Context, name string) (*ServerInfo, e
// and restore Jobs mount), so existence here is exactly existence at Job mount // and restore Jobs mount), so existence here is exactly existence at Job mount
// time. NotFound is (false, nil): the caller refuses with a specific 409. // time. NotFound is (false, nil): the caller refuses with a specific 409.
func (k *K8sCluster) WorldVolumeExists(ctx context.Context, name string) (bool, error) { func (k *K8sCluster) WorldVolumeExists(ctx context.Context, name string) (bool, error) {
var pvc corev1.PersistentVolumeClaim var ms v1alpha1.MinecraftServer
err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: naming.WorldPVCName(name)}, &pvc) err := k.getServer(ctx, name, &ms)
if apierrors.IsNotFound(err) { claim := naming.WorldPVCName(name)
return false, nil if err == nil {
} claim = ms.WorldPVC()
if err != nil { } else if !errors.Is(err, ErrNotFound) {
return false, err return false, err
} }
var pvc corev1.PersistentVolumeClaim
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: claim}, &pvc); err == nil {
return true, nil return true, nil
} else if !apierrors.IsNotFound(err) {
return false, err
}
if ms.Name == "" {
var retained corev1.PersistentVolumeClaimList
if err := k.c.List(ctx, &retained, client.InNamespace(k.namespace), client.MatchingLabels{v1alpha1.LabelServer: name}); err != nil {
return false, err
}
return len(retained.Items) > 0, nil
}
return false, nil
} }
// PodImages lists the image of every container and init container of every pod // PodImages lists the image of every container and init container of every pod
@@ -183,6 +200,7 @@ func (k *K8sCluster) CreateServer(ctx context.Context, in CreateServerInput) err
Namespace: k.namespace, Namespace: k.namespace,
}, },
Spec: v1alpha1.MinecraftServerSpec{ Spec: v1alpha1.MinecraftServerSpec{
NodeName: in.NodeName,
Subdomain: in.Subdomain, Subdomain: in.Subdomain,
DisplayName: in.DisplayName, DisplayName: in.DisplayName,
Image: in.Image, Image: in.Image,
@@ -262,6 +280,22 @@ func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed boo
if err := k.getServer(ctx, name, &ms); err != nil { if err := k.getServer(ctx, name, &ms); err != nil {
return err return err
} }
node := ms.Spec.NodeName
if node == "" {
node = ms.Status.NodeName
}
if node == "" {
node = k.controller
}
if k.distributed && node != "" {
var n corev1.Node
if err := k.c.Get(ctx, types.NamespacedName{Name: node}, &n); err != nil {
return err
}
if !placement.Admitted(&n, k.controller) || n.Spec.Unschedulable {
return newError(503, "node_unavailable", "execution node is offline")
}
}
kind, held, err := k.maintenanceHolder(ctx, &ms) kind, held, err := k.maintenanceHolder(ctx, &ms)
if err != nil { if err != nil {
return err return err
@@ -335,6 +369,9 @@ func (k *K8sCluster) ReleaseMaintenance(ctx context.Context, name string) error
} }
return err return err
} }
if value := ms.Annotations[maintenance.Annotation]; strings.HasPrefix(value, maintenance.KindMigration+"@") {
return nil
}
if _, ok := ms.Annotations[maintenance.Annotation]; !ok { if _, ok := ms.Annotations[maintenance.Annotation]; !ok {
return nil return nil
} }
@@ -445,8 +482,13 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
memStr = limit.String() memStr = limit.String()
} }
node := ms.Spec.NodeName
if node == "" {
node = ms.Status.NodeName
}
return &ServerInfo{ return &ServerInfo{
Name: ms.Name, Name: ms.Name,
NodeName: node,
Subdomain: ms.Spec.Subdomain, Subdomain: ms.Spec.Subdomain,
Phase: string(ms.Status.Phase), Phase: string(ms.Status.Phase),
Ready: ms.Status.Ready, Ready: ms.Status.Ready,
@@ -483,3 +525,11 @@ func idleStopSeconds(ms *v1alpha1.MinecraftServer) int32 {
} }
return ms.Spec.Idle.EmptySecondsBeforeStop return ms.Spec.Idle.EmptySecondsBeforeStop
} }
func (k *K8sCluster) WithDistributed(enabled bool, controller ...string) *K8sCluster {
k.distributed = enabled
if len(controller) > 0 {
k.controller = controller[0]
}
return k
}
+34
View File
@@ -7,9 +7,12 @@ import (
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -19,6 +22,37 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/fake"
) )
func TestPersistentMigrationBlocksWakeAndWorldOperations(t *testing.T) {
scheme := runtime.NewScheme()
v1alpha1.AddToScheme(scheme)
corev1.AddToScheme(scheme)
batchv1.AddToScheme(scheme)
s := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft", Annotations: map[string]string{maintenance.Annotation: maintenance.LockValue(maintenance.KindMigration, time.Now().Add(-24*time.Hour))}}, Spec: v1alpha1.MinecraftServerSpec{DesiredState: v1alpha1.DesiredStopped}, Status: v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseStopped}}
s.Spec.Storage.ClaimName = "world-survival-migrated"
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(s, &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: s.WorldPVC(), Namespace: s.Namespace}}).Build()
k := NewK8sCluster(c, s.Namespace)
ctx := context.Background()
if exists, err := k.WorldVolumeExists(ctx, s.Name); err != nil || !exists {
t.Fatal("active volume ignored", err)
}
if err := k.SetDesiredState(ctx, s.Name, v1alpha1.DesiredRunning); !errors.Is(err, ErrMaintenanceInProgress) {
t.Fatal("migration admitted wake", err)
}
for _, kind := range []string{maintenance.KindBackup, maintenance.KindFileWrite, maintenance.KindReap} {
if err := k.AcquireMaintenance(ctx, s.Name, kind); !errors.Is(err, ErrMaintenanceInProgress) {
t.Fatal("migration admitted", kind, err)
}
}
if err := k.ReleaseMaintenance(ctx, s.Name); err != nil {
t.Fatal(err)
}
var current v1alpha1.MinecraftServer
c.Get(ctx, types.NamespacedName{Namespace: s.Namespace, Name: s.Name}, &current)
if current.Annotations[maintenance.Annotation] == "" {
t.Fatal("normal release cleared persistent migration lock")
}
}
// K8sCluster is documented as integration-tested against a live cluster rather // K8sCluster is documented as integration-tested against a live cluster rather
// than covered by the hermetic suite, and for most of it that is the right call — // than covered by the hermetic suite, and for most of it that is the right call —
// merge-patch semantics are not worth faking. This one test departs from it // merge-patch semantics are not worth faking. This one test departs from it
+3
View File
@@ -202,6 +202,9 @@ func (k *K8sJobStatus) PendingRestoreChains(ctx context.Context) ([]RestoreChain
snapshot = ChainSnapshotFailed snapshot = ChainSnapshotFailed
} }
} }
if j.Labels["felis.lolicon.best/archive-pending"] == "true" && snapshot == ChainSnapshotSucceeded {
snapshot = ChainSnapshotRunning
}
out = append(out, RestoreChain{ out = append(out, RestoreChain{
Job: j.Name, Job: j.Name,
Server: j.Labels[jobServerLabel], Server: j.Labels[jobServerLabel],
@@ -1,6 +1,7 @@
package v1alpha1 package v1alpha1
import ( import (
"felis.lolicon.best/internal/naming"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -159,6 +160,9 @@ type MinecraftServerSpec struct {
// ReaperExempt opts this server out of the world reaper entirely (spec §18). // ReaperExempt opts this server out of the world reaper entirely (spec §18).
ReaperExempt bool `json:"reaperExempt,omitempty"` ReaperExempt bool `json:"reaperExempt,omitempty"`
// NodeName is an administrator-approved execution node; empty preserves legacy placement.
NodeName string `json:"nodeName,omitempty"`
// DesiredState toggles the server up or down (default Stopped). // DesiredState toggles the server up or down (default Stopped).
// +kubebuilder:validation:Enum=Running;Stopped // +kubebuilder:validation:Enum=Running;Stopped
DesiredState DesiredState `json:"desiredState,omitempty"` DesiredState DesiredState `json:"desiredState,omitempty"`
@@ -245,6 +249,8 @@ type SecretKeyRef struct {
// StorageSpec configures the world PVC (spec §4 spec.storage). // StorageSpec configures the world PVC (spec §4 spec.storage).
type StorageSpec struct { type StorageSpec struct {
// ClaimName is managed internally by stopped-world migration, never by a public spec patch.
ClaimName string `json:"claimName,omitempty"`
// Size is the requested PVC capacity (e.g. "10Gi"). // Size is the requested PVC capacity (e.g. "10Gi").
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
// StorageClassName selects the StorageClass; empty uses the default. // StorageClassName selects the StorageClass; empty uses the default.
@@ -317,6 +323,7 @@ type IdleSpec struct {
// MinecraftServerStatus is the observed state (spec §4 status.*). // MinecraftServerStatus is the observed state (spec §4 status.*).
type MinecraftServerStatus struct { type MinecraftServerStatus struct {
NodeName string `json:"nodeName,omitempty"`
// Phase is the coarse lifecycle phase. // Phase is the coarse lifecycle phase.
Phase Phase `json:"phase,omitempty"` Phase Phase `json:"phase,omitempty"`
// Ready is true only after a successful RCON probe (loader-agnostic; a // Ready is true only after a successful RCON probe (loader-agnostic; a
@@ -377,3 +384,11 @@ type PlayersStatus struct {
Online int32 `json:"online"` Online int32 `json:"online"`
Max int32 `json:"max"` Max int32 `json:"max"`
} }
// WorldPVC resolves the active world, including servers created before migration support.
func (s *MinecraftServer) WorldPVC() string {
if s.Spec.Storage.ClaimName != "" {
return s.Spec.Storage.ClaimName
}
return naming.WorldPVCName(s.Name)
}
+573
View File
@@ -0,0 +1,573 @@
// Package archivetransfer serves archive bytes on A. It has no Kubernetes or database access.
package archivetransfer
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/worldexport"
)
const TokenEnv = "FELIS_ARCHIVE_TOKEN"
const KeyEnv = "FELIS_ARCHIVE_KEY"
const LabelPending = "felis.lolicon.best/archive-pending"
const Annotation = "felis.lolicon.best/archive-transfer"
const DefaultLimit int64 = 100 << 30
var idRE = regexp.MustCompile(`^[a-f0-9]{32}$`)
// Ticket names exactly one operation, server and archive. Only A holds the signing key.
type Ticket struct {
ID string `json:"id"`
Server string `json:"server"`
Method string `json:"method"`
Ref string `json:"ref"`
SHA256 string `json:"sha256,omitempty"`
Limit int64 `json:"limit"`
Expires time.Time `json:"expires"`
}
type Receipt struct {
Ref string `json:"ref"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type Client struct {
URL, Root, Key string
Limit int64
}
func ID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
return hex.EncodeToString(b[:])
}
func (c Client) Issue(server, method, ref, sum string, ttl time.Duration) (Ticket, string, string, error) {
if len(c.Key) < 32 || c.URL == "" || ttl <= 0 || ttl > 24*time.Hour {
return Ticket{}, "", "", errors.New("archive transport is not configured or ticket lifetime is invalid")
}
id := ID()
if method == http.MethodPut {
ref = filepath.Join(c.Root, fmt.Sprintf("%s-%d.tar.gz", server, time.Now().UnixNano()))
}
limit := c.Limit
if limit <= 0 {
limit = DefaultLimit
}
t := Ticket{ID: id, Server: server, Method: method, Ref: ref, SHA256: sum, Limit: limit, Expires: time.Now().Add(ttl)}
if err := validate(t, c.Root, limit); err != nil {
return Ticket{}, "", "", err
}
raw, _ := json.Marshal(t)
payload := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, []byte(c.Key))
mac.Write([]byte(payload))
token := payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return t, strings.TrimRight(c.URL, "/") + "/transfers/" + id, token, nil
}
func validate(t Ticket, root string, limit int64) error {
if !idRE.MatchString(t.ID) || t.Limit <= 0 || t.Limit > limit {
return errors.New("invalid transfer bounds")
}
if err := naming.ValidateSystemServerName(t.Server); err != nil {
return err
}
if t.Method != http.MethodPut && t.Method != http.MethodGet {
return errors.New("invalid transfer operation")
}
if !filepath.IsAbs(root) || filepath.Dir(t.Ref) != filepath.Clean(root) || !strings.HasSuffix(t.Ref, ".tar.gz") {
return errors.New("archive must be directly inside the archive root")
}
if !strings.HasPrefix(filepath.Base(t.Ref), t.Server+"-") {
return errors.New("archive belongs to another server")
}
if t.SHA256 != "" {
b, err := hex.DecodeString(t.SHA256)
if err != nil || len(b) != sha256.Size {
return errors.New("invalid SHA-256")
}
}
return nil
}
// Server journals consumption before IO, so process restarts cannot enable replay.
// A failure consumes the ticket too: the controller issues a fresh ticket on retry.
type Server struct {
Root, Key string
Limit int64
mu sync.Mutex
freeBytes func() (int64, error)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" && r.Method == http.MethodGet {
w.WriteHeader(http.StatusNoContent)
return
}
if r.URL.Path == "/archives" && r.Method == http.MethodDelete {
if len(s.Key) < 32 || !hmac.Equal([]byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")), []byte(s.Key)) {
http.Error(w, "unauthorized", 401)
return
}
var q struct {
Ref string `json:"ref"`
}
if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&q) != nil || filepath.Dir(q.Ref) != filepath.Clean(s.Root) {
http.Error(w, "bad ref", 400)
return
}
if err := os.Remove(q.Ref); err != nil && !errors.Is(err, os.ErrNotExist) {
http.Error(w, "archive delete failed", 500)
return
}
if err := syncDir(s.Root); err != nil {
http.Error(w, "archive delete commit failed", 500)
return
}
w.WriteHeader(204)
return
}
if r.URL.Path == "/inspect" && r.Method == http.MethodPost {
if len(s.Key) < 32 || !hmac.Equal([]byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")), []byte(s.Key)) {
http.Error(w, "unauthorized", 401)
return
}
var q struct {
Ref string `json:"ref"`
}
if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&q) != nil || filepath.Dir(q.Ref) != filepath.Clean(s.Root) {
http.Error(w, "bad archive ref", 400)
return
}
local := &backup.TarLocal{BackupRoot: s.Root}
sum, err := local.Verify(r.Context(), backup.ArchiveRef(q.Ref), "")
if err != nil {
http.Error(w, "archive is absent or corrupt", 422)
return
}
st, err := os.Stat(q.Ref)
if err != nil {
http.Error(w, "archive absent", 404)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Receipt{Ref: q.Ref, SHA256: sum, Size: st.Size()})
return
}
if strings.HasPrefix(r.URL.Path, "/receipts/") && r.Method == http.MethodGet {
if len(s.Key) < 32 || !hmac.Equal([]byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")), []byte(s.Key)) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := strings.TrimPrefix(r.URL.Path, "/receipts/")
if !idRE.MatchString(id) {
http.NotFound(w, r)
return
}
rec, err := s.receipt(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rec)
return
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
payload, signature, ok := strings.Cut(token, ".")
mac := hmac.New(sha256.New, []byte(s.Key))
mac.Write([]byte(payload))
sig, err := base64.RawURLEncoding.DecodeString(signature)
if !ok || err != nil || len(s.Key) < 32 || !hmac.Equal(sig, mac.Sum(nil)) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
var t Ticket
limit := s.Limit
if limit <= 0 {
limit = DefaultLimit
}
if err != nil || json.Unmarshal(raw, &t) != nil || validate(t, s.Root, limit) != nil || time.Now().After(t.Expires) || t.Expires.After(time.Now().Add(24*time.Hour)) || t.Method != r.Method || r.URL.Path != "/transfers/"+t.ID {
http.Error(w, "invalid or expired transfer", http.StatusForbidden)
return
}
if err := s.consume(t); err != nil {
if errors.Is(err, os.ErrExist) {
http.Error(w, "transfer already consumed", http.StatusConflict)
} else {
http.Error(w, "cannot journal transfer", http.StatusInsufficientStorage)
}
return
}
if t.Method == http.MethodGet {
s.download(w, r, t)
return
}
// Serialize uploads and disk checks to preserve a free-space reserve.
s.mu.Lock()
defer s.mu.Unlock()
if err := s.upload(r, t); err != nil {
http.Error(w, "archive upload failed: "+err.Error(), http.StatusUnprocessableEntity)
return
}
w.WriteHeader(http.StatusNoContent)
}
func syncDir(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return f.Sync()
}
func (s *Server) consume(t Ticket) error {
id := t.ID
dir := filepath.Join(s.Root, ".transfers")
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, id+".used"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
raw, _ := json.Marshal(t)
_, err = f.Write(raw)
if err == nil {
err = f.Sync()
}
cerr := f.Close()
if err != nil {
return err
}
if cerr != nil {
return cerr
}
return syncDir(dir)
}
func (s *Server) upload(r *http.Request, t Ticket) error {
available, err := s.availableBytes()
if err != nil {
return err
}
bound := min(t.Limit, available)
if bound <= 0 {
return backup.ErrNoRoom
}
tmp := filepath.Join(s.Root, "."+filepath.Base(t.Ref)+".partial")
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer os.Remove(tmp)
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(f, hash), io.LimitReader(r.Body, bound+1))
if err == nil && n > bound {
err = errors.New("archive exceeds size or disk limit")
}
digest := "sha-256=:" + base64.StdEncoding.EncodeToString(hash.Sum(nil)) + ":"
if err == nil && r.Trailer.Get(worldexport.DigestTrailer) != digest {
err = errors.New("SHA-256 trailer is absent or incorrect")
}
sum := hex.EncodeToString(hash.Sum(nil))
if err == nil && t.SHA256 != "" && t.SHA256 != sum {
err = errors.New("SHA-256 does not match ticket")
}
if err == nil {
err = f.Sync()
}
cerr := f.Close()
if err == nil {
err = cerr
}
if err != nil {
return err
}
local := &backup.TarLocal{BackupRoot: s.Root}
if _, err = local.Verify(r.Context(), backup.ArchiveRef(tmp), sum); err != nil {
return err
}
// Never overwrite a committed archive, including one whose receipt was interrupted.
if err = os.Link(tmp, t.Ref); err != nil {
return err
}
if err = os.Remove(tmp); err != nil {
return err
}
if err = syncDir(s.Root); err != nil {
return err
}
rec := Receipt{Ref: t.Ref, Size: n, SHA256: sum}
raw, _ := json.Marshal(rec)
journal := filepath.Join(s.Root, ".transfers", t.ID+".json")
f, err = os.OpenFile(journal, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
_, err = f.Write(raw)
if err == nil {
err = f.Sync()
}
cerr = f.Close()
if err == nil {
err = cerr
}
if err != nil {
return err
}
return syncDir(filepath.Dir(journal))
}
func (s *Server) download(w http.ResponseWriter, r *http.Request, t Ticket) {
root, err := os.OpenRoot(s.Root)
if err != nil {
http.Error(w, "archive unavailable", 503)
return
}
defer root.Close()
f, err := root.Open(filepath.Base(t.Ref))
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() || st.Size() > t.Limit {
http.Error(w, "archive exceeds transfer bounds", 413)
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", fmt.Sprint(st.Size()))
io.Copy(w, f)
}
func (c Client) Receipt(ctx context.Context, id string) (Receipt, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.URL, "/")+"/receipts/"+id, nil)
if err != nil {
return Receipt{}, err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
hc := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := hc.Do(req)
if err != nil {
return Receipt{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Receipt{}, fmt.Errorf("archive receipt: %s", resp.Status)
}
var rec Receipt
err = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&rec)
return rec, err
}
// Fetch stages only the authorized archive and verifies its hash before extraction.
func Fetch(ctx context.Context, url, token, dest, want string, limit int64) error {
if want == "" {
return errors.New("download requires recorded SHA-256")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
hc := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("archive download: %s", resp.Status)
}
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(f, hash), io.LimitReader(resp.Body, limit+1))
if err == nil && (n > limit || hex.EncodeToString(hash.Sum(nil)) != want) {
err = errors.New("archive size or SHA-256 mismatch")
}
if err == nil {
err = f.Sync()
}
cerr := f.Close()
if err == nil {
err = cerr
}
if err != nil {
os.Remove(dest)
}
return err
}
func (c Client) Inspect(ctx context.Context, ref string) (Receipt, error) {
raw, _ := json.Marshal(map[string]string{"ref": ref})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.URL, "/")+"/inspect", strings.NewReader(string(raw)))
if err != nil {
return Receipt{}, err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
hc := &http.Client{Timeout: 30 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := hc.Do(req)
if err != nil {
return Receipt{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Receipt{}, fmt.Errorf("archive inspect: %s", resp.Status)
}
var rec Receipt
err = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&rec)
return rec, err
}
func (c Client) Delete(ctx context.Context, ref backup.ArchiveRef) error {
raw, _ := json.Marshal(map[string]string{"ref": string(ref)})
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, strings.TrimRight(c.URL, "/")+"/archives", strings.NewReader(string(raw)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
hc := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 204 && resp.StatusCode != 404 {
return fmt.Errorf("archive delete: %s", resp.Status)
}
return nil
}
func (s *Server) receipt(ctx context.Context, id string) (Receipt, error) {
s.mu.Lock()
defer s.mu.Unlock()
path := filepath.Join(s.Root, ".transfers", id+".json")
var rec Receipt
if raw, err := os.ReadFile(path); err == nil && json.Unmarshal(raw, &rec) == nil {
return rec, nil
}
raw, err := os.ReadFile(filepath.Join(s.Root, ".transfers", id+".used"))
if err != nil {
return rec, err
}
var t Ticket
if err = json.Unmarshal(raw, &t); err != nil {
return rec, err
}
if t.Method != http.MethodPut {
return rec, errors.New("not an upload")
}
sum, err := (&backup.TarLocal{BackupRoot: s.Root}).Verify(ctx, backup.ArchiveRef(t.Ref), t.SHA256)
if err != nil {
return rec, err
}
st, err := os.Stat(t.Ref)
if err != nil || st.Size() > t.Limit {
return rec, errors.New("invalid committed archive")
}
rec = Receipt{Ref: t.Ref, Size: st.Size(), SHA256: sum}
raw, _ = json.Marshal(rec)
tmp := path + ".partial"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return rec, err
}
_, err = f.Write(raw)
if err == nil {
err = f.Sync()
}
cerr := f.Close()
if err == nil {
err = cerr
}
if err != nil {
return rec, err
}
if err = os.Rename(tmp, path); err != nil {
return rec, err
}
return rec, syncDir(filepath.Dir(path))
}
func (s *Server) availableBytes() (int64, error) {
if s.freeBytes != nil {
return s.freeBytes()
}
var st syscall.Statfs_t
if err := syscall.Statfs(s.Root, &st); err != nil {
return 0, err
}
return int64(st.Bavail)*int64(st.Bsize) - int64(float64(st.Blocks)*float64(st.Bsize)*backup.MinFreeAfter), nil
}
// Sweep keeps receipts for committed archives across arbitrarily long A outages.
// Expired failed transfers and downloads need no replay journal: signature expiry
// still rejects them. The extra day leaves no overlap with an active upload.
func (s *Server) Sweep(now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
dir := filepath.Join(s.Root, ".transfers")
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".used") {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return err
}
var ticket Ticket
if json.Unmarshal(raw, &ticket) != nil || now.Before(ticket.Expires.Add(24*time.Hour)) {
continue
}
if ticket.Method == http.MethodPut {
if _, err := os.Stat(ticket.Ref); err == nil {
continue
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
for _, name := range []string{e.Name(), ticket.ID + ".json", ticket.ID + ".json.partial"} {
if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
return syncDir(dir)
}
+228
View File
@@ -0,0 +1,228 @@
package archivetransfer
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/worldexport"
)
func archiveBytes(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "world/level.dat", Mode: 0644, Size: 5}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte("world")); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func upload(t *testing.T, url, token string, body []byte, digest bool) int {
t.Helper()
r, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
r.Header.Set("Authorization", "Bearer "+token)
if digest {
sum := sha256.Sum256(body)
r.ContentLength = -1
r.Trailer = http.Header{worldexport.DigestTrailer: {"sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"}}
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
t.Fatal(err)
}
response, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
t.Log(string(response))
}
resp.Body.Close()
return resp.StatusCode
}
func TestDurableTransferAndRestore(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
key := strings.Repeat("k", 32)
s := &Server{Root: root, Key: key, Limit: 1 << 20, freeBytes: func() (int64, error) { return 1 << 30, nil }}
ts := httptest.NewServer(s)
defer ts.Close()
c := Client{Root: root, Key: key, URL: ts.URL, Limit: 1 << 20}
ticket, url, token, err := c.Issue("alice", "PUT", "", "", time.Minute)
if err != nil {
t.Fatal(err)
}
body := archiveBytes(t)
if code := upload(t, url, token, body, true); code != 204 {
t.Fatalf("upload %d", code)
}
if code := upload(t, url, token, body, true); code != 409 {
t.Fatalf("replay %d", code)
}
rec, err := c.Receipt(ctx, ticket.ID)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
if rec.Size != int64(len(body)) || rec.SHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("receipt %+v", rec)
}
// Simulate A losing the response and crashing between archive commit and receipt write.
if err := os.Remove(filepath.Join(root, ".transfers", ticket.ID+".json")); err != nil {
t.Fatal(err)
}
restarted := &Server{Root: root, Key: key, Limit: 1 << 20, freeBytes: func() (int64, error) { return 1 << 30, nil }}
if recovered, err := restarted.receipt(ctx, ticket.ID); err != nil || recovered != rec {
t.Fatalf("recovery %+v: %v", recovered, err)
}
if _, _, _, err := c.Issue("bob", "GET", rec.Ref, rec.SHA256, time.Minute); err == nil {
t.Fatal("cross-server download ticket accepted")
}
_, getURL, getToken, err := c.Issue("alice", "GET", rec.Ref, rec.SHA256, time.Minute)
if err != nil {
t.Fatal(err)
}
dest := filepath.Join(t.TempDir(), "download.tar.gz")
if err := Fetch(ctx, getURL, getToken, dest, rec.SHA256, 1<<20); err != nil {
t.Fatal(err)
}
if err := Fetch(ctx, getURL, getToken, filepath.Join(t.TempDir(), "replay"), rec.SHA256, 1<<20); err == nil {
t.Fatal("download replay accepted")
}
world := t.TempDir()
a := &backup.TarLocal{BackupRoot: filepath.Dir(dest), Resolve: func(string) (string, error) { return world, nil }}
if err := a.Restore(ctx, backup.ArchiveRef(dest), "alice"); err != nil {
t.Fatal(err)
}
if err := backup.VerifyRestored(ctx, dest, world); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(world, "world", "level.dat"), []byte("wrong"), 0644); err != nil {
t.Fatal(err)
}
if err := backup.VerifyRestored(ctx, dest, world); err == nil {
t.Fatal("read-back corruption accepted")
}
}
func TestRejectedTransfersNeverCommit(t *testing.T) {
for _, tc := range []struct {
name string
limit int64
body []byte
digest bool
}{
{"missing checksum", 1 << 20, archiveBytes(t), false},
{"invalid tar", 1 << 20, []byte("corrupt archive"), true},
{"size limit", 10, archiveBytes(t), true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
key := strings.Repeat("k", 32)
ts := httptest.NewServer(&Server{Root: root, Key: key, Limit: tc.limit, freeBytes: func() (int64, error) { return 1 << 30, nil }})
defer ts.Close()
c := Client{Root: root, Key: key, URL: ts.URL, Limit: tc.limit}
ticket, url, token, err := c.Issue("alice", "PUT", "", "", time.Minute)
if err != nil {
t.Fatal(err)
}
if code := upload(t, url, token, tc.body, tc.digest); code != 422 {
t.Fatalf("status %d", code)
}
if _, err := os.Stat(ticket.Ref); !os.IsNotExist(err) {
t.Fatalf("archive committed: %v", err)
}
if _, err := c.Receipt(context.Background(), ticket.ID); err == nil {
t.Fatal("failed upload has receipt")
}
if code := upload(t, url, token, tc.body, true); code != 409 {
t.Fatalf("failed upload replay %d", code)
}
})
}
}
func signTicket(t Ticket, key string) string {
raw, _ := json.Marshal(t)
payload := base64.RawURLEncoding.EncodeToString(raw)
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
func TestExpiredWrongMethodAndPath(t *testing.T) {
root := t.TempDir()
key := strings.Repeat("k", 32)
s := &Server{Root: root, Key: key}
c := Client{Root: root, Key: key, URL: "http://archive"}
ticket, _, token, err := c.Issue("alice", "PUT", "", "", time.Minute)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ method, path, token string }{
{"GET", "/transfers/" + ticket.ID, token},
{"PUT", "/transfers/" + ID(), token},
{"PUT", "/transfers/" + ticket.ID, token + "broken"},
} {
r := httptest.NewRequest(tc.method, tc.path, nil)
r.Header.Set("Authorization", "Bearer "+tc.token)
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != 403 && w.Code != 401 {
t.Fatalf("scope accepted: %d", w.Code)
}
}
ticket.Expires = time.Now().Add(-time.Minute)
r := httptest.NewRequest("PUT", "/transfers/"+ticket.ID, nil)
r.Header.Set("Authorization", "Bearer "+signTicket(ticket, key))
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != 403 {
t.Fatalf("expired %d", w.Code)
}
}
func TestFullDiskDoesNotCommit(t *testing.T) {
root := t.TempDir()
key := strings.Repeat("k", 32)
ts := httptest.NewServer(&Server{Root: root, Key: key, freeBytes: func() (int64, error) { return 0, nil }})
defer ts.Close()
c := Client{Root: root, Key: key, URL: ts.URL}
ticket, url, token, err := c.Issue("alice", "PUT", "", "", time.Minute)
if err != nil {
t.Fatal(err)
}
if code := upload(t, url, token, archiveBytes(t), true); code != 422 {
t.Fatal("disk full accepted", code)
}
if _, err := os.Stat(ticket.Ref); !os.IsNotExist(err) {
t.Fatal("full disk committed archive", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
)
// VerifyRestored reads target files back and compares them with every regular archive entry.
// OpenRoot keeps a malicious path or existing symlink inside the world volume.
func VerifyRestored(ctx context.Context, ref, world string) error {
root, err := os.OpenRoot(world)
if err != nil {
return err
}
defer root.Close()
f, err := os.Open(ref)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
if err := ctx.Err(); err != nil {
return err
}
h, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeRegA {
continue
}
dst, err := root.Open(h.Name)
if err != nil {
return err
}
st, err := dst.Stat()
if err != nil || !st.Mode().IsRegular() || st.Size() != h.Size {
dst.Close()
return fmt.Errorf("restored entry %q has the wrong type or size", h.Name)
}
sourceHash, targetHash := sha256.New(), sha256.New()
_, err = io.Copy(sourceHash, tr)
if err == nil {
_, err = io.Copy(targetHash, dst)
}
dst.Close()
if err != nil {
return err
}
if string(sourceHash.Sum(nil)) != string(targetHash.Sum(nil)) {
return fmt.Errorf("restored entry %q failed SHA-256 read-back", h.Name)
}
}
}
+25 -1
View File
@@ -35,6 +35,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
) )
// ErrAlreadyExists is returned by a Jobs implementation when a backup Job for a // ErrAlreadyExists is returned by a Jobs implementation when a backup Job for a
@@ -57,6 +58,7 @@ type Jobs interface {
// the caller leaves the API's Backuper nil so the endpoint reports 503 rather than // the caller leaves the API's Backuper nil so the endpoint reports 503 rather than
// enqueuing a Job that cannot run. // enqueuing a Job that cannot run.
type Config struct { type Config struct {
ResolveWorld placement.Resolver
// Namespace is where the world PVCs live and the backup Job runs (the minecraft // Namespace is where the world PVCs live and the backup Job runs (the minecraft
// namespace), co-located with the world it snapshots. // namespace), co-located with the world it snapshots.
Namespace string Namespace string
@@ -178,7 +180,15 @@ type Backuper struct {
// land on different nodes the RWO attach fails one cleanly. Add single-flight-on- // land on different nodes the RWO attach fails one cleanly. Add single-flight-on-
// running only if a real double-tap storm ever shows up. // running only if a real double-tap storm ever shows up.
func (b *Backuper) Backup(ctx context.Context, serverName, formerOwner string) error { func (b *Backuper) Backup(ctx context.Context, serverName, formerOwner string) error {
if err := b.Jobs.CreateBackupJob(ctx, b.jobParams(serverName, formerOwner)); err != nil { p := b.jobParams(serverName, formerOwner)
if b.Config.ResolveWorld != nil {
w, err := b.Config.ResolveWorld(ctx, serverName)
if err != nil {
return err
}
p.WorldPVC = w.Claim
}
if err := b.Jobs.CreateBackupJob(ctx, p); err != nil {
if errors.Is(err, ErrAlreadyExists) { if errors.Is(err, ErrAlreadyExists) {
return nil // suffix collision — treat as enqueued return nil // suffix collision — treat as enqueued
} }
@@ -195,6 +205,13 @@ func (b *Backuper) Backup(ctx context.Context, serverName, formerOwner string) e
// the world volume as a restore until then (internal/maintenance). // the world volume as a restore until then (internal/maintenance).
func (b *Backuper) BackupThenRestore(ctx context.Context, serverName, formerOwner, backupID, backupRef string) error { func (b *Backuper) BackupThenRestore(ctx context.Context, serverName, formerOwner, backupID, backupRef string) error {
p := b.jobParams(serverName, formerOwner) p := b.jobParams(serverName, formerOwner)
if b.Config.ResolveWorld != nil {
w, err := b.Config.ResolveWorld(ctx, serverName)
if err != nil {
return err
}
p.WorldPVC = w.Claim
}
p.RestoreRef, p.RestoreBackupID = backupRef, backupID p.RestoreRef, p.RestoreBackupID = backupRef, backupID
if err := b.Jobs.CreateBackupJob(ctx, p); err != nil { if err := b.Jobs.CreateBackupJob(ctx, p); err != nil {
if errors.Is(err, ErrAlreadyExists) { if errors.Is(err, ErrAlreadyExists) {
@@ -210,6 +227,13 @@ func (b *Backuper) BackupThenRestore(ctx context.Context, serverName, formerOwne
// [archive] scheduled_keep, so the owner's own backups keep their count. // [archive] scheduled_keep, so the owner's own backups keep their count.
func (b *Backuper) BackupScheduled(ctx context.Context, serverName, formerOwner string) error { func (b *Backuper) BackupScheduled(ctx context.Context, serverName, formerOwner string) error {
p := b.jobParams(serverName, formerOwner) p := b.jobParams(serverName, formerOwner)
if b.Config.ResolveWorld != nil {
w, err := b.Config.ResolveWorld(ctx, serverName)
if err != nil {
return err
}
p.WorldPVC = w.Claim
}
p.Scheduled = true p.Scheduled = true
if err := b.Jobs.CreateBackupJob(ctx, p); err != nil { if err := b.Jobs.CreateBackupJob(ctx, p); err != nil {
if errors.Is(err, ErrAlreadyExists) { if errors.Is(err, ErrAlreadyExists) {
+278
View File
@@ -0,0 +1,278 @@
// Package distributed extends the existing Job executors with archive transport and stopped migration.
package distributed
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/restore"
"felis.lolicon.best/internal/worldexport"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const LabelTransfer = "felis.lolicon.best/archive-job"
type Backup struct {
ID string `json:"id"`
Server string `json:"server"`
Owner string `json:"owner"`
Reason string `json:"reason"`
Protect string `json:"protect,omitempty"`
Receipt archivetransfer.Receipt `json:"receipt"`
}
type pendingBackup struct {
Ticket archivetransfer.Ticket
Owner, Reason, Protect string
}
type Manager struct {
Client client.Client
Namespace, Image, Controller string
Archive archivetransfer.Client
Resolve placement.Resolver
// Record is idempotent by transfer ID and runs on A after durable archive commit.
Record func(context.Context, Backup) error
}
func (m *Manager) CreateBackupJob(ctx context.Context, p backupjob.JobParams) error {
world, err := m.Resolve(ctx, p.Server)
if err != nil {
return err
}
job, t, err := m.uploadJob(p.Server, world.Claim, world.Node, p.JobName)
if err != nil {
return err
}
reason := "manual"
if p.Scheduled {
reason = backupjob.ReasonScheduled
}
if p.RestoreRef != "" {
reason = backupjob.ReasonPreRestore
}
meta := pendingBackup{Ticket: t, Owner: p.FormerOwner, Reason: reason, Protect: p.RestoreBackupID}
raw, _ := json.Marshal(meta)
job.Annotations = map[string]string{archivetransfer.Annotation: string(raw)}
job.Labels[maintenance.LabelManagedBy] = "felis-backup"
job.Labels[archivetransfer.LabelPending] = "true"
if p.RestoreRef != "" {
job.Labels[maintenance.LabelThenRestore] = maintenance.ThenRestorePending
job.Annotations[maintenance.AnnotationRestoreRef] = p.RestoreRef
job.Annotations[maintenance.AnnotationRestoreBackupID] = p.RestoreBackupID
}
job.Spec.TTLSecondsAfterFinished = nil
err = m.Client.Create(ctx, job)
if apierrors.IsAlreadyExists(err) {
return backupjob.ErrAlreadyExists
}
return err
}
func (m *Manager) uploadJob(server, claim, node, name string) (*batchv1.Job, archivetransfer.Ticket, error) {
t, url, token, err := m.Archive.Issue(server, http.MethodPut, "", "", 2*time.Hour)
if err != nil {
return nil, t, err
}
job, err := worldexport.ExportJob(worldexport.JobParams{Server: server, ID: t.ID[:16], Mode: worldexport.ModeWorld, WorldPVC: claim, TargetURL: url, Token: token, Namespace: m.Namespace, ServiceAccount: "felis-restore", Image: m.Image, WorldsRoot: "/world", Deadline: 2 * time.Hour})
if err != nil {
return nil, t, err
}
job.Name = name
job.Labels[LabelTransfer] = "true"
job.Spec.Template.Labels[LabelTransfer] = "true"
job.Spec.Template.Spec.Containers[0].Args = append(job.Spec.Template.Spec.Containers[0].Args, "--archive-raw")
m.pin(&job.Spec.Template.Spec, node)
return job, t, nil
}
func (m *Manager) pin(p *corev1.PodSpec, node string) {
if node == "" {
node = m.Controller
}
p.NodeSelector = map[string]string{placement.LabelIdentity: node}
}
func (m *Manager) restoreParams(ctx context.Context, p restore.JobParams) (restore.JobParams, error) {
rec, err := m.Archive.Inspect(ctx, p.BackupRef)
if err != nil {
return p, err
}
_, url, token, err := m.Archive.Issue(p.Server, http.MethodGet, p.BackupRef, rec.SHA256, 2*time.Hour)
if err != nil {
return p, err
}
p.SourceURL, p.Token, p.SHA256, p.MaxBytes = url, token, rec.SHA256, m.Archive.Limit
if p.MaxBytes <= 0 {
p.MaxBytes = archivetransfer.DefaultLimit
}
p.BackupPVC = ""
return p, nil
}
func (m *Manager) CreateRestoreJob(ctx context.Context, p restore.JobParams) error {
world, err := m.Resolve(ctx, p.Server)
if err != nil {
return err
}
p.WorldPVC = world.Claim
p, err = m.restoreParams(ctx, p)
if err != nil {
return err
}
// Preserve the existing deterministic-name conflict and finished-Job retry rules.
return restore.NewK8sJobs(&pinnedClient{Client: m.Client, node: world.Node, manager: m}).CreateRestoreJob(ctx, p)
}
type pinnedClient struct {
client.Client
node string
manager *Manager
}
func (c *pinnedClient) Create(ctx context.Context, o client.Object, opts ...client.CreateOption) error {
if j, ok := o.(*batchv1.Job); ok {
c.manager.pin(&j.Spec.Template.Spec, c.node)
j.Labels[LabelTransfer] = "true"
j.Spec.Template.Labels[LabelTransfer] = "true"
}
return c.Client.Create(ctx, o, opts...)
}
// SettleBackups is retried after A restarts. Jobs remain durable until the row is recorded.
func (m *Manager) SettleBackups(ctx context.Context) error {
var list batchv1.JobList
if err := m.Client.List(ctx, &list, client.InNamespace(m.Namespace), client.MatchingLabels{archivetransfer.LabelPending: "true"}); err != nil {
return err
}
var errs []error
for i := range list.Items {
j := &list.Items[i]
var p pendingBackup
if err := json.Unmarshal([]byte(j.Annotations[archivetransfer.Annotation]), &p); err != nil {
errs = append(errs, err)
continue
}
rec, err := m.Archive.Receipt(ctx, p.Ticket.ID)
if err != nil {
if !maintenance.JobFinished(j) {
continue
}
// A terminal upload failure holds nothing, but must never start its restore chain.
if jobSucceeded(j) {
errs = append(errs, fmt.Errorf("backup %s awaits durable receipt: %w", j.Name, err))
continue
}
} else {
if rec.Ref != p.Ticket.Ref || rec.SHA256 == "" || rec.Size <= 0 || rec.Size > p.Ticket.Limit {
errs = append(errs, fmt.Errorf("invalid receipt for %s", j.Name))
continue
}
if m.Record == nil {
errs = append(errs, errors.New("backup recorder unavailable"))
continue
}
if err := m.Record(ctx, Backup{ID: p.Ticket.ID, Server: p.Ticket.Server, Owner: p.Owner, Reason: p.Reason, Protect: p.Protect, Receipt: rec}); err != nil {
errs = append(errs, err)
continue
}
}
before := j.DeepCopy()
delete(j.Labels, archivetransfer.LabelPending)
ttl := int32(600)
j.Spec.TTLSecondsAfterFinished = &ttl
if err := m.Client.Patch(ctx, j, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func jobSucceeded(j *batchv1.Job) bool {
for _, c := range j.Status.Conditions {
if c.Type == batchv1.JobComplete && c.Status == corev1.ConditionTrue {
return true
}
}
return false
}
// RemoteArchiver lets the existing reaper make every decision on A and snapshot only one remote PVC.
// Local verification, retention and offsite continue to use the same tarLocal paths.
type RemoteArchiver struct {
*backup.TarLocal
Manager *Manager
}
// A failed migration may wait for operator intervention longer than normal
// backup retention. Keep its safety archive until the persistent lock releases.
func (a *RemoteArchiver) Delete(ctx context.Context, ref backup.ArchiveRef) error {
var servers v1alpha1.MinecraftServerList
if err := a.Manager.Client.List(ctx, &servers, client.InNamespace(a.Manager.Namespace)); err != nil {
return err
}
for i := range servers.Items {
op, err := readOperation(&servers.Items[i])
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if err == nil && op.State != "succeeded" && op.Backup.Ref == string(ref) {
return fmt.Errorf("%w: migration retains its safety archive", ErrBusy)
}
}
return a.TarLocal.Delete(ctx, ref)
}
func (a *RemoteArchiver) Archive(ctx context.Context, server, pvc string) (backup.Archived, error) {
m := a.Manager
world, err := m.Resolve(ctx, server)
if err != nil {
return backup.Archived{}, err
}
if world.Claim != pvc {
return backup.Archived{}, errors.New("active PVC changed")
}
id := archivetransfer.ID()
name := "reap-" + id[:16]
j, t, err := m.uploadJob(server, pvc, world.Node, name)
if err != nil {
return backup.Archived{}, err
}
if err = m.Client.Create(ctx, j); err != nil {
return backup.Archived{}, err
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
rec, err := m.Archive.Receipt(ctx, t.ID)
if err == nil {
return backup.Archived{Ref: backup.ArchiveRef(rec.Ref), Size: rec.Size, SHA256: rec.SHA256}, nil
}
var job batchv1.Job
if err = m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: name}, &job); err != nil {
return backup.Archived{}, err
}
if maintenance.JobFinished(&job) && !jobSucceeded(&job) {
return backup.Archived{}, errors.New("remote archive Job failed")
}
select {
case <-ctx.Done():
return backup.Archived{}, ctx.Err()
case <-ticker.C:
}
}
}
+521
View File
@@ -0,0 +1,521 @@
package distributed
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/restore"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const MigrationAnnotation = "felis.lolicon.best/migration"
// Admission errors keep HTTP policy in the API layer.
var ErrBusy = errors.New("server must be fully stopped with no maintenance operation")
var ErrNotFound = errors.New("migration not found")
type Node struct {
Name string `json:"name"`
Role string `json:"role"`
Ready bool `json:"ready"`
Approved bool `json:"approved"`
Addresses []string `json:"addresses"`
Architecture string `json:"architecture"`
}
func (m *Manager) Nodes(ctx context.Context) ([]Node, error) {
var list corev1.NodeList
if err := m.Client.List(ctx, &list); err != nil {
return nil, err
}
out := make([]Node, 0, len(list.Items))
for _, n := range list.Items {
info := Node{Name: n.Name, Role: n.Labels[placement.LabelRole], Ready: placement.Online(&n), Approved: n.Labels[placement.LabelApproved] == "true" && !n.Spec.Unschedulable, Addresses: []string{}, Architecture: n.Status.NodeInfo.Architecture}
for _, a := range n.Status.Addresses {
if a.Type == corev1.NodeInternalIP || a.Type == corev1.NodeExternalIP {
info.Addresses = append(info.Addresses, a.Address)
}
}
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (m *Manager) ValidateNode(ctx context.Context, name string) error {
return placement.Worker(ctx, m.Client, name)
}
// Operation is persisted on the CR together with its non-expiring maintenance lock.
// SourcePVC is retained even after success, and retry never changes the committed active world.
type Operation struct {
ID string `json:"id"`
Server string `json:"server"`
State string `json:"state"`
Stage string `json:"stage"`
SourceNode string `json:"sourceNode"`
TargetNode string `json:"targetNode"`
SourcePVC string `json:"sourcePVC"`
TargetPVC string `json:"targetPVC"`
Backup archivetransfer.Receipt `json:"backup"`
Owner string `json:"-"`
Started time.Time `json:"started"`
Updated time.Time `json:"updated"`
Error string `json:"error,omitempty"`
Switched bool `json:"switched"`
Attempt int `json:"attempt"`
}
// owner travels in persistence, but never on the public operation view.
type persistedOperation struct {
Operation
OwnerID string `json:"ownerId"`
}
func readOperation(s *v1alpha1.MinecraftServer) (Operation, error) {
var stored persistedOperation
if s.Annotations[MigrationAnnotation] == "" {
return Operation{}, ErrNotFound
}
if err := json.Unmarshal([]byte(s.Annotations[MigrationAnnotation]), &stored); err != nil {
return Operation{}, err
}
stored.Operation.Owner = stored.OwnerID
return stored.Operation, nil
}
func (m *Manager) Migration(ctx context.Context, server, id string) (Operation, error) {
var s v1alpha1.MinecraftServer
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: server}, &s); err != nil {
return Operation{}, err
}
op, err := readOperation(&s)
if err == nil && id != "" && op.ID != id {
return Operation{}, ErrNotFound
}
return op, err
}
func (m *Manager) quiet(ctx context.Context, s *v1alpha1.MinecraftServer, own ...string) error {
if s.Spec.DesiredState != v1alpha1.DesiredStopped || s.Status.Phase != v1alpha1.PhaseStopped || s.Status.Ready {
return ErrBusy
}
var pods corev1.PodList
if err := m.Client.List(ctx, &pods, client.InNamespace(m.Namespace), client.MatchingLabels{v1alpha1.LabelServer: s.Name}); err != nil {
return err
}
// Even terminal maintenance Pods must have exited before a new attempt writes its PVC.
for _, p := range pods.Items {
allowed := len(own) > 0 && p.Labels[MigrationAnnotation] == own[0]
if p.Labels[v1alpha1.LabelComponent] == "server" || (!allowed && p.Status.Phase != corev1.PodSucceeded && p.Status.Phase != corev1.PodFailed) {
return ErrBusy
}
}
return nil
}
func (m *Manager) BeginMigration(ctx context.Context, server, target, owner string) (Operation, error) {
if err := m.ValidateNode(ctx, target); err != nil {
return Operation{}, err
}
var op Operation
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
var s v1alpha1.MinecraftServer
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: server}, &s); err != nil {
return err
}
if previous, err := readOperation(&s); err == nil && previous.State != "succeeded" {
if previous.TargetNode == target {
op = previous
return nil
}
return ErrBusy
} else if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if s.Spec.ReaperExempt || s.Labels[v1alpha1.LabelSystemRole] != "" {
return ErrBusy
}
if err := m.quiet(ctx, &s); err != nil {
return err
}
var jobs batchv1.JobList
if err := m.Client.List(ctx, &jobs, client.InNamespace(m.Namespace), client.MatchingLabels{maintenance.LabelServer: server}); err != nil {
return err
}
if _, held := maintenance.Holder(server, s.Annotations, jobs.Items, time.Now()); held {
return ErrBusy
}
world, err := m.Resolve(ctx, server)
if err != nil {
return err
}
source := world.Node
if source == "" {
source = m.Controller
}
if source == target {
return errors.New("source and target nodes are identical")
}
// Bound local-path worlds have a physical node; reject a guessed or mismatched source.
var pvc corev1.PersistentVolumeClaim
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: world.Claim}, &pvc); err != nil {
return err
}
if pvc.Spec.VolumeName == "" {
return errors.New("source world is not bound")
}
if err := m.volumeOnNode(ctx, pvc.Spec.VolumeName, source); err != nil {
return err
}
id := archivetransfer.ID()
now := time.Now().UTC()
op = Operation{ID: id, Server: server, State: "backing_up", Stage: "backing_up", SourceNode: source, TargetNode: target, SourcePVC: world.Claim, TargetPVC: "world-" + server + "-m" + id[:12], Owner: owner, Started: now, Updated: now}
return m.save(ctx, &s, op, true)
})
return op, err
}
func (m *Manager) volumeOnNode(ctx context.Context, volume, node string) error {
var pv corev1.PersistentVolume
if err := m.Client.Get(ctx, types.NamespacedName{Name: volume}, &pv); err != nil {
return err
}
if pv.Spec.NodeAffinity == nil || pv.Spec.NodeAffinity.Required == nil {
return errors.New("migration requires a node-local volume with node affinity")
}
var n corev1.Node
if err := m.Client.Get(ctx, types.NamespacedName{Name: node}, &n); err != nil {
return err
}
for _, term := range pv.Spec.NodeAffinity.Required.NodeSelectorTerms {
if len(term.MatchFields) > 0 {
continue
}
matched := len(term.MatchExpressions) > 0
for _, e := range term.MatchExpressions {
if e.Operator != corev1.NodeSelectorOpIn {
matched = false
break
}
found := false
for _, v := range e.Values {
if n.Labels[e.Key] == v {
found = true
}
}
if !found {
matched = false
break
}
}
if matched {
return nil
}
}
return errors.New("world volume is not on the recorded execution node")
}
func (m *Manager) save(ctx context.Context, s *v1alpha1.MinecraftServer, op Operation, lock bool) error {
before := s.DeepCopy()
op.Updated = time.Now().UTC()
raw, _ := json.Marshal(persistedOperation{Operation: op, OwnerID: op.Owner})
if s.Annotations == nil {
s.Annotations = map[string]string{}
}
s.Annotations[MigrationAnnotation] = string(raw)
if lock {
s.Annotations[maintenance.Annotation] = maintenance.LockValue(maintenance.KindMigration, op.Started)
} else {
delete(s.Annotations, maintenance.Annotation)
}
return m.Client.Patch(ctx, s, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{}))
}
func (m *Manager) RetryMigration(ctx context.Context, server, id string) (Operation, error) {
var op Operation
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
var s v1alpha1.MinecraftServer
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: server}, &s); err != nil {
return err
}
var err error
op, err = readOperation(&s)
if err != nil {
return err
}
if op.ID != id {
return ErrNotFound
}
if op.State != "failed" {
return ErrBusy
}
if err := m.quiet(ctx, &s); err != nil {
return err
}
if err := m.ValidateNode(ctx, op.TargetNode); err != nil {
return err
}
op.State = op.Stage
op.Error = ""
op.Attempt++
return m.save(ctx, &s, op, true)
})
return op, err
}
func (m *Manager) ReconcileMigrations(ctx context.Context) error {
var servers v1alpha1.MinecraftServerList
if err := m.Client.List(ctx, &servers, client.InNamespace(m.Namespace)); err != nil {
return err
}
var errs []error
for i := range servers.Items {
s := &servers.Items[i]
op, err := readOperation(s)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
errs = append(errs, err)
continue
}
if op.State == "succeeded" {
if err := m.expireMigrationJobs(ctx, op.ID); err != nil {
errs = append(errs, err)
}
continue
}
if op.State == "failed" {
continue
}
err = m.advance(ctx, s, &op)
if err != nil {
if apierrors.IsConflict(err) {
continue
}
op.Stage = op.State
op.State = "failed"
op.Error = err.Error()
if saveErr := m.save(ctx, s, op, true); saveErr != nil {
errs = append(errs, saveErr)
}
}
}
return errors.Join(errs...)
}
func (m *Manager) advance(ctx context.Context, s *v1alpha1.MinecraftServer, op *Operation) error {
if err := m.quiet(ctx, s, op.ID); err != nil {
return err
}
if s.Annotations[maintenance.Annotation] != maintenance.LockValue(maintenance.KindMigration, op.Started) {
return errors.New("persistent migration lock was changed")
}
if err := m.ValidateNode(ctx, op.TargetNode); err != nil {
return err
}
if !op.Switched && (s.WorldPVC() != op.SourcePVC || (s.Spec.NodeName != "" && s.Spec.NodeName != op.SourceNode)) {
return errors.New("source placement changed during migration")
}
var source corev1.Node
if !op.Switched {
if err := m.Client.Get(ctx, types.NamespacedName{Name: op.SourceNode}, &source); err != nil {
return err
}
if !placement.Online(&source) {
return errors.New("source node is offline")
}
}
name := fmt.Sprintf("migration-%s-%d", op.ID[:16], op.Attempt)
switch op.State {
case "backing_up":
var j batchv1.Job
err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: name + "-backup"}, &j)
if apierrors.IsNotFound(err) {
job, t, err := m.uploadJob(op.Server, op.SourcePVC, op.SourceNode, name+"-backup")
if err != nil {
return err
}
raw, _ := json.Marshal(t)
job.Annotations = map[string]string{archivetransfer.Annotation: string(raw)}
job.Labels[MigrationAnnotation] = op.ID
job.Spec.Template.Labels[MigrationAnnotation] = op.ID
job.Spec.TTLSecondsAfterFinished = nil
return m.Client.Create(ctx, job)
}
if err != nil {
return err
}
var t archivetransfer.Ticket
if err = json.Unmarshal([]byte(j.Annotations[archivetransfer.Annotation]), &t); err != nil {
return err
}
rec, err := m.Archive.Receipt(ctx, t.ID)
if err != nil {
if maintenance.JobFinished(&j) {
return fmt.Errorf("migration backup has no durable receipt: %w", err)
}
return nil
}
if rec.Ref != t.Ref || rec.SHA256 == "" {
return errors.New("migration backup receipt mismatch")
}
if m.Record == nil {
return errors.New("backup recorder unavailable")
}
if err = m.Record(ctx, Backup{ID: t.ID, Server: op.Server, Owner: op.Owner, Reason: "pre_restore", Receipt: rec}); err != nil {
return err
}
op.Backup = rec
op.State = "restoring"
op.Stage = op.State
return m.save(ctx, s, *op, true)
case "restoring":
if err := m.ensureTarget(ctx, s, op); err != nil {
return err
}
var j batchv1.Job
err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: name + "-restore"}, &j)
if apierrors.IsNotFound(err) {
p := restore.JobParams{Server: op.Server, WorldPVC: op.TargetPVC, BackupRef: op.Backup.Ref, ArchiveStore: "tarLocal", Namespace: m.Namespace, ServiceAccount: "felis-restore", Image: m.Image, WorldsRoot: "/world", BackupRoot: m.Archive.Root, Deadline: 2 * time.Hour}
p, err = m.restoreParams(ctx, p)
if err != nil {
return err
}
if p.SHA256 != op.Backup.SHA256 {
return errors.New("migration archive digest changed")
}
job, err := restore.RestoreJob(p)
if err != nil {
return err
}
job.Name = name + "-restore"
m.pin(&job.Spec.Template.Spec, op.TargetNode)
job.Labels[LabelTransfer] = "true"
job.Spec.Template.Labels[LabelTransfer] = "true"
job.Labels[MigrationAnnotation] = op.ID
job.Spec.Template.Labels[MigrationAnnotation] = op.ID
job.Spec.TTLSecondsAfterFinished = nil
return m.Client.Create(ctx, job)
}
if err != nil {
return err
}
if !maintenance.JobFinished(&j) {
return nil
}
if !jobSucceeded(&j) {
return errors.New("target restore or read-back verification failed")
}
// Job Complete alone is not enough for a RWO handoff: all its processes must be gone.
if err := m.quiet(ctx, s); err != nil {
return nil
}
var pvc corev1.PersistentVolumeClaim
if err = m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.TargetPVC}, &pvc); err != nil {
return err
}
if err = m.volumeOnNode(ctx, pvc.Spec.VolumeName, op.TargetNode); err != nil {
return err
}
op.State = "switching"
op.Stage = op.State
return m.save(ctx, s, *op, true)
case "switching":
var sts appsv1.StatefulSet
err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: s.Name}, &sts)
if err == nil {
if sts.Spec.Replicas != nil && *sts.Spec.Replicas != 0 {
return ErrBusy
}
if sts.Status.Replicas != 0 {
return nil
}
policy := metav1.DeletePropagationForeground
return m.Client.Delete(ctx, &sts, &client.DeleteOptions{PropagationPolicy: &policy, Preconditions: &metav1.Preconditions{UID: &sts.UID, ResourceVersion: &sts.ResourceVersion}})
}
if !apierrors.IsNotFound(err) {
return err
}
// One optimistic CR write commits node, claim and progress. No failure can roll this back.
target := s.DeepCopy()
committed := *op
target.Spec.NodeName = op.TargetNode
target.Spec.Storage.ClaimName = op.TargetPVC
committed.Switched = true
committed.State = "succeeded"
committed.Stage = "succeeded"
committed.Updated = time.Now().UTC()
raw, _ := json.Marshal(persistedOperation{Operation: committed, OwnerID: op.Owner})
target.Annotations[MigrationAnnotation] = string(raw)
delete(target.Annotations, maintenance.Annotation)
if err := m.Client.Patch(ctx, target, client.MergeFromWithOptions(s.DeepCopy(), client.MergeFromWithOptimisticLock{})); err != nil {
return err
}
*s, *op = *target, committed
return nil
default:
return fmt.Errorf("unknown migration stage %q", op.State)
}
}
func (m *Manager) ensureTarget(ctx context.Context, s *v1alpha1.MinecraftServer, op *Operation) error {
var pvc corev1.PersistentVolumeClaim
err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.TargetPVC}, &pvc)
if err == nil {
if pvc.Labels[MigrationAnnotation] != op.ID {
return errors.New("target PVC identity mismatch")
}
return nil
}
if !apierrors.IsNotFound(err) {
return err
}
var source corev1.PersistentVolumeClaim
if err = m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.SourcePVC}, &source); err != nil {
return err
}
size := source.Spec.Resources.Requests[corev1.ResourceStorage]
if size.IsZero() {
size = resource.MustParse("8Gi")
}
pvc = corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: op.TargetPVC, Namespace: m.Namespace, Labels: map[string]string{maintenance.LabelServer: s.Name, MigrationAnnotation: op.ID}}, Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, StorageClassName: source.Spec.StorageClassName, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: size}}}}
return m.Client.Create(ctx, &pvc)
}
func (m *Manager) expireMigrationJobs(ctx context.Context, id string) error {
var jobs batchv1.JobList
if err := m.Client.List(ctx, &jobs, client.InNamespace(m.Namespace), client.MatchingLabels{MigrationAnnotation: id}); err != nil {
return err
}
for i := range jobs.Items {
j := &jobs.Items[i]
if j.Spec.TTLSecondsAfterFinished != nil || !maintenance.JobFinished(j) {
continue
}
before := j.DeepCopy()
ttl := int32(600)
j.Spec.TTLSecondsAfterFinished = &ttl
if err := m.Client.Patch(ctx, j, client.MergeFrom(before)); err != nil {
return err
}
}
return nil
}
+352
View File
@@ -0,0 +1,352 @@
package distributed
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/placement"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)
func fixture(t *testing.T) (*Manager, context.Context) {
t.Helper()
scheme := runtime.NewScheme()
for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, batchv1.AddToScheme, appsv1.AddToScheme, v1alpha1.AddToScheme} {
if err := add(scheme); err != nil {
t.Fatal(err)
}
}
node := func(name string) *corev1.Node {
return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{placement.LabelIdentity: name, placement.LabelRole: placement.RoleWorker, placement.LabelApproved: "true", "kubernetes.io/hostname": name}}, Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}}}
}
s := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "alice", Namespace: "minecraft"}, Spec: v1alpha1.MinecraftServerSpec{DesiredState: v1alpha1.DesiredStopped, NodeName: "b"}, Status: v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseStopped}}
s.Spec.Storage.Size = "1Gi"
pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: s.WorldPVC(), Namespace: "minecraft"}, Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "source", Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}}}}
zero := int32(0)
cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(s, &batchv1.Job{}).WithObjects(node("b"), node("c"), s, pvc, localPV("source", "b"), &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "alice", Namespace: "minecraft"}, Spec: appsv1.StatefulSetSpec{Replicas: &zero}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "alice", Namespace: "minecraft"}, Spec: corev1.ServiceSpec{ClusterIP: "10.43.0.80"}}).Build()
var receipt archivetransfer.Receipt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/inspect" && r.Method == "POST" || strings.HasPrefix(r.URL.Path, "/receipts/") {
var jobs batchv1.JobList
cl.List(r.Context(), &jobs)
for _, j := range jobs.Items {
var ticket archivetransfer.Ticket
if json.Unmarshal([]byte(j.Annotations[archivetransfer.Annotation]), &ticket) == nil && ticket.Ref != "" {
receipt = archivetransfer.Receipt{Ref: ticket.Ref, SHA256: strings.Repeat("a", 64), Size: 50}
}
}
if receipt.Ref == "" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(receipt)
return
}
http.NotFound(w, r)
}))
t.Cleanup(server.Close)
m := &Manager{Client: cl, Namespace: "minecraft", Controller: "a", Image: "registry.local/felis:1", Archive: archivetransfer.Client{Root: "/backups", URL: server.URL, Key: strings.Repeat("k", 32)}, Record: func(context.Context, Backup) error { return nil }}
m.Resolve = placement.Resolve(cl, "minecraft")
return m, context.Background()
}
func localPV(name, node string) *corev1.PersistentVolume {
return &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: corev1.PersistentVolumeSpec{NodeAffinity: &corev1.VolumeNodeAffinity{Required: &corev1.NodeSelector{NodeSelectorTerms: []corev1.NodeSelectorTerm{{MatchExpressions: []corev1.NodeSelectorRequirement{{Key: "kubernetes.io/hostname", Operator: corev1.NodeSelectorOpIn, Values: []string{node}}}}}}}}}
}
func getServer(t *testing.T, m *Manager, ctx context.Context) *v1alpha1.MinecraftServer {
t.Helper()
var s v1alpha1.MinecraftServer
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: "alice"}, &s); err != nil {
t.Fatal(err)
}
return &s
}
func TestMigrationSurvivesRestartAndKeepsIdentity(t *testing.T) {
m, ctx := fixture(t)
op, err := m.BeginMigration(ctx, "alice", "c", "owner")
if err != nil {
t.Fatal(err)
}
if again, err := m.BeginMigration(ctx, "alice", "c", "owner"); err != nil || again.ID != op.ID {
t.Fatalf("duplicate %+v: %v", again, err)
}
s := getServer(t, m, ctx)
if kind, held := maintenance.Holder(s.Name, s.Annotations, nil, time.Now().Add(365*24*time.Hour)); !held || kind != maintenance.KindMigration {
t.Fatal("migration lock expired")
}
if _, err := m.BeginMigration(ctx, "alice", "b", "owner"); !errors.Is(err, ErrBusy) {
t.Fatalf("competing migration: %v", err)
}
// A restart reconstructs the coordinator solely from persisted CR and Job state.
restarted := *m
m = &restarted
for i := 0; i < 3; i++ {
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
}
current, err := m.Migration(ctx, "alice", op.ID)
if err != nil || current.State != "restoring" {
t.Fatalf("progress %+v: %v", current, err)
}
var pvc corev1.PersistentVolumeClaim
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.TargetPVC}, &pvc); err != nil {
t.Fatal(err)
}
pvc.Spec.VolumeName = "target"
if err := m.Client.Update(ctx, &pvc); err != nil {
t.Fatal(err)
}
if err := m.Client.Create(ctx, localPV("target", "c")); err != nil {
t.Fatal(err)
}
var jobs batchv1.JobList
if err := m.Client.List(ctx, &jobs); err != nil {
t.Fatal(err)
}
for i := range jobs.Items {
j := &jobs.Items[i]
assertJobIsolation(t, j)
j.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
if err := m.Client.Status().Update(ctx, j); err != nil {
t.Fatal(err)
}
}
for i := 0; i < 3; i++ {
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
}
s = getServer(t, m, ctx)
if s.Spec.NodeName != "c" || s.WorldPVC() != op.TargetPVC || s.Spec.DesiredState != v1alpha1.DesiredStopped || s.Status.Ready {
t.Fatalf("unsafe switch %+v", s)
}
if _, held := maintenance.Holder(s.Name, s.Annotations, nil, time.Now()); held {
t.Fatal("success did not release lock")
}
var svc corev1.Service
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: "alice"}, &svc); err != nil || svc.Spec.ClusterIP != "10.43.0.80" {
t.Fatalf("service changed: %v", err)
}
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.SourcePVC}, &pvc); err != nil {
t.Fatal("source PVC removed", err)
}
var sts appsv1.StatefulSet
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: "alice"}, &sts); !apierrors.IsNotFound(err) {
t.Fatal("stopped immutable StatefulSet not removed", err)
}
}
func assertJobIsolation(t *testing.T, j *batchv1.Job) {
t.Helper()
p := j.Spec.Template.Spec
if p.AutomountServiceAccountToken == nil || *p.AutomountServiceAccountToken || p.HostNetwork || p.HostPID || p.HostIPC {
t.Fatal("maintenance has host credentials/namespaces")
}
claims := 0
for _, v := range p.Volumes {
if v.Secret != nil || v.HostPath != nil {
t.Fatal("maintenance carries secret or host mount")
}
if v.PersistentVolumeClaim != nil {
claims++
}
}
if claims != 1 {
t.Fatalf("cross-node job mounts %d PVCs", claims)
}
for _, c := range p.Containers {
if c.SecurityContext.AllowPrivilegeEscalation == nil || *c.SecurityContext.AllowPrivilegeEscalation {
t.Fatal("maintenance can escalate")
}
for _, e := range c.Env {
if strings.Contains(e.Name, "DATABASE") || e.Name == archivetransfer.KeyEnv {
t.Fatal("full credentials leaked")
}
}
}
}
func TestLostSourceFailsClosedAndRetryKeepsSource(t *testing.T) {
m, ctx := fixture(t)
op, err := m.BeginMigration(ctx, "alice", "c", "owner")
if err != nil {
t.Fatal(err)
}
var n corev1.Node
m.Client.Get(ctx, types.NamespacedName{Name: "b"}, &n)
n.Status.Conditions = nil
if err := m.Client.Status().Update(ctx, &n); err != nil {
t.Fatal(err)
}
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
failed, err := m.Migration(ctx, "alice", op.ID)
if err != nil || failed.State != "failed" {
t.Fatalf("offline source %+v: %v", failed, err)
}
s := getServer(t, m, ctx)
if s.WorldPVC() != op.SourcePVC || s.Spec.NodeName != "b" {
t.Fatal("failed migration switched placement")
}
if _, held := maintenance.Holder(s.Name, s.Annotations, nil, time.Now().Add(time.Hour)); !held {
t.Fatal("failed migration lost lock")
}
if _, err := m.RetryMigration(ctx, "alice", op.ID); err != nil {
t.Fatal(err)
}
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
if current, _ := m.Migration(ctx, "alice", op.ID); current.State != "failed" {
t.Fatal("retry started tasks on offline source")
}
}
func TestMigrationRejectsLiveProcessAndUnapprovedTarget(t *testing.T) {
m, ctx := fixture(t)
if err := m.Client.Create(ctx, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "alice-0", Namespace: m.Namespace, Labels: map[string]string{v1alpha1.LabelServer: "alice", v1alpha1.LabelComponent: "server"}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}); err != nil {
t.Fatal(err)
}
if _, err := m.BeginMigration(ctx, "alice", "c", "owner"); !errors.Is(err, ErrBusy) {
t.Fatal("live source accepted", err)
}
var n corev1.Node
m.Client.Get(ctx, types.NamespacedName{Name: "c"}, &n)
delete(n.Labels, placement.LabelApproved)
m.Client.Update(ctx, &n)
if err := m.ValidateNode(ctx, "c"); err == nil {
t.Fatal("unapproved worker accepted")
}
}
func TestMigrationFailureRetainsSourceAndCanRetry(t *testing.T) {
for _, stage := range []string{"restoring", "switching"} {
t.Run(stage, func(t *testing.T) {
m, ctx := fixture(t)
op, err := m.BeginMigration(ctx, "alice", "c", "owner")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
}
var jobs batchv1.JobList
if err := m.Client.List(ctx, &jobs); err != nil {
t.Fatal(err)
}
for i := range jobs.Items {
j := &jobs.Items[i]
condition := batchv1.JobComplete
if stage == "restoring" && strings.HasSuffix(j.Name, "-restore") {
condition = batchv1.JobFailed
}
j.Status.Conditions = []batchv1.JobCondition{{Type: condition, Status: corev1.ConditionTrue}}
if err := m.Client.Status().Update(ctx, j); err != nil {
t.Fatal(err)
}
}
if stage == "switching" {
var pvc corev1.PersistentVolumeClaim
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: op.TargetPVC}, &pvc); err != nil {
t.Fatal(err)
}
pvc.Spec.VolumeName = "target"
if err := m.Client.Update(ctx, &pvc); err != nil {
t.Fatal(err)
}
if err := m.Client.Create(ctx, localPV("target", "c")); err != nil {
t.Fatal(err)
}
fail := true
m.Client = interceptor.NewClient(m.Client.(client.WithWatch), interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if s, ok := obj.(*v1alpha1.MinecraftServer); ok && s.Spec.NodeName == "c" && fail {
fail = false
return errors.New("commit interrupted")
}
return c.Patch(ctx, obj, patch, opts...)
},
})
}
for i := 0; i < 3; i++ {
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
}
failed, err := m.Migration(ctx, "alice", op.ID)
if err != nil || failed.State != "failed" || failed.Stage != stage || failed.Switched || failed.Error == "" {
t.Fatalf("failure %+v: %v", failed, err)
}
s := getServer(t, m, ctx)
if s.WorldPVC() != op.SourcePVC || s.Spec.NodeName != "b" || s.Spec.DesiredState != v1alpha1.DesiredStopped {
t.Fatal("failed operation changed active source")
}
if _, held := maintenance.Holder(s.Name, s.Annotations, nil, time.Now().Add(30*24*time.Hour)); !held {
t.Fatal("failed operation released lock")
}
archiver := &RemoteArchiver{TarLocal: &backup.TarLocal{BackupRoot: t.TempDir()}, Manager: m}
if err := archiver.Delete(ctx, backup.ArchiveRef(failed.Backup.Ref)); !errors.Is(err, ErrBusy) {
t.Fatalf("failed migration lost its safety archive: %v", err)
}
if retry, err := m.RetryMigration(ctx, "alice", op.ID); err != nil || retry.State != stage || retry.Attempt != 1 {
t.Fatalf("retry %+v: %v", retry, err)
}
if err := m.ReconcileMigrations(ctx); err != nil {
t.Fatal(err)
}
if stage == "switching" {
if done, _ := m.Migration(ctx, "alice", op.ID); done.State != "succeeded" || !done.Switched {
t.Fatalf("commit retry %+v", done)
}
}
})
}
}
func TestBackupResolvesActiveClaimAndPreservesConflictContract(t *testing.T) {
m, ctx := fixture(t)
p := backupjob.JobParams{Server: "alice", WorldPVC: "stale-claim", JobName: "manual-backup"}
if err := m.CreateBackupJob(ctx, p); err != nil {
t.Fatal(err)
}
var j batchv1.Job
if err := m.Client.Get(ctx, types.NamespacedName{Namespace: m.Namespace, Name: p.JobName}, &j); err != nil {
t.Fatal(err)
}
assertJobIsolation(t, &j)
for _, v := range j.Spec.Template.Spec.Volumes {
if v.PersistentVolumeClaim != nil && v.PersistentVolumeClaim.ClaimName != getServer(t, m, ctx).WorldPVC() {
t.Fatal("backup used a stale world claim")
}
}
if err := m.CreateBackupJob(ctx, p); !errors.Is(err, backupjob.ErrAlreadyExists) {
t.Fatalf("duplicate backup: %v", err)
}
}
+17
View File
@@ -52,6 +52,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
) )
// Errors the Editor returns, which internal/api maps onto HTTP status codes // Errors the Editor returns, which internal/api maps onto HTTP status codes
@@ -106,6 +107,7 @@ type Runner interface {
// deployment-specific, and when it is empty cmd/felis leaves the API's FileEditor // deployment-specific, and when it is empty cmd/felis leaves the API's FileEditor
// nil so the endpoints report 503 rather than creating a Job that cannot run. // nil so the endpoints report 503 rather than creating a Job that cannot run.
type Config struct { type Config struct {
ResolveWorld placement.Resolver
// Namespace is where the world PVCs live and the Job runs (the minecraft // Namespace is where the world PVCs live and the Job runs (the minecraft
// namespace), co-located with the world it edits. // namespace), co-located with the world it edits.
Namespace string Namespace string
@@ -338,6 +340,14 @@ func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, e
return Result{}, err return Result{}, err
} }
if cfg.ResolveWorld != nil {
world, err := cfg.ResolveWorld(ctx, server)
if err != nil {
return Result{}, err
}
p.WorldPVC, p.NodeName = world.Claim, world.Node
}
// Bound the wait here rather than trusting the caller's context: this is an HTTP // Bound the wait here rather than trusting the caller's context: this is an HTTP
// handler's goroutine and the Pod it waits on may never become ready (an // handler's goroutine and the Pod it waits on may never become ready (an
// unschedulable node, an unpullable image). The Job's own activeDeadlineSeconds // unschedulable node, an unpullable image). The Job's own activeDeadlineSeconds
@@ -438,6 +448,13 @@ func (e *Editor) start(ctx context.Context, server string, p JobParams) (OpState
if err != nil { if err != nil {
return OpState{}, err return OpState{}, err
} }
if cfg.ResolveWorld != nil {
world, err := cfg.ResolveWorld(ctx, server)
if err != nil {
return OpState{}, err
}
p.WorldPVC, p.NodeName = world.Claim, world.Node
}
p.Async = true p.Async = true
p.Deadline, p.TTLAfterFinished, p.CPULimit = cfg.AsyncDeadline, cfg.AsyncTTL, cfg.AsyncCPULimit p.Deadline, p.TTLAfterFinished, p.CPULimit = cfg.AsyncDeadline, cfg.AsyncTTL, cfg.AsyncCPULimit
if err := e.Runner.Start(ctx, p); err != nil { if err := e.Runner.Start(ctx, p); err != nil {
+10
View File
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"time" "time"
"felis.lolicon.best/internal/placement"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
@@ -43,6 +44,7 @@ const (
// operation + Config by the Editor. jobspec is a pure function of them so the // operation + Config by the Editor. jobspec is a pure function of them so the
// security-critical Job shape is unit-tested without a cluster. // security-critical Job shape is unit-tested without a cluster.
type JobParams struct { type JobParams struct {
NodeName string
Server string Server string
// OpID is the per-invocation identifier that both names the Job and labels its // OpID is the per-invocation identifier that both names the Job and labels its
// Pod. See Editor.run for why every invocation gets a fresh one. // Pod. See Editor.run for why every invocation gets a fresh one.
@@ -273,6 +275,7 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
ObjectMeta: metav1.ObjectMeta{Labels: filesLabels(p)}, ObjectMeta: metav1.ObjectMeta{Labels: filesLabels(p)},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever, RestartPolicy: corev1.RestartPolicyNever,
NodeSelector: jobNodeSelector(p.NodeName),
ServiceAccountName: p.ServiceAccount, ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false), AutomountServiceAccountToken: boolPtr(false),
SecurityContext: filesPodSecurityContext(p), SecurityContext: filesPodSecurityContext(p),
@@ -356,3 +359,10 @@ func filesAnnotations(p JobParams) map[string]string {
} }
return map[string]string{AnnotationPath: p.Path} return map[string]string{AnnotationPath: p.Path}
} }
func jobNodeSelector(name string) map[string]string {
if name == "" {
return nil
}
return map[string]string{placement.LabelIdentity: name}
}
+5 -1
View File
@@ -90,6 +90,7 @@ const (
// Kinds of holder. // Kinds of holder.
const ( const (
KindMigration = "migration"
KindRestore = "restore" KindRestore = "restore"
KindBackup = "backup" KindBackup = "backup"
KindFileWrite = "file-write" KindFileWrite = "file-write"
@@ -191,6 +192,9 @@ func Holder(server string, annotations map[string]string, jobs []batchv1.Job, no
if j.Labels[LabelServer] != server { if j.Labels[LabelServer] != server {
continue continue
} }
if j.Labels["felis.lolicon.best/archive-pending"] == "true" {
return KindBackup, true
}
if RestorePending(j) { if RestorePending(j) {
return KindRestore, true return KindRestore, true
} }
@@ -202,7 +206,7 @@ func Holder(server string, annotations map[string]string, jobs []batchv1.Job, no
} }
} }
if v, ok := annotations[Annotation]; ok { if v, ok := annotations[Annotation]; ok {
if kind, at, ok := parseLock(v); ok && now.Sub(at) < Grace && at.Sub(now) < Grace { if kind, at, ok := parseLock(v); ok && (kind == KindMigration || (now.Sub(at) < Grace && at.Sub(now) < Grace)) {
return kind, true return kind, true
} }
} }
+17 -2
View File
@@ -8,6 +8,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
@@ -217,7 +218,7 @@ func healthHandler(server *v1alpha1.MinecraftServer) corev1.ProbeHandler {
// graceful shutdown is terminationGracePeriodSeconds, the time the server gets to // graceful shutdown is terminationGracePeriodSeconds, the time the server gets to
// save on SIGTERM; the reconciler flushes the world over RCON before it scales to // save on SIGTERM; the reconciler flushes the world over RCON before it scales to
// zero (saveBeforeStop). // zero (saveBeforeStop).
func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisImage string) (*appsv1.StatefulSet, error) { func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisImage string, gateProbe ...string) (*appsv1.StatefulSet, error) {
storageSize := server.Spec.Storage.Size storageSize := server.Spec.Storage.Size
if storageSize == "" { if storageSize == "" {
storageSize = defaultStorageSize storageSize = defaultStorageSize
@@ -278,7 +279,14 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
if server.Labels[v1alpha1.LabelSystemRole] == "" { if server.Labels[v1alpha1.LabelSystemRole] == "" {
initContainers = append(initContainers, forwardingInitContainer(felisImage)) initContainers = append(initContainers, forwardingInitContainer(felisImage))
} }
initContainers = append(initContainers, egressGateInitContainer(felisImage)) gate := egressGateInitContainer(felisImage)
if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") {
gate.Command = append(gate.Command[:len(gate.Command)-1], "--positive-probe", "kube-dns.kube-system.svc:53")
if len(gateProbe) > 0 && gateProbe[0] != "" {
gate.Command = append(gate.Command, "--probe", gateProbe[0])
}
}
initContainers = append(initContainers, gate)
} }
grace := graceSeconds(server) grace := graceSeconds(server)
@@ -334,6 +342,13 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
}, },
}, },
} }
if server.Spec.Storage.ClaimName != "" {
sts.Spec.VolumeClaimTemplates = nil
sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, corev1.Volume{Name: dataVolumeName, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: server.WorldPVC()}}})
}
if server.Spec.NodeName != "" {
sts.Spec.Template.Spec.NodeSelector = map[string]string{placement.LabelIdentity: server.Spec.NodeName}
}
return sts, nil return sts, nil
} }
@@ -6,9 +6,39 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
func TestMigratedWorldPlacementAndFailClosedGate(t *testing.T) {
s := &v1alpha1.MinecraftServer{}
s.Spec.NodeName = "c"
s.Spec.Storage.ClaimName = "world-alice-migrated"
sts, err := buildStatefulSet(s, 1, "felis:test", "felis-api.felis.svc:443")
if err != nil {
t.Fatal(err)
}
if len(sts.Spec.VolumeClaimTemplates) != 0 || sts.Spec.Template.Spec.NodeSelector[placement.LabelIdentity] != "c" {
t.Fatal("migration rebuilt or moved the wrong world")
}
found := false
for _, v := range sts.Spec.Template.Spec.Volumes {
if v.PersistentVolumeClaim != nil && v.PersistentVolumeClaim.ClaimName == s.Spec.Storage.ClaimName {
found = true
}
}
if !found {
t.Fatal("active PVC not mounted")
}
for _, c := range sts.Spec.Template.Spec.InitContainers {
if c.Name == "egress-gate" {
if slices.Contains(c.Command, "--fail-open") || !slices.Contains(c.Command, "--positive-probe") {
t.Fatal("distributed gate can fail open")
}
}
}
}
// findEnv returns the env var with the given name, or nil. // findEnv returns the env var with the given name, or nil.
func findEnv(env []corev1.EnvVar, name string) *corev1.EnvVar { func findEnv(env []corev1.EnvVar, name string) *corev1.EnvVar {
for i := range env { for i := range env {
+49 -1
View File
@@ -18,6 +18,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/metrics" "felis.lolicon.best/internal/metrics"
"felis.lolicon.best/internal/placement"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -116,6 +117,9 @@ func podTemplateStamp(tmpl *corev1.PodTemplateSpec, felisImage string) (string,
// Reconciler reconciles a MinecraftServer with its managed children. // Reconciler reconciles a MinecraftServer with its managed children.
type Reconciler struct { type Reconciler struct {
Nodes client.Reader
EgressProbe string
ControllerNode string
client.Client client.Client
Scheme *runtime.Scheme Scheme *runtime.Scheme
// Prober gates readiness on RCON reachability. // Prober gates readiness on RCON reachability.
@@ -312,6 +316,46 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
return ctrl.Result{RequeueAfter: requeueMaintenance}, nil return ctrl.Result{RequeueAfter: requeueMaintenance}, nil
} }
if r.Nodes != nil {
node := server.Spec.NodeName
var pod corev1.Pod
if err := r.podReader().Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name + "-0"}, &pod); err == nil {
server.Status.NodeName = pod.Spec.NodeName
if node == "" {
node = pod.Spec.NodeName
}
} else if !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
if node == "" {
node = r.ControllerNode
}
if node != "" {
var n corev1.Node
err := r.Nodes.Get(ctx, types.NamespacedName{Name: node}, &n)
if err != nil && !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
if err != nil || !placement.Admitted(&n, r.ControllerNode) {
var svc corev1.Service
if err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &svc); err == nil {
if svc.Spec.Selector == nil {
svc.Spec.Selector = map[string]string{}
}
svc.Spec.Selector["felis.lolicon.best/node-online"] = "true"
if err := r.Update(ctx, &svc); err != nil {
return ctrl.Result{}, err
}
} else if !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
server.Status.Ready = false
server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback}
r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, "NodeUnavailable", "execution node is offline; automatic relocation is disabled")
return ctrl.Result{RequeueAfter: 10 * time.Second}, r.patchStatus(ctx, server)
}
}
}
endpointAddress, err := r.ensureServices(ctx, server) endpointAddress, err := r.ensureServices(ctx, server)
if err != nil { if err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
@@ -330,7 +374,11 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
return ctrl.Result{RequeueAfter: requeueSecret}, nil return ctrl.Result{RequeueAfter: requeueSecret}, nil
} }
desired, err := buildStatefulSet(server, 1, r.FelisImage) placed := server.DeepCopy()
if placed.Spec.NodeName == "" {
placed.Spec.NodeName = r.ControllerNode
}
desired, err := buildStatefulSet(placed, 1, r.FelisImage, r.EgressProbe)
if err != nil { if err != nil {
// A malformed spec (e.g. bad storage quantity) is terminal until edited. // A malformed spec (e.g. bad storage quantity) is terminal until edited.
r.markFailed(server, "InvalidSpec", err.Error()) r.markFailed(server, "InvalidSpec", err.Error())
+5 -1
View File
@@ -20,6 +20,7 @@ import (
var static embed.FS var static embed.FS
type runtimeConfig struct { type runtimeConfig struct {
Distributed bool `json:"distributed,omitempty"`
APIBase string `json:"apiBase"` APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"` RootDomain string `json:"rootDomain"`
PanelHostname string `json:"panelHostname,omitempty"` PanelHostname string `json:"panelHostname,omitempty"`
@@ -112,13 +113,14 @@ func parseBuildVersion(raw string) buildInfo {
// right surface (player console vs SysAdmin console) without a rebuild. gamePort // right surface (player console vs SysAdmin console) without a rebuild. gamePort
// is the public Minecraft port ([velocity] game_port), which the SPA appends to // is the public Minecraft port ([velocity] game_port), which the SPA appends to
// the server addresses players copy; 0 or 25565 leaves them bare. // the server addresses players copy; 0 or 25565 leaves them bare.
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string) http.Handler { func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string, distributed ...bool) http.Handler {
files, err := fs.Sub(static, "static") files, err := fs.Sub(static, "static")
if err != nil { if err != nil {
panic(err) panic(err)
} }
return &handler{ return &handler{
api: api, api: api,
distributed: len(distributed) > 0 && distributed[0],
rootDomain: rootDomain, rootDomain: rootDomain,
panelHostname: panelHost, panelHostname: panelHost,
adminHostname: adminHost, adminHostname: adminHost,
@@ -143,6 +145,7 @@ func publicGamePort(p int) int {
} }
type handler struct { type handler struct {
distributed bool
api http.Handler api http.Handler
rootDomain string rootDomain string
panelHostname string panelHostname string
@@ -223,6 +226,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(runtimeConfig{ _ = json.NewEncoder(w).Encode(runtimeConfig{
APIBase: "/api/v1", APIBase: "/api/v1",
Distributed: h.distributed,
RootDomain: h.rootDomain, RootDomain: h.rootDomain,
PanelHostname: h.panelHostname, PanelHostname: h.panelHostname,
AdminHostname: h.adminHostname, AdminHostname: h.adminHostname,
+84
View File
@@ -0,0 +1,84 @@
// Package placement resolves worlds and checks the protected node admission labels.
package placement
import (
"context"
"fmt"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
LabelRole = "felis.node-restriction.kubernetes.io/role"
LabelIdentity = "felis.node-restriction.kubernetes.io/identity"
LabelApproved = "felis.node-restriction.kubernetes.io/approved"
RoleController = "controller"
RoleWorker = "worker"
)
type World struct{ Claim, Node string }
type Resolver func(context.Context, string) (World, error)
func Online(n *corev1.Node) bool {
for _, c := range n.Status.Conditions {
if c.Type == corev1.NodeReady {
return c.Status == corev1.ConditionTrue && n.DeletionTimestamp == nil
}
}
return false
}
// Admitted accepts only A's protected controller identity or an approved worker.
func Admitted(n *corev1.Node, controller string) bool {
if !Online(n) || n.Labels[LabelIdentity] != n.Name {
return false
}
if n.Name == controller {
return n.Labels[LabelRole] == RoleController
}
return n.Labels[LabelRole] == RoleWorker && n.Labels[LabelApproved] == "true"
}
func Worker(ctx context.Context, r client.Reader, name string) error {
var n corev1.Node
if err := r.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil {
return err
}
if !Admitted(&n, "") || n.Spec.Unschedulable {
return fmt.Errorf("node %q is not an online, approved worker", name)
}
return nil
}
func Resolve(r client.Reader, namespace string, controller ...string) Resolver {
return func(ctx context.Context, name string) (World, error) {
var s v1alpha1.MinecraftServer
if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &s); err != nil {
return World{}, err
}
node := s.Spec.NodeName
if node == "" {
node = s.Status.NodeName
}
if node == "" && len(controller) > 0 {
node = controller[0]
}
if node != "" {
var n corev1.Node
if err := r.Get(ctx, types.NamespacedName{Name: node}, &n); err != nil {
return World{}, err
}
a := ""
if len(controller) > 0 {
a = controller[0]
}
if !Admitted(&n, a) {
return World{}, fmt.Errorf("node %q is offline", node)
}
}
return World{Claim: s.WorldPVC(), Node: node}, nil
}
}
+5
View File
@@ -72,6 +72,11 @@ func Objects(p Params) []Object {
objs = append(objs, rb) objs = append(objs, rb)
} }
if p.Distributed {
objs = append(objs, DistributedRBAC(p)...)
objs = append(objs, ArchiveNetworkPolicies(p)...)
}
// Weak Job SAs. They come from the build/restore packages (single source of // Weak Job SAs. They come from the build/restore packages (single source of
// truth for AutomountServiceAccountToken=false), which set ObjectMeta but not // truth for AutomountServiceAccountToken=false), which set ObjectMeta but not
// TypeMeta — stamp it so the YAML header is present. The restore Job runs in // TypeMeta — stamp it so the YAML header is present. The restore Job runs in
+82
View File
@@ -0,0 +1,82 @@
package platform
import (
"fmt"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/distributed"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)
const ArchiveName = "felis-archive"
const ArchiveSecret = "felis-archive-key"
const ArchivePort int32 = 8090
func archiveKeyEnv() corev1.EnvVar {
return corev1.EnvVar{Name: archivetransfer.KeyEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: ArchiveSecret}, Key: "key"}}}
}
func distributedEnv(p Params) []corev1.EnvVar {
return []corev1.EnvVar{{Name: "FELIS_DISTRIBUTED", Value: "true"}, {Name: "FELIS_CONTROLLER_NODE", Value: p.ControllerNode}, {Name: "FELIS_ARCHIVE_URL", Value: fmt.Sprintf("http://%s.%s.svc:%d", ArchiveName, p.MinecraftNamespace, ArchivePort)}, {Name: "FELIS_EGRESS_PROBE", Value: p.EgressProbe}, archiveKeyEnv()}
}
func ArchiveDeployment(p Params) *appsv1.Deployment {
labels := map[string]string{"app.kubernetes.io/name": ArchiveName}
container := corev1.Container{Name: ArchiveName, Image: p.FelisImage, Command: []string{felisBinaryPath, "archive-serve"}, Args: []string{"--root", p.ArchiveLocalPath}, Env: []corev1.EnvVar{archiveKeyEnv()}, Ports: []corev1.ContainerPort{{Name: "archive", ContainerPort: ArchivePort}}, Resources: controlPlaneResources(), SecurityContext: &corev1.SecurityContext{RunAsUser: int64Ptr(0), RunAsNonRoot: boolPtr(false), AllowPrivilegeEscalation: boolPtr(false), Privileged: boolPtr(false), ReadOnlyRootFilesystem: boolPtr(true), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}, VolumeMounts: []corev1.VolumeMount{{Name: "archives", MountPath: p.ArchiveLocalPath}}}
container.ReadinessProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstr.FromString("archive")}}}
container.LivenessProbe = container.ReadinessProbe.DeepCopy()
return &appsv1.Deployment{TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, ObjectMeta: metav1.ObjectMeta{Name: ArchiveName, Namespace: p.MinecraftNamespace, Labels: labels}, Spec: appsv1.DeploymentSpec{Replicas: int32Ptr(1), Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, Selector: &metav1.LabelSelector{MatchLabels: labels}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{NodeSelector: controllerSelector(p), AutomountServiceAccountToken: boolPtr(false), SecurityContext: &corev1.PodSecurityContext{SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}}, Containers: []corev1.Container{container}, Volumes: []corev1.Volume{{Name: "archives", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: p.BackupPVC}}}}}}}}
}
func ArchiveService(p Params) *corev1.Service {
return &corev1.Service{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, ObjectMeta: metav1.ObjectMeta{Name: ArchiveName, Namespace: p.MinecraftNamespace}, Spec: corev1.ServiceSpec{Selector: map[string]string{"app.kubernetes.io/name": ArchiveName}, Ports: []corev1.ServicePort{{Name: "archive", Port: ArchivePort, TargetPort: intstr.FromInt32(ArchivePort)}}}}
}
// Cluster grants are read-only and bound only to A's API/operator/reaper identities.
func DistributedRBAC(p Params) []Object {
var out []Object
for _, entry := range []struct {
name, ns string
rules []rbacv1.PolicyRule
}{
{SAAPI, p.ControlNamespace, []rbacv1.PolicyRule{rule([]string{groupCore}, []string{"nodes"}, []string{"get", "list"}), rule([]string{groupCore}, []string{"persistentvolumes"}, []string{"get"})}},
{SAOperator, p.ControlNamespace, []rbacv1.PolicyRule{rule([]string{groupCore}, []string{"nodes"}, []string{"get"})}},
{SAReaper, p.MinecraftNamespace, []rbacv1.PolicyRule{rule([]string{groupCore}, []string{"nodes"}, []string{"get"})}},
} {
name := entry.name + "-" + p.MinecraftNamespace + "-nodes"
out = append(out, &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, ObjectMeta: metav1.ObjectMeta{Name: name}, Rules: entry.rules}, &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRoleBinding"}, ObjectMeta: metav1.ObjectMeta{Name: name}, RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: name}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: entry.name, Namespace: entry.ns}}})
}
return out
}
func ArchiveNetworkPolicies(p Params) []Object {
tcp, udp := corev1.ProtocolTCP, corev1.ProtocolUDP
archive := metav1.LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/name": ArchiveName}}
job := metav1.LabelSelector{MatchLabels: map[string]string{distributed.LabelTransfer: "true"}}
control := networkingv1.NetworkPolicyPeer{NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.ControlNamespace}}, PodSelector: &metav1.LabelSelector{MatchLabels: controlPlanePodLabels(ComponentAPI)}}
reaper := networkingv1.NetworkPolicyPeer{PodSelector: &metav1.LabelSelector{MatchLabels: controlPlanePodLabels(ComponentReaper)}}
peers := []networkingv1.NetworkPolicyPeer{control, reaper, {PodSelector: &job}}
ingress := &networkingv1.NetworkPolicy{TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "felis-archive", Namespace: p.MinecraftNamespace}, Spec: networkingv1.NetworkPolicySpec{PodSelector: archive, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress}, Ingress: []networkingv1.NetworkPolicyIngressRule{{From: peers, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: portPtr(ArchivePort)}}}}}}
egress := &networkingv1.NetworkPolicy{TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "felis-archive-jobs", Namespace: p.MinecraftNamespace}, Spec: networkingv1.NetworkPolicySpec{PodSelector: job, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeEgress}, Egress: []networkingv1.NetworkPolicyEgressRule{
{To: []networkingv1.NetworkPolicyPeer{{PodSelector: &archive}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: portPtr(ArchivePort)}}},
{To: []networkingv1.NetworkPolicyPeer{{NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "kube-system"}}, PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"k8s-app": "kube-dns"}}}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &udp, Port: portPtr(53)}, {Protocol: &tcp, Port: portPtr(53)}}},
}}}
// Files/export maintenance Pods may send results only to A's existing upload receiver.
// Transfer Pods are excluded so grants remain limited to the archive service.
maintenance := &networkingv1.NetworkPolicy{TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "felis-maintenance-egress", Namespace: p.MinecraftNamespace}, Spec: networkingv1.NetworkPolicySpec{
PodSelector: metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "app.kubernetes.io/managed-by", Operator: metav1.LabelSelectorOpIn, Values: []string{"felis-files", "felis-export", "felis-restore", "felis-backup"}},
{Key: distributed.LabelTransfer, Operator: metav1.LabelSelectorOpDoesNotExist},
}}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeEgress},
Egress: []networkingv1.NetworkPolicyEgressRule{
{To: []networkingv1.NetworkPolicyPeer{control}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: portPtr(8081)}}},
egress.Spec.Egress[1],
},
}}
return []Object{ingress, egress, maintenance}
}
func portPtr(p int32) *intstr.IntOrString { v := intstr.FromInt32(p); return &v }
+61
View File
@@ -0,0 +1,61 @@
package platform
import (
"testing"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/placement"
appsv1 "k8s.io/api/apps/v1"
rbacv1 "k8s.io/api/rbac/v1"
)
func TestDistributedControllerPinningAndArchivePowers(t *testing.T) {
p := testParams()
p.Distributed = true
p.ControllerNode = "a"
p.EgressProbe = "felis-api.felis.svc:443"
p.BackupPVC = "felis-backups"
p.ArchiveLocalPath = "/backups"
p.VelocityCIDRs = []string{"192.0.2.1/32"}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
found := false
for _, obj := range Objects(p) {
if d, ok := obj.(*appsv1.Deployment); ok {
if d.Spec.Template.Spec.NodeSelector[placement.LabelIdentity] != "a" {
t.Fatalf("controller workload %s may run on worker", d.Name)
}
if d.Name == ArchiveName {
found = true
s := d.Spec.Template.Spec
if s.AutomountServiceAccountToken == nil || *s.AutomountServiceAccountToken || s.ServiceAccountName != "" || len(s.Volumes) != 1 || s.Volumes[0].PersistentVolumeClaim.ClaimName != p.BackupPVC {
t.Fatal("archive has extra powers")
}
for _, c := range s.Containers {
for _, e := range c.Env {
if e.Name != archivetransfer.KeyEnv {
t.Fatal("archive received controller config")
}
}
}
}
}
if r, ok := obj.(*rbacv1.ClusterRole); ok {
for _, rule := range r.Rules {
for _, verb := range rule.Verbs {
if verb != "get" && verb != "list" {
t.Fatal("distributed cluster grant writes", r.Name, verb)
}
}
}
}
}
if !found {
t.Fatal("archive service missing")
}
p.VelocityCIDRs = []string{"10.0.0.0/8"}
if p.Validate() == nil {
t.Fatal("broad Velocity source accepted")
}
}
+24
View File
@@ -2,6 +2,7 @@ package platform
import ( import (
"fmt" "fmt"
"net"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
) )
@@ -89,6 +90,10 @@ const (
// Params parameterises the install bundle. Namespaces and the registry location // Params parameterises the install bundle. Namespaces and the registry location
// have safe defaults; VelocityCIDRs has none — see the field comment. // have safe defaults; VelocityCIDRs has none — see the field comment.
type Params struct { type Params struct {
Distributed bool
ControllerNode string
EgressProbe string
RegistryNodeCIDRs []string
// ControlNamespace is where felis-api/operator run; their SAs live here and the // ControlNamespace is where felis-api/operator run; their SAs live here and the
// RoleBindings' subjects reference them here, even though the Roles they bind to // RoleBindings' subjects reference them here, even though the Roles they bind to
// live in the minecraft (and build) namespaces. The reaper alone runs — CronJob // live in the minecraft (and build) namespaces. The reaper alone runs — CronJob
@@ -212,6 +217,25 @@ type Params struct {
// Validate reports a Params the renderer cannot turn into objects. // Validate reports a Params the renderer cannot turn into objects.
func (p Params) Validate() error { func (p Params) Validate() error {
if p.Distributed && (p.ControllerNode == "" || p.EgressProbe == "" || p.BackupPVC == "" || p.ArchiveLocalPath == "") {
return fmt.Errorf("distributed mode requires controller node, egress probe, backup PVC and archive path")
}
if p.Distributed && len(p.ServerEgressAllowCIDRs) > 0 {
return fmt.Errorf("distributed mode does not permit private game egress exceptions")
}
if p.Distributed {
for _, cidr := range append(append([]string{}, p.VelocityCIDRs...), p.RegistryNodeCIDRs...) {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("invalid node source %q", cidr)
}
ones, bits := network.Mask.Size()
if ones != bits {
return fmt.Errorf("distributed node sources must be exact /32 or /128 addresses: %q", cidr)
}
}
}
for _, q := range []struct{ name, v string }{ for _, q := range []struct{ name, v string }{
{"registry storage", p.RegistryStorage}, {"registry storage", p.RegistryStorage},
{"uploads storage", p.UploadsStorage}, {"uploads storage", p.UploadsStorage},
+3
View File
@@ -266,6 +266,9 @@ func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy {
Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
}}, }},
) )
for _, cidr := range p.RegistryNodeCIDRs {
np.Spec.Ingress[0].From = append(np.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
return np return np
} }
+1
View File
@@ -206,6 +206,7 @@ func postgresDeployment(p Params) *appsv1.Deployment {
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels}, ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
NodeSelector: controllerSelector(p),
AutomountServiceAccountToken: boolPtr(false), AutomountServiceAccountToken: boolPtr(false),
EnableServiceLinks: boolPtr(false), EnableServiceLinks: boolPtr(false),
PriorityClassName: controlPlanePriorityName, PriorityClassName: controlPlanePriorityName,
+13 -5
View File
@@ -19,7 +19,7 @@ var groupFelis = v1alpha1.GroupName // "felis.lolicon.best"
// RBAC is the control-plane authorization bundle: one SA per identity and the // RBAC is the control-plane authorization bundle: one SA per identity and the
// namespaced Roles + RoleBindings that grant each exactly the verbs its code path // namespaced Roles + RoleBindings that grant each exactly the verbs its code path
// exercises. There is deliberately no ClusterRole or ClusterRoleBinding anywhere. // exercises. DistributedRBAC adds read-only node/PV grants when distributed mode is enabled.
type RBAC struct { type RBAC struct {
ServiceAccounts []*corev1.ServiceAccount ServiceAccounts []*corev1.ServiceAccount
Roles []*rbacv1.Role Roles []*rbacv1.Role
@@ -100,7 +100,7 @@ func ControlPlaneRBAC(p Params) RBAC {
// than its logs). // than its logs).
func APIMinecraftRole(p Params) *rbacv1.Role { func APIMinecraftRole(p Params) *rbacv1.Role {
p = p.withDefaults() p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, []rbacv1.PolicyRule{ rules := []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}), rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}),
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}), rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
// get-only: WorldVolumeExists does a single direct Get of the world PVC; // get-only: WorldVolumeExists does a single direct Get of the world PVC;
@@ -116,7 +116,11 @@ func APIMinecraftRole(p Params) *rbacv1.Role {
// the verbs stay tight — list on pods, get on pods/log, and nothing else. // the verbs stay tight — list on pods, get on pods/log, and nothing else.
rule([]string{groupCore}, []string{"pods"}, []string{"list"}), rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}), rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
}) }
if p.Distributed {
rules = append(rules, rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"create", "list"}), rule([]string{groupApps}, []string{"statefulsets"}, []string{"get", "delete"}))
}
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, rules)
} }
// APIBuildRole grants felis-api the build-Job lifecycle in the build namespace // APIBuildRole grants felis-api the build-Job lifecycle in the build namespace
@@ -218,12 +222,16 @@ func OperatorRole(p Params) *rbacv1.Role {
// request and never deletes a CR itself. // request and never deletes a CR itself.
func ReaperRole(p Params) *rbacv1.Role { func ReaperRole(p Params) *rbacv1.Role {
p = p.withDefaults() p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{ rules := []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch", "delete"}), rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch", "delete"}),
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}), rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}),
rule([]string{groupCore}, []string{"pods"}, []string{"list"}), rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupBatch}, []string{"jobs"}, []string{"list"}), rule([]string{groupBatch}, []string{"jobs"}, []string{"list"}),
}) }
if p.Distributed {
rules = append(rules, rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get"}), rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"list"}))
}
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, rules)
} }
// controlPlaneServiceAccount renders a control-plane SA. Unlike the weak // controlPlaneServiceAccount renders a control-plane SA. Unlike the weak
+32 -3
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -273,11 +274,16 @@ func Workloads(p Params) []Object {
objs = append(objs, backupPVC(p)) objs = append(objs, backupPVC(p))
} }
switch { switch {
case p.Distributed && retentionEnabled(p):
objs = append(objs, reaperCronJob(p))
case reaperEnabled(p): case reaperEnabled(p):
objs = append(objs, worldsRootPV(p), worldsRootPVC(p), reaperCronJob(p)) objs = append(objs, worldsRootPV(p), worldsRootPVC(p), reaperCronJob(p))
case retentionEnabled(p): case retentionEnabled(p):
objs = append(objs, reaperCronJob(p)) objs = append(objs, reaperCronJob(p))
} }
if p.Distributed {
objs = append(objs, ArchiveDeployment(p), ArchiveService(p))
}
return objs return objs
} }
@@ -315,7 +321,7 @@ const controlPlanePriorityName = "system-cluster-critical"
// together so a partial configuration fails loudly rather than silently dropping // together so a partial configuration fails loudly rather than silently dropping
// retention here. // retention here.
func reaperEnabled(p Params) bool { func reaperEnabled(p Params) bool {
return p.WorldsHostPath != "" && retentionEnabled(p) return (p.WorldsHostPath != "" || p.Distributed) && retentionEnabled(p)
} }
// retentionEnabled reports whether the archive store can be looked after: the // retentionEnabled reports whether the archive store can be looked after: the
@@ -716,6 +722,11 @@ func reaperCronJob(p Params) *batchv1.CronJob {
}) })
} }
if p.Distributed {
container.Args = []string{"--config", configFilePath}
container.Env = append(container.Env, distributedEnv(p)...)
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_IMAGE", Value: p.FelisImage})
}
return &batchv1.CronJob{ return &batchv1.CronJob{
TypeMeta: metav1.TypeMeta{APIVersion: "batch/v1", Kind: "CronJob"}, TypeMeta: metav1.TypeMeta{APIVersion: "batch/v1", Kind: "CronJob"},
// The CronJob lives in the MINECRAFT namespace: a Pod can only mount PVCs // The CronJob lives in the MINECRAFT namespace: a Pod can only mount PVCs
@@ -844,10 +855,12 @@ func reaperPodSpec(p Params, container corev1.Container, volumes []corev1.Volume
Containers: []corev1.Container{container}, Containers: []corev1.Container{container},
Volumes: volumes, Volumes: volumes,
} }
if p.ReaperNode != "" { if p.ControllerNode != "" {
spec.NodeSelector = controllerSelector(p)
} else if p.ReaperNode != "" {
spec.NodeSelector = map[string]string{"kubernetes.io/hostname": p.ReaperNode} spec.NodeSelector = map[string]string{"kubernetes.io/hostname": p.ReaperNode}
} }
if p.WorldsHostPath != "" { if p.WorldsHostPath != "" || p.Distributed {
spec.ServiceAccountName = SAReaper spec.ServiceAccountName = SAReaper
} else { } else {
spec.ServiceAccountName = "default" spec.ServiceAccountName = "default"
@@ -868,6 +881,13 @@ func reaperPodSpec(p Params, container corev1.Container, volumes []corev1.Volume
// Recreate guarantees the old pod is gone before the new one starts. // Recreate guarantees the old pod is gone before the new one starts.
func controlPlaneDeployment(p Params, sa string, container corev1.Container, volumes []corev1.Volume) *appsv1.Deployment { func controlPlaneDeployment(p Params, sa string, container corev1.Container, volumes []corev1.Volume) *appsv1.Deployment {
labels := controlPlanePodLabels(container.Name) labels := controlPlanePodLabels(container.Name)
if p.Distributed {
if sa == SAOperator {
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_DISTRIBUTED", Value: "true"}, corev1.EnvVar{Name: "FELIS_CONTROLLER_NODE", Value: p.ControllerNode}, corev1.EnvVar{Name: "FELIS_EGRESS_PROBE", Value: p.EgressProbe})
} else {
container.Env = append(container.Env, distributedEnv(p)...)
}
}
return &appsv1.Deployment{ return &appsv1.Deployment{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
ObjectMeta: metav1.ObjectMeta{Name: sa, Namespace: p.ControlNamespace, Labels: labels}, ObjectMeta: metav1.ObjectMeta{Name: sa, Namespace: p.ControlNamespace, Labels: labels},
@@ -878,6 +898,7 @@ func controlPlaneDeployment(p Params, sa string, container corev1.Container, vol
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels}, ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
NodeSelector: controllerSelector(p),
ServiceAccountName: sa, ServiceAccountName: sa,
PriorityClassName: controlPlanePriorityName, PriorityClassName: controlPlanePriorityName,
SecurityContext: hardenedPodSecurityContext(), SecurityContext: hardenedPodSecurityContext(),
@@ -1003,6 +1024,7 @@ func registryDeployment(p Params) *appsv1.Deployment {
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels}, ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
NodeSelector: controllerSelector(p),
AutomountServiceAccountToken: boolPtr(false), AutomountServiceAccountToken: boolPtr(false),
PriorityClassName: controlPlanePriorityName, PriorityClassName: controlPlanePriorityName,
SecurityContext: hardenedPodSecurityContext(), SecurityContext: hardenedPodSecurityContext(),
@@ -1373,3 +1395,10 @@ func hardenedContainerSecurityContext() *corev1.SecurityContext {
func boolPtr(b bool) *bool { return &b } func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i } func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i } func int64Ptr(i int64) *int64 { return &i }
func controllerSelector(p Params) map[string]string {
if p.ControllerNode == "" {
return nil
}
return map[string]string{placement.LabelIdentity: p.ControllerNode, placement.LabelRole: placement.RoleController}
}
+14 -1
View File
@@ -42,6 +42,7 @@ type K8sCluster struct {
// beat is how often a held lock is rewritten; maintenance.Grace/4 unless a // beat is how often a held lock is rewritten; maintenance.Grace/4 unless a
// test shortens it. // test shortens it.
beat time.Duration beat time.Duration
distributed bool
} }
// NewK8sCluster builds a Cluster over c, scoped to namespace. // NewK8sCluster builds a Cluster over c, scoped to namespace.
@@ -49,6 +50,18 @@ func NewK8sCluster(c client.Client, namespace string) *K8sCluster {
return &K8sCluster{c: c, namespace: namespace} return &K8sCluster{c: c, namespace: namespace}
} }
func (k *K8sCluster) WithDistributed(enabled bool) *K8sCluster { k.distributed = enabled; return k }
func (k *K8sCluster) RetainedWorlds(ctx context.Context, name string) (bool, error) {
if !k.distributed {
return false, nil
}
var pvcs corev1.PersistentVolumeClaimList
if err := k.c.List(ctx, &pvcs, client.InNamespace(k.namespace), client.MatchingLabels{maintenance.LabelServer: name}); err != nil {
return false, err
}
return len(pvcs.Items) > 0, nil
}
func (k *K8sCluster) clock() time.Time { func (k *K8sCluster) clock() time.Time {
if k.now != nil { if k.now != nil {
return k.now() return k.now()
@@ -61,7 +74,7 @@ func (k *K8sCluster) Inspect(ctx context.Context, name string) (ServerCRD, error
if err := k.get(ctx, name, &ms); err != nil { if err := k.get(ctx, name, &ms); err != nil {
return ServerCRD{}, err return ServerCRD{}, err
} }
return ServerCRD{Exempt: ms.Spec.ReaperExempt, PVC: WorldPVCName(name), UID: string(ms.UID)}, nil return ServerCRD{Exempt: ms.Spec.ReaperExempt, PVC: ms.WorldPVC(), UID: string(ms.UID)}, nil
} }
// HoldWorld implements Cluster. The lock is the same Annotation felis-api // HoldWorld implements Cluster. The lock is the same Annotation felis-api
+1 -1
View File
@@ -76,7 +76,7 @@ func (s *PGStore) FreshBackup(ctx context.Context, server string, since time.Tim
func (s *PGStore) InsertBackup(ctx context.Context, rec BackupRecord) error { func (s *PGStore) InsertBackup(ctx context.Context, rec BackupRecord) error {
const q = `INSERT INTO world_backups const q = `INSERT INTO world_backups
(id, server_name, former_owner, backup_ref, size_bytes, reason, status, created_at, expires_at, sha256, skipped_entries) (id, server_name, former_owner, backup_ref, size_bytes, reason, status, created_at, expires_at, sha256, skipped_entries)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, 'present', now(), $7, NULLIF($8, ''), $9)` VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, 'present', now(), $7, NULLIF($8, ''), $9) ON CONFLICT (id) DO NOTHING`
_, err := s.db.ExecContext(ctx, q, _, err := s.db.ExecContext(ctx, q,
rec.ID, rec.ServerName, rec.FormerOwner, rec.BackupRef, rec.SizeBytes, rec.Reason, rec.ExpiresAt, rec.ID, rec.ServerName, rec.FormerOwner, rec.BackupRef, rec.SizeBytes, rec.Reason, rec.ExpiresAt,
rec.SHA256, rec.SkippedEntries) rec.SHA256, rec.SkippedEntries)
+11
View File
@@ -734,6 +734,17 @@ func (r *Reaper) retire(ctx context.Context, now time.Time, c Candidate, crd Ser
// MinecraftServer the reaper cannot hold it still to archive it: an operator // MinecraftServer the reaper cannot hold it still to archive it: an operator
// takes it from there, and the run reports the server until then. // takes it from there, and the run reports the server until then.
func (r *Reaper) forgetServer(ctx context.Context, now time.Time, c Candidate, sum *Summary) error { func (r *Reaper) forgetServer(ctx context.Context, now time.Time, c Candidate, sum *Summary) error {
if cluster, ok := r.Cluster.(interface {
RetainedWorlds(context.Context, string) (bool, error)
}); ok {
retained, err := cluster.RetainedWorlds(ctx, c.Name)
if err != nil {
return err
}
if retained {
return fmt.Errorf("retained world volumes require explicit administrator cleanup")
}
}
pvc := WorldPVCName(c.Name) pvc := WorldPVCName(c.Name)
exists, err := r.Cluster.WorldExists(ctx, pvc) exists, err := r.Cluster.WorldExists(ctx, pvc)
if err != nil { if err != nil {
+15 -1
View File
@@ -1,7 +1,9 @@
package restore package restore
import ( import (
"felis.lolicon.best/internal/archivetransfer"
"fmt" "fmt"
"strconv"
"time" "time"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
@@ -34,6 +36,8 @@ const (
// archive ref + Config by the Restorer. jobspec is a pure function of them so // 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. // the security-critical Job shape is unit-tested without a cluster.
type JobParams struct { type JobParams struct {
SourceURL, Token, SHA256 string
MaxBytes int64
Server string Server string
WorldPVC string WorldPVC string
BackupPVC string BackupPVC string
@@ -92,7 +96,7 @@ func RestoreJob(p JobParams) (*batchv1.Job, error) {
if p.Image == "" { if p.Image == "" {
return nil, fmt.Errorf("restore: image is empty") 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") return nil, fmt.Errorf("restore: world and backup PVC names are required")
} }
limits, err := resourceLimits(p.CPULimit, p.MemLimit) 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 return job, nil
} }
+11 -1
View File
@@ -32,6 +32,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
) )
// ErrAlreadyExists is returned by a Jobs implementation when a restore Job for a // 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 // either is empty the caller leaves the API's Restorer nil so the endpoint
// reports 503 rather than enqueuing a Job that cannot run. // reports 503 rather than enqueuing a Job that cannot run.
type Config struct { type Config struct {
ResolveWorld placement.Resolver
// Namespace is where the world PVCs live and the restore Job runs (the // 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 // minecraft namespace). The Job is intentionally co-located with the world it
// restores; it never runs in the felis control-plane namespace. // 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 // 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. // duplicate, and not a false "restoring" for a retry after a failure.
func (r *Restorer) Restore(ctx context.Context, serverName, backupRef string) error { 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) { if errors.Is(err, ErrAlreadyExists) {
return nil // already enqueued — idempotent return nil // already enqueued — idempotent
} }
+10
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"felis.lolicon.best/internal/placement"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
@@ -57,6 +58,7 @@ const (
// JobParams are the rendered inputs to an export Job. ExportJob is a pure // 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. // function of them, so the Job shape is unit-tested without a cluster.
type JobParams struct { type JobParams struct {
NodeName string
Server string Server string
// ID names this export: it is the tail of the Job name and of the internal // 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. // upload path, so two exports of one server never collide.
@@ -227,6 +229,7 @@ func ExportJob(p JobParams) (*batchv1.Job, error) {
ObjectMeta: metav1.ObjectMeta{Labels: exportLabels(p)}, ObjectMeta: metav1.ObjectMeta{Labels: exportLabels(p)},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever, RestartPolicy: corev1.RestartPolicyNever,
NodeSelector: jobNodeSelector(p.NodeName),
ServiceAccountName: p.ServiceAccount, ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false), AutomountServiceAccountToken: boolPtr(false),
SecurityContext: sc, SecurityContext: sc,
@@ -269,3 +272,10 @@ func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
func boolPtr(b bool) *bool { return &b } func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i } func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i } func int64Ptr(i int64) *int64 { return &i }
func jobNodeSelector(name string) map[string]string {
if name == "" {
return nil
}
return map[string]string{placement.LabelIdentity: name}
}
+13 -1
View File
@@ -20,6 +20,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/placement"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -47,6 +48,7 @@ type Request struct {
// cmd/felis leaves the API's Exporter nil when either is missing, and the // cmd/felis leaves the API's Exporter nil when either is missing, and the
// routes answer 503. // routes answer 503.
type Config struct { type Config struct {
ResolveWorld placement.Resolver
Namespace string Namespace string
ServiceAccount string ServiceAccount string
Image string Image string
@@ -112,9 +114,19 @@ func New(cs kubernetes.Interface, cfg Config) *Exporter {
// Start creates the export Job for r and returns its name. // Start creates the export Job for r and returns its name.
func (e *Exporter) Start(ctx context.Context, r Request) (string, error) { func (e *Exporter) Start(ctx context.Context, r Request) (string, error) {
c := e.cfg 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{ job, err := ExportJob(JobParams{
Server: r.Server, ID: r.ID, Mode: r.Mode, Server: r.Server, ID: r.ID, Mode: r.Mode,
WorldPVC: naming.WorldPVCName(r.Server), BackupPVC: c.BackupPVC, BackupRef: r.BackupRef, NodeName: node,
WorldPVC: claim, BackupPVC: c.BackupPVC, BackupRef: r.BackupRef,
BackupSHA256: r.BackupSHA256, Path: r.Path, Dir: r.Dir, BackupSHA256: r.BackupSHA256, Path: r.Path, Dir: r.Dir,
TargetURL: r.TargetURL, Token: r.Token, TargetURL: r.TargetURL, Token: r.Token,
Namespace: c.Namespace, ServiceAccount: c.ServiceAccount, Image: c.Image, Namespace: c.Namespace, ServiceAccount: c.ServiceAccount, Image: c.Image,
@@ -6,7 +6,7 @@ import { CreateServerDialog } from "./CreateServerDialog";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
import type { CreateServerRequest, WhitelistImage } from "@/lib/types"; import type { CreateServerRequest, WhitelistImage } from "@/lib/types";
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() })); const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn(), nodes: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => { vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>(); const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } }; return { ...actual, api: { ...actual.api, ...calls } };
@@ -258,3 +258,25 @@ describe("CreateServerDialog on reopening", () => {
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull();
}); });
}); });
describe("distributed placement", () => {
it("requires an online approved worker and sends its name", async () => {
calls.nodes.mockResolvedValue([
{name:"b",role:"worker",ready:true,approved:true},
{name:"c",role:"worker",ready:false,approved:true},
{name:"a",role:"controller",ready:true,approved:true},
]);
const user = userEvent.setup();
render(<CreateServerDialog cfg={{...cfg,distributed:true}} onCreated={onCreated} />);
await user.click(screen.getByRole("button",{name:"New server"}));
await fillValid(user);
expect(create().disabled).toBe(true);
await user.click(screen.getByRole("combobox",{name:"Execution node"}));
expect(screen.queryByRole("option",{name:"c"})).toBeNull();
expect(screen.queryByRole("option",{name:"a"})).toBeNull();
await user.click(await screen.findByRole("option",{name:"b"}));
await user.click(create());
expect(sent().nodeName).toBe("b");
});
});
@@ -56,6 +56,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
// or retired since the page loaded shows up or drops out without a reload. // or retired since the page loaded shows up or drops out without a reload.
const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]); const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]);
const nodes = useAsync(() => (open && cfg.distributed ? api.nodes() : Promise.resolve(null)), [open, cfg.distributed]);
const workers = (nodes.data ?? []).filter((n) => n.role === "worker" && n.ready && n.approved);
const [form, setForm] = useState<CreateServerRequest>({ const [form, setForm] = useState<CreateServerRequest>({
name: "", name: "",
subdomain: "", subdomain: "",
@@ -89,6 +92,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
nameIssue === null && nameIssue === null &&
subdomainIssue === null && subdomainIssue === null &&
!!image && !!image &&
(!cfg.distributed || workers.some((n) => n.name === form.nodeName)) &&
!!form.memory && !!form.memory &&
!!form.storage && !!form.storage &&
!submitting; !submitting;
@@ -216,6 +220,18 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
)} )}
</div> </div>
{cfg.distributed && (
<div className="grid gap-2">
<Label htmlFor="cs-node">{t("node")}</Label>
<Select value={form.nodeName ?? ""} onValueChange={(v) => set("nodeName", v)}>
<SelectTrigger id="cs-node"><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
</Select>
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
{!nodes.loading && !nodes.error && workers.length === 0 && <p className="text-xs text-muted-foreground">{t("node_none")}</p>}
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="create-server-memory">{t("create_server_memory")}</Label> <Label htmlFor="create-server-memory">{t("create_server_memory")}</Label>
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MigrationDialog } from "./DistributedNodes";
const calls = vi.hoisted(() => ({ nodes: vi.fn(), migration: vi.fn(), retryMigration: vi.fn(), migrateServer: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
beforeEach(() => {
vi.clearAllMocks();
calls.nodes.mockResolvedValue([{ name: "c", role: "worker", ready: true, approved: true }]);
calls.migration.mockResolvedValue({ id: "persisted-op", state: "failed", sourceNode: "b", targetNode: "c", error: "source node is offline" });
calls.retryMigration.mockResolvedValue({ id: "persisted-op", state: "backing_up" });
});
describe("durable migration", () => {
it("loads the persisted operation on opening and retries its ID", async () => {
const user = userEvent.setup();
const changed = vi.fn();
render(<MigrationDialog name="survival" nodeName="b" stopped onChanged={changed} />);
await user.click(screen.getByRole("button", { name: "Migrate world" }));
await screen.findByText("source node is offline");
expect(calls.migration).toHaveBeenCalledWith("survival");
expect(screen.getByText("persisted-op")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(calls.retryMigration).toHaveBeenCalledWith("survival", "persisted-op"));
expect(changed).toHaveBeenCalled();
expect(calls.migrateServer).not.toHaveBeenCalled();
});
it("keeps retry disabled until the server is stopped", async () => {
const user = userEvent.setup();
render(<MigrationDialog name="survival" nodeName="b" stopped={false} onChanged={() => {}} />);
await user.click(screen.getByRole("button", { name: "Migrate world" }));
const retry = await screen.findByRole("button", { name: "Try again" });
expect((retry as HTMLButtonElement).disabled).toBe(true);
await user.click(retry);
expect(calls.retryMigration).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api, humanizeError } from "@/lib/api";
import { useAsync, usePolling } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { InlineError } from "@/components/MessageLine";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
export function DistributedNodes() {
const { t } = useTranslation("servers");
const nodes = useAsync(api.nodes);
usePolling(nodes.reload, 10_000);
return (
<div className="rounded-lg border p-4 space-y-2">
<h2 className="font-medium">{t("nodes")}</h2>
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
<ul className="space-y-1 text-sm">{nodes.data?.map((n) => (
<li key={n.name} className="flex flex-wrap gap-3">
<code>{n.name}</code><span>{n.role || t("node_pending")}</span>
<span>{n.ready ? t("node_online") : t("node_offline")}</span>
<span>{n.approved ? t("node_approved") : t("node_pending")}</span>
<span className="text-muted-foreground">{n.architecture} · {n.addresses.join(", ")}</span>
</li>
))}</ul>
</div>
);
}
export function MigrationDialog({ name, nodeName, stopped, onChanged }: {
name: string; nodeName?: string; stopped: boolean; onChanged: () => void;
}) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const nodes = useAsync(() => open ? api.nodes() : Promise.resolve(null), [open]);
const progress = useAsync(async () => {
if (!open) return null;
try { return await api.migration(name); }
catch (e) { if ((e as { status?: number }).status === 404) return null; throw e; }
}, [open, name]);
usePolling(() => { if (open) progress.reload(); }, 3_000);
const op = progress.data;
const active = !!op && op.state !== "succeeded" && op.state !== "failed";
const workers = (nodes.data ?? []).filter((n) => n.ready && n.approved && n.role === "worker" && n.name !== nodeName);
async function run(retry: boolean) {
setBusy(true); setError(null);
try {
if (retry && op) await api.retryMigration(name, op.id);
else await api.migrateServer(name, target);
progress.reload(); onChanged();
} catch (e) { setError(humanizeError(e)); }
finally { setBusy(false); }
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm" variant="outline">{t("migration")}</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t("migration")}</DialogTitle><DialogDescription>{t("migration_hint")}</DialogDescription></DialogHeader>
{nodeName && <p className="text-sm">{t("node")}: {nodeName}</p>}
{op && <div className="space-y-1 text-sm" role="status">
<p>{op.sourceNode} → {op.targetNode}</p>
<p>{t(`migration_state_${op.state}`, { defaultValue: op.state })}</p>
<code className="text-xs break-all">{op.id}</code>
{op.error && <InlineError message={op.error} />}
</div>}
{!!progress.error && <InlineError message={humanizeError(progress.error)} />}
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
<Label htmlFor={`target-${name}`}>{t("node_choose")}</Label>
<Select value={target} onValueChange={setTarget} disabled={active || busy || op?.state === "failed"}>
<SelectTrigger id={`target-${name}`}><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
</Select>
{!stopped && <p className="text-sm text-muted-foreground">{t("migration_stop")}</p>}
<InlineError message={error} />
{op?.state === "failed" ? (
<Button onClick={() => run(true)} disabled={!stopped || busy}>{t("common:try_again")}</Button>
) : (
<Button onClick={() => run(false)} disabled={!stopped || active || busy || progress.loading || !!progress.error || !workers.some((n) => n.name === target)}>{t("migration_start")}</Button>
)}
</DialogContent>
</Dialog>
);
}
+18 -1
View File
@@ -251,5 +251,22 @@
"retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.", "retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.",
"retiring_badge_release": "Given up", "retiring_badge_release": "Given up",
"retiring_badge_delete": "Deleting", "retiring_badge_delete": "Deleting",
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then." "retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then.",
"nodes": "Execution nodes",
"node": "Execution node",
"node_choose": "Choose an approved node",
"node_none": "No online approved worker nodes",
"node_online": "Online",
"node_offline": "Offline",
"node_approved": "Approved",
"node_pending": "Pending approval",
"migration": "Migrate world",
"migration_hint": "The server stays stopped after migration. The source volume is retained; check the world before starting it.",
"migration_stop": "Stop the server and wait for the game process to exit first.",
"migration_start": "Start migration",
"migration_state_backing_up": "Archiving source world",
"migration_state_restoring": "Restoring and verifying target world",
"migration_state_switching": "Switching active volume",
"migration_state_succeeded": "Migration complete; start manually after inspection",
"migration_state_failed": "Migration failed; server remains stopped"
} }
+18 -1
View File
@@ -250,5 +250,22 @@
"retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。", "retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。",
"retiring_badge_release": "等待回收", "retiring_badge_release": "等待回收",
"retiring_badge_delete": "等待删除", "retiring_badge_delete": "等待删除",
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。" "retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。",
"nodes": "执行节点",
"node": "执行位置",
"node_choose": "选择已批准的节点",
"node_none": "没有在线且已批准的执行节点",
"node_online": "在线",
"node_offline": "失联",
"node_approved": "已批准",
"node_pending": "待批准",
"migration": "停服迁移",
"migration_hint": "迁移完成后仍保持停服。源卷保留,请检查后手动启动。",
"migration_stop": "请先停服并等待游戏进程退出。",
"migration_start": "开始迁移",
"migration_state_backing_up": "正在归档源卷",
"migration_state_restoring": "正在恢复并校验目标卷",
"migration_state_switching": "正在切换活动卷",
"migration_state_succeeded": "迁移完成,等待手动启动",
"migration_state_failed": "迁移失败,服务器保持停服"
} }
+9
View File
@@ -1,5 +1,7 @@
import type { import type {
AccessResult, AccessResult,
ExecutionNode,
WorldMigration,
AllowlistEntry, AllowlistEntry,
ApiError, ApiError,
AutostartPolicy, AutostartPolicy,
@@ -594,6 +596,13 @@ export const api = rejectingSync({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, },
nodes: () => request<{ nodes: ExecutionNode[] }>("GET", "/nodes").then((r) => r.nodes),
migration: (name: string) => request<WorldMigration>("GET", `/servers/${encodeURIComponent(name)}/migrations`),
migrateServer: (name: string, targetNode: string) =>
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations`, { targetNode }),
retryMigration: (name: string, id: string) =>
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations/${encodeURIComponent(id)}/retry`),
createServer: (req: CreateServerRequest) => createServer: (req: CreateServerRequest) =>
request<{ name: string; subdomain: string; desiredState: string }>( request<{ name: string; subdomain: string; desiredState: string }>(
"POST", "POST",
+1
View File
@@ -33,6 +33,7 @@ describe("loadConfig", () => {
const cfg = await loadConfig(); const cfg = await loadConfig();
expect(cfg).toEqual({ expect(cfg).toEqual({
apiBase: "/api/v1", apiBase: "/api/v1",
distributed: false,
rootDomain: "mc.example", rootDomain: "mc.example",
panelHostname: undefined, panelHostname: undefined,
adminHostname: "op.console.mc.example", adminHostname: "op.console.mc.example",
+2
View File
@@ -16,6 +16,7 @@ export interface BuildInfo {
} }
export interface RuntimeConfig { export interface RuntimeConfig {
distributed?: boolean;
apiBase: string; apiBase: string;
rootDomain: string; rootDomain: string;
/** Player-console hostname (console.<root>), absent when unconfigured. */ /** Player-console hostname (console.<root>), absent when unconfigured. */
@@ -79,6 +80,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
} }
cached = { cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase, apiBase: raw.apiBase ?? FALLBACK.apiBase,
distributed: raw.distributed === true,
rootDomain: raw.rootDomain, rootDomain: raw.rootDomain,
panelHostname: raw.panelHostname, panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname, adminHostname: raw.adminHostname,
+253
View File
@@ -4,6 +4,75 @@
*/ */
export interface paths { export interface paths {
"/api/v1/nodes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List execution nodes (administrator). */
get: operations["executionNodes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read durable migration progress. */
get: operations["latestWorldMigrationStatus"];
put?: never;
/** Migrate an already stopped world; persistent lock survives controller restart. */
post: operations["startWorldMigration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read durable migration progress. */
get: operations["worldMigrationStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations/{id}/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Retry a failed migration, retaining the source and stopped state. */
post: operations["retryWorldMigration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": { "/healthz": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2810,8 +2879,42 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
Phase: "Unknown" | "Stopped" | "Starting" | "Running" | "Stopping" | "Failed"; Phase: "Unknown" | "Stopped" | "Starting" | "Running" | "Stopping" | "Failed";
ExecutionNode: {
name: string;
role: string;
ready: boolean;
approved: boolean;
addresses: string[];
architecture: string;
};
WorldMigration: {
id: string;
server: string;
/** @enum {string} */
state: "backing_up" | "restoring" | "switching" | "succeeded" | "failed";
stage: string;
sourceNode: string;
targetNode: string;
sourcePVC: string;
targetPVC: string;
backup: {
ref: string;
/** Format: int64 */
size: number;
sha256: string;
};
/** Format: date-time */
started: string;
/** Format: date-time */
updated: string;
switched: boolean;
attempt: number;
error?: string;
};
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */ /** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
ServerInfo: { ServerInfo: {
/** @description Execution node; legacy servers report the observed node. */
nodeName?: string;
name: string; name: string;
subdomain: string; subdomain: string;
phase: components["schemas"]["Phase"]; phase: components["schemas"]["Phase"];
@@ -3561,6 +3664,154 @@ export interface components {
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { export interface operations {
executionNodes: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
nodes: components["schemas"]["ExecutionNode"][];
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
latestWorldMigrationStatus: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
startWorldMigration: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
targetNode: string;
};
};
};
responses: {
/** @description Accepted operation or current state. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
worldMigrationStatus: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
retryWorldMigration: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
healthz: { healthz: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3721,6 +3972,8 @@ export interface operations {
requestBody: { requestBody: {
content: { content: {
"application/json": { "application/json": {
/** @description Required approved worker in distributed mode; administrator only. */
nodeName?: string;
name: string; name: string;
subdomain: string; subdomain: string;
/** @description Trimmed; at most 64 characters, all visible ones or spaces (400 bad_display_name otherwise). */ /** @description Trimmed; at most 64 characters, all visible ones or spaces (400 bad_display_name otherwise). */
+24
View File
@@ -59,6 +59,7 @@ export interface MyServerView {
* A caller who does not own the server gets the public subset, so everything * A caller who does not own the server gets the public subset, so everything
* past the counts may be absent. */ * past the counts may be absent. */
export interface ServerStatus { export interface ServerStatus {
nodeName?: string;
name: string; name: string;
subdomain: string; subdomain: string;
phase: Phase; phase: Phase;
@@ -340,6 +341,7 @@ export interface WhitelistImage {
/** CreateServerRequest is the §15 structured form — the ONLY create path. */ /** CreateServerRequest is the §15 structured form — the ONLY create path. */
export interface CreateServerRequest { export interface CreateServerRequest {
nodeName?: string;
name: string; name: string;
subdomain: string; subdomain: string;
displayName?: string; displayName?: string;
@@ -716,3 +718,25 @@ export interface SessionView {
/** On the holder's own list only: the session this request came in on. */ /** On the holder's own list only: the session this request came in on. */
current?: boolean; current?: boolean;
} }
export interface ExecutionNode {
name: string;
role: string;
ready: boolean;
approved: boolean;
addresses: string[];
architecture: string;
}
export interface WorldMigration {
id: string;
server: string;
state: string;
stage: string;
sourceNode: string;
targetNode: string;
sourcePVC: string;
targetPVC: string;
error?: string;
switched: boolean;
attempt: number;
}
+11
View File
@@ -34,6 +34,7 @@ import { PhaseBadge, PHASE_KEY, PHASE_COLOR, shownPhase, startFailure } from "@/
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States"; import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { DistributedNodes, MigrationDialog } from "@/components/DistributedNodes";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress"; import { CopyAddress } from "@/components/CopyAddress";
@@ -106,6 +107,7 @@ function compareBy(key: SortKey): (a: UnifiedServer, b: UnifiedServer) => number
} }
interface UnifiedServer { interface UnifiedServer {
nodeName?: string;
name: string; name: string;
subdomain: string; subdomain: string;
/** The owner-chosen label; the list leads with it and keeps the name beside. */ /** The owner-chosen label; the list leads with it and keeps the name beside. */
@@ -163,6 +165,7 @@ export function ServersPage() {
if (isAdmin) { if (isAdmin) {
return (data as FleetServer[]).map((s) => ({ return (data as FleetServer[]).map((s) => ({
name: s.name, name: s.name,
nodeName: s.nodeName,
displayName: s.displayName, displayName: s.displayName,
subdomain: s.subdomain, subdomain: s.subdomain,
phase: s.phase, phase: s.phase,
@@ -276,6 +279,8 @@ export function ServersPage() {
className="mb-6" className="mb-6"
/> />
{isAdmin && cfg?.distributed && <DistributedNodes />}
{showInitialLoading ? ( {showInitialLoading ? (
<Loading /> <Loading />
) : showInitialError ? ( ) : showInitialError ? (
@@ -508,6 +513,7 @@ function ServerActions({
className?: string; className?: string;
}) { }) {
const { t: ts } = useTranslation("servers"); const { t: ts } = useTranslation("servers");
const cfg = useConfig();
const [busy, setBusy] = useState<null | "claim">(null); const [busy, setBusy] = useState<null | "claim">(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -543,6 +549,7 @@ function ServerActions({
return ( return (
<div className={cn("flex flex-col items-end gap-1", className)}> <div className={cn("flex flex-col items-end gap-1", className)}>
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap"> <div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
{isAdmin && cfg?.distributed && <MigrationDialog name={server.name} nodeName={server.nodeName} stopped={server.phase === "Stopped" && !server.ready && server.desiredState === "Stopped" && !server.retiring} onChanged={onChanged} />}
{claimable && ( {claimable && (
<> <>
<Button <Button
@@ -655,6 +662,10 @@ function PlayersLabel({ server }: { server: UnifiedServer }) {
} }
function EndpointLabel({ server }: { server: UnifiedServer }) { function EndpointLabel({ server }: { server: UnifiedServer }) {
return <div className="space-y-1">{server.nodeName && <code className="text-xs">{server.nodeName}</code>}<EndpointAddress server={server} /></div>;
}
function EndpointAddress({ server }: { server: UnifiedServer }) {
const { t } = useTranslation("ops"); const { t } = useTranslation("ops");
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null; const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
if (!endpoint) { if (!endpoint) {