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:
77 files changed
+5224
-73
No files matched your search
+50
-6
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+6
-1
@@ -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:
|
||||||
err = exportWorld(ctx, *target, token, *worldsRoot, stdout)
|
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)
|
||||||
|
}
|
||||||
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(®istryNodes, "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,
|
||||||
|
|||||||
@@ -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}, {®istrySvc, 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"}, ®istryNP); 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, ®istryNP, 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, ®istrySvc, &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))
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -114,7 +114,10 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
|
|||||||
// The operator's own image, for the forwarding-config initContainer it
|
// The operator's own image, for the forwarding-config initContainer it
|
||||||
// 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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+56
@@ -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'
|
||||||
@@ -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/)。
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
+10
-4
@@ -29,10 +29,11 @@ import (
|
|||||||
|
|
||||||
// API holds the dependencies shared by every handler.
|
// API holds the dependencies shared by every handler.
|
||||||
type API struct {
|
type API struct {
|
||||||
Repo Repo
|
Distribution Distribution
|
||||||
Cluster Cluster
|
Repo Repo
|
||||||
Internal InternalAuth
|
Cluster Cluster
|
||||||
External ExternalAuth
|
Internal InternalAuth
|
||||||
|
External ExternalAuth
|
||||||
|
|
||||||
// Builder is the image build subsystem (spec §16). It is optional: when nil
|
// Builder is the image build subsystem (spec §16). It is optional: when nil
|
||||||
// the /images routes report 503 rather than 404, so the admin boundary is
|
// the /images routes report 503 rather than 404, so the admin boundary is
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 +34,10 @@ 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 {
|
||||||
c client.Client
|
distributed bool
|
||||||
namespace string
|
controller string
|
||||||
|
c client.Client
|
||||||
|
namespace string
|
||||||
// servers serves the fleet-wide reads; nil means c.
|
// servers serves the fleet-wide reads; nil means c.
|
||||||
servers client.Reader
|
servers client.Reader
|
||||||
// synced reports whether servers has its first full list; nil means no cache.
|
// synced reports whether servers has its first full list; nil means no cache.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
return true, nil
|
var pvc corev1.PersistentVolumeClaim
|
||||||
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: claim}, &pvc); err == 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
|
||||||
|
}
|
||||||
@@ -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}, ¤t)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +44,8 @@ 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 {
|
||||||
Server string
|
NodeName 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.
|
||||||
OpID string
|
OpID string
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -41,7 +41,8 @@ type K8sCluster struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+31
-17
@@ -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,22 +36,24 @@ 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 {
|
||||||
Server string
|
SourceURL, Token, SHA256 string
|
||||||
WorldPVC string
|
MaxBytes int64
|
||||||
BackupPVC string
|
Server string
|
||||||
BackupRef string
|
WorldPVC string
|
||||||
ArchiveStore string
|
BackupPVC string
|
||||||
Namespace string
|
BackupRef string
|
||||||
ServiceAccount string
|
ArchiveStore string
|
||||||
Image string
|
Namespace string
|
||||||
BackupRoot string
|
ServiceAccount string
|
||||||
WorldsRoot string
|
Image string
|
||||||
Deadline time.Duration
|
BackupRoot string
|
||||||
CPULimit string
|
WorldsRoot string
|
||||||
MemLimit string
|
Deadline time.Duration
|
||||||
RunAsUser int64
|
CPULimit string
|
||||||
RunAsGroup int64
|
MemLimit string
|
||||||
FSGroup int64
|
RunAsUser int64
|
||||||
|
RunAsGroup int64
|
||||||
|
FSGroup int64
|
||||||
|
|
||||||
TTLAfterFinished time.Duration
|
TTLAfterFinished time.Duration
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +58,8 @@ 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 {
|
||||||
Server string
|
NodeName 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.
|
||||||
ID string
|
ID string
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "迁移失败,服务器保持停服"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user