Files
admin ab77fa3544 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>
2026-06-08 16:20:09 +02:00

70 lines
2.0 KiB
Go

package hub
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
const testBearer = "super-secret-bearer-key"
func TestClient_SetsBearerAndParsesEnvelope(t *testing.T) {
var gotAuth, gotCT, gotPath, gotMethod string
c := testClient(func(r *http.Request) (*http.Response, error) {
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
gotPath = r.URL.Path
gotMethod = r.Method
return httpResp(200, `{"status":"ok","poll_interval_seconds":900}`), nil
})
env, err := c.Report(context.Background(), &HostReport{HostID: "h"})
if err != nil {
t.Fatalf("Report: %v", err)
}
if gotAuth != "Bearer "+testBearer {
t.Errorf("auth header = %q", gotAuth)
}
if gotCT != "application/json" {
t.Errorf("content-type = %q", gotCT)
}
if gotMethod != http.MethodPost || gotPath != reportPath {
t.Errorf("method/path = %s %s", gotMethod, gotPath)
}
if env.PollIntervalSeconds == nil || *env.PollIntervalSeconds != 900 {
t.Errorf("envelope poll = %v", env.PollIntervalSeconds)
}
}
func TestClient_Non2xxTypedErrorRedactsToken(t *testing.T) {
c := testClient(func(r *http.Request) (*http.Response, error) {
return httpResp(503, "service unavailable"), nil
})
_, err := c.Report(context.Background(), &HostReport{HostID: "h"})
var he *HTTPError
if !errors.As(err, &he) {
t.Fatalf("want *HTTPError, got %T: %v", err, err)
}
if he.StatusCode != 503 {
t.Errorf("status = %d", he.StatusCode)
}
if strings.Contains(err.Error(), testBearer) {
t.Fatalf("bearer token leaked into error: %q", err.Error())
}
}
func TestClient_TransportErrorTypedRedactsToken(t *testing.T) {
c := testClient(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
})
_, err := c.Report(context.Background(), &HostReport{HostID: "h"})
var te *TransportError
if !errors.As(err, &te) {
t.Fatalf("want *TransportError, got %T: %v", err, err)
}
if strings.Contains(err.Error(), testBearer) {
t.Fatalf("bearer token leaked into error: %q", err.Error())
}
}