fix(install): backups exist on a default install; retention resolves real world dirs (#6)
Three faces of one gap, all on the supported install path: - Backup/restore answered 503 out of the box: nothing ever rendered the archive PVC, so FELIS_BACKUP_PVC was unset. The bundle now renders the PVC (Minecraft namespace, RWO 10Gi, cluster default class) and 'felis manifests' names it by default (--backup-pvc= is the explicit no-store shape); bootstrap passes it through so the generated felis.toml [archive] local_path and the jobs' mount path come from one variable. - Retention was unreachable: bootstrap never passed the reaper flags. It now forwards FELIS_WORLDS_HOST_PATH/FELIS_ARCHIVE_LOCAL_PATH, so one env enables the daily CronJob; unset keeps today's fail-safe (no reaper, nothing deleted). - Even when enabled it could not find a world on a stock install: resolveWorldDir now also resolves the exact local-path directory <pv-name>_<ns>_<pvc-name> read from the live PVC's volumeName (never a glob, so a stale deleted PV's bytes can't be archived in place of the current world). Reaper Role gains persistentvolumeclaims:get (weaker than the delete it already held). README (zh/en) stops promising automatic/scheduled backups and states retention is opt-in. bootstrap_test covers the env->flag contract.
This commit is contained in:
14 files changed
+394
-82
No files matched your search
@@ -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 登录**:支持指纹、面容、硬件密钥等无密码认证方式。
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+23
-17
@@ -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 <path>/<pvc>; 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 <path>/<pvc>, or as the stock local-path directory <path>/<pv-name>_<ns>_<pvc-name> (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/<pvc> on the node: a stock local-path-provisioner lays "+
|
||||
"volumes under PV-name paths (.../pvc-<uuid>_<ns>_<pvc>/), so unless the worlds StorageClass is "+
|
||||
"arranged to expose <path>/<pvc>, the reaper tars an empty directory;\n"+
|
||||
" - the node's world volumes must actually live below %s: the reaper resolves a world as "+
|
||||
"%s/<pvc>, then as the stock local-path directory <path>/<pv-name>_<ns>_<pvc-name> (what k3s "+
|
||||
"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"+
|
||||
" - 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{
|
||||
|
||||
@@ -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 <path>/<pvc> 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())
|
||||
}
|
||||
|
||||
+58
-20
@@ -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: <root>/<pvc>)")
|
||||
worldsRoot := fs.String("worlds-root", "/worlds", "mount root under which world PVCs are visible (tarLocal: <root>/<pvc>, else the stock local-path <root>/<pv-name>_<ns>_<pvc-name>)")
|
||||
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 <worldsRoot>/<pvc>, 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. <root>/<pvc> — the reaper's documented arrangement (worlds exposed by PVC
|
||||
// name, e.g. via mounting each volume or a crafted storage class).
|
||||
// 2. <root>/<pv-name>_<namespace>_<pvc-name> — 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) {
|
||||
|
||||
@@ -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 <root>/<pvc> 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+42
-6
@@ -63,6 +63,15 @@
|
||||
# FELIS_ROOT_DOMAIN deployment root domain (default: <node-ip>.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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <WorldsHostPath>/<pvc> — 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 <root>/<pvc>. Stock local-path-provisioner lays volumes out
|
||||
// under PV-name paths (…/pvc-<uuid>_<ns>_<pvc>/), NOT <root>/<pvc>, so this mount
|
||||
// only finds worlds if the operator/storage is deliberately arranged to expose
|
||||
// them as <root>/<pvc>. 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 <root>/<pvc> first and then for the stock local-path-provisioner layout
|
||||
// <root>/<pv-name>_<ns>_<pvc-name> — 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 <root>/<pvc> 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
|
||||
|
||||
@@ -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"}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 <WorldsHostPath>/<pvc> on a given cluster depends on
|
||||
// how that node's storage is arranged (stock local-path-provisioner uses
|
||||
// PV-name paths, not <root>/<pvc>) 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 <WorldsHostPath>/<pvc>
|
||||
// or in the stock local-path layout k3s writes under its storage root
|
||||
// (<pv-name>_<ns>_<pvc-name>, 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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user