diff --git a/README.md b/README.md index 57c5099..22b690a 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ A Kubernetes-driven Minecraft server hosting platform — one command to deploy, - **即开即玩**:玩家尝试连接时自动唤醒服务器,空闲后自动休眠,像游戏主机一样省资源。 - **Web 控制面板**:浏览器中查看服务器状态、在线玩家与资源用量,管理备份与恢复。 -- **自动备份与恢复**:定时将世界打包存档,支持从任意备份点一键回滚。 -- **智慧回收**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间。 +- **备份与恢复**:一键把世界打包进集群内的归档库,支持从任意备份点回滚;默认安装就已启用(归档 PVC 与路径由安装器一并生成)。 +- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。 - **多核心支持**:兼容 Paper、Fabric、Forge、NeoForge,经由 Velocity 代理统一入口。 - **模组自助提交**:玩家自行上传模组包,服主审批通过后自动构建并部署。 - **Passkey 登录**:支持指纹、面容、硬件密钥等无密码认证方式。 diff --git a/README_EN.md b/README_EN.md index 8758511..ed8d9fc 100644 --- a/README_EN.md +++ b/README_EN.md @@ -17,8 +17,8 @@ Table of Contents - **Wake on Join**: Servers start automatically when a player connects, and stop when idle — like hibernate for your server. - **Web Dashboard**: Monitor server status, online players, and resource usage from your browser, with backup and restore management. -- **Auto Backup & Restore**: Scheduled world backups with one-click rollback from any backup point. -- **World Reaper**: Worlds idle for more than 15 days are automatically backed up and removed to free disk space. +- **Backup & Restore**: One-click world snapshots into the cluster's archive store, with rollback from any backup point — enabled by default (the installer renders the archive PVC and its path). +- **World Reaper** (opt in): Worlds idle for more than 15 days are automatically backed up and removed to free disk space. Enable it by setting `FELIS_WORLDS_HOST_PATH` at install time (on k3s: `/var/lib/rancher/k3s/storage`); without it, no world is ever deleted. - **Multi-core Support**: Compatible with Paper, Fabric, Forge, and NeoForge, federated behind a Velocity proxy. - **Modpack Submission**: Players submit custom modpacks; admin approval triggers automatic build and deployment. - **Passkey Login**: Passwordless authentication via fingerprint, face recognition, or hardware security keys. diff --git a/cmd/felis/manifests.go b/cmd/felis/manifests.go index c2ee7e4..51fb333 100644 --- a/cmd/felis/manifests.go +++ b/cmd/felis/manifests.go @@ -26,8 +26,10 @@ func (m *multiFlag) Set(v string) error { // felis-reaper identity only when the retention reaper is enabled, gated with // its CronJob), the weak build/restore Job SAs, the build/minecraft // NetworkPolicies, and the running control-plane workloads (felis-api/operator -// Deployments + the in-cluster registry Deployment/Service/PVC) — as a single -// multi-document YAML stream on stdout, ready for `kubectl apply -f -`. +// Deployments + the in-cluster registry Deployment/Service/PVC + the +// world-archive PVC that backs backup/restore, unless --backup-pvc is emptied) +// — as a single multi-document YAML stream on stdout, ready for +// `kubectl apply -f -`. // // It is a pure renderer: it never contacts a cluster and holds no credentials. // --velocity-cidr records the proxy host addresses allowed by the game NetworkPolicy. @@ -44,8 +46,8 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { panelNodePort := fs.Int("panel-node-port", int(platform.DefaultPanelNodePort), "NodePort that exposes the built-in HTTPS panel/API origin") felisImage := fs.String("felis-image", "", "container image the felis-api/operator Deployments run, also passed through as FELIS_IMAGE (REQUIRED)") registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry:2)") - backupPVC := fs.String("backup-pvc", "", "name of the backup PVC advertised to the restore executor via FELIS_BACKUP_PVC (default none = restore endpoint returns 503)") - worldsHostPath := fs.String("worlds-host-path", "", "node directory under which each world PVC is visible as /; enables the reaper CronJob (requires --backup-pvc and --archive-local-path)") + backupPVC := fs.String("backup-pvc", "felis-backups", "name of the world-archive PVC this bundle renders in the Minecraft namespace and advertises to the backup/restore executors via FELIS_BACKUP_PVC (default: felis-backups; pass an empty value to render none, leaving backup/restore answering 503)") + worldsHostPath := fs.String("worlds-host-path", "", "node directory the reaper reads worlds from: each world PVC resolves as /, or as the stock local-path directory /__ (k3s storage root: /var/lib/rancher/k3s/storage); enables the reaper CronJob (requires --archive-local-path and a non-empty --backup-pvc)") archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [archive] local_path") var velocityCIDRs multiFlag fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of a Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)") @@ -82,17 +84,20 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { return 2 } - // Retention/reaper rendering is opt-in and needs all three storage coordinates - // together: where worlds live (to read+archive them), the backup PVC (to write - // archives into), and the path it is mounted at (which MUST equal felis.toml - // [archive] local_path so tarLocal's absolute archive refs resolve). A partial - // configuration is almost certainly an operator mistake, so fail loud rather than - // silently drop retention. Asking for it without the other two is rejected; an - // empty trio renders the bundle WITHOUT the reaper and says so. + // Retention/reaper rendering is opt-in and needs a storage topology together: + // where worlds live (to read+archive them), a backup PVC (to write archives + // into — rendered from --backup-pvc), and the path it is mounted at (which MUST + // equal felis.toml [archive] local_path so tarLocal's absolute archive refs + // resolve). A partial configuration is almost certainly an operator mistake, so + // fail loud rather than silently drop retention or render a reaper with nowhere + // to write. The backup PVC itself defaults to felis-backups (it is what makes a + // default install's backup endpoint work at all); retention additionally needs + // --worlds-host-path. if *worldsHostPath != "" { if *backupPVC == "" || *archiveLocalPath == "" { fmt.Fprintln(stderr, "felis manifests: --worlds-host-path enables the reaper CronJob and requires "+ - "--backup-pvc and --archive-local-path too (--archive-local-path must equal felis.toml [archive] local_path)") + "--archive-local-path (must equal felis.toml [archive] local_path) and a non-empty --backup-pvc "+ + "(the archive store; default felis-backups)") return 2 } // The reaper WILL render. Two deployment preconditions this generator cannot @@ -102,15 +107,16 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { // flag/field docs, but nobody deploying from stdout reads those.) fmt.Fprintf(stderr, "felis manifests: note: rendering the retention reaper CronJob (worlds hostPath %q). "+ "Two preconditions are NOT verified here:\n"+ - " - each world PVC must be visible at %s/ on the node: a stock local-path-provisioner lays "+ - "volumes under PV-name paths (.../pvc-__/), so unless the worlds StorageClass is "+ - "arranged to expose /, the reaper tars an empty directory;\n"+ + " - the node's world volumes must actually live below %s: the reaper resolves a world as "+ + "%s/, then as the stock local-path directory /__ (what k3s "+ + "writes under /var/lib/rancher/k3s/storage). Any other provisioner needs its volumes exposed as "+ + "/, or each candidate's archive fails and the world is preserved;\n"+ " - the CronJob sets NO nodeSelector: a single-node starter pins it to the worlds implicitly, but "+ "on a multi-node cluster you MUST add a nodeSelector for the node holding the worlds, or the reaper "+ - "may schedule where the hostPath is empty.\n", *worldsHostPath, *worldsHostPath) + "may schedule where the hostPath is empty.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath) } else { fmt.Fprintln(stderr, "felis manifests: note: retention reaper CronJob not rendered "+ - "(pass --worlds-host-path, --backup-pvc and --archive-local-path to enable it)") + "(pass --worlds-host-path and --archive-local-path — the archive PVC defaults to felis-backups — to enable it)") } out, err := platform.RenderYAML(platform.Params{ diff --git a/cmd/felis/manifests_test.go b/cmd/felis/manifests_test.go index 5eb981b..303c46d 100644 --- a/cmd/felis/manifests_test.go +++ b/cmd/felis/manifests_test.go @@ -77,6 +77,10 @@ func TestManifestsRendersBundle(t *testing.T) { "10.0.0.5/32", // The felis image flows through to the Deployments. "registry.felis.svc:5000/felis:v1", + // Backup works out of the box: the archive PVC renders and the api gets + // the env that wires the backup/restore executors to it. + "name: felis-backups", + "name: FELIS_BACKUP_PVC", } { if !strings.Contains(text, want) { t.Errorf("rendered bundle missing %q", want) @@ -95,15 +99,19 @@ func TestManifestsRendersBundle(t *testing.T) { } } -// TestManifestsReaperRequiresTrio proves --worlds-host-path is a fail-loud opt-in: -// asking for the reaper without the backup PVC and its mount path (which must equal -// [archive] local_path) is rejected rather than silently dropping retention. -func TestManifestsReaperRequiresTrio(t *testing.T) { +// TestManifestsReaperRequiresStorage proves --worlds-host-path is a fail-loud +// opt-in: asking for the reaper without a writable archive store (the backup PVC, +// which defaults to felis-backups but can be emptied) and its mount path (which +// must equal [archive] local_path) is rejected rather than silently dropping +// retention or deleting worlds it could not archive first. +func TestManifestsReaperRequiresStorage(t *testing.T) { base := []string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", "--worlds-host-path", "/var/lib/felis/worlds"} for _, extra := range [][]string{ - {}, // neither backup-pvc nor archive-local-path - {"--backup-pvc", "felis-backups"}, // missing archive-local-path - {"--archive-local-path", "/backups"}, // missing backup-pvc + {}, // missing archive-local-path (backup-pvc defaults) + {"--backup-pvc", "other"}, // still missing archive-local-path + // A reaper with no archive store would have nowhere to write the archive + // it must verify before deleting a world; emptying the PVC is rejected. + {"--archive-local-path", "/backups", "--backup-pvc="}, } { var out, errBuf bytes.Buffer code := run(append(append([]string{}, base...), extra...), &out, &errBuf) @@ -119,6 +127,23 @@ func TestManifestsReaperRequiresTrio(t *testing.T) { } } +// TestManifestsBackupPVCOptOut proves --backup-pvc= renders a bundle with no +// archive store at all: no PVC and no FELIS_BACKUP_PVC env, so backup/restore +// answer 503 instead of pointing Jobs at a claim nobody provisions. +func TestManifestsBackupPVCOptOut(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"manifests", "--felis-image", "reg/felis:test", + "--velocity-cidr", "10.0.0.5/32", "--backup-pvc="}, &out, &errBuf) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, errBuf.String()) + } + for _, absent := range []string{"felis-backups", "FELIS_BACKUP_PVC"} { + if strings.Contains(out.String(), absent) { + t.Errorf("--backup-pvc= bundle must not contain %q", absent) + } + } +} + // TestManifestsRendersReaper proves the happy path with the full retention trio: // a batch/v1 CronJob is emitted, named felis-reaper, mounting the backup PVC at the // supplied archive path. @@ -150,7 +175,7 @@ func TestManifestsRendersReaper(t *testing.T) { // this generator cannot verify (else a misarranged hostPath silently no-ops // retention): the / arrangement-dependency and the multi-node // nodeSelector hazard. - for _, want := range []string{"local-path-provisioner", "nodeSelector"} { + for _, want := range []string{"local-path", "nodeSelector"} { if !strings.Contains(errBuf.String(), want) { t.Errorf("reaper render must warn operators about %q on stderr, got %q", want, errBuf.String()) } diff --git a/cmd/felis/reaper.go b/cmd/felis/reaper.go index 96f3cf4..6d4842b 100644 --- a/cmd/felis/reaper.go +++ b/cmd/felis/reaper.go @@ -1,9 +1,11 @@ package main import ( + "context" "flag" "fmt" "io" + "os" "path/filepath" "strconv" "strings" @@ -14,6 +16,7 @@ import ( "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/store" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -30,7 +33,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("reaper", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") - worldsRoot := fs.String("worlds-root", "/worlds", "mount root under which world PVCs are visible (tarLocal: /)") + worldsRoot := fs.String("worlds-root", "/worlds", "mount root under which world PVCs are visible (tarLocal: /, else the stock local-path /__)") if err := fs.Parse(args); err != nil { return 2 } @@ -47,21 +50,8 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { return 1 } - archiver, err := buildArchiver(cfg, *worldsRoot) - if err != nil { - fmt.Fprintf(stderr, "felis reaper: %v\n", err) - return 1 - } - ctx := ctrl.SetupSignalHandler() - drv, err := store.Open(ctx, cfg.Database.URL) - if err != nil { - fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err) - return 1 - } - defer drv.Close() - scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(v1alpha1.AddToScheme(scheme)) @@ -71,6 +61,19 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int { return 1 } + archiver, err := buildArchiver(ctx, cfg, *worldsRoot, cl) + if err != nil { + fmt.Fprintf(stderr, "felis reaper: %v\n", err) + return 1 + } + + drv, err := store.Open(ctx, cfg.Database.URL) + if err != nil { + fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err) + return 1 + } + defer drv.Close() + r := &reaper.Reaper{ Cfg: rcfg, Store: reaper.NewPGStore(drv.DB()), @@ -122,22 +125,57 @@ func reaperConfig(cfg *config.Config) (reaper.Config, error) { } // buildArchiver constructs the WorldArchiver. Only tarLocal is implemented in -// this build; the resolver maps each world PVC to /, the mount -// convention the reaper Job is deployed with. -func buildArchiver(cfg *config.Config, worldsRoot string) (backup.WorldArchiver, error) { +// this build; the resolver maps each world PVC to its directory under worldsRoot +// (resolveWorldDir). +func buildArchiver(ctx context.Context, cfg *config.Config, worldsRoot string, cl client.Client) (backup.WorldArchiver, error) { switch cfg.Archive.Store { case "tarLocal": return &backup.TarLocal{ BackupRoot: cfg.Archive.LocalPath, - Resolve: func(pvc string) (string, error) { - return filepath.Join(worldsRoot, pvc), nil - }, + Resolve: resolveWorldDir(ctx, cl, cfg.K8s.Namespace, worldsRoot), }, nil default: return nil, fmt.Errorf("[archive] store %q is not implemented in this build (only tarLocal)", cfg.Archive.Store) } } +// resolveWorldDir maps a world PVC to its directory under worldsRoot, supporting +// the two layouts a Felis host actually has: +// +// 1. / — the reaper's documented arrangement (worlds exposed by PVC +// name, e.g. via mounting each volume or a crafted storage class). +// 2. /__ — what a stock k3s install gets: +// local-path-provisioner stores every volume under its storage root as that +// exact directory name. Without this arm, retention on a default install could +// only ever fail to find a world (a no-op reaper, or worse an operator +// arranging paths by hand). +// +// The second path is derived EXACTLY from the live PVC's spec.volumeName, never +// from a glob: a leftover directory of an old, deleted PV must never be mistaken +// for the world the PVC currently binds, because the reaper archives the resolved +// directory and then deletes that PVC — archiving stale bytes and deleting the +// real world would be data loss. When neither path exists the first is returned, +// so the archive walk fails loudly against the documented path. +func resolveWorldDir(ctx context.Context, cl client.Client, namespace, worldsRoot string) backup.PVCResolver { + return func(pvc string) (string, error) { + direct := filepath.Join(worldsRoot, pvc) + if _, err := os.Stat(direct); err == nil { + return direct, nil + } + var claim corev1.PersistentVolumeClaim + if err := cl.Get(ctx, client.ObjectKey{Namespace: namespace, Name: pvc}, &claim); err != nil { + return "", fmt.Errorf("resolve world PVC %s: %w", pvc, err) + } + if pv := claim.Spec.VolumeName; pv != "" { + volDir := filepath.Join(worldsRoot, fmt.Sprintf("%s_%s_%s", pv, claim.Namespace, claim.Name)) + if _, err := os.Stat(volDir); err == nil { + return volDir, nil + } + } + return direct, nil + } +} + // parseSpanDuration parses the human spans used in felis.toml's [archive] table: // "3mo" (months≈30d), "15d" (days), or any time.ParseDuration unit ("12h"). func parseSpanDuration(s string) (time.Duration, error) { diff --git a/cmd/felis/reaper_test.go b/cmd/felis/reaper_test.go new file mode 100644 index 0000000..5dbd4f3 --- /dev/null +++ b/cmd/felis/reaper_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// TestResolveWorldDir pins the two world layouts the reaper must find, and the +// fail-closed miss. The stock local-path arm is derived from the live PVC's +// volumeName — a name-based guess (glob) could tar a stale deleted PV's bytes and +// then delete the current world, which is why it is read from the API instead. +func TestResolveWorldDir(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + + // Arrange a world under the documented / layout. + named := filepath.Join(root, "world-named-0") + if err := os.MkdirAll(named, 0o750); err != nil { + t.Fatal(err) + } + // Arrange a second world the way k3s local-path stores it. + pvDir := filepath.Join(root, "pvc-11111111-2222-3333-4444-555555555555_minecraft_world-live-0") + if err := os.MkdirAll(pvDir, 0o750); err != nil { + t.Fatal(err) + } + + claim := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "world-live-0", Namespace: "minecraft"}, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pvc-11111111-2222-3333-4444-555555555555", + }, + } + cl := fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(claim).Build() + resolve := resolveWorldDir(ctx, cl, "minecraft", root) + + t.Run("documented name layout wins", func(t *testing.T) { + got, err := resolve("world-named-0") + if err != nil || got != named { + t.Fatalf("resolve = (%q, %v), want (%q, nil)", got, err, named) + } + }) + + t.Run("stock local-path layout resolves exactly", func(t *testing.T) { + got, err := resolve("world-live-0") + if err != nil || got != pvDir { + t.Fatalf("resolve = (%q, %v), want (%q, nil)", got, err, pvDir) + } + }) + + t.Run("neither layout present falls back to the documented path", func(t *testing.T) { + // The claim exists but its directory does not: return the documented path so + // the archive walk fails there, and the reaper preserves the world. + missing := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "world-gone-0", Namespace: "minecraft"}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pvc-99999999-0000-0000-0000-000000000000"}, + } + cl := fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(missing).Build() + got, err := resolveWorldDir(ctx, cl, "minecraft", root)("world-gone-0") + if err != nil || got != filepath.Join(root, "world-gone-0") { + t.Fatalf("resolve = (%q, %v), want (%q, nil)", got, err, filepath.Join(root, "world-gone-0")) + } + }) + + t.Run("unknown pvc is an error, not a guess", func(t *testing.T) { + _, err := resolve("world-unknown-0") + if err == nil || !strings.Contains(err.Error(), "resolve world PVC world-unknown-0") { + t.Fatalf("err = %v, want a resolve-world-PVC error", err) + } + }) +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index ce94f9d..ed61e44 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -63,6 +63,15 @@ # FELIS_ROOT_DOMAIN deployment root domain (default: .nip.io) # FELIS_PANEL_NODEPORT local HTTPS panel/API NodePort (default: 30443) # FELIS_EGRESS_MODE loadbalancer|nodeport (default: nodeport — no MetalLB on a demo box) +# FELIS_BACKUP_PVC world-archive PVC the installer renders and felis-api hands to its +# backup/restore Jobs (default: felis-backups; empty string disables +# backups — the endpoints answer 503) +# FELIS_ARCHIVE_LOCAL_PATH path that PVC is mounted at inside those Jobs; written into +# felis.toml [archive] local_path (default: /var/lib/felis/archives) +# FELIS_WORLDS_HOST_PATH node directory holding the world volumes (on the k3s this +# installer provisions: /var/lib/rancher/k3s/storage). Setting it +# enables the daily retention reaper, which archives and then deletes +# worlds idle beyond the retention window (default: unset = no reaper) # PKG_LOCK_TIMEOUT seconds to wait for package-manager locks (default: 900) # APT_LOCK_TIMEOUT legacy alias for PKG_LOCK_TIMEOUT set -Eeuo pipefail @@ -106,6 +115,18 @@ FELIS_VERSION_BASE="" FELIS_IMAGE="${FELIS_IMAGE:-felis:demo}" FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}" FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}" +# World-archive storage. The installer renders this PVC (minecraft namespace) and felis-api +# advertises it to its backup/restore Jobs; emptying it disables backups (503). The archive +# path is written into felis.toml so the Jobs' mount and [archive] local_path agree by +# construction — a mismatch would leave tarLocal's absolute archive refs unresolvable. +FELIS_BACKUP_PVC="${FELIS_BACKUP_PVC:-felis-backups}" +FELIS_ARCHIVE_LOCAL_PATH="${FELIS_ARCHIVE_LOCAL_PATH:-/var/lib/felis/archives}" +# Retention is opt-in because it DELETES worlds (after a verified archive): point this at the +# node directory the world volumes live under. On the k3s this installer provisions that is +# /var/lib/rancher/k3s/storage — the reaper resolves each PVC's local-path directory exactly +# from its volumeName. Left unset, no reaper CronJob renders and archives accumulate until +# the backup PVC fills (then backups fail loudly; nothing is deleted). +FELIS_WORLDS_HOST_PATH="${FELIS_WORLDS_HOST_PATH:-}" INSTALL_MODE="${FELIS_INSTALL_MODE:-}" # Loopback by default: hasJoined is an unauthenticated endpoint by protocol (Velocity # sends no token), so a public bind is a free auth relay — anyone can point their own @@ -2047,7 +2068,7 @@ build_namespace = "${BUILD_NS}" [archive] store = "tarLocal" -local_path = "/var/lib/felis/archives" +local_path = "${FELIS_ARCHIVE_LOCAL_PATH}" [auth] admin_hostname = "op.console.${FELIS_ROOT_DOMAIN}" @@ -2137,11 +2158,26 @@ deploy_bundle() { --dry-run=client -o yaml | kube apply -f - log "rendering + applying the control-plane bundle" - "$HOST_BIN" manifests \ - --felis-image "$FELIS_IMAGE" \ - --panel-node-port "$FELIS_PANEL_NODEPORT" \ - --velocity-cidr "${NODE_IP}/32" \ - | kube apply -f - + local -a manifest_args=( + --felis-image "$FELIS_IMAGE" + --panel-node-port "$FELIS_PANEL_NODEPORT" + --velocity-cidr "${NODE_IP}/32" + ) + # Backups are on by default (the renderer's own default names felis-backups); an emptied + # FELIS_BACKUP_PVC asks for the no-backup shape explicitly, and a custom name must be + # passed through or the api would advertise a PVC the bundle never created. + if [ -n "$FELIS_BACKUP_PVC" ]; then + manifest_args+=(--backup-pvc "$FELIS_BACKUP_PVC") + else + manifest_args+=(--backup-pvc=) + fi + # Retention renders only when the operator names where the worlds live; the archive path + # always travels with it because it must equal the [archive] local_path written above. + if [ -n "$FELIS_WORLDS_HOST_PATH" ]; then + log "retention enabled: the daily reaper will read worlds from ${FELIS_WORLDS_HOST_PATH}" + manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH" --archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH") + fi + "$HOST_BIN" manifests "${manifest_args[@]}" | kube apply -f - restart_existing_control_plane "$had_api" "$had_operator" log "waiting for control-plane rollouts" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index a3fe0b4..aebf5e7 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -574,6 +574,45 @@ mkdir -p "$sdir/src/.git" expect "a failed fetch into an existing checkout names the token" "set FELIS_GITHUB_TOKEN" \ "$(run_fetch "$sdir/src")" +# --- default install keeps backups, and retention envs reach the renderer ---------------- +# A default install must render the world-archive PVC (without one, backup/restore answer an +# honest 503), and FELIS_WORLDS_HOST_PATH must turn into the reaper's two flags or an +# operator's retention enablement silently renders no CronJob. Extracted, not retyped. + +mblock="$(awk '/^ local -a manifest_args=\(/,/kube apply -f -/' "$BS")" +[ -n "$mblock" ] || { echo "FAIL: no manifest_args block found in $BS"; exit 1; } +[ "$(printf '%s\n' "$mblock" | wc -l)" -lt 30 ] \ + || { echo "FAIL: the extracted block is not the manifest_args block -- did it move?"; exit 1; } + +run_bundle_flags() { # backup-pvc worlds-host-path + FELIS_IMAGE=reg/felis:test FELIS_PANEL_NODEPORT=30443 NODE_IP=10.0.0.5 \ + FELIS_BACKUP_PVC="$1" FELIS_WORLDS_HOST_PATH="$2" FELIS_ARCHIVE_LOCAL_PATH=/var/lib/felis/archives \ + HOST_BIN=myManifests bash -c ' + log() { :; } + kube() { cat; } + myManifests() { printf "%s\n" "$@"; } + run_bundle() { + '"$mblock"' + } + run_bundle' +} + +out="$(run_bundle_flags felis-backups '')" +expect "a default install asks the renderer for the archive PVC" "--backup-pvc +felis-backups" "$out" +case "$out" in + *--worlds-host-path*) echo "FAIL: no reaper flags may render without FELIS_WORLDS_HOST_PATH"; fails=$((fails + 1)) ;; +esac + +out="$(run_bundle_flags '' '')" +expect "an emptied FELIS_BACKUP_PVC is the explicit no-backup shape" "--backup-pvc=" "$out" + +out="$(run_bundle_flags felis-backups /var/lib/rancher/k3s/storage)" +expect "enabling retention passes the worlds root" "--worlds-host-path +/var/lib/rancher/k3s/storage" "$out" +expect "enabling retention passes the archive mount that must match felis.toml" "--archive-local-path +/var/lib/felis/archives" "$out" + # --------------------------------------------------------------------------------------- if [ "$fails" -eq 0 ]; then echo "ALL PASS" diff --git a/internal/platform/bundle.go b/internal/platform/bundle.go index 41dbca4..bc27a97 100644 --- a/internal/platform/bundle.go +++ b/internal/platform/bundle.go @@ -32,7 +32,9 @@ type Object interface { // Scope: this is the authorization + network fence (spec §21, §22) plus the // running control-plane workloads it fences — the felis-api / felis-operator // Deployments and the in-cluster registry (Deployment + Service + PVC), which -// make the SAs and NetworkPolicy peers refer to something real (see workloads.go). +// make the SAs and NetworkPolicy peers refer to something real, plus the +// world-archive PVC that backs backup/restore when a backup PVC is named (see +// workloads.go). // The reaper CronJob is also part of Workloads, rendered only when the retention // storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath — // workloads.go documents the gate and the shape-asserted hostPath caveat). The diff --git a/internal/platform/identities.go b/internal/platform/identities.go index d3c4da9..aedc240 100644 --- a/internal/platform/identities.go +++ b/internal/platform/identities.go @@ -107,14 +107,16 @@ type Params struct { FelisImage string // RegistryImage is the in-cluster registry image. Defaults to registry:2. RegistryImage string - // BackupPVC is the name of the backup PersistentVolumeClaim the felis-api pod - // advertises to its restore executor via FELIS_BACKUP_PVC. It is OPTIONAL: with - // no backup PVC the restore endpoint degrades to 503 (cmd/felis/api.go), so the - // env var is rendered only when this is set. It must name the same PVC that the - // felis.toml archive.local_path is the mount path for, but that agreement lives - // in the out-of-band config Secret and cannot be enforced by the manifest. The - // reaper CronJob (when rendered) mounts this same PVC read-write to write - // archives into it — see WorldsHostPath / ArchiveLocalPath. + // BackupPVC is the name of the world-archive PersistentVolumeClaim. The bundle + // RENDERS this PVC (backupPVC in workloads.go, Minecraft namespace — where every + // pod that mounts it runs) and felis-api advertises the name to its backup/restore + // executors via FELIS_BACKUP_PVC. An empty name renders neither: no PVC, no env, + // and the backup/restore endpoints degrade to 503 (cmd/felis/api.go) rather than + // enqueuing a Job that cannot mount its backup. The PVC name itself carries no + // path meaning; the in-pod mount path is felis.toml's [archive] local_path (the + // Jobs mount the PVC there, and tarLocal writes archive refs as absolute paths + // under it). The reaper CronJob (when rendered) mounts this same PVC read-write + // to write archives into it — see WorldsHostPath / ArchiveLocalPath. BackupPVC string // WorldsHostPath is the node directory under which each server's world PVC is // visible as / — the on-disk root the reaper CronJob mounts @@ -128,12 +130,18 @@ type Params struct { // a later storage evolution. Setting it REQUIRES BackupPVC and ArchiveLocalPath // too — `felis manifests` enforces the trio (fail-loud). // - // SHAPE-ASSERTED, runtime-unverified, and ARRANGEMENT-DEPENDENT: the reaper's - // resolver looks for /. Stock local-path-provisioner lays volumes out - // under PV-name paths (…/pvc-__/), NOT /, so this mount - // only finds worlds if the operator/storage is deliberately arranged to expose - // them as /. The rendered CronJob is the correct K8s object; whether - // the tar finds a world on a given cluster is not provable without one. + // ARRANGEMENT: the reaper's resolver (cmd/felis/reaper.resolveWorldDir) looks + // for / first and then for the stock local-path-provisioner layout + // /__ — the exact directory name k3s uses under + // its storage root (/var/lib/rancher/k3s/storage), derived from the live PVC's + // spec.volumeName. So pointing this at the k3s storage root is the supported + // way to enable retention on a stock install; other provisioners work if they + // expose volumes as / or are read through the same PVC. On a + // multi-node cluster every node HAS the root directory, but a world's directory + // only exists on the node holding its volume: the CronJob schedules anywhere, + // so a world found nowhere on that node fails the archive and is preserved. + // The rendered CronJob is the correct K8s object; the actual tar depends on the + // hosting node, which is not provable without a cluster. WorldsHostPath string // ArchiveLocalPath is the path the backup PVC is mounted at inside the reaper // CronJob's pod, and MUST equal felis.toml's [archive] local_path. tarLocal writes diff --git a/internal/platform/rbac.go b/internal/platform/rbac.go index 8d6d660..270d13c 100644 --- a/internal/platform/rbac.go +++ b/internal/platform/rbac.go @@ -162,6 +162,11 @@ func OperatorRole(p Params) *rbacv1.Role { // minecraftservers but cannot create them, and holds no power over StatefulSets, // Services, or Secrets — those belong to the operator and api. // +// persistentvolumeclaims also carries get: resolving where a world lives +// (cmd/felis/reaper.resolveWorldDir) reads the PVC's volumeName to derive the +// stock local-path directory name. get is strictly weaker than the delete the +// same rule already grants, so it widens nothing. +// // Note no identity anywhere holds minecraftservers:delete. That is intentional, not // a missing grant: reaping releases a server by flipping desiredState=Stopped and // reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in @@ -172,7 +177,7 @@ func ReaperRole(p Params) *rbacv1.Role { p = p.withDefaults() return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{ rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}), - rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"delete"}), + rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}), }) } diff --git a/internal/platform/rbac_test.go b/internal/platform/rbac_test.go index d3c3f70..8811827 100644 --- a/internal/platform/rbac_test.go +++ b/internal/platform/rbac_test.go @@ -183,8 +183,8 @@ func TestOperatorRole_ScopeExact(t *testing.T) { func TestReaperRole_ScopeExact(t *testing.T) { rp := roleByName(t, ControlPlaneRBAC(reaperParams()).Roles, "felis-reaper") - if !hasRule(rp, groupCore, "persistentvolumeclaims", "delete") { - t.Error("reaper must delete PVCs (world reclamation)") + if !hasRule(rp, groupCore, "persistentvolumeclaims", "delete") || !hasRule(rp, groupCore, "persistentvolumeclaims", "get") { + t.Error("reaper must get (resolve the volume) and delete (reclaim) PVCs") } if !hasRule(rp, groupFelis, "minecraftservers", "patch") || !hasRule(rp, groupFelis, "minecraftservers", "get") { t.Error("reaper must get+patch minecraftservers (to Stop them)") diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 83e68b1..b4819ce 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -32,12 +32,14 @@ import ( // read-only — is the only one coherent with the operator's per-server // ReadWriteOnce world PVCs (a shared RWX worlds mount would contradict them), so // that is what renders; when the trio is absent no CronJob is emitted, which is -// the fail-safe choice for a workload that deletes PVCs. SHAPE-ASSERTED and -// runtime-unverified: the rendered CronJob is the correct K8s object, but whether -// the tar finds a world under / on a given cluster depends on -// how that node's storage is arranged (stock local-path-provisioner uses -// PV-name paths, not /) and is not provable without a cluster — see the -// WorldsHostPath field doc. No nodeSelector is set: the single-node starter pins +// the fail-safe choice for a workload that deletes PVCs. The reaper resolver +// (cmd/felis/reaper.resolveWorldDir) finds worlds either as / +// or in the stock local-path layout k3s writes under its storage root +// (__, read from the live PVC), so pointing +// --worlds-host-path at /var/lib/rancher/k3s/storage works on a default install — +// see the WorldsHostPath field doc. Whether the tar finds a world still depends on +// the hosting node, and is not provable without a cluster. No nodeSelector is set: +// the single-node starter pins // the worlds to one node implicitly; a multi-node deployment MUST add one (or the // CronJob could schedule on a node where the hostPath is empty) — a hazard left on // record here until multi-node retention is built. @@ -86,6 +88,13 @@ const ( // local-storage install (no such Secret) still starts. uploadsPVCName = "felis-uploads" uploadsStorageSize = "5Gi" + // backupStorageSize is the world-archive store's default capacity. It is a + // starter-sized floor (registry 10Gi, uploads 5Gi sit beside it): archives are + // compressed worlds and a fresh one is ~200MB, so this holds many while leaving + // the growth knob (resize the PVC / move to a snapshot backend, spec §19) to the + // operator. There is no storageClassName: the cluster default is the only safe + // binding, exactly like registryPVC. + backupStorageSize = "10Gi" // UploadsLocalPath is the in-pod mount of the uploads PVC; a local // user_uploads_context points here so the derived context ref and the on-disk // write location agree. Exported so the setup wizard stamps it into felis.toml. @@ -165,9 +174,10 @@ func InternalAPIBaseURL(controlNamespace string) string { // Workloads renders the running control-plane: the felis-api Deployment, the // felis-operator Deployment, and the in-cluster registry (Deployment + Service + -// PVC), plus the reaper CronJob when reaperEnabled(p). FelisImage is required — -// `felis manifests` enforces it (fail-loud), so a rendered bundle always names a -// concrete image. +// PVC), the world-archive PVC when p.BackupPVC names it (it backs the +// backup/restore Jobs and the reaper), plus the reaper CronJob when +// reaperEnabled(p). FelisImage is required — `felis manifests` enforces it +// (fail-loud), so a rendered bundle always names a concrete image. func Workloads(p Params) []Object { p = p.withDefaults() objs := []Object{ @@ -180,6 +190,9 @@ func Workloads(p Params) []Object { registryPVC(p), uploadsPVC(p), } + if p.BackupPVC != "" { + objs = append(objs, backupPVC(p)) + } if reaperEnabled(p) { objs = append(objs, reaperCronJob(p)) } @@ -656,6 +669,30 @@ func uploadsPVC(p Params) *corev1.PersistentVolumeClaim { } } +// backupPVC renders the world-archive store (spec §18/§19): the PVC the backup +// and restore Jobs mount read-write, and the one the reaper CronJob writes +// archives into. It renders ONLY when p.BackupPVC names it, so the same value +// gates the PVC and the FELIS_BACKUP_PVC env on the felis-api Deployment — with +// no name there is no PVC, no env, and the backup/restore endpoints keep +// answering an honest 503 rather than enqueuing a Job that cannot mount its +// backup. It lives in the Minecraft namespace because every pod that mounts it +// runs there (a Pod can only mount PVCs from its own namespace; the reaper +// CronJob is rendered there for the same reason). No storageClassName: binding +// the cluster default is the only safe default. +func backupPVC(p Params) *corev1.PersistentVolumeClaim { + p = p.withDefaults() + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PersistentVolumeClaim"}, + ObjectMeta: metav1.ObjectMeta{Name: p.BackupPVC, Namespace: p.MinecraftNamespace, Labels: controlPlanePodLabels(ComponentAPI)}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(backupStorageSize)}, + }, + }, + } +} + // registryLabels are the registry's recommended labels. Note the absence of // part-of=felis-control-plane: that is what keeps the registry out of the RCON // NetworkPolicy peer's reach (asserted in workloads_test.go). diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index 55b3a9d..ad0c8e2 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -321,6 +321,45 @@ func TestAPIDeployment_BackupPVC(t *testing.T) { } } +// TestBackupPVC_RendersWithTheStore proves the world-archive PVC is part of the +// bundle exactly when a backup PVC is named, and that it lands in the Minecraft +// namespace where every pod mounting it runs (the backup/restore Jobs and the +// reaper CronJob) — the one property that made the reaper's first placement +// unschedulable. Without a name the bundle must NOT create one: the api env is +// gated on the same value, so the endpoints answer 503 instead of pointing a Job +// at a claim nobody provisioned. +func TestBackupPVC_RendersWithTheStore(t *testing.T) { + p := testParams() + p.BackupPVC = "felis-backups" + var got *corev1.PersistentVolumeClaim + for _, o := range Workloads(p) { + if pvc, ok := o.(*corev1.PersistentVolumeClaim); ok && pvc.Name == "felis-backups" { + got = pvc + } + } + if got == nil { + t.Fatalf("Workloads() must render PVC %q when BackupPVC is set", p.BackupPVC) + } + if got.Namespace != p.MinecraftNamespace { + t.Errorf("backup PVC namespace = %q, want %q (Pod↔PVC mounts are same-namespace only)", got.Namespace, p.MinecraftNamespace) + } + if len(got.Spec.AccessModes) != 1 || got.Spec.AccessModes[0] != corev1.ReadWriteOnce { + t.Errorf("backup PVC access modes = %v, want [ReadWriteOnce]", got.Spec.AccessModes) + } + if q := got.Spec.Resources.Requests.Storage(); q == nil || q.String() != backupStorageSize { + t.Errorf("backup PVC storage = %v, want %s", q, backupStorageSize) + } + if got.Spec.StorageClassName != nil { + t.Errorf("backup PVC pins storageClassName %q; the cluster default is the only safe binding", *got.Spec.StorageClassName) + } + + for _, o := range Workloads(testParams()) { + if pvc, ok := o.(*corev1.PersistentVolumeClaim); ok && pvc.Name == "felis-backups" { + t.Error("no backup PVC may render when none is named") + } + } +} + // TestOperatorDeployment_Wiring pins the operator entrypoint, its namespace split, // and its deliberately smaller surface (NO config Secret — it holds no DB URL). func TestOperatorDeployment_Wiring(t *testing.T) {