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