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>
This commit is contained in:
+38
-3
@@ -25,6 +25,7 @@ const reportPath = "/api/v1/host-report"
|
||||
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
|
||||
}
|
||||
@@ -51,12 +52,12 @@ func NewClient(cfg config.HubConfig, logger *slog.Logger) (*Client, error) {
|
||||
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
}
|
||||
return newClient(cfg.URL, cfg.APIKey, hc, logger), nil
|
||||
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 string, hc *http.Client, logger *slog.Logger) *Client {
|
||||
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hc: hc, logger: logger}
|
||||
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
|
||||
@@ -109,6 +110,40 @@ func (c *Client) Report(ctx context.Context, r *HostReport) (*ControlEnvelope, e
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user