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