Files
felhom-agent/internal/proxmox/mutate.go
T
admin b527430ec7 v0.6.0-rc1: slice 6 Phase A — backup + the self-restore-test (local target)
The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→
teardown) that closes "a backup you haven't restored isn't a backup". All benign
(reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local
target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent.

- proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID.
- reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down
  pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a
  leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent).
- internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence
  scheduler (4th daemon goroutine, default 24h) + in-memory report store.
- hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical +
  bidirectional key-set tests; hub handler logs a FAILED restore-test prominently.
- config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:49:39 +02:00

191 lines
8.2 KiB
Go

package proxmox
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
)
// Async mutating operations. Each is API-token-covered (the FelhomAgent role) and
// returns a UPID string; the caller MUST WaitTask on it and assert exitstatus OK.
// The HTTP 200 here is not proof of success (phase1-2 §1.3).
// BackupMode is the vzdump mode.
type BackupMode string
const (
// ModeStop: orderly guest shutdown -> backup -> restart. Highest consistency.
// For LXC the shutdown/restart is internal to vzdump and needs only VM.Backup
// (NOT VM.PowerMgmt) — phase1-2 §1.4.
ModeStop BackupMode = "stop"
// ModeSnapshot: lowest downtime; for an LXC this is crash-consistent only (no
// fsfreeze) — app-consistency is the controller's job (proxmox-platform.md §4.2).
ModeSnapshot BackupMode = "snapshot"
)
// RestoreLXCOptions parameterizes a restore. This is the PRIMARY create path:
// a token-authorized restore preserves features=nesting=1,keyctl=1 from the
// archive, so it needs no root (phase3 §B3). Fresh `pct create` with keyctl is
// the only root-fenced create (see Privileged.CreateGoldenLXC).
type RestoreLXCOptions struct {
VMID int // target VMID (fresh id)
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"
Force bool // overwrite an existing VMID (destructive — caller must have authority)
}
// RestoreLXC restores an LXC from a vzdump/PBS archive via POST /nodes/{node}/lxc
// (restore=1). Returns the UPID. NOTE: pct restore preserves the source MAC +
// hostname — reset network identity before starting alongside the original
// (phase1-2 §2.2). Identity reset is a SetConfig call the caller makes after.
func (c *Client) RestoreLXC(ctx context.Context, opts RestoreLXCOptions) (string, error) {
if opts.VMID == 0 || opts.Archive == "" || opts.Storage == "" {
return "", fmt.Errorf("proxmox: RestoreLXC needs vmid, archive and storage")
}
v := url.Values{}
v.Set("vmid", strconv.Itoa(opts.VMID))
v.Set("ostemplate", opts.Archive) // pct restore source
v.Set("restore", "1")
v.Set("storage", opts.Storage)
if opts.Force {
v.Set("force", "1")
}
return c.dataString(ctx, http.MethodPost, "/nodes/"+c.node+"/lxc", v)
}
// VzdumpOptions parameterizes a backup.
type VzdumpOptions struct {
VMID int
Storage string // a storage whose content includes "backup" (e.g. "local") — NOT local-lvm
Mode BackupMode // ModeStop | ModeSnapshot
Compress string // "zstd" (default), "lzo", "gzip", or "" for none
// Notes is the PVE `notes-template` for the backup (a template string PVE expands,
// e.g. with {{guestname}}/{{node}}). Optional.
Notes string
}
// Vzdump starts a backup via POST /nodes/{node}/vzdump. Returns the UPID. An
// agent-initiated vzdump is crash-consistent only for an LXC (no fsfreeze) —
// app-consistency needs the controller to quiesce first (slice 8).
func (c *Client) Vzdump(ctx context.Context, opts VzdumpOptions) (string, error) {
if opts.VMID == 0 || opts.Storage == "" || opts.Mode == "" {
return "", fmt.Errorf("proxmox: Vzdump needs vmid, storage and mode")
}
v := url.Values{}
v.Set("vmid", strconv.Itoa(opts.VMID))
v.Set("storage", opts.Storage)
v.Set("mode", string(opts.Mode))
if opts.Compress == "" {
opts.Compress = "zstd"
}
v.Set("compress", opts.Compress)
if opts.Notes != "" {
v.Set("notes-template", opts.Notes) // PVE 9.x param name (verified on demo)
}
return c.dataString(ctx, http.MethodPost, "/nodes/"+c.node+"/vzdump", v)
}
// DestroyLXC destroys a guest via DELETE /nodes/{node}/lxc/{vmid}. Returns the UPID.
// `purge=1` also drops the guest from jobs/HA; `destroy-unreferenced-disks=1` reaps any
// orphaned volumes. This is the scratch-guest teardown primitive (slice 6); it is
// destructive-class and the caller MUST route it through the reversibility gate
// (benign only by agent-internal scratch/same-txn provenance — see reconcile.Classify).
func (c *Client) DestroyLXC(ctx context.Context, vmid int) (string, error) {
if vmid == 0 {
return "", fmt.Errorf("proxmox: DestroyLXC needs a vmid")
}
v := url.Values{}
v.Set("purge", "1")
v.Set("destroy-unreferenced-disks", "1")
path := fmt.Sprintf("/nodes/%s/lxc/%d", c.node, vmid)
return c.dataString(ctx, http.MethodDelete, path, v)
}
// Snapshot creates an LXC snapshot via POST /nodes/{node}/lxc/{vmid}/snapshot.
// A running, unprivileged LXC can be snapshotted on LVM-thin with no stop
// (phase1-2 §1.6) — this is the snapshot-before-change primitive.
func (c *Client) Snapshot(ctx context.Context, vmid int, snapname, description string) (string, error) {
if vmid == 0 || snapname == "" {
return "", fmt.Errorf("proxmox: Snapshot needs vmid and snapname")
}
v := url.Values{}
v.Set("snapname", snapname)
if description != "" {
v.Set("description", description)
}
path := fmt.Sprintf("/nodes/%s/lxc/%d/snapshot", c.node, vmid)
return c.dataString(ctx, http.MethodPost, path, v)
}
// Rollback rolls an LXC back to a snapshot via
// POST /nodes/{node}/lxc/{vmid}/snapshot/{snap}/rollback.
func (c *Client) Rollback(ctx context.Context, vmid int, snapname string) (string, error) {
if vmid == 0 || snapname == "" {
return "", fmt.Errorf("proxmox: Rollback needs vmid and snapname")
}
path := fmt.Sprintf("/nodes/%s/lxc/%d/snapshot/%s/rollback", c.node, vmid, url.PathEscape(snapname))
return c.dataString(ctx, http.MethodPost, path, url.Values{})
}
// DeleteSnapshot removes an LXC snapshot via
// DELETE /nodes/{node}/lxc/{vmid}/snapshot/{snap}.
func (c *Client) DeleteSnapshot(ctx context.Context, vmid int, snapname string) (string, error) {
if vmid == 0 || snapname == "" {
return "", fmt.Errorf("proxmox: DeleteSnapshot needs vmid and snapname")
}
path := fmt.Sprintf("/nodes/%s/lxc/%d/snapshot/%s", c.node, vmid, url.PathEscape(snapname))
return c.dataString(ctx, http.MethodDelete, path, nil)
}
// SetConfig applies config changes via PUT /nodes/{node}/lxc/{vmid}/config
// (e.g. memory, cores, net0, mpN with a backup flag). PVE may apply this
// synchronously (no UPID) — the returned string is empty in that case, and "" is
// not an error. When a UPID is returned, WaitTask on it.
//
// Identity reset after a restore (phase1-2 §2.2) is a SetConfig with
// params{"net0": "name=eth0,bridge=vmbr0,ip=dhcp"} (regenerates the MAC).
func (c *Client) SetConfig(ctx context.Context, vmid int, params map[string]string) (string, error) {
if vmid == 0 || len(params) == 0 {
return "", fmt.Errorf("proxmox: SetConfig needs vmid and at least one param")
}
v := url.Values{}
for k, val := range params {
v.Set(k, val)
}
path := fmt.Sprintf("/nodes/%s/lxc/%d/config", c.node, vmid)
return c.dataString(ctx, http.MethodPut, path, v)
}
// ResizeLXC grows a guest volume via PUT /nodes/{node}/lxc/{vmid}/resize
// (token-covered: VM.Config.Disk + Datastore.AllocateSpace). Returns the UPID.
//
// disk is the volume key (e.g. "rootfs", "mp0"); size is a Proxmox size string. A
// LEADING '+' means GROW BY that amount (e.g. "+5G"); an absolute value can only ever
// grow (Proxmox rejects a shrink for a mounted/most volumes, but the agent must NOT rely
// on that — the reconcile layer is responsible for refusing a shrink before it reaches
// here, since a data-losing shrink is a destructive op, not a benign resize).
func (c *Client) ResizeLXC(ctx context.Context, vmid int, disk, size string) (string, error) {
if vmid == 0 || disk == "" || size == "" {
return "", fmt.Errorf("proxmox: ResizeLXC needs vmid, disk and size")
}
v := url.Values{}
v.Set("disk", disk)
v.Set("size", size)
path := fmt.Sprintf("/nodes/%s/lxc/%d/resize", c.node, vmid)
return c.dataString(ctx, http.MethodPut, path, v)
}
// Start starts a guest via POST /nodes/{node}/lxc/{vmid}/status/start (VM.PowerMgmt).
func (c *Client) Start(ctx context.Context, vmid int) (string, error) {
path := fmt.Sprintf("/nodes/%s/lxc/%d/status/start", c.node, vmid)
return c.dataString(ctx, http.MethodPost, path, url.Values{})
}
// Stop stops a guest via POST /nodes/{node}/lxc/{vmid}/status/stop (VM.PowerMgmt).
func (c *Client) Stop(ctx context.Context, vmid int) (string, error) {
path := fmt.Sprintf("/nodes/%s/lxc/%d/status/stop", c.node, vmid)
return c.dataString(ctx, http.MethodPost, path, url.Values{})
}