v0.62.0 — A1: pool-membership ownership check for the stale-lock reaper

staleLockController.Guests() = ListLXC ∩ GET /pools/felhom members (ownership
PROVEN via the pool registry, never assumed from enumeration scope); pool-read
failure fail-safes the whole recovery through the existing guest-list guard.
New Client.Pool read (needs Pool.Audit — host-install v1.9.0; Pool.Allocate
does NOT satisfy it, spike T2). Composed pve:pool-read capability (non-critical)
+ --selftest pool-read line. Red-proofed negative tests drive the REAL
controller over a broad-token-shaped fake.

Per SPIKE-a1-pool-membership-read-2026-07-03.md; audit A1
(AUDIT-blast-radius-hostroot-localapi-2026-07-02).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-03 13:25:05 +02:00
parent 3e760a559a
commit 3f37c5fc23
8 changed files with 386 additions and 26 deletions
+45 -4
View File
@@ -2,6 +2,8 @@ package localapi
import (
"context"
"fmt"
"log/slog"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
@@ -22,6 +24,10 @@ import (
// Scope is deliberately narrow: only the two vzdump-related locks are ever touched. migrate/disk/create/
// rollback/mounted/snapshot locks are left alone (they belong to a different operation, mid-flight or
// needing different handling). The recovery is idempotent and never touches a guest without a stale lock.
//
// Ownership (audit A1, v0.62.0): the scan covers ONLY felhom-pool members — the production controller
// intersects ListLXC with GET /pools/{pool} membership, so even under a broad token the reaper can
// never unlock/start a co-tenant's guest. A failed pool read fails safe (whole recovery skipped).
// staleBackupLocks are the lock values an interrupted vzdump can leave. ONLY these are cleared.
var staleBackupLocks = map[string]bool{
@@ -134,11 +140,14 @@ func (s *Server) recoverOneStaleLock(ctx context.Context, g proxmox.Guest) {
type staleLockController struct {
px staleLockAPI
runner proxmox.Runner
pool string // ownership registry: only members of this PVE pool are ever scanned (A1)
logger *slog.Logger // the one success-path scan-summary line; nil = silent
}
// staleLockAPI is the subset of *proxmox.Client the controller uses (kept narrow for clarity/testing).
type staleLockAPI interface {
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
Pool(ctx context.Context, name string) (proxmox.PoolInfo, error)
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
ListSnapshots(ctx context.Context, vmid int) ([]proxmox.Snapshot, error)
ListRunningTasks(ctx context.Context) ([]proxmox.TaskStatus, error)
@@ -148,16 +157,48 @@ type staleLockAPI interface {
}
// NewStaleLockController builds the production controller. Returns nil if px or runner is nil (the
// feature then stays unwired and RecoverStaleLockedGuests is a no-op).
func NewStaleLockController(px staleLockAPI, runner proxmox.Runner) StaleLockController {
// feature then stays unwired and RecoverStaleLockedGuests is a no-op). pool names the PVE pool the
// scan is restricted to (reconcile.DefaultPool in production).
func NewStaleLockController(px staleLockAPI, runner proxmox.Runner, pool string, logger *slog.Logger) StaleLockController {
if px == nil || runner == nil {
return nil
}
return &staleLockController{px: px, runner: runner}
return &staleLockController{px: px, runner: runner, pool: pool, logger: logger}
}
// Guests returns ListLXC ∩ the felhom pool's members (audit A1: ownership is PROVEN via the pool
// registry, never assumed from enumeration scope). Under the pool-scoped token the intersect is a
// no-op (ListLXC is already pool-filtered — spike T1); under a broad token it is the guard that
// keeps the reaper off co-tenant guests. A pool-read failure returns an error — the caller's
// existing "guest list unavailable — skipping recovery" guard then fail-safes the whole scan
// (unknown ownership ⇒ don't act, mirroring reconcile/recover.go's proof-of-launch gate). NEVER
// fall back to the unfiltered ListLXC list on error.
func (c *staleLockController) Guests(ctx context.Context) ([]proxmox.Guest, error) {
return c.px.ListLXC(ctx)
lxc, err := c.px.ListLXC(ctx)
if err != nil {
return nil, err
}
pool, err := c.px.Pool(ctx, c.pool)
if err != nil {
return nil, fmt.Errorf("pool membership read (pool=%s): %w", c.pool, err)
}
// A pool can hold storages too (type "storage", no vmid) — membership is nonzero-vmid guests only.
members := make(map[int]bool, len(pool.Members))
for _, m := range pool.Members {
if m.VMID != 0 && m.Type != "storage" {
members[m.VMID] = true
}
}
owned := lxc[:0:0]
for _, g := range lxc {
if members[g.VMID] {
owned = append(owned, g)
}
}
if c.logger != nil {
c.logger.Info("stale-lock: scanning pool guests", "pool", c.pool, "listed", len(lxc), "scanned", len(owned))
}
return owned, nil
}
func (c *staleLockController) Lock(ctx context.Context, vmid int) (string, bool, error) {