v0.53.0: restore guests INTO the felhom pool (pool-scoped-ACL enabler)
RestoreLXCOptions.Pool → pct restore --pool (omit-when-empty). New reconcile.DefaultPool="felhom"; BringUpSpec.Pool threaded to the bring-up restore; BOTH restore sites pool the guest (provision/DR via spec.Pool set to DefaultPool by the CLI; restore-test scratch via DefaultPool = SPIKE residual #2). No agent ACL change (ships in host-install v1.6.0); the pool param is inert until the token has Pool.Allocate + the pool exists, so publishing is safe ahead of the coordinated swap. Tests + red-proofs; go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,28 @@
|
|||||||
|
## v0.53.0 — restore guests INTO the felhom pool (pool-scoped-ACL enabler) (2026-07-01)
|
||||||
|
|
||||||
|
Colleague-safety batch #4 phase b (agent half). Enables the agent token to be scoped from `/` to
|
||||||
|
`/pool/felhom` + `/storage/<targets>` (real blast-radius containment on a shared host) by making every
|
||||||
|
restore allocate the guest INTO the pool — the only way a fresh vmid authorizes under a pool-scoped
|
||||||
|
token. Grounded by `felhom.eu/documentation/audits/SPIKE-pool-scoped-acl-2026-07-01.md` (PASS).
|
||||||
|
|
||||||
|
- **`internal/proxmox/mutate.go`:** `RestoreLXCOptions` gains `Pool string`; `RestoreLXC` sends
|
||||||
|
`pool=<p>` only when non-empty (`pct restore --pool`). Omit-when-empty (a broad-token restore needs
|
||||||
|
no pool) — unit-tested + red-proofed.
|
||||||
|
- **`internal/reconcile`:** new `const DefaultPool = "felhom"` (single source of truth); `BringUpSpec`
|
||||||
|
gains `Pool`, threaded to the bring-up restore. **Both restore sites** now pool the guest: the
|
||||||
|
provision/DR bring-up (`Pool: spec.Pool`, set to `DefaultPool` by the CLI) AND the **restore-test**
|
||||||
|
scratch guest (`Pool: DefaultPool`) — the latter closes SPIKE residual #2 (a pool-scoped token would
|
||||||
|
otherwise 403 on the out-of-pool scratch guest).
|
||||||
|
- **`cmd/felhom-agent/main.go`:** both `BringUpSpec` literals (bring-up/DR + provision) set
|
||||||
|
`Pool: reconcile.DefaultPool`.
|
||||||
|
- **No ACL/priv change in the agent** — that ships in the host-install script (v1.6.0). The pool param
|
||||||
|
is INERT until the token is granted `Pool.Allocate` at `/pool/felhom` and the pool exists; publishing
|
||||||
|
is therefore safe ahead of the coordinated ACL swap.
|
||||||
|
- New tests: `proxmox.TestRestoreLXC_PoolParam` (set → `pool=felhom`; empty → omitted),
|
||||||
|
`reconcile.TestRestoreSitesUsePool` (both restore sites carry `DefaultPool`). Both red-proofed
|
||||||
|
(unconditional `Set` → omit test fails; drop either site's `Pool` → both-sites test fails). `go
|
||||||
|
build`/`vet`/`test ./...` clean.
|
||||||
|
|
||||||
## v0.52.0 — operator-opt-in CPU/RAM cap for the provisioned guest (`-cores` / `-memory`) (2026-07-01)
|
## 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
|
Colleague-safety batch #3. So a trial appliance guest on a colleague's SHARED production Proxmox does
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ so cleanly if the token/endpoint isn't configured.
|
|||||||
into the SAME pre-start config PUT as the identity reset (via `BringUpSpec.Cores/MemoryMB`), so the
|
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.
|
guest never boots uncapped — useful when the appliance shares a host with other guests.
|
||||||
|
|
||||||
|
**Pool-scoped restore (v0.53.0):** every restore (provision bring-up, DR, and restore-test) allocates
|
||||||
|
the guest INTO the `felhom` PVE pool (`reconcile.DefaultPool`; `RestoreLXCOptions.Pool` → `pct restore
|
||||||
|
--pool`). This is what lets the agent token be scoped to `/pool/felhom` + `/storage/<targets>` instead
|
||||||
|
of `/` (blast-radius containment on a shared host) — the restore is how a fresh vmid is allocated under
|
||||||
|
that scoped token (`VM.Allocate`+`Pool.Allocate` at `/pool/felhom`). Layout + validation:
|
||||||
|
`felhom.eu/documentation/audits/SPIKE-pool-scoped-acl-2026-07-01.md`.
|
||||||
|
|
||||||
## Process model
|
## Process model
|
||||||
|
|
||||||
Native Go binary, systemd service, **non-root** `felhom-agent` service user holding the scoped token,
|
Native Go binary, systemd service, **non-root** `felhom-agent` service user holding the scoped token,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import (
|
|||||||
|
|
||||||
// version is the agent version. Overridable at build time with
|
// version is the agent version. Overridable at build time with
|
||||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||||
var version = "0.52.0"
|
var version = "0.53.0"
|
||||||
|
|
||||||
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
||||||
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
||||||
@@ -1216,6 +1216,7 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log
|
|||||||
spec := reconcile.BringUpSpec{
|
spec := reconcile.BringUpSpec{
|
||||||
Mode: bmode, Archive: archive, VMID: vmid, RestoreStorage: cfg.Backup.RestoreStorage,
|
Mode: bmode, Archive: archive, VMID: vmid, RestoreStorage: cfg.Backup.RestoreStorage,
|
||||||
Hostname: hostname, KeepMAC: bmode == reconcile.ModeDRGuestLoss,
|
Hostname: hostname, KeepMAC: bmode == reconcile.ModeDRGuestLoss,
|
||||||
|
Pool: reconcile.DefaultPool, // restore into the felhom pool (both provision + DR); required under the scoped token
|
||||||
Cores: sizing.Cores, MemoryMB: sizing.MemoryMB,
|
Cores: sizing.Cores, MemoryMB: sizing.MemoryMB,
|
||||||
RootfsGrowGB: sizing.RootfsGrowGB, DataVolGrowGB: sizing.DataVolGrowGB, DataVolMount: sizing.DataVolMount,
|
RootfsGrowGB: sizing.RootfsGrowGB, DataVolGrowGB: sizing.DataVolGrowGB, DataVolMount: sizing.DataVolMount,
|
||||||
SysDataGrowGB: sizing.SysDataGrowGB, SysDataMount: sizing.SysDataMount,
|
SysDataGrowGB: sizing.SysDataGrowGB, SysDataMount: sizing.SysDataMount,
|
||||||
@@ -1370,6 +1371,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
|
|||||||
res := engine.RunBringUp(ctx, reconcile.BringUpSpec{
|
res := engine.RunBringUp(ctx, reconcile.BringUpSpec{
|
||||||
Mode: reconcile.ModeProvision, Archive: a.archive, VMID: a.vmid,
|
Mode: reconcile.ModeProvision, Archive: a.archive, VMID: a.vmid,
|
||||||
RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname,
|
RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname,
|
||||||
|
Pool: reconcile.DefaultPool, // provision into the felhom pool; required under the scoped token
|
||||||
Cores: a.sizing.Cores, MemoryMB: a.sizing.MemoryMB,
|
Cores: a.sizing.Cores, MemoryMB: a.sizing.MemoryMB,
|
||||||
RootfsGrowGB: a.sizing.RootfsGrowGB, DataVolGrowGB: a.sizing.DataVolGrowGB, DataVolMount: a.sizing.DataVolMount,
|
RootfsGrowGB: a.sizing.RootfsGrowGB, DataVolGrowGB: a.sizing.DataVolGrowGB, DataVolMount: a.sizing.DataVolMount,
|
||||||
SysDataGrowGB: a.sizing.SysDataGrowGB, SysDataMount: a.sizing.SysDataMount,
|
SysDataGrowGB: a.sizing.SysDataGrowGB, SysDataMount: a.sizing.SysDataMount,
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ type RestoreLXCOptions struct {
|
|||||||
Archive string // source archive volid, e.g. "local:backup/vzdump-lxc-9001-...tar.zst"
|
Archive string // source archive volid, e.g. "local:backup/vzdump-lxc-9001-...tar.zst"
|
||||||
Storage string // target storage for the rootfs, e.g. "local-lvm"
|
Storage string // target storage for the rootfs, e.g. "local-lvm"
|
||||||
Force bool // overwrite an existing VMID (destructive — caller must have authority)
|
Force bool // overwrite an existing VMID (destructive — caller must have authority)
|
||||||
|
// Pool allocates the restored guest INTO a PVE pool (pct restore --pool). "" = no pool. Under a
|
||||||
|
// pool-scoped token this is REQUIRED for the restore to authorize (VM.Allocate + Pool.Allocate are
|
||||||
|
// granted at /pool/<pool>, not /), and it makes the guest reachable by the scoped token afterwards
|
||||||
|
// (SPIKE-pool-scoped-acl-2026-07-01). Empty is valid — a broad-token restore needs no pool.
|
||||||
|
Pool string
|
||||||
// MountOverrides overrides specific mountpoints at restore time (mpN -> full value, e.g.
|
// MountOverrides overrides specific mountpoints at restore time (mpN -> full value, e.g.
|
||||||
// "local-lvm:1,mp=/data,backup=0"). A restore param takes precedence over the archive's own
|
// "local-lvm:1,mp=/data,backup=0"). A restore param takes precedence over the archive's own
|
||||||
// mpN. The restore-test uses this to neutralize a SOURCE host bind-mount mountpoint (slice-10
|
// mpN. The restore-test uses this to neutralize a SOURCE host bind-mount mountpoint (slice-10
|
||||||
@@ -59,6 +64,9 @@ func (c *Client) RestoreLXC(ctx context.Context, opts RestoreLXCOptions) (string
|
|||||||
if opts.Force {
|
if opts.Force {
|
||||||
v.Set("force", "1")
|
v.Set("force", "1")
|
||||||
}
|
}
|
||||||
|
if opts.Pool != "" {
|
||||||
|
v.Set("pool", opts.Pool) // allocate into the pool (pool-scoped token needs this — see RestoreLXCOptions.Pool)
|
||||||
|
}
|
||||||
for k, val := range opts.MountOverrides {
|
for k, val := range opts.MountOverrides {
|
||||||
v.Set(k, val) // e.g. mp0 -> "local-lvm:1,mp=/data,backup=0" (overrides the archive's mp0)
|
v.Set(k, val) // e.g. mp0 -> "local-lvm:1,mp=/data,backup=0" (overrides the archive's mp0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreLXC sends pool= only when Pool is set. Under a pool-scoped token the restore MUST carry
|
||||||
|
// pool=felhom (else VM.Allocate/Pool.Allocate 403); with Pool empty it MUST be omitted (a naive
|
||||||
|
// unconditional Set would send an empty pool= — omit-when-empty is the guarantee).
|
||||||
|
func TestRestoreLXC_PoolParam(t *testing.T) {
|
||||||
|
var body string
|
||||||
|
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
body = string(b)
|
||||||
|
return jsonResp(200, `{"data":"`+testUPID+`"}`), nil
|
||||||
|
}}
|
||||||
|
c := newTestClient(d)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pool set → pool=felhom present in the POST body.
|
||||||
|
if _, err := c.RestoreLXC(ctx, RestoreLXCOptions{VMID: 9100, Archive: "local:backup/a.tar.zst", Storage: "local-lvm", Pool: "felhom"}); err != nil {
|
||||||
|
t.Fatalf("RestoreLXC(Pool): %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "pool=felhom") {
|
||||||
|
t.Errorf("Pool set: expected pool=felhom in body, got %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool empty → NO pool key at all (omit-when-empty).
|
||||||
|
if _, err := c.RestoreLXC(ctx, RestoreLXCOptions{VMID: 9100, Archive: "local:backup/a.tar.zst", Storage: "local-lvm"}); err != nil {
|
||||||
|
t.Fatalf("RestoreLXC(no Pool): %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "pool=") {
|
||||||
|
t.Errorf("Pool empty: pool key must be ABSENT, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,12 @@ const bringUpKind = "bring_up"
|
|||||||
// DefaultDataVolMount is the mpN slot the golden bakes the Docker-data volume (/var/lib/docker) at.
|
// DefaultDataVolMount is the mpN slot the golden bakes the Docker-data volume (/var/lib/docker) at.
|
||||||
const DefaultDataVolMount = "mp0"
|
const DefaultDataVolMount = "mp0"
|
||||||
|
|
||||||
|
// DefaultPool is the PVE pool every Felhom-managed guest is restored INTO. Under the pool-scoped agent
|
||||||
|
// token, restore-into-pool is how a fresh vmid gets allocated (VM.Allocate + Pool.Allocate at
|
||||||
|
// /pool/felhom) and how the guest becomes reachable by the scoped token (SPIKE-pool-scoped-acl-2026-07-01).
|
||||||
|
// Single source of truth for both restore sites (provision bring-up + restore-test).
|
||||||
|
const DefaultPool = "felhom"
|
||||||
|
|
||||||
// DefaultSysDataMount is the mpN slot the golden bakes the SSD user-data volume (/mnt/sys_drive) at.
|
// DefaultSysDataMount is the mpN slot the golden bakes the SSD user-data volume (/mnt/sys_drive) at.
|
||||||
// This is the controller's system_data_path; provision grows it (SysDataGrowGB) like the Docker-data
|
// This is the controller's system_data_path; provision grows it (SysDataGrowGB) like the Docker-data
|
||||||
// volume. mp1 is the natural next bring-up slot (mp8/mp9 are added by the provision back-half).
|
// volume. mp1 is the natural next bring-up slot (mp8/mp9 are added by the provision back-half).
|
||||||
@@ -76,6 +82,7 @@ type BringUpSpec struct {
|
|||||||
VMID int // caller-provided target VMID (NOT the restore-test band / 9999)
|
VMID int // caller-provided target VMID (NOT the restore-test band / 9999)
|
||||||
RestoreStorage string // rootfs target storage
|
RestoreStorage string // rootfs target storage
|
||||||
Hostname string // hostname to set (provision); ignored for DR (continuity)
|
Hostname string // hostname to set (provision); ignored for DR (continuity)
|
||||||
|
Pool string // restore the guest INTO this PVE pool ("" = none); required under a pool-scoped token
|
||||||
Cores int // 0 = leave as restored
|
Cores int // 0 = leave as restored
|
||||||
MemoryMB int // 0 = leave as restored
|
MemoryMB int // 0 = leave as restored
|
||||||
RootfsGrowGB int // optional grow-only rootfs resize (0 = skip)
|
RootfsGrowGB int // optional grow-only rootfs resize (0 = skip)
|
||||||
@@ -200,7 +207,7 @@ func (e *Engine) runBringUp(ctx context.Context, spec BringUpSpec, res *BringUpR
|
|||||||
|
|
||||||
// 1. Restore archive → VMID (token-covered ClassCreate; keyctl preserved — phase3 + spike).
|
// 1. Restore archive → VMID (token-covered ClassCreate; keyctl preserved — phase3 + spike).
|
||||||
upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{
|
upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{
|
||||||
VMID: spec.VMID, Archive: spec.Archive, Storage: spec.RestoreStorage,
|
VMID: spec.VMID, Archive: spec.Archive, Storage: spec.RestoreStorage, Pool: spec.Pool,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Err = fmt.Errorf("reconcile: bring-up restore: %w", err)
|
res.Err = fmt.Errorf("reconcile: bring-up restore: %w", err)
|
||||||
|
|||||||
@@ -119,6 +119,42 @@ func TestBuildBringUpConfig_ResourceCaps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both restore sites allocate the guest INTO the felhom pool (SPIKE 3b): the provision bring-up
|
||||||
|
// threads spec.Pool, and the restore-test hardcodes DefaultPool — else a pool-scoped token 403s on
|
||||||
|
// the created guest's config/start/destroy. Asserts via the fakeAPI's captured RestoreLXCOptions.
|
||||||
|
func TestRestoreSitesUsePool(t *testing.T) {
|
||||||
|
// provision bring-up: spec.Pool must flow to the restore
|
||||||
|
const vmid = 8060
|
||||||
|
api := &fakeAPI{cfg: map[int]proxmox.GuestConfig{vmid: scratchCfg()}}
|
||||||
|
e, _, q := newEngine(t, api, EmptyProvider{})
|
||||||
|
defer q.Close()
|
||||||
|
res := e.RunBringUp(context.Background(), BringUpSpec{
|
||||||
|
Mode: ModeProvision, Archive: "local:backup/g.tar.zst", VMID: vmid,
|
||||||
|
RestoreStorage: "local-lvm", Pool: DefaultPool,
|
||||||
|
})
|
||||||
|
if res.Err != nil || !res.Pass {
|
||||||
|
t.Fatalf("bring-up must pass: %+v", res)
|
||||||
|
}
|
||||||
|
if len(api.restores) != 1 || api.restores[0].Pool != DefaultPool {
|
||||||
|
t.Fatalf("provision restore must set Pool=%q, got %+v", DefaultPool, api.restores)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restore-test: the scratch restore hardcodes DefaultPool
|
||||||
|
api2 := &fakeAPI{cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
|
||||||
|
e2, _, q2 := newEngine(t, api2, EmptyProvider{})
|
||||||
|
defer q2.Close()
|
||||||
|
rt := e2.RunRestoreTest(context.Background(), RestoreTestSpec{
|
||||||
|
Archive: "local:backup/x.tar.zst", RestoreStorage: "local-lvm",
|
||||||
|
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
|
||||||
|
})
|
||||||
|
if rt.Err != nil || !rt.Pass {
|
||||||
|
t.Fatalf("restore-test must pass: %+v", rt)
|
||||||
|
}
|
||||||
|
if len(api2.restores) != 1 || api2.restores[0].Pool != DefaultPool {
|
||||||
|
t.Fatalf("restore-test restore must set Pool=%q, got %+v", DefaultPool, api2.restores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The golden-carried Docker-data volume is grown via a SEPARATE resize on its mpN slot (B4),
|
// The golden-carried Docker-data volume is grown via a SEPARATE resize on its mpN slot (B4),
|
||||||
// alongside (but distinct from) the rootfs grow.
|
// alongside (but distinct from) the rootfs grow.
|
||||||
func TestRunBringUp_StorageSplit_DataVolGrow(t *testing.T) {
|
func TestRunBringUp_StorageSplit_DataVolGrow(t *testing.T) {
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{
|
upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{
|
||||||
VMID: vmid, Archive: spec.Archive, Storage: spec.RestoreStorage, MountOverrides: mountOverrides,
|
// Pool=DefaultPool so the scratch guest is created INTO the felhom pool — else a pool-scoped
|
||||||
|
// token 403s on the scratch guest's config/start/destroy (SPIKE residual #2).
|
||||||
|
VMID: vmid, Archive: spec.Archive, Storage: spec.RestoreStorage, MountOverrides: mountOverrides, Pool: DefaultPool,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Err = fmt.Errorf("reconcile: restore-test restore: %w", err)
|
res.Err = fmt.Errorf("reconcile: restore-test restore: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user