feat(hub): host-report client + collector + first daemon loop (slice 3, v0.3.0)

internal/hub: the agent's first daemon — a periodic read-only host-report POSTed to
the hub (the heartbeat; no separate ping).

- HostReport wire contract (shared field-for-field with the hub ingest): host
  metrics, guests (vmid + spec), cloudflared status; storage/backups/restore-tests/
  pbs/audit collections DEFINED but emitted empty (slices 5/6 fill).
- Collector over a read-only proxmoxReader (adapted to the real proxmox surface;
  no proxmox changes) + a CloudflaredProber. Partial-failure: NodeStatus fail = hard
  (skip POST); per-guest GuestConfig fail = status "unknown", still report.
- Client: Bearer-auth POST, standard TLS (system roots / optional ca_file), typed
  TransportError/HTTPError, token never in errors.
- Loop: immediate first report, adopt hub poll_interval (clamp [60,3600]), resilient
  to collect/report errors, clean ctx-cancel shutdown.
- ControlEnvelope: only poll_interval_seconds acted on; blocked/desired_generation/
  has_signed_ops parsed-but-ignored (slice 4).
- config: HubConfig + FELHOM_AGENT_HUB_* overlay + mode-aware HubConfig.Validate +
  WithDefaults + hub-key redaction; example config updated.
- main: no-selftest mode is now the daemon; added --selftest=hub. Version -> 0.3.0.

Tests: report serialization, client (incl. token-redaction), collector partial-
failure, loop continuation+interval adoption, config. internal/proxmox + internal/
authz untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:20:09 +02:00
parent f0fee7e193
commit ab77fa3544
16 changed files with 1352 additions and 91 deletions
+116 -30
View File
@@ -1,13 +1,14 @@
// Command felhom-agent is the host agent (slice 1: scaffold + proxmox layer).
// Command felhom-agent is the host agent.
//
// 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).
// With no --selftest flag it runs as the daemon: the host-report poll loop
// (slice 3) that periodically POSTs a read-only host-report to the hub (the
// heartbeat). --selftest=read|task exercise the proxmox layer; --selftest=hub does
// one collect+report against the hub and prints what it would send.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -18,13 +19,14 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.2.0"
var version = "0.3.0"
func main() {
var (
@@ -34,7 +36,7 @@ func main() {
showVersion bool
)
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.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report to the hub")
flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task (the reversible snapshot/rollback exercise)")
flag.BoolVar(&showVersion, "version", false, "print version and exit")
flag.Parse()
@@ -58,19 +60,116 @@ func main() {
switch selftest.mode {
case "":
// No daemon loop yet.
logger.Info("felhom-agent scaffold; no run loop yet",
"version", version,
"hint", "use --selftest (read-only) or --selftest=task --vmid N")
// TODO: poll loop — slice 3/4.
return
os.Exit(runDaemon(cfg, logger))
case "read":
os.Exit(runSelftestRead(context.Background(), cfg, logger))
case "task":
os.Exit(runSelftestTask(context.Background(), cfg, logger, vmid))
case "hub":
os.Exit(runSelftestHub(context.Background(), cfg, logger))
}
}
// newProxmoxClient builds the read-path proxmox client from config.
func newProxmoxClient(cfg config.Config) (*proxmox.Client, error) {
return 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,
},
})
}
// runDaemon is the default mode: collect a host-report and POST it to the hub on a
// loop. Requires both proxmox (to collect) and hub config.
func runDaemon(cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "daemon: proxmox not configured:", err)
return 2
}
if err := cfg.Hub.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "daemon: hub not configured:", err)
return 2
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "daemon: proxmox client:", err)
return 1
}
client, err := hub.NewClient(cfg.Hub, logger)
if err != nil {
fmt.Fprintln(os.Stderr, "daemon: hub client:", err)
return 1
}
hcfg := cfg.Hub.WithDefaults()
collector := hub.NewCollector(px, hub.SystemctlProber{}, cfg.Hub.HostID, version, logger)
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("felhom-agent daemon starting",
"version", version, "host_id", cfg.Hub.HostID, "hub_url", cfg.Hub.URL,
"interval_s", hcfg.PollSeconds) // hub key intentionally not logged
if err := loop.Run(ctx); err != nil {
logger.Error("daemon: loop exited with error", "err", err)
return 1
}
return 0
}
// runSelftestHub validates hub config, does ONE collect + report, and prints the
// report it would send plus the envelope it got back.
func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox not configured:", err)
return 1
}
if err := cfg.Hub.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "selftest: hub not configured:", err)
return 1
}
px, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: proxmox client:", err)
return 1
}
client, err := hub.NewClient(cfg.Hub, logger)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: hub client:", err)
return 1
}
collector := hub.NewCollector(px, hub.SystemctlProber{}, cfg.Hub.HostID, version, logger)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
fmt.Printf("=== felhom-agent %s selftest=hub (host_id=%s url=%s) ===\n", version, cfg.Hub.HostID, cfg.Hub.URL)
report, err := collector.Collect(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] collect:", err)
return 1
}
if b, e := json.MarshalIndent(report, " ", " "); e == nil {
fmt.Println(" --- report it would send ---")
fmt.Println(" " + string(b))
}
env, err := client.Report(ctx, report)
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] report:", err)
return 1
}
if b, e := json.MarshalIndent(env, " ", " "); e == nil {
fmt.Println(" --- control envelope received ---")
fmt.Println(" " + string(b))
}
fmt.Println("=== selftest=hub OK ===")
return 0
}
// 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).
@@ -81,16 +180,7 @@ func runSelftestRead(ctx context.Context, cfg config.Config, logger *slog.Logger
}
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,
},
})
client, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
@@ -163,13 +253,7 @@ func runSelftestTask(ctx context.Context, cfg config.Config, logger *slog.Logger
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,
},
})
client, err := newProxmoxClient(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "selftest: client init:", err)
return 1
@@ -239,6 +323,8 @@ func (f *selftestFlag) Set(v string) error {
f.mode = "read"
case "task":
f.mode = "task"
case "hub":
f.mode = "hub"
default:
return fmt.Errorf("invalid --selftest value %q (want read|task)", v)
}