From 0573ec95c14d0c00ca1dfff098e8032a981b6b3a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 1 Jul 2026 08:03:57 +0200 Subject: [PATCH] v0.52.0: operator-opt-in CPU/RAM cap for the provisioned guest (-cores/-memory) New -cores N / -memory M (MiB) flags for --selftest=bring-up|provision (0 = keep golden default), flowed through bringUpSizing into reconcile.BringUpSpec so the existing buildBringUpConfig emits cores/memory into the pre-start config PUT (the cap lands before first boot). No engine change. New pure-function test TestBuildBringUpConfig_ResourceCaps asserts the set + omit-when-zero cases (red-proof run + reverted). go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ README.md | 5 +++++ cmd/felhom-agent/main.go | 16 ++++++++++++---- internal/reconcile/bringup_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f5c0a..efff7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## v0.52.0 — operator-opt-in CPU/RAM cap for the provisioned guest (`-cores` / `-memory`) (2026-07-01) + +Colleague-safety batch #3. So a trial appliance guest on a colleague's SHARED production Proxmox does +not pressure his existing guests, the operator can now cap the guest's CPU cores + RAM **at provision +time, before the guest's first boot** (the peak container-pull moment). Pure CLI→spec plumbing — the +reconcile engine already applied the cap; this only wires the flags to it. + +- **`cmd/felhom-agent/main.go`:** new `-cores N` / `-memory M` (MiB) flags for + `--selftest=bring-up|provision` (0 = keep the golden's baked size). They flow through `bringUpSizing` + (now carries `Cores`/`MemoryMB`) into the `reconcile.BringUpSpec{Cores,MemoryMB}` built by BOTH + `runSelftestBringUp` and `runSelftestProvision`. The `-selftest` usage string documents them. +- **No engine change.** `internal/reconcile/bringup.go` already carries `BringUpSpec.Cores/MemoryMB` + (0 = leave as restored) and `buildBringUpConfig` already emits `cores`/`memory` into the SAME + coalesced config PUT as the identity reset — which runs BEFORE `e.api.Start`, so the cap lands + pre-boot. Rejected the `pct set`-post-provision alternative (runs after boot = an uncapped window; + bypasses the token/audit; second config source of truth). +- **Omit-when-zero guarantee:** an unset cap (0) emits NEITHER `cores` NOR `memory`, so an uncapped + provision keeps the golden defaults (no regression for the normal single-purpose box) and can never + shrink the guest to 0 cores. New pure-function test `TestBuildBringUpConfig_ResourceCaps` asserts + both the set (`cores=2`,`memory=4096`) and the absent-when-unset cases; a red-proof (unconditional + emit) was run and confirmed to fail the omit assertion, then reverted. +- **Deploy dependency:** a FRESH host-install `--cores`/`--memory` (felhom.eu script v1.4.0) requires + the hub artifact manifest to serve **agent ≥ v0.52.0**, else the old agent rejects the unknown flag. + The flags are opt-in, so nobody hits this until they intentionally cap. +- `go build` / `go vet` / `go test ./...` clean. + ## v0.51.0 — local vzdump retention default (`--prune-backups keep-last=3`) (2026-06-30) The PREVENTIVE counterpart to the hub's host_disk + storage_fill detectors: the agent's periodic local diff --git a/README.md b/README.md index 51dcd59..f92cd6b 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,11 @@ so cleanly if the token/endpoint isn't configured. `--selftest=task --vmid N` (explicitly gated) exercises `WaitTask` on a **reversible** op (snapshot → rollback → delete-snapshot) against guest `N`. Default `--selftest` never mutates. +`--selftest=bring-up|provision` accept an optional operator **CPU/RAM cap**: `-cores N` and +`-memory M` (MiB). Both default to `0` = keep the golden's baked sizes. When set, the cap is written +into the SAME pre-start config PUT as the identity reset (via `BringUpSpec.Cores/MemoryMB`), so the +guest never boots uncapped — useful when the appliance shares a host with other guests. + ## Process model Native Go binary, systemd service, **non-root** `felhom-agent` service user holding the scoped token, diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index a884428..d6cee2c 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -45,7 +45,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.51.0" +var version = "0.52.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots @@ -91,6 +91,8 @@ func main() { dataVolMount string sysDataGrow int sysDataMount string + cores int + memoryMB int pbsStorage string paperkey bool offline bool @@ -109,7 +111,7 @@ func main() { showVersion bool ) flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") - flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-hub-password; optional -rootfs-grow/-datavol-grow/-sysdata-grow; keeps the guest)") + flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; optional -cores/-memory cap; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-hub-password; optional -rootfs-grow/-datavol-grow/-sysdata-grow/-cores/-memory; keeps the guest)") flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task|backup|bring-up") flag.DurationVar(&watch, "watch", 0, "for --selftest=storage: run the watchdog verbose for this duration (e.g. 3m) with the re-mount response live; 0 = observe pass only") flag.StringVar(&archive, "archive", "", "for --selftest=restore-test|bring-up: the backup volid to restore (restore-test: default newest on the local target)") @@ -121,6 +123,8 @@ func main() { flag.StringVar(&dataVolMount, "datavol-mount", "", "for --selftest=bring-up|provision: the mpN slot of the Docker-data volume to grow (default mp0)") flag.IntVar(&sysDataGrow, "sysdata-grow", 0, "for --selftest=bring-up|provision: grow the golden's SSD user-data volume (mp1, /mnt/sys_drive) by this many GiB (0 = keep golden size)") flag.StringVar(&sysDataMount, "sysdata-mount", "", "for --selftest=bring-up|provision: the mpN slot of the user-data volume to grow (default mp1)") + flag.IntVar(&cores, "cores", 0, "for --selftest=bring-up|provision: cap the guest to N CPU cores (0 = keep golden default). Applied in the pre-start config PUT.") + flag.IntVar(&memoryMB, "memory", 0, "for --selftest=bring-up|provision: cap the guest RAM to N MiB (0 = keep golden default). Applied pre-start.") flag.StringVar(&pbsStorage, "storage", "", "for --selftest=escrow-create: the pbs storage whose key to escrow (default: escrow.pbs_storage_id)") flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)") flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))") @@ -178,13 +182,13 @@ func main() { case "bring-up": os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep, bringUpSizing{RootfsGrowGB: rootfsGrow, DataVolGrowGB: dataVolGrow, DataVolMount: dataVolMount, - SysDataGrowGB: sysDataGrow, SysDataMount: sysDataMount})) + SysDataGrowGB: sysDataGrow, SysDataMount: sysDataMount, Cores: cores, MemoryMB: memoryMB})) case "provision": os.Exit(runSelftestProvision(context.Background(), cfg, logger, provisionArgs{ archive: archive, vmid: vmid, hostname: hostname, customerID: custID, hubPassword: hubPassword, sizing: bringUpSizing{RootfsGrowGB: rootfsGrow, DataVolGrowGB: dataVolGrow, DataVolMount: dataVolMount, - SysDataGrowGB: sysDataGrow, SysDataMount: sysDataMount}, + SysDataGrowGB: sysDataGrow, SysDataMount: sysDataMount, Cores: cores, MemoryMB: memoryMB}, })) case "escrow-create": os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath)) @@ -1152,6 +1156,8 @@ type bringUpSizing struct { DataVolMount string SysDataGrowGB int SysDataMount string + Cores int // 0 = keep golden default (CPU-core cap applied pre-start) + MemoryMB int // 0 = keep golden default (RAM cap in MiB, applied pre-start) } func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Logger, mode, archive string, vmid int, hostname string, keep bool, sizing bringUpSizing) int { @@ -1210,6 +1216,7 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log spec := reconcile.BringUpSpec{ Mode: bmode, Archive: archive, VMID: vmid, RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname, KeepMAC: bmode == reconcile.ModeDRGuestLoss, + Cores: sizing.Cores, MemoryMB: sizing.MemoryMB, RootfsGrowGB: sizing.RootfsGrowGB, DataVolGrowGB: sizing.DataVolGrowGB, DataVolMount: sizing.DataVolMount, SysDataGrowGB: sizing.SysDataGrowGB, SysDataMount: sizing.SysDataMount, } @@ -1363,6 +1370,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L res := engine.RunBringUp(ctx, reconcile.BringUpSpec{ Mode: reconcile.ModeProvision, Archive: a.archive, VMID: a.vmid, RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname, + Cores: a.sizing.Cores, MemoryMB: a.sizing.MemoryMB, RootfsGrowGB: a.sizing.RootfsGrowGB, DataVolGrowGB: a.sizing.DataVolGrowGB, DataVolMount: a.sizing.DataVolMount, SysDataGrowGB: a.sizing.SysDataGrowGB, SysDataMount: a.sizing.SysDataMount, }) diff --git a/internal/reconcile/bringup_test.go b/internal/reconcile/bringup_test.go index 11fabfd..2835ba8 100644 --- a/internal/reconcile/bringup_test.go +++ b/internal/reconcile/bringup_test.go @@ -92,6 +92,33 @@ func TestBuildBringUpConfig_BackupFlagOnDataMount(t *testing.T) { } } +// The operator-opt-in CPU/RAM cap is emitted into the SAME pre-start config PUT (so the guest never +// boots uncapped), and — critically — is OMITTED when unset (0), so an uncapped provision keeps the +// golden's baked sizes instead of being shrunk to 0. Pure-function check on buildBringUpConfig. +func TestBuildBringUpConfig_ResourceCaps(t *testing.T) { + // caps set → both keys present, MiB integer for memory + capped := buildBringUpConfig(BringUpSpec{ + Mode: ModeProvision, Cores: 2, MemoryMB: 4096, + }, scratchCfg()) + if capped["cores"] != "2" { + t.Errorf("cores cap must be emitted: cores=%q", capped["cores"]) + } + if capped["memory"] != "4096" { + t.Errorf("memory cap must be emitted (MiB): memory=%q", capped["memory"]) + } + + // caps unset (0) → NEITHER key present (omit-when-zero; else the guest would shrink to 0 cores) + uncapped := buildBringUpConfig(BringUpSpec{ + Mode: ModeProvision, Cores: 0, MemoryMB: 0, + }, scratchCfg()) + if _, ok := uncapped["cores"]; ok { + t.Errorf("cores must be ABSENT when unset (golden default), got %q", uncapped["cores"]) + } + if _, ok := uncapped["memory"]; ok { + t.Errorf("memory must be ABSENT when unset (golden default), got %q", uncapped["memory"]) + } +} + // The golden-carried Docker-data volume is grown via a SEPARATE resize on its mpN slot (B4), // alongside (but distinct from) the rootfs grow. func TestRunBringUp_StorageSplit_DataVolGrow(t *testing.T) {