Files
felhom-agent/internal/proxmox/query.go
T
admin 3bf0110697 GL-5: explicit rootfs override for DR restore (live-discovered PVE constraint)
The live validation hit PVE's all-or-nothing restore rule: mpN params
without an explicit rootfs -> HTTP 500 "mount points configured, but
'rootfs' not set" (the same constraint restoretest.go:211 documents for the
live-config path; the spike never ran an override restore). The lost guest
has no live config, so the rootfs SIZE now comes from the archive's own
embedded config via NEW Client.ExtractArchiveConfig (GET vzdump/
extractconfig - verified live: answers 200 under the scoped agent token;
PBS keys stay server-side, the spike's candidate-1 rejection holds; used
for the SIZE ONLY - the bind layout stays the platform constants).
Unparseable/unreadable archive config -> clean refusal before any restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-08 09:02:36 +02:00

144 lines
6.1 KiB
Go

package proxmox
import (
"context"
"fmt"
"net/url"
)
// Read-only query operations. All API-backed (Datastore.Audit / VM.Audit /
// Sys.Audit). These are what `felhom-agent --selftest` exercises against a live
// host — they mutate nothing.
// Version returns GET /version.
func (c *Client) Version(ctx context.Context) (Version, error) {
var v Version
return v, c.get(ctx, "/version", &v)
}
// Nodes returns GET /nodes. Use this to confirm the node name and read each
// node's ssl_fingerprint (which is what to pin in TLSConfig).
func (c *Client) Nodes(ctx context.Context) ([]Node, error) {
var ns []Node
return ns, c.get(ctx, "/nodes", &ns)
}
// NodeStatus returns GET /nodes/{node}/status (host metrics; needs Sys.Audit).
func (c *Client) NodeStatus(ctx context.Context) (NodeStatus, error) {
var s NodeStatus
return s, c.get(ctx, "/nodes/"+c.node+"/status", &s)
}
// ListLXC returns GET /nodes/{node}/lxc (the guests on this node).
func (c *Client) ListLXC(ctx context.Context) ([]Guest, error) {
var gs []Guest
return gs, c.get(ctx, "/nodes/"+c.node+"/lxc", &gs)
}
// Pool returns GET /pools/{name} (the pool's membership — the stale-lock reaper's ownership
// registry, audit A1). Requires `Pool.Audit` at `/pool/{name}` — NOTE: `Pool.Allocate` does NOT
// satisfy the read (spike SPIKE-a1-pool-membership-read T2: the live 403 named Pool.Audit with
// Allocate already granted). Host-install v1.9.0+ grants it in the FelhomAgentGuest role.
func (c *Client) Pool(ctx context.Context, name string) (PoolInfo, error) {
var p PoolInfo
return p, c.get(ctx, "/pools/"+url.PathEscape(name), &p)
}
// GuestStatus returns GET /nodes/{node}/lxc/{vmid}/status/current. The API body
// has no vmid field (it is in the path), so it is set from the argument.
func (c *Client) GuestStatus(ctx context.Context, vmid int) (Guest, error) {
var g Guest
path := fmt.Sprintf("/nodes/%s/lxc/%d/status/current", c.node, vmid)
if err := c.get(ctx, path, &g); err != nil {
return Guest{}, err
}
g.VMID = vmid
return g, nil
}
// GuestConfig returns GET /nodes/{node}/lxc/{vmid}/config.
func (c *Client) GuestConfig(ctx context.Context, vmid int) (GuestConfig, error) {
var cfg GuestConfig
path := fmt.Sprintf("/nodes/%s/lxc/%d/config", c.node, vmid)
return cfg, c.get(ctx, path, &cfg)
}
// ExtractArchiveConfig returns GET /nodes/{node}/vzdump/extractconfig — the guest config embedded
// in a backup archive, as raw pct-conf text. Token-covered (verified live under the scoped agent
// token on PVE 9.2.2, GL-5); for a PBS archive the storage-configured encryption key stays
// SERVER-side — the agent never touches key material (the DR spike's candidate-1 rejection holds).
// The DR bring-up reads ONLY the rootfs size from it: PVE refuses a restore that carries mpN
// params without an explicit rootfs, and the lost guest has no live config to size it from.
func (c *Client) ExtractArchiveConfig(ctx context.Context, volume string) (string, error) {
var out string
path := "/nodes/" + c.node + "/vzdump/extractconfig?volume=" + url.QueryEscape(volume)
return out, c.get(ctx, path, &out)
}
// ListSnapshots returns GET /nodes/{node}/lxc/{vmid}/snapshot (the guest's snapshots, including the
// synthetic "current"). The startup stale-lock recovery uses it to find a dangling "vzdump" snapshot
// left by an interrupted snapshot-mode backup.
func (c *Client) ListSnapshots(ctx context.Context, vmid int) ([]Snapshot, error) {
var ss []Snapshot
path := fmt.Sprintf("/nodes/%s/lxc/%d/snapshot", c.node, vmid)
return ss, c.get(ctx, path, &ss)
}
// ListRunningTasks returns the node's currently-active tasks (GET /nodes/{node}/tasks?source=active).
// The startup stale-lock recovery uses it as the load-bearing safety guard: a backup lock is cleared
// ONLY when no vzdump task is genuinely in-flight for the guest (an agent restart while a real backup
// runs must never clear the live lock). NOTE: PVE 9.x rejects `?running=1` ("property not defined in
// schema") — `source=active` is the supported filter (it returns the RUNNING tasks); confirmed live on
// felhom-pve (PVE 9.2.2). active-source entries carry status "RUNNING"; we match on type+id, so the
// case difference vs the by-UPID status endpoint ("running") is moot.
func (c *Client) ListRunningTasks(ctx context.Context) ([]TaskStatus, error) {
var ts []TaskStatus
return ts, c.get(ctx, "/nodes/"+c.node+"/tasks?source=active", &ts)
}
// ListStorage returns GET /storage (cluster-wide storage definitions).
func (c *Client) ListStorage(ctx context.Context) ([]Storage, error) {
var ss []Storage
return ss, c.get(ctx, "/storage", &ss)
}
// NodeStorage returns GET /nodes/{node}/storage (storage with live usage).
func (c *Client) NodeStorage(ctx context.Context) ([]Storage, error) {
var ss []Storage
return ss, c.get(ctx, "/nodes/"+c.node+"/storage", &ss)
}
// StorageContent returns GET /nodes/{node}/storage/{store}/content (e.g. vzdump
// archives + CT templates available for a restore).
func (c *Client) StorageContent(ctx context.Context, store string) ([]StorageContent, error) {
var cs []StorageContent
path := fmt.Sprintf("/nodes/%s/storage/%s/content", c.node, url.PathEscape(store))
return cs, c.get(ctx, path, &cs)
}
// LatestBackupVolID resolves the most recent vzdump archive for vmid on a backup storage.
// It lists the store's content, keeps only backup archives for that vmid, and returns the
// one with the greatest CTime. This is how a backup's produced archive is resolved after
// Vzdump+WaitTask (the task status carries no result volid), and how the restore-test picks
// a backup to restore. Returns ("", nil) when the guest has no archive on that store.
func (c *Client) LatestBackupVolID(ctx context.Context, store string, vmid int) (string, error) {
contents, err := c.StorageContent(ctx, store)
if err != nil {
return "", err
}
var bestVol string
var bestCTime int64 = -1
for _, e := range contents {
if e.Content != "backup" || e.VMID != vmid {
continue
}
if e.CTime > bestCTime {
bestCTime, bestVol = e.CTime, e.VolID
}
}
return bestVol, nil
}
// urlEscape escapes a path segment (a UPID contains ':' and '@').
func urlEscape(s string) string { return url.PathEscape(s) }