ab77fa3544
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>
70 lines
2.0 KiB
Go
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())
|
|
}
|
|
}
|