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:
@@ -0,0 +1,77 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user