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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user