Files
admin a042316d6d feat(agent): scaffold + proxmox interaction layer (slice 1)
Stand up the felhom-agent project (module gitea.dooplex.hu/admin/felhom-agent,
binary felhom-agent) and the internal/proxmox package: the typed library every
other agent module calls to talk to Proxmox.

- API-first Client (hand-rolled REST over net/http, PVEAPIToken auth) with typed
  read ops (version/nodes/status/lxc/config/storage) and async mutating ops
  (restore/vzdump/snapshot/rollback/delete-snapshot/setconfig/start/stop), each
  returning a UPID. WaitTask polls task status until stopped and asserts
  exitstatus OK (authz can surface at task exec, not the POST — phase1-2 §1.3).
- Fenced Privileged (root-CLI) backend for the THREE proven exceptions only
  (keyctl pct create, USB mount/fstab, SMART/sensors); each cites why it can't be
  the API. Fence is structural (Client never shells out, Privileged never HTTPs)
  and asserted in routing_test.go.
- TLS: SHA-256 leaf-cert pinning or CA file; insecure mode explicit + off by
  default. No blanket verification disable.
- 403 -> privilege-named APIError; failed task -> privilege-named TaskError.
- JSON config + env overrides (token never logged); slog logging.
- cmd/felhom-agent --selftest (read-only health report) + gated --selftest=task
  (reversible snapshot/rollback/delete exercise of WaitTask). No daemon loop yet.
- Types grounded in the spike findings and exact JSON shapes captured live from
  demo-felhom (PVE 9.2.2). Unit tests use a mock transport + runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:34:32 +02:00

79 lines
2.6 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)
}
// 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)
}
// 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)
}
// urlEscape escapes a path segment (a UPID contains ':' and '@').
func urlEscape(s string) string { return url.PathEscape(s) }