Merge pull request 'Grants compose each membership from the build that runs as well as the trunk's, so a running build keeps its reads (issue 1966, recovery)' (#178) from fix/1966-membership-keeps-running-reads into main

This commit was merged in pull request #178.
This commit is contained in:
2026-11-08 16:03:19 +00:00
5 changed files with 119 additions and 8 deletions
+84
View File
@@ -4000,3 +4000,87 @@ func (g *seatGate) MayAct(seats ...string) bool {
func short(d string) string { func short(d string) string {
return strings.TrimPrefix(d, "sha256:")[:min(12, len(strings.TrimPrefix(d, "sha256:")))] return strings.TrimPrefix(d, "sha256:")[:min(12, len(strings.TrimPrefix(d, "sha256:")))]
} }
// A membership is composed from the build each machine runs now, the want's and the trunk's, so none loses a read (issue 1966:
// the vault build running on the node-engines read mesh-delivery-assigner.placement, which the trunk's manifest no longer does).
func TestMembershipKeepsWhatTheRunningBuildReads(t *testing.T) {
reader := func(reads ...string) catalogue.Manifest {
return catalogue.Manifest{Module: "mesh-vault", Version: "1", RunsAs: "mesh-vault", Reads: reads,
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-vault/broker"}}}
}
owner := catalogue.Manifest{Module: "mesh-delivery-assigner", Version: "1", RunsAs: "mesh-delivery-assigner",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/a/broker"}}}
rows := []struct {
name string
had, running map[string]catalogue.Manifest
reads []string
want []string
}{
{"the trunk alone", nil, nil, []string{"mesh-config.values"}, []string{"mesh-config.values"}},
{"the build run reads what the trunk dropped", map[string]catalogue.Manifest{"a.mesh-vault": reader("mesh-delivery-assigner.placement")}, nil, []string{"mesh-config.values"}, []string{"mesh-config.values", "mesh-delivery-assigner.placement"}},
{"the want's build reads what the trunk dropped", nil, map[string]catalogue.Manifest{"a.mesh-vault": reader("mesh-delivery-assigner.placement")}, []string{"mesh-config.values"}, []string{"mesh-config.values", "mesh-delivery-assigner.placement"}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
in := grants.Inputs{Nodes: []string{"a"}, Assigned: map[string][]string{"a": {"mesh-vault", "mesh-delivery-assigner"}},
Manifests: map[string]catalogue.Manifest{"mesh-vault": reader(row.reads...), "mesh-delivery-assigner": owner},
Running: map[string]catalogue.Manifest{}, Had: map[string]catalogue.Manifest{}}
maps.Copy(in.Running, row.running)
maps.Copy(in.Had, row.had)
recs, _ := grants.ListRecords(in)
var got []string
for _, s := range grants.Memberships(recs)["a.mesh-vault"].Membership.State {
if !s.Writes {
got = append(got, s.Name)
}
}
slices.Sort(got)
slices.Sort(row.want)
if !slices.Equal(got, row.want) {
t.Errorf("reads = %v, want %v", got, row.want)
}
})
}
}
// A pass reads each machine's have for the build it runs (issue 1966): its membership keeps what that older build reads, a
// have or build that cannot be read holds the pass back, and the seats held are the ones that build claims (ADR 0660 §2).
func TestPassComposesMembershipFromTheHaveBuild(t *testing.T) {
const old = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept","mod-x.old"]}`
runs := func(b *fakeBus) {
b.manifestOf("mod-y", yManifest)
digest := meshcatalogclient.ManifestDigest([]byte(old))
b.put(grants.ReadManifests, digest, meshcatalog.ManifestsByDigestValue{V: keys.Version, Module: "mod-y", Manifest: old})
fp := "src2:" + strings.Repeat("cd", 32)
b.put(grants.ReadHave, "b.mod-y", nodeengine.HaveByNodeModuleValue{V: keys.Version, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK, Fp: fp})
key, _ := builds.BuilderBuildKey("mod-y", fp)
b.put(grants.ReadBuilds, key, map[string]any{"v": keys.Version, "state": "done", "manifest": digest})
}
t.Run("the published membership keeps a read of the build the have names", func(t *testing.T) {
b := fixture()
runs(b)
partOn(b).Pass()
if got := string(b.own[grants.StateMemberships]["b.mod-y"]); !strings.Contains(got, "mod-x.old") || !strings.Contains(got, "mod-x.kept") {
t.Errorf("b.mod-y's membership lacks the running build's read: %s", got)
}
})
t.Run("a pass that cannot read the build a have names keeps the membership last published", func(t *testing.T) {
b := fixture()
runs(b)
part := partOn(b)
part.Pass()
b.refused[grants.ReadBuilds] = true
part.Pass()
if got := string(b.own[grants.StateMemberships]["b.mod-y"]); !strings.Contains(got, "mod-x.old") {
t.Errorf("b.mod-y's membership lost the running build's read when its build could not be read: %s", got)
}
})
t.Run("a build that cannot be read is a failed read", func(t *testing.T) {
b := fixture()
runs(b)
b.refused[grants.ReadBuilds] = true
if f := grants.ReadInputs(b, &grants.Manifests{}).ReadFailed(); !strings.Contains(strings.Join(f, "\n"), grants.ReadBuilds) {
t.Errorf("a build read refused is not a failed read: %v", f)
}
})
}
+22 -4
View File
@@ -151,6 +151,12 @@ type Inputs struct {
// Running is the manifest a machine is wanted to run, by `<node>.<module>`: the one the builder built at the // Running is the manifest a machine is wanted to run, by `<node>.<module>`: the one the builder built at the
// fingerprint the assigner's want names. An assignment with none is composed from the trunk's manifest. // fingerprint the assigner's want names. An assignment with none is composed from the trunk's manifest.
Running map[string]catalogue.Manifest Running map[string]catalogue.Manifest
// Had is the manifest of the build each machine runs now (its have's fingerprint, the builder's build of it), by
// `<node>.<module>`; the want's Running may already be a newer build. A membership is composed from both and the trunk's.
Had map[string]catalogue.Manifest
// HadFailed is why the build a machine runs could not be read this pass (a have or a build read that failed), by
// `<node>.<module>`: its membership is then composed without that build's reads, and the part keeps the last published.
HadFailed map[string]string
// Values is each resolved config value, by `<node>/<module>/<key>`. // Values is each resolved config value, by `<node>/<module>/<key>`.
Values map[string]any Values map[string]any
// BusNode is the machine running the bus's module, and Loaded what its node agent says it loaded. // BusNode is the machine running the bus's module, and Loaded what its node agent says it loaded.
@@ -322,7 +328,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
gate := cache.gate gate := cache.gate
cache.mu.Unlock() cache.mu.Unlock()
in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{}, in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{},
NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, Had: map[string]catalogue.Manifest{}, HadFailed: map[string]string{},
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
in.SeatListUnread = gate != nil && !gate.MayAct() in.SeatListUnread = gate != nil && !gate.MayAct()
nodes := map[string]bool{} nodes := map[string]bool{}
@@ -515,11 +521,14 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
wants := map[string][]byte{} wants := map[string][]byte{}
if in.ByHaves == "" { if in.ByHaves == "" {
wants = each(r, ReadWants, &in.Gaps) wants = each(r, ReadWants, &in.Gaps)
// What each machine runs now is its have's build, which the want may already have left (issue 1966): the
// memberships keep what either build reads, calls and consumes.
runningByHaves(r, &in, cache, in.Had)
} else { } else {
// Placed by the haves, what runs is what each have's fingerprint names, not a want: the assigner's wants are its // Placed by the haves, what runs is what each have's fingerprint names, not a want: the assigner's wants are its
// view too, and not read in such a pass. // view too, and not read in such a pass.
in.Gaps = append(in.Gaps, Gap{Input: ReadWants, Why: "not read: the modules are placed by the node-engines' haves this pass, so what runs is composed from each have's fingerprint"}) in.Gaps = append(in.Gaps, Gap{Input: ReadWants, Why: "not read: the modules are placed by the node-engines' haves this pass, so what runs is composed from each have's fingerprint"})
fromTrunk = runningByHaves(r, &in, cache) fromTrunk = runningByHaves(r, &in, cache, in.Running)
} }
for key, raw := range wants { for key, raw := range wants {
node, module, err := meshwantsclient.ParseWantKey(key) node, module, err := meshwantsclient.ParseWantKey(key)
@@ -743,11 +752,16 @@ func placeByHaves(r Reader, in *Inputs, haveKeys []string) {
// runningByHaves is, in a pass placed by the haves, the manifest each placed module runs: its have's fingerprint, the // runningByHaves is, in a pass placed by the haves, the manifest each placed module runs: its have's fingerprint, the
// builder's build of it, the manifest it built; the placed modules with none, `<node>.<module>`, to compose from the trunk. // builder's build of it, the manifest it built; the placed modules with none, `<node>.<module>`, to compose from the trunk.
func runningByHaves(r Reader, in *Inputs, cache *Manifests) []string { func runningByHaves(r Reader, in *Inputs, cache *Manifests, into map[string]catalogue.Manifest) []string {
var fromTrunk []string var fromTrunk []string
for node, modules := range in.Assigned { for node, modules := range in.Assigned {
for _, module := range modules { for _, module := range modules {
raw, found, err := r.Get(ReadHave, node+"."+module) raw, found, err := r.Get(ReadHave, node+"."+module)
if err != nil {
in.Gaps = append(in.Gaps, Gap{Input: ReadHave + " " + node + "." + module, Why: "cannot be read, so the build it runs is unknown: " + err.Error(), Failed: true})
in.HadFailed[node+"."+module] = ReadHave + " cannot be read"
}
if err != nil || !found { if err != nil || !found {
fromTrunk = append(fromTrunk, node+"."+module) fromTrunk = append(fromTrunk, node+"."+module)
continue continue
@@ -763,6 +777,10 @@ func runningByHaves(r Reader, in *Inputs, cache *Manifests) []string {
continue continue
} }
braw, found, err := r.Get(ReadBuilds, bk) braw, found, err := r.Get(ReadBuilds, bk)
if err != nil {
in.HadFailed[node+"."+module] = ReadBuilds + " cannot be read"
in.Gaps = append(in.Gaps, Gap{Input: ReadBuilds + " " + bk, Why: "cannot be read, so the manifest of the build " + node + "." + module + " runs is unknown: " + err.Error(), Failed: true})
}
if err != nil || !found { if err != nil || !found {
fromTrunk = append(fromTrunk, node+"."+module) fromTrunk = append(fromTrunk, node+"."+module)
continue continue
@@ -773,7 +791,7 @@ func runningByHaves(r Reader, in *Inputs, cache *Manifests) []string {
continue continue
} }
if m, err := cache.get(r, b.Manifest); err == nil { if m, err := cache.get(r, b.Manifest); err == nil {
in.Running[node+"."+module] = m into[node+"."+module] = m
} else { } else {
fromTrunk = append(fromTrunk, node+"."+module) fromTrunk = append(fromTrunk, node+"."+module)
} }
+6 -2
View File
@@ -407,7 +407,7 @@ func (p *Part) Compose() Result {
} }
p.writeSealed(&res, list, n, leftOut, dropped, in, bus, lost, users, loaded) p.writeSealed(&res, list, n, leftOut, dropped, in, bus, lost, users, loaded)
} }
ms := Memberships(recs) ms := Memberships(listRecs)
res.Memberships = len(ms) res.Memberships = len(ms)
members := make([]membership.Membership, 0, len(ms)) members := make([]membership.Membership, 0, len(ms))
for _, m := range ms { for _, m := range ms {
@@ -436,7 +436,11 @@ func (p *Part) Compose() Result {
// A pass that composes part of the mesh only grows a published membership: it publishes the union of the one last // A pass that composes part of the mesh only grows a published membership: it publishes the union of the one last
// published and the one composed (keeploaded.go, issue 1966), so a tool runner or a seat holder goes on serving what it // published and the one composed (keeploaded.go, issue 1966), so a tool runner or a seat holder goes on serving what it
// serves and still receives what the pass that ends the hold needs. // serves and still receives what the pass that ends the hold needs.
if why := in.keepsLoaded(); found && why != "" { why := in.keepsLoaded()
if why == "" && in.HadFailed[key] != "" {
why = "the build it runs is unread (" + in.HadFailed[key] + ")"
}
if found && why != "" {
if bare, err := membership.BareGrantsMembership(was); err == nil { if bare, err := membership.BareGrantsMembership(was); err == nil {
if body, err := json.Marshal(ms[key].Membership); err == nil { if body, err := json.Marshal(ms[key].Membership); err == nil {
if grown, kept, err := UnionMembership(bare, body); err == nil && len(kept) > 0 { if grown, kept, err := UnionMembership(bare, body); err == nil && len(kept) > 0 {
@@ -21,7 +21,9 @@ func Records(in Inputs) (buscompose.Records, []Gap) { return records(in, false)
// ListRecords is Records for the user list and the grants (issue 774): a module whose wanted build's manifest is not the // ListRecords is Records for the user list and the grants (issue 774): a module whose wanted build's manifest is not the
// trunk's is declared with what both grant (unionDeclared), so the list carries the trunk manifest's subjects before the // trunk's is declared with what both grant (unionDeclared), so the list carries the trunk manifest's subjects before the
// assigner moves the want to it, and a grant at the trunk's fingerprint waits for nothing the want's move would bring. // assigner moves the want to it, and a grant at the trunk's fingerprint waits for nothing the want's move would bring.
// Memberships and consumers stay with Records: they say what runs. Pure. // Memberships are composed from it too, and from the build each machine runs now (Inputs.Had): while a machine's build
// differs from the want's or the trunk's, neither loses a read, call or consume (ADR 0660 §2 applied to memberships,
// issue 1966). Consumers stay with Records. Pure.
func ListRecords(in Inputs) (buscompose.Records, []Gap) { return records(in, true) } func ListRecords(in Inputs) (buscompose.Records, []Gap) { return records(in, true) }
func records(in Inputs, union bool) (buscompose.Records, []Gap) { func records(in Inputs, union bool) (buscompose.Records, []Gap) {
@@ -106,6 +108,9 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) {
if union && onTrunk && !reflect.DeepEqual(m, trunk) { if union && onTrunk && !reflect.DeepEqual(m, trunk) {
d = unionDeclared(d, declare(trunk)) d = unionDeclared(d, declare(trunk))
} }
if had, ok := in.Had[node+"."+module]; union && ok && !reflect.DeepEqual(m, had) {
d = unionDeclared(d, declare(had))
}
out.Assigned[node] = append(out.Assigned[node], d) out.Assigned[node] = append(out.Assigned[node], d)
if m.Instances == catalogue.InstancesInterchangeable || union && onTrunk && trunk.Instances == catalogue.InstancesInterchangeable { if m.Instances == catalogue.InstancesInterchangeable || union && onTrunk && trunk.Instances == catalogue.InstancesInterchangeable {
out.Interchangeable[m.Module] = true out.Interchangeable[m.Module] = true
+1 -1
View File
@@ -111,7 +111,7 @@ func (p *Part) MembershipOf(node, module string) (any, error) {
return nil, errors.New("membership names a node and a module, or neither for every membership held back") return nil, errors.New("membership names a node and a module, or neither for every membership held back")
} }
in := p.inputs() in := p.inputs()
recs, left := Records(in) recs, left := ListRecords(in)
notFree := WithRootFree(&recs, p.Call) notFree := WithRootFree(&recs, p.Call)
m, ok := Memberships(recs)[node+"."+module] m, ok := Memberships(recs)[node+"."+module]
out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree} out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree}