Files
felhom-agent/internal/hub/client.go
T
admin 8ecf8929fb slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)
The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:02:59 +02:00

154 lines
5.1 KiB
Go

package hub
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
)
const reportPath = "/api/v1/host-report"
// Client posts host-reports to the hub. Auth is a per-host Bearer key. Transport is
// standard TLS (system roots, or a CAFile pool); verification is always on — the hub
// has a real cert (unlike the Proxmox self-signed path), so there is no insecure mode.
type Client struct {
baseURL string
apiKey string
hostID string // for the slice-10A desired-state/jobs paths (/hosts/{hostID}/…)
hc *http.Client
logger *slog.Logger
}
// NewClient builds a hub client from config (defaults applied). It never logs the key.
func NewClient(cfg config.HubConfig, logger *slog.Logger) (*Client, error) {
cfg = cfg.WithDefaults()
if logger == nil {
logger = slog.Default()
}
tlsCfg := &tls.Config{} // system roots
if cfg.CAFile != "" {
pem, err := os.ReadFile(cfg.CAFile)
if err != nil {
return nil, fmt.Errorf("hub: reading ca_file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("hub: ca_file %q contained no usable certificates", cfg.CAFile)
}
tlsCfg.RootCAs = pool
}
hc := &http.Client{
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
}
return newClient(cfg.URL, cfg.APIKey, cfg.HostID, hc, logger), nil
}
// newClient is the shared constructor (tests inject a mock-transport *http.Client).
func newClient(baseURL, apiKey, hostID string, hc *http.Client, logger *slog.Logger) *Client {
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hostID: hostID, hc: hc, logger: logger}
}
// TransportError is a network/connection failure (no HTTP response). It never
// contains the bearer token.
type TransportError struct{ Err error }
func (e *TransportError) Error() string { return "hub: transport error: " + e.Err.Error() }
func (e *TransportError) Unwrap() error { return e.Err }
// HTTPError is a non-2xx response. BodyTail is a short, token-free excerpt.
type HTTPError struct {
StatusCode int
BodyTail string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("hub: HTTP %d: %s", e.StatusCode, e.BodyTail)
}
// Report POSTs the host-report and returns the parsed control envelope. The report
// IS the heartbeat (locked decision 1). Errors are typed (transport vs HTTP) and
// never include the bearer token.
func (c *Client) Report(ctx context.Context, r *HostReport) (*ControlEnvelope, error) {
body, err := json.Marshal(r)
if err != nil {
return nil, fmt.Errorf("hub: marshaling report: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+reportPath, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("hub: building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, &TransportError{Err: err} // token is in the request header, never the error
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
}
var env ControlEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("hub: decoding control envelope: %w", err)
}
return &env, nil
}
// FetchDesiredState GETs the host's authoritative desired-state (slice 10A — the "Down" channel's
// heavy payload). The agent calls this ONLY when the heartbeat envelope's DesiredGeneration has
// advanced past its cached one (the heartbeat stays light; the state moves on change). It is
// self-scoped server-side: the per-host key only ever reads ITS OWN host (the client uses its
// configured hostID). Errors are typed (transport vs HTTP) and never include the bearer token.
func (c *Client) FetchDesiredState(ctx context.Context) (*DesiredStateResponse, error) {
if c.hostID == "" {
return nil, fmt.Errorf("hub: FetchDesiredState requires a configured host_id")
}
url := c.baseURL + "/api/v1/hosts/" + c.hostID + "/desired-state"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("hub: building desired-state request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, &TransportError{Err: err}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
}
var out DesiredStateResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("hub: decoding desired-state: %w", err)
}
return &out, nil
}
func tail(b []byte, max int) string {
s := strings.TrimSpace(string(b))
if len(s) > max {
return s[:max] + "…"
}
return s
}