diff --git a/cmd/mesh-controller/facts.go b/cmd/mesh-controller/facts.go index 67bd4c07..32a3b3dd 100644 --- a/cmd/mesh-controller/facts.go +++ b/cmd/mesh-controller/facts.go @@ -169,10 +169,10 @@ func factsStore(ctx context.Context, open *stores) (string, error) { return address, nil } -// writtenFormat is the format of the snapshot this controller writes: 1, the format its own reader knew before mesh-sdk's -// go/facts took the type over (issue 1390, novox/hq ADR 0456). The readers (the merge gate, the check) read formats 1 and -// 2; the writer moves to 2, which also carries each machine's grants and bindings, in a later release of its own. -const writtenFormat = 1 +// writtenFormat is the format of the snapshot this controller writes: mesh-sdk's go/facts current format, 2, which carries +// each machine's grants and credential bindings besides everything format 1 held (issue 1394, novox/hq ADR 0456 clause 2). +// Every reader (the merge gate, the build seat's check) reads formats 1 and 2. +const writtenFormat = snapshot.Format // gatherFacts composes one snapshot from the store: read only, nothing made, nothing sent. func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot.Facts, error) { @@ -367,6 +367,23 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())}) } } + // Who was issued a credential by this machine, by name only: never what the credential is, sealed or not. + issued, err := inv.SecretsFrom(ctx, n.Name) + if err != nil { + return snapshot.Facts{}, err + } + for _, g := range issued { + m.Grants = append(m.Grants, scrub.Grant(g.Name, g.Consumer, g.ConsumerModule, g.Local)) + } + bound, err := inv.BindingsFor(ctx, n.Name) + if err != nil { + return snapshot.Facts{}, err + } + for consumer, byProvision := range bound { + for provision, c := range byProvision { + m.Bindings = append(m.Bindings, scrub.Binding(consumer, provision, c.Node, c.Module)) + } + } m.Declaration = declarationFacts(ctx, open, n.Name, gensErr, scrub) if ctx.Err() != nil { return snapshot.Facts{}, ctx.Err() diff --git a/cmd/mesh-controller/facts_format_test.go b/cmd/mesh-controller/facts_format_test.go index b5583fcd..de724581 100644 --- a/cmd/mesh-controller/facts_format_test.go +++ b/cmd/mesh-controller/facts_format_test.go @@ -9,12 +9,11 @@ import ( snapshot "git.novox.be/novox/mesh-sdk/go/facts" ) -// 1390 (novox/hq ADR 0456): the snapshot this controller writes is format 1, and the reader it shares with the build seat -// (mesh-sdk's go/facts) takes formats 1 and 2 and refuses one newer than it knows. The writer moves to format 2 in a release -// of its own. It failed first with the controller's own reader, which refused format 2 as newer than it knew. -func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) { - if writtenFormat != 1 { - t.Fatalf("the writer writes format %d, not 1", writtenFormat) +// 1394 (novox/hq ADR 0456 clause 2): the snapshot this controller writes is format 2, which carries each machine's grants and +// bindings, and the reader it shares with the build seat (mesh-sdk's go/facts) takes formats 1 and 2 and refuses one newer. +func TestTheWriterWritesFormatTwoAndTheReaderTakesBothFormats(t *testing.T) { + if writtenFormat != 2 { + t.Fatalf("the writer writes format %d, not 2", writtenFormat) } taken := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) for format, want := range map[int]bool{1: true, 2: true, 3: false} { @@ -32,10 +31,22 @@ func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) { t.Errorf("format %d is not refused as newer: %v", format, err) } } - // What the writer composes carries no field of format 2: a reader of 1 and of 2 read it alike. - body, err := (snapshot.Facts{Format: writtenFormat, Taken: taken, Machines: []snapshot.Machine{{Name: "abcdef", Length: 6}}}).Encode() - if err != nil { - t.Fatal(err) + // Format 2 carries a machine's grants and bindings, and they are encoded in one order whatever order they were made in. + grants := []snapshot.Grant{{Provision: "s3-bucket", ConsumerNode: "bbbbbb", ConsumerModule: "files"}, + {Provision: "s3-bucket", ConsumerNode: "aaaaaa", ConsumerModule: "files"}} + bindings := []snapshot.Binding{{ConsumerModule: "files", Provision: "s3-bucket", ProviderNode: "cccccc", ProviderModule: "objects"}} + encode := func(g []snapshot.Grant) []byte { + body, err := (snapshot.Facts{Format: writtenFormat, Taken: taken, Machines: []snapshot.Machine{{Name: "abcdef", Length: 6, + Grants: g, Bindings: bindings}}}).Encode() + if err != nil { + t.Fatal(err) + } + return body + } + body := encode(grants) + // 1394/AC3 (the bytes do not depend on the order the rows came in) + if reversed := encode([]snapshot.Grant{grants[1], grants[0]}); string(reversed) != string(body) { + t.Error("grants in another order encode to other bytes") } var raw struct { Format int `json:"facts"` @@ -44,15 +55,14 @@ func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) { if err := json.Unmarshal(body, &raw); err != nil { t.Fatal(err) } - if raw.Format != 1 { + if raw.Format != 2 { t.Errorf("the snapshot says format %d", raw.Format) } for _, m := range raw.Machines { - if _, has := m["grants"]; has { - t.Error("a format 1 snapshot carries grants") - } - if _, has := m["bindings"]; has { - t.Error("a format 1 snapshot carries bindings") + for _, key := range []string{"grants", "bindings"} { + if _, has := m[key]; !has { + t.Errorf("a format 2 snapshot carries no %s", key) + } } } } diff --git a/cmd/mesh-controller/facts_test.go b/cmd/mesh-controller/facts_test.go index 5edc0095..bbbcf851 100644 --- a/cmd/mesh-controller/facts_test.go +++ b/cmd/mesh-controller/facts_test.go @@ -9,6 +9,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" snapshot "git.novox.be/novox/mesh-sdk/go/facts" + "github.com/novox/mesh-controller/internal/inventory" ) // The facts snapshot (novox/hq to-be 45 ยง9): composed from the store, every machine under a pseudonym @@ -55,6 +56,11 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) { // Sealed, never kept plain (ADR 0004): the sealed blobs are what the store holds, and none may leave. secrets = append(secrets, s.ForConsumer, s.ForProvider) } + // A binding recorded for the consumer on the laptop, to the provider on the anchor. + if err := open.inventory.RecordBindings(ctx, "laptop", []inventory.Binding{{Machine: "laptop", Consumer: "files", + Provision: "s3-bucket", Provider: catalogue.Chosen{Node: "anchor", Module: "objects"}}}); err != nil { + t.Fatal(err) + } return open, secrets } @@ -64,8 +70,8 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) { if err != nil { t.Fatal(err) } - if f.Format != 1 { - t.Fatalf("the writer wrote format %d: it keeps format 1 until its own release moves it (issue 1390)", f.Format) + if f.Format != 2 { + t.Fatalf("the writer wrote format %d, not 2 (issue 1394)", f.Format) } body, err := f.Encode() if err != nil { @@ -89,6 +95,20 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) { } } + // 1394/AC1: the provider's machine lists the grant, the consumer's machine the binding, machine names scrubbed. + anchorFacts, _ := f.Machine(snapshot.Pseudonym("machine", "anchor")) + laptopFacts, _ := f.Machine(snapshot.Pseudonym("machine", "laptop")) + wantGrant := snapshot.Grant{Provision: "s3-bucket", ConsumerNode: snapshot.Pseudonym("machine", "laptop"), ConsumerModule: "files"} + if len(anchorFacts.Grants) != 1 || anchorFacts.Grants[0] != wantGrant { + t.Errorf("the anchor's grants read as %+v, want %+v", anchorFacts.Grants, wantGrant) + } + wantBinding := snapshot.Binding{ConsumerModule: "files", Provision: "s3-bucket", + ProviderNode: snapshot.Pseudonym("machine", "anchor"), ProviderModule: "objects"} + if len(laptopFacts.Bindings) != 1 || laptopFacts.Bindings[0] != wantBinding { + t.Errorf("the laptop's bindings read as %+v, want %+v", laptopFacts.Bindings, wantBinding) + } + // 1394/AC2: the text scan above ran over a snapshot that holds those rows, so no sealed value is among them. + // What a check needs is there: every machine, its length, its modules, its declaration composing. if len(f.Machines) != 2 || f.Longest() != len("laptop") { t.Fatalf("machines %+v, longest %d", f.Machines, f.Longest())