feat(reconcile): re-assert pool membership after restore-over-existing (campaign-2 R2, v0.74.0)

Pool membership is what lets the pool-scoped token reach a guest; pct restore
--pool sets it only at CREATE, so a restore over an existing VMID drops the guest
from the felhom pool and 403s the next restore-test/DR on VM.Audit. This empty-pool
state is the true root cause of the campaign's "R1" (bind-mount restore failing was
a symptom — restore-test's existing bind neutralization never ran without config-read).

Add Client.PoolAddVMID (PUT /pools, additive+idempotent, Pool.Allocate) and call it
in bring-up after liveness when spec.Pool!="" — warn-not-fail on a hiccup (liveness
wins). B3 scratch-teardown 403 diagnosed as a cascade (restoretest already passes
Pool). Role/ACL untouched. Tests + red-proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-07 18:42:19 +02:00
parent e04b75e1f8
commit ca0b169a4e
8 changed files with 278 additions and 0 deletions
+31
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
)
// Async mutating operations. Each is API-token-covered (the FelhomAgent role) and
@@ -185,6 +186,36 @@ func (c *Client) SetConfig(ctx context.Context, vmid int, params map[string]stri
return c.dataString(ctx, http.MethodPut, path, v)
}
// PoolAddVMID adds a guest to a PVE pool via PUT /pools/{pool} with vms={vmid}.
//
// Membership is what makes a pool-scoped token (FelhomAgentGuest @ /pool/<pool>) reach a guest: the
// grant applies only to pool MEMBERS. `pct restore --pool` sets membership at CREATE, but a restore
// OVER AN EXISTING VMID (the P9/host-loss finale) does NOT re-apply it — so a destroy-restore
// silently drops the guest from the pool and 403s the NEXT restore-test/DR (campaign-2 R2). This
// re-asserts it after such a restore.
//
// PVE semantics: `PUT /pools/{poolid}` with `vms` is ADDITIVE (a merge) — `delete=1` is required to
// REMOVE, so passing a single vmid adds it without disturbing existing members. Adding a guest that
// is already a member is treated as a no-op success (idempotent): PVE reports "already" in the error
// body, which we swallow. Requires Pool.Allocate at /pool/<pool> (the token has it).
func (c *Client) PoolAddVMID(ctx context.Context, pool string, vmid int) error {
if pool == "" || vmid == 0 {
return fmt.Errorf("proxmox: PoolAddVMID needs pool and vmid")
}
v := url.Values{}
v.Set("vms", strconv.Itoa(vmid))
path := "/pools/" + url.PathEscape(pool)
_, err := c.dataString(ctx, http.MethodPut, path, v)
if err != nil {
// Idempotent: a guest already in the pool is success, not a failure.
if ae, ok := err.(*APIError); ok && strings.Contains(strings.ToLower(ae.Body), "already") {
return nil
}
return err
}
return nil
}
// ResizeLXC grows a guest volume via PUT /nodes/{node}/lxc/{vmid}/resize
// (token-covered: VM.Config.Disk + Datastore.AllocateSpace). Returns the UPID.
//