Write the facts snapshot in format 2, with each machine's grants and credential bindings (design 47 R4c-2, issue 1394)

Every reader takes format 2 since R4b (ADR 0456 clause 1), and the assigner's composition check reads the grants and bindings (ADR 0456 clause 2): the writer moves now, names only, never a sealed value.
This commit is contained in:
2026-10-20 18:26:07 +00:00
parent f2d7bc9480
commit b75fb7899e
3 changed files with 69 additions and 22 deletions
+21 -4
View File
@@ -169,10 +169,10 @@ func factsStore(ctx context.Context, open *stores) (string, error) {
return address, nil return address, nil
} }
// writtenFormat is the format of the snapshot this controller writes: 1, the format its own reader knew before mesh-sdk's // writtenFormat is the format of the snapshot this controller writes: mesh-sdk's go/facts current format, 2, which carries
// go/facts took the type over (issue 1390, novox/hq ADR 0456). The readers (the merge gate, the check) read formats 1 and // each machine's grants and credential bindings besides everything format 1 held (issue 1394, novox/hq ADR 0456 clause 2).
// 2; the writer moves to 2, which also carries each machine's grants and bindings, in a later release of its own. // Every reader (the merge gate, the build seat's check) reads formats 1 and 2.
const writtenFormat = 1 const writtenFormat = snapshot.Format
// gatherFacts composes one snapshot from the store: read only, nothing made, nothing sent. // 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) { 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())}) 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) m.Declaration = declarationFacts(ctx, open, n.Name, gensErr, scrub)
if ctx.Err() != nil { if ctx.Err() != nil {
return snapshot.Facts{}, ctx.Err() return snapshot.Facts{}, ctx.Err()
+26 -16
View File
@@ -9,12 +9,11 @@ import (
snapshot "git.novox.be/novox/mesh-sdk/go/facts" 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 // 1394 (novox/hq ADR 0456 clause 2): the snapshot this controller writes is format 2, which carries each machine's grants and
// (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 // bindings, and the reader it shares with the build seat (mesh-sdk's go/facts) takes formats 1 and 2 and refuses one newer.
// of its own. It failed first with the controller's own reader, which refused format 2 as newer than it knew. func TestTheWriterWritesFormatTwoAndTheReaderTakesBothFormats(t *testing.T) {
func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) { if writtenFormat != 2 {
if writtenFormat != 1 { t.Fatalf("the writer writes format %d, not 2", writtenFormat)
t.Fatalf("the writer writes format %d, not 1", writtenFormat)
} }
taken := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) 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} { 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) 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. // Format 2 carries a machine's grants and bindings, and they are encoded in one order whatever order they were made in.
body, err := (snapshot.Facts{Format: writtenFormat, Taken: taken, Machines: []snapshot.Machine{{Name: "abcdef", Length: 6}}}).Encode() grants := []snapshot.Grant{{Provision: "s3-bucket", ConsumerNode: "bbbbbb", ConsumerModule: "files"},
if err != nil { {Provision: "s3-bucket", ConsumerNode: "aaaaaa", ConsumerModule: "files"}}
t.Fatal(err) 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 { var raw struct {
Format int `json:"facts"` Format int `json:"facts"`
@@ -44,15 +55,14 @@ func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) {
if err := json.Unmarshal(body, &raw); err != nil { if err := json.Unmarshal(body, &raw); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if raw.Format != 1 { if raw.Format != 2 {
t.Errorf("the snapshot says format %d", raw.Format) t.Errorf("the snapshot says format %d", raw.Format)
} }
for _, m := range raw.Machines { for _, m := range raw.Machines {
if _, has := m["grants"]; has { for _, key := range []string{"grants", "bindings"} {
t.Error("a format 1 snapshot carries grants") if _, has := m[key]; !has {
} t.Errorf("a format 2 snapshot carries no %s", key)
if _, has := m["bindings"]; has { }
t.Error("a format 1 snapshot carries bindings")
} }
} }
} }
+22 -2
View File
@@ -9,6 +9,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
snapshot "git.novox.be/novox/mesh-sdk/go/facts" 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 // 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. // 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) 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 return open, secrets
} }
@@ -64,8 +70,8 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if f.Format != 1 { if f.Format != 2 {
t.Fatalf("the writer wrote format %d: it keeps format 1 until its own release moves it (issue 1390)", f.Format) t.Fatalf("the writer wrote format %d, not 2 (issue 1394)", f.Format)
} }
body, err := f.Encode() body, err := f.Encode()
if err != nil { 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. // What a check needs is there: every machine, its length, its modules, its declaration composing.
if len(f.Machines) != 2 || f.Longest() != len("laptop") { if len(f.Machines) != 2 || f.Longest() != len("laptop") {
t.Fatalf("machines %+v, longest %d", f.Machines, f.Longest()) t.Fatalf("machines %+v, longest %d", f.Machines, f.Longest())