8ecf8929fb
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>
78 lines
2.5 KiB
Go
78 lines
2.5 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// FetchDesiredState GETs the SELF-SCOPED path with the bearer token and decodes the response.
|
|
func TestFetchDesiredState_PathAuthAndDecode(t *testing.T) {
|
|
var gotPath, gotAuth, gotMethod string
|
|
c := testClient(func(r *http.Request) (*http.Response, error) {
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotMethod = r.Method
|
|
return httpResp(200, `{"generation":4,"desired_state":{"guests":[
|
|
{"vmid":100,"run":"running"},
|
|
{"vmid":200,"decommission":true}
|
|
],"restore_directive":{"mode":"guest_loss","archive":"local:backup/x","vmid":100}}}`), nil
|
|
})
|
|
|
|
resp, err := c.FetchDesiredState(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("FetchDesiredState: %v", err)
|
|
}
|
|
// Self-scoped: the client only ever fetches ITS OWN host (the configured host_id).
|
|
if gotMethod != http.MethodGet || gotPath != "/api/v1/hosts/demo-host-01/desired-state" {
|
|
t.Errorf("request = %s %s, want GET /api/v1/hosts/demo-host-01/desired-state", gotMethod, gotPath)
|
|
}
|
|
if gotAuth != "Bearer super-secret-bearer-key" {
|
|
t.Errorf("auth header = %q, want the per-host bearer", gotAuth)
|
|
}
|
|
if resp.Generation != 4 {
|
|
t.Errorf("generation = %d, want 4", resp.Generation)
|
|
}
|
|
if len(resp.DesiredState.Guests) != 2 {
|
|
t.Fatalf("guests = %d, want 2", len(resp.DesiredState.Guests))
|
|
}
|
|
if resp.DesiredState.Guests[0].Run != "running" || !resp.DesiredState.Guests[1].Decommission {
|
|
t.Errorf("guests = %+v", resp.DesiredState.Guests)
|
|
}
|
|
// Forward-compat restore_directive is carried through (consumed in 10D).
|
|
if resp.DesiredState.RestoreDirective == nil || resp.DesiredState.RestoreDirective.Mode != "guest_loss" {
|
|
t.Errorf("restore_directive = %+v, want carried (mode guest_loss)", resp.DesiredState.RestoreDirective)
|
|
}
|
|
}
|
|
|
|
// A non-2xx response is a typed HTTPError (e.g. a 403 self-scope refusal from the hub).
|
|
func TestFetchDesiredState_HTTPError(t *testing.T) {
|
|
c := testClient(func(r *http.Request) (*http.Response, error) {
|
|
return httpResp(403, `Forbidden: host_id mismatch`), nil
|
|
})
|
|
_, err := c.FetchDesiredState(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected an error on 403")
|
|
}
|
|
var he *HTTPError
|
|
if !asHTTPError(err, &he) || he.StatusCode != 403 {
|
|
t.Errorf("err = %v, want HTTPError 403", err)
|
|
}
|
|
}
|
|
|
|
func asHTTPError(err error, target **HTTPError) bool {
|
|
for err != nil {
|
|
if he, ok := err.(*HTTPError); ok {
|
|
*target = he
|
|
return true
|
|
}
|
|
type unwrapper interface{ Unwrap() error }
|
|
if u, ok := err.(unwrapper); ok {
|
|
err = u.Unwrap()
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|