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>
This commit is contained in:
2026-06-08 14:34:32 +02:00
parent 4d84207572
commit a042316d6d
24 changed files with 2240 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
// Command felhom-agent is the host agent (slice 1: scaffold + proxmox layer).
//
// This slice is wiring only: it has no daemon/reconcile loop yet (slice 3/4). It
// exposes a read-only --selftest that exercises the proxmox package against a live
// host, and an explicitly-gated --selftest=task that exercises WaitTask on a
// reversible op (snapshot -> rollback -> delete-snapshot).
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
func main() {
var (
cfgPath string
selftest selftestFlag
vmid int
)
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid)")
flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task (the reversible snapshot/rollback exercise)")
flag.Parse()
cfg, err := config.Load(cfgPath)
if err != nil {
// A missing default config file is fine if env provides the values; only a
// present-but-unreadable/invalid file is fatal here.
if !(os.IsNotExist(errors.Unwrap(err)) && cfgPath == flag.Lookup("config").DefValue) {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(2)
}
cfg = config.Default()
}
logger := applog.New(cfg.LogLevel)
switch selftest.mode {
case "":
// No daemon loop yet.
logger.Info("felhom-agent slice-1 scaffold; no run loop yet",
"hint", "use --selftest (read-only) or --selftest=task --vmid N")
// TODO: poll loop — slice 3/4.
return
case "read":
os.Exit(runSelftestRead(context.Background(), cfg, logger))
case "task":
os.Exit(runSelftestTask(context.Background(), cfg, logger, vmid))
}
}
// runSelftestRead loads config, builds the API client, and runs the read-only
// queries against the live host, printing a short health report. It mutates
// nothing. Missing/invalid config is reported cleanly (no panic).
func runSelftestRead(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: not configured:", err)
return 1
}
logger.Info("selftest (read-only) starting", "config", fmt.Sprintf("%+v", cfg.Redacted().Proxmox))
client, err := proxmox.NewClient(proxmox.Config{
Endpoint: cfg.Proxmox.Endpoint,
Node: cfg.Proxmox.Node,
Token: cfg.Proxmox.Token,
TLS: proxmox.TLSConfig{
CAFile: cfg.Proxmox.TLS.CAFile,
Fingerprint: cfg.Proxmox.TLS.Fingerprint,
InsecureSkipVerify: cfg.Proxmox.TLS.InsecureSkipVerify,
},
})
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
fmt.Println("=== felhom-agent selftest (read-only) ===")
fmt.Printf("endpoint : %s node=%s\n", cfg.Proxmox.Endpoint, cfg.Proxmox.Node)
fail := 0
report := func(label string, err error) bool {
if err != nil {
fmt.Printf(" [FAIL] %-14s %v\n", label, err)
fail++
return false
}
return true
}
if v, err := client.Version(ctx); report("version", err) {
fmt.Printf(" [ ok ] %-14s PVE %s (release %s)\n", "version", v.Version, v.Release)
}
if nodes, err := client.Nodes(ctx); report("nodes", err) {
fmt.Printf(" [ ok ] %-14s %d node(s)\n", "nodes", len(nodes))
for _, n := range nodes {
marker := " "
if n.Node == cfg.Proxmox.Node {
marker = "* "
}
fmt.Printf(" %s%s status=%s fp=%s…\n", marker, n.Node, n.Status, head(n.SSLFingerprint, 17))
}
}
if s, err := client.NodeStatus(ctx); report("node status", err) {
fmt.Printf(" [ ok ] %-14s up %s, load %v, mem %s/%s, root %s/%s\n", "node status",
dur(s.Uptime), s.LoadAvg,
gib(s.Memory.Used), gib(s.Memory.Total), gib(s.RootFS.Used), gib(s.RootFS.Total))
}
if gs, err := client.ListLXC(ctx); report("list lxc", err) {
fmt.Printf(" [ ok ] %-14s %d guest(s)\n", "list lxc", len(gs))
for _, g := range gs {
fmt.Printf(" - %d %q status=%s\n", g.VMID, g.Name, g.Status)
}
}
if ss, err := client.NodeStorage(ctx); report("storage", err) {
fmt.Printf(" [ ok ] %-14s %d store(s)\n", "storage", len(ss))
for _, s := range ss {
fmt.Printf(" - %-10s type=%-8s content=%s used=%s/%s\n",
s.Storage, s.Type, s.Content, gib(s.Used), gib(s.Total))
}
}
if fail > 0 {
fmt.Printf("=== selftest FAILED (%d check(s)) ===\n", fail)
return 1
}
fmt.Println("=== selftest OK ===")
return 0
}
// runSelftestTask exercises WaitTask on a reversible op against -vmid: snapshot ->
// rollback -> delete-snapshot. Explicitly gated; never runs under bare --selftest.
func runSelftestTask(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: not configured:", err)
return 1
}
if vmid == 0 {
fmt.Fprintln(os.Stderr, "selftest=task requires -vmid N (a guest safe to snapshot/rollback)")
return 2
}
client, err := proxmox.NewClient(proxmox.Config{
Endpoint: cfg.Proxmox.Endpoint, Node: cfg.Proxmox.Node, Token: cfg.Proxmox.Token,
TLS: proxmox.TLSConfig{
CAFile: cfg.Proxmox.TLS.CAFile, Fingerprint: cfg.Proxmox.TLS.Fingerprint,
InsecureSkipVerify: cfg.Proxmox.TLS.InsecureSkipVerify,
},
})
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
}
// Ctrl-C aborts the wait cleanly.
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
const snap = "felhom-selftest"
steps := []struct {
name string
do func() (string, error)
}{
{"snapshot", func() (string, error) { return client.Snapshot(ctx, vmid, snap, "felhom-agent selftest") }},
{"rollback", func() (string, error) { return client.Rollback(ctx, vmid, snap) }},
{"delete-snapshot", func() (string, error) { return client.DeleteSnapshot(ctx, vmid, snap) }},
}
fmt.Printf("=== felhom-agent selftest=task (vmid %d, snapshot %q) ===\n", vmid, snap)
for _, st := range steps {
upid, err := st.do()
if err != nil {
fmt.Printf(" [FAIL] %-16s %v\n", st.name, err)
return 1
}
fmt.Printf(" .... %-16s upid=%s\n", st.name, upid)
status, err := client.WaitTask(ctx, upid, proxmox.WaitOptions{})
if err != nil {
fmt.Printf(" [FAIL] %-16s %v\n", st.name, err)
return 1
}
fmt.Printf(" [ ok ] %-16s exitstatus=%s\n", st.name, status.ExitStatus)
}
fmt.Println("=== selftest=task OK ===")
return 0
}
// --- small helpers / flag type ---
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func dur(seconds int64) string { return (time.Duration(seconds) * time.Second).String() }
func gib(bytes int64) string { return fmt.Sprintf("%.1fGiB", float64(bytes)/(1<<30)) }
// selftestFlag is a flag.Value that also satisfies IsBoolFlag, so `--selftest`
// works bare (read-only) and `--selftest=task` / `--selftest=read` set the mode.
type selftestFlag struct{ mode string }
func (f *selftestFlag) String() string { return f.mode }
func (f *selftestFlag) IsBoolFlag() bool { return true }
func (f *selftestFlag) Set(v string) error {
switch v {
case "true", "", "read":
f.mode = "read"
case "task":
f.mode = "task"
default:
return fmt.Errorf("invalid --selftest value %q (want read|task)", v)
}
return nil
}