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>
76 lines
3.0 KiB
Go
76 lines
3.0 KiB
Go
package hub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// The desired-state wire is a contract DUPLICATED across two repos (no shared types module yet).
|
|
// testdata/desired-state.golden.json and testdata/control-envelope.golden.json MUST be kept
|
|
// byte-identical with felhom.eu/hub's copies; these tests decode them through the agent structs
|
|
// and key-set-compare, catching drift between the struct and the served shape.
|
|
func TestDesiredStateGolden_DecodesAndKeySet(t *testing.T) {
|
|
raw, err := os.ReadFile("testdata/desired-state.golden.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var resp DesiredStateResponse
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("golden does not decode into DesiredStateResponse: %v", err)
|
|
}
|
|
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))
|
|
}
|
|
benign := resp.DesiredState.Guests[0]
|
|
if benign.VMID != 100 || benign.Run != "running" || benign.Spec == nil || benign.Spec.Cores != 2 || benign.Description == nil {
|
|
t.Errorf("benign guest = %+v", benign)
|
|
}
|
|
destructive := resp.DesiredState.Guests[1]
|
|
if destructive.VMID != 200 || !destructive.Decommission {
|
|
t.Errorf("destructive guest = %+v, want vmid 200 decommission", destructive)
|
|
}
|
|
if resp.DesiredState.PBSNamespace != "felhom-cust-acme" {
|
|
t.Errorf("pbs_namespace = %q", resp.DesiredState.PBSNamespace)
|
|
}
|
|
if resp.DesiredState.RestoreDirective == nil || resp.DesiredState.RestoreDirective.Mode != "guest_loss" {
|
|
t.Errorf("restore_directive = %+v, want carried (guest_loss)", resp.DesiredState.RestoreDirective)
|
|
}
|
|
|
|
// Bidirectional key-set drift guard: the marshaled struct's keys must match the golden's
|
|
// (top-level, the desired_state object, and a guest element).
|
|
var golden map[string]any
|
|
json.Unmarshal(raw, &golden)
|
|
b, _ := json.Marshal(resp)
|
|
var got map[string]any
|
|
json.Unmarshal(b, &got)
|
|
assertSameKeys(t, "<desired-state top>", golden, got)
|
|
assertSameKeys(t, "desired_state", golden["desired_state"], got["desired_state"])
|
|
assertSameKeys(t, "desired_state.guests[0]",
|
|
firstElem(golden["desired_state"].(map[string]any)["guests"]),
|
|
firstElem(got["desired_state"].(map[string]any)["guests"]))
|
|
assertSameKeys(t, "desired_state.restore_directive",
|
|
golden["desired_state"].(map[string]any)["restore_directive"],
|
|
got["desired_state"].(map[string]any)["restore_directive"])
|
|
}
|
|
|
|
func TestControlEnvelopeGolden_Decodes(t *testing.T) {
|
|
raw, err := os.ReadFile("testdata/control-envelope.golden.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var env ControlEnvelope
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
t.Fatalf("golden does not decode into ControlEnvelope: %v", err)
|
|
}
|
|
if env.Status != "ok" || env.PollIntervalSeconds == nil || *env.PollIntervalSeconds != 900 {
|
|
t.Errorf("envelope poll/status = %+v", env)
|
|
}
|
|
if env.DesiredGeneration != 4 || !env.HasSignedOps || env.Blocked {
|
|
t.Errorf("envelope flags = gen %d signed %v blocked %v", env.DesiredGeneration, env.HasSignedOps, env.Blocked)
|
|
}
|
|
}
|