diff --git a/cmd/felis/manifests.go b/cmd/felis/manifests.go index 947e1e2..b17a01d 100644 --- a/cmd/felis/manifests.go +++ b/cmd/felis/manifests.go @@ -49,6 +49,9 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { 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") + registryStorage := fs.String("registry-storage", "", "capacity the registry PVC requests (default 10Gi; k3s local-path does not enforce it)") + uploadsStorage := fs.String("uploads-storage", "", "capacity the uploads PVC requests (default 5Gi; k3s local-path does not enforce it)") + backupStorage := fs.String("backup-storage", "", "capacity the world-archive PVC requests (default 10Gi; k3s local-path does not enforce it)") reaperNode := fs.String("reaper-node", "", "node that holds --worlds-host-path: pins the reaper CronJob's pod there via nodeSelector kubernetes.io/hostname (multi-node clusters need this, or the reaper may schedule where the hostPath is empty)") var velocityCIDRs multiFlag fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of a Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)") @@ -139,7 +142,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { "(pass --worlds-host-path and --archive-local-path — the archive PVC defaults to felis-backups — to enable it)") } - out, err := platform.RenderYAML(platform.Params{ + params := platform.Params{ ControlNamespace: *controlNS, MinecraftNamespace: *minecraftNS, BuildNamespace: *buildNS, @@ -157,7 +160,16 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { ServerEgressDenyCIDRs: []string(serverDenyCIDRs), ServerEgressAllowCIDRs: []string(serverAllowCIDRs), - }) + + RegistryStorage: *registryStorage, + UploadsStorage: *uploadsStorage, + BackupStorage: *backupStorage, + } + if err := params.Validate(); err != nil { + fmt.Fprintf(stderr, "felis manifests: %v\n", err) + return 2 + } + out, err := platform.RenderYAML(params) if err != nil { fmt.Fprintf(stderr, "felis manifests: render: %v\n", err) return 1 diff --git a/cmd/felis/manifests_test.go b/cmd/felis/manifests_test.go index 83fa25c..6fd40d0 100644 --- a/cmd/felis/manifests_test.go +++ b/cmd/felis/manifests_test.go @@ -219,3 +219,27 @@ func TestManifestsReaperNodePin(t *testing.T) { t.Errorf("--reaper-node without --worlds-host-path: exit = %d, want 2", code) } } + +// TestManifestsStorageSizes proves the PVC size flags reach the rendered claims +// and a size the API server would reject fails before anything is applied. +func TestManifestsStorageSizes(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", + "--registry-storage", "40Gi", "--uploads-storage", "8Gi"}, &out, &errBuf) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, errBuf.String()) + } + for _, want := range []string{"storage: 40Gi", "storage: 8Gi"} { + if !strings.Contains(out.String(), want) { + t.Errorf("bundle lacks %q", want) + } + } + + out.Reset() + errBuf.Reset() + code = run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", + "--registry-storage", "lots"}, &out, &errBuf) + if code == 0 || !strings.Contains(errBuf.String(), "registry storage") { + t.Errorf("--registry-storage lots: exit %d, stderr %q; want a refusal naming the flag", code, errBuf.String()) + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 2986842..f680030 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -77,6 +77,10 @@ # worlds idle beyond the retention window, and grants the reaper's # uid (1000) traverse access to that root — k3s ships it 0700 # root:root (default: unset = no reaper) +# FELIS_REGISTRY_STORAGE / FELIS_UPLOADS_STORAGE / FELIS_BACKUP_STORAGE capacity the +# registry, uploads and world-archive PVCs request on first install +# (defaults: 10Gi, 5Gi, 10Gi). An existing claim keeps its size; on +# k3s local-path the number is not enforced, see troubleshooting §9 # PKG_LOCK_TIMEOUT seconds to wait for package-manager locks (default: 900) # APT_LOCK_TIMEOUT legacy alias for PKG_LOCK_TIMEOUT set -Eeuo pipefail @@ -134,6 +138,10 @@ FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}" # 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}" +# PVC capacities; empty keeps `felis manifests`' defaults. +FELIS_REGISTRY_STORAGE="${FELIS_REGISTRY_STORAGE:-}" +FELIS_UPLOADS_STORAGE="${FELIS_UPLOADS_STORAGE:-}" +FELIS_BACKUP_STORAGE="${FELIS_BACKUP_STORAGE:-}" # 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 @@ -2828,6 +2836,15 @@ deploy_bundle() { fi manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH" --archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH") fi + local size + size="$(pvc_size "$CONTROL_NS" registry "$FELIS_REGISTRY_STORAGE" FELIS_REGISTRY_STORAGE)" + if [ -n "$size" ]; then manifest_args+=(--registry-storage "$size"); fi + size="$(pvc_size "$CONTROL_NS" felis-uploads "$FELIS_UPLOADS_STORAGE" FELIS_UPLOADS_STORAGE)" + if [ -n "$size" ]; then manifest_args+=(--uploads-storage "$size"); fi + if [ -n "$FELIS_BACKUP_PVC" ]; then + size="$(pvc_size "$MINECRAFT_NS" "$FELIS_BACKUP_PVC" "$FELIS_BACKUP_STORAGE" FELIS_BACKUP_STORAGE)" + if [ -n "$size" ]; then manifest_args+=(--backup-storage "$size"); fi + fi "$HOST_BIN" manifests "${manifest_args[@]}" | kube apply -f - restart_existing_control_plane "$had_api" "$had_operator" @@ -2841,6 +2858,24 @@ deploy_bundle() { done } +# pvc_size prints the size to render the claim +# with: its current request when it exists, else the wanted size (empty = the renderer's +# default). A claim's request can only grow, and only on a storage class that allows +# expansion (k3s local-path does not), so re-applying a different size would fail the +# whole apply; a mismatch is reported and left to the operator. +pvc_size() { + local ns="$1" claim="$2" want="$3" env="$4" have + have="$(kube -n "$ns" get pvc "$claim" -o jsonpath='{.spec.resources.requests.storage}' 2>/dev/null || true)" + if [ -z "$have" ]; then + printf '%s' "$want" + return 0 + fi + if [ -n "$want" ] && [ "$want" != "$have" ]; then + warn "PVC ${ns}/${claim} already requests ${have}; keeping it (${env}=${want} applies to a new claim; grow this one with kubectl patch where its storage class allows expansion)" + fi + printf '%s' "$have" +} + restart_existing_control_plane() { local had_api="$1" had_operator="$2" [ "$had_api$had_operator" != "00" ] || return 0 diff --git a/internal/platform/identities.go b/internal/platform/identities.go index 84272e8..be039c1 100644 --- a/internal/platform/identities.go +++ b/internal/platform/identities.go @@ -1,5 +1,11 @@ package platform +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/resource" +) + // Identity constants and the Params that parameterise the install bundle. // // The label and SA-name constants are PINNED here because three independent @@ -171,6 +177,35 @@ type Params struct { // must-match. It is reaper-only and has no default; empty (with WorldsHostPath set) // is rejected fail-loud by the generator. ArchiveLocalPath string + // RegistryStorage, UploadsStorage and BackupStorage are the capacities the + // registry, uploads and world-archive PVCs request ("20Gi"); empty keeps + // 10Gi, 5Gi and 10Gi. A PVC's request can only grow, and only on a class that + // allows expansion, so the installer keeps an existing PVC's size. On k3s + // local-path the request is a label: the volume is a directory on the node's + // disk and nothing stops it outgrowing the number; the registry pruner, the + // uploads budget (user_uploads_max_bytes) and the archive cap + // (max_local_bytes) are what bound them there. + RegistryStorage string + UploadsStorage string + BackupStorage string +} + +// Validate reports a Params the renderer cannot turn into objects. +func (p Params) Validate() error { + for _, q := range []struct{ name, v string }{ + {"registry storage", p.RegistryStorage}, + {"uploads storage", p.UploadsStorage}, + {"backup storage", p.BackupStorage}, + } { + if q.v == "" { + continue + } + v, err := resource.ParseQuantity(q.v) + if err != nil || v.Sign() <= 0 { + return fmt.Errorf("%s %q is not a positive size such as 20Gi", q.name, q.v) + } + } + return nil } // withDefaults returns a copy of p with zero namespace/registry fields filled. @@ -198,6 +233,15 @@ func (p Params) withDefaults() Params { if p.RegistryImage == "" { p.RegistryImage = defaultRegistryImage } + if p.RegistryStorage == "" { + p.RegistryStorage = registryStorageSize + } + if p.UploadsStorage == "" { + p.UploadsStorage = uploadsStorageSize + } + if p.BackupStorage == "" { + p.BackupStorage = backupStorageSize + } return p } diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 88fa9de..61911ff 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -1102,7 +1102,7 @@ func registryPVC(p Params) *corev1.PersistentVolumeClaim { Spec: corev1.PersistentVolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(registryStorageSize)}, + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(p.RegistryStorage)}, }, }, } @@ -1123,7 +1123,7 @@ func uploadsPVC(p Params) *corev1.PersistentVolumeClaim { Spec: corev1.PersistentVolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(uploadsStorageSize)}, + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(p.UploadsStorage)}, }, }, } @@ -1147,7 +1147,7 @@ func backupPVC(p Params) *corev1.PersistentVolumeClaim { Spec: corev1.PersistentVolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(backupStorageSize)}, + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(p.BackupStorage)}, }, }, } diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index 4308b47..e6a44d3 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -1131,3 +1131,37 @@ func TestOperatorMetricsService(t *testing.T) { t.Error("Workloads does not render the operator metrics Service") } } + +// TestPVCSizes proves the three claim sizes follow Params and default when unset, +// and that Validate refuses a size the API server would reject. +func TestPVCSizes(t *testing.T) { + sizes := func(p Params) map[string]string { + got := map[string]string{} + for _, o := range Workloads(p.withDefaults()) { + if pvc, ok := o.(*corev1.PersistentVolumeClaim); ok { + got[pvc.Name] = pvc.Spec.Resources.Requests.Storage().String() + } + } + return got + } + p := testParams() + p.BackupPVC = "felis-backups" + def := sizes(p) + if def[registryName] != registryStorageSize || def[uploadsPVCName] != uploadsStorageSize || def["felis-backups"] != backupStorageSize { + t.Errorf("default sizes = %v", def) + } + p.RegistryStorage, p.UploadsStorage, p.BackupStorage = "40Gi", "8Gi", "100Gi" + if got := sizes(p); got[registryName] != "40Gi" || got[uploadsPVCName] != "8Gi" || got["felis-backups"] != "100Gi" { + t.Errorf("configured sizes = %v", got) + } + if err := p.Validate(); err != nil { + t.Errorf("Validate(%+v) = %v", p, err) + } + for _, bad := range []string{"lots", "0", "-1Gi"} { + q := testParams() + q.UploadsStorage = bad + if err := q.Validate(); err == nil { + t.Errorf("Validate accepted uploads storage %q", bad) + } + } +}