diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 08c7e06..2cce385 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -75,6 +75,10 @@ var defaultSeats = []Seat{ // rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient // connection would mint a credential for each. {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"}, + // The vault: the controller seals every minted credential with what it provides, which is the + // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a + // second claimant, refused by name, rather than a candidate for a pin. + {Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161"}, // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — // images and archives, by digest — which is why the provision is the artifact store and not an diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 1b33a72..f30a92e 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,7 +44,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - if len(Seats()) != 14 { + if len(Seats()) != 15 { t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } diff --git a/internal/catalogue/vault_seat_test.go b/internal/catalogue/vault_seat_test.go new file mode 100644 index 0000000..0f8827c --- /dev/null +++ b/internal/catalogue/vault_seat_test.go @@ -0,0 +1,25 @@ +package catalogue + +import "testing" + +// The vault's provision is one the controller itself dereferences — every minted credential is +// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second +// claimant refused by name rather than a candidate for a pin (novox/hq ADR 0161, issue 106). +func TestTheVaultsSeatDeliversSecret(t *testing.T) { + seat, known := SeatNamed("mesh-vault") + if !known { + t.Fatal("mesh-vault is not in the mesh's own set") + } + if seat.Scope != ScopeMesh || seat.Delivers != "secret" { + t.Fatalf("mesh-vault is %s-scoped and delivers %q; one per mesh, delivering secret", seat.Scope, seat.Delivers) + } + vault := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}} + if err := CanHold(vault, seat); err != nil { + t.Fatalf("the vault, claiming its seat and providing secret, was refused: %v", err) + } + another := Manifest{Module: "other-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}} + if err := CanHold(another, seat); err == nil { + t.Fatal("a provider of secret that does not claim the seat was allowed to hold it") + } +} diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 7661996..3878dde 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -320,6 +320,13 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err return false, err } } + if len(report.Profile) > 0 { + // The latest wins, as at enrolment: a capability the machine lost is one the plan must + // stop counting on (novox/hq ADR 0161). + if err := e.Inventory.RecordProfile(ctx, node.ID, report.Profile); err != nil { + return false, err + } + } // What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it. if report.Tunnel != nil { if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{ diff --git a/internal/link/profile_report_test.go b/internal/link/profile_report_test.go new file mode 100644 index 0000000..5477f1f --- /dev/null +++ b/internal/link/profile_report_test.go @@ -0,0 +1,45 @@ +package link_test + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/link" +) + +// A report may carry the machine's profile, detected again by the apply that reports, and the latest +// replaces what enrolment recorded (novox/hq ADR 0161): a machine that switched its network manager +// is a machine whose uplink holder lacks a capability at its next push, not at its next enrolment. +func TestAReportsProfileReplacesTheEnrolledOne(t *testing.T) { + e, _, _ := anEnrolledHub(t) + ctx := t.Context() + first := map[string]any{"capabilities": []any{map[string]any{"name": "uplink-networkmanager", "present": true}}} + if _, err := e.Heard(ctx, link.Report{Node: "anchor", Profile: first}); err != nil { + t.Fatal(err) + } + got, err := e.Inventory.Profile(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "uplink-networkmanager" || !got[0].Present { + t.Fatalf("the report's profile was not kept: %+v", got) + } + // The machine switched managers; the next report says so and the old fact is gone. + second := map[string]any{"capabilities": []any{map[string]any{"name": "uplink-systemd-networkd", "present": true}}} + if _, err := e.Heard(ctx, link.Report{Node: "anchor", Profile: second}); err != nil { + t.Fatal(err) + } + got, err = e.Inventory.Profile(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "uplink-systemd-networkd" { + t.Fatalf("the latest profile did not replace the earlier one: %+v", got) + } + // A report with no profile leaves the last one standing. + if _, err := e.Heard(ctx, link.Report{Node: "anchor", Host: "1"}); err != nil { + t.Fatal(err) + } + if got, _ = e.Inventory.Profile(ctx, "anchor"); len(got) != 1 { + t.Fatalf("a report without a profile erased it: %+v", got) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index cd34e2d..34240ba 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -193,6 +193,12 @@ type Report struct { // refuses it whole — which is right, and makes every new field a flag day that the mesh could // not see coming. Host string `json:"host,omitempty"` + + // Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the + // same shape enrolment sends, so a machine that gained or lost a capability — switched its + // network manager — is known at its next push and not at its next enrolment. Absent from a host + // older than this, and then the enrolment's profile stands. + Profile map[string]any `json:"profile,omitempty"` // Reachable is what can be reached on the machine now: every listening socket and every // published container port. Only an adopted node reports it; it is what converging previews. Reachable []Reach `json:"reachable,omitempty"`