Merge pull request 'The seed leaves a module as it runs today when it is held, failed or at an old-form build, and the modules held only behind it too (issue 1126)' (#127) from fix/1126-seed-oldbuild into main

This commit was merged in pull request #127.
This commit is contained in:
2026-10-28 23:05:04 +00:00
7 changed files with 108 additions and 21 deletions
+9
View File
@@ -318,6 +318,15 @@ func Stub(m catalogue.Manifest) catalogue.Manifest {
m.OwnsPaths = catalogue.DeclaredPaths(m) m.OwnsPaths = catalogue.DeclaredPaths(m)
} }
m.Build = nil m.Build = nil
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
// items stay, and the templates they render through: a held module's contributions to other modules' collections
// (seat contributions, shell blocks, seat data) still reach their collectors.
if m.Handed != nil {
h := *m.Handed
h.Templates = nil
m.Handed = &h
}
var dirs []map[string]any var dirs []map[string]any
for _, r := range m.Resources { for _, r := range m.Resources {
if r["type"] == "directory" { if r["type"] == "directory" {
+43 -5
View File
@@ -209,21 +209,59 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
return a return a
} }
// heldAsToday moves each refusal of a module the node holds today (Scope.HeldToday) into Held: the candidate holds it as // heldBehind is why a module is held with a held one it reads (composeEach's dependents).
// the node does, which is no change (issue 1126: a seed reproduces today, and today holds it too). func heldBehind(m string) string {
func heldAsToday(a *delivery.CandidateAnswer, today []string) { return "it reads what " + m + " contributes or listens on, and " + m + " is held"
}
// behind is the held module a refusal's words say it is held behind, or "".
func behind(why string) string {
rest, ok := strings.CutPrefix(why, "it reads what ")
if !ok {
return ""
}
m, _, ok := strings.Cut(rest, " contributes or listens on, and ")
if !ok || why != heldBehind(m) {
return ""
}
return m
}
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
// held, failed, running a build of the old form), and of each module held only behind one of those: the candidate keeps
// them as the node does, which is no change (issue 1126: a seed reproduces today, and today keeps them so). Each says
// how it stands today (ModuleRefused.Today).
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
if len(today) == 0 { if len(today) == 0 {
return return
} }
how := map[string]string{}
for m, w := range today {
how[m] = w
}
for moved := true; moved; {
moved = false
kept := a.Refusals[:0] kept := a.Refusals[:0]
for _, r := range a.Refusals { for _, r := range a.Refusals {
if slices.Contains(today, r.Module) { w, keep := how[r.Module]
if !keep {
if m := behind(r.Why); m != "" {
if _, behindKept := how[m]; behindKept {
w, keep = "held behind "+m, true
}
}
}
if keep {
r.Today = w
how[r.Module] = w
a.Held = append(a.Held, r) a.Held = append(a.Held, r)
moved = true
} else { } else {
kept = append(kept, r) kept = append(kept, r)
} }
} }
a.Refusals = kept a.Refusals = kept
}
if len(a.Refusals) == 0 { if len(a.Refusals) == 0 {
a.Refusals = nil a.Refusals = nil
} }
@@ -313,7 +351,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
// What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not // What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not
// necessarily the build's that runs: what reads it is held with it (review of the apply, round 2). // necessarily the build's that runs: what reads it is held with it (review of the apply, round 2).
for _, r := range dependents(in, node, src[m], failed) { for _, r := range dependents(in, node, src[m], failed) {
hold(r, "it reads what "+m+" contributes or listens on, and "+m+" is held", false) hold(r, heldBehind(m), false)
} }
} }
for _, m := range sortedKeys(failed) { for _, m := range sortedKeys(failed) {
+35 -2
View File
@@ -93,17 +93,25 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
} }
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
// refused module held today the verdict is no refusal, and each is named under Held. // refused module held today the verdict is no refusal, and each is named under Held.
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api", "spiegel", "web"}}, today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
t.Errorf("held as today: %+v", today) t.Errorf("held as today: %+v", today)
} }
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api"}}, part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
t.Errorf("held as today in part: %+v", part) t.Errorf("held as today in part: %+v", part)
} }
// A module held only behind one kept today is kept as today too, saying behind which (novox's mailu behind de-spiegel).
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
{Module: "mailu", Why: heldBehind("de-spiegel")}, {Module: "proxy", Why: heldBehind("mailu")}, {Module: "web", Why: "no build"}}}); true {
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
if len(a.Refusals) != 1 || a.Refusals[0].Module != "web" || len(a.Held) != 3 || a.Held[2].Today != "held behind mailu" {
t.Errorf("held behind a module kept today: %+v", a)
}
}
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
@@ -286,6 +294,31 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
} }
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
// contributes to other modules' collections still reaches them.
for _, row := range []struct {
name string
handed *catalogue.Handed
items []catalogue.Item
}{
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
} {
kept := f1
kept.Handed, kept.Items = row.handed, row.items
st := compose.Stub(kept)
if st.Handed != nil && st.Handed.Templates != nil {
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
}
if !reflect.DeepEqual(st.Items, row.items) {
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
}
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
}
}
var k1, k2 any var k1, k2 any
_ = json.Unmarshal(keptComposed, &k1) _ = json.Unmarshal(keptComposed, &k1)
_ = json.Unmarshal(keptFile, &k2) _ = json.Unmarshal(keptFile, &k2)
+4 -4
View File
@@ -134,10 +134,10 @@ type Scope struct {
Placements map[string]*delivery.Placement Placements map[string]*delivery.Placement
InOwn []string InOwn []string
Running []delivery.Composed Running []delivery.Composed
// HeldToday is, in an answer, the modules the node holds today ("node" for its own composition): their have's phase // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
// is held, or they are held inside its own composition. A candidate that holds one too holds it as today, which is // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
// said under CandidateAnswer.Held and is no refusal (issue 1126). // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
HeldToday []string HeldToday map[string]string
// SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
// as differing. // as differing.
+6 -2
View File
@@ -258,9 +258,13 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
if held := v.Answers[n].Held; len(held) > 0 { if held := v.Answers[n].Held; len(held) > 0 {
var names []string var names []string
for _, h := range held { for _, h := range held {
names = append(names, h.Module) today := h.Today
if today == "" {
today = "held"
} }
fmt.Fprintf(&b, "; stays held as today: %s", strings.Join(names, ", ")) names = append(names, h.Module+" ("+today+")")
}
fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", "))
} }
} }
return b.String() return b.String()
+1 -1
View File
@@ -79,7 +79,7 @@ func TestJudge(t *testing.T) {
t.Errorf("the difference is named %q", got) t.Errorf("the difference is named %q", got)
} }
} }
if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") { if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") {
t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v))
} }
if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) {
+4 -1
View File
@@ -190,10 +190,13 @@ type ModuleAdded struct {
Unissued []string `json:"unissued,omitempty"` Unissued []string `json:"unissued,omitempty"`
} }
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. // ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. Today,
// in CandidateAnswer.Held, is how the node keeps it today (held, failed, runs a build of the old form, held behind
// another), which the candidate keeps.
type ModuleRefused struct { type ModuleRefused struct {
Module string `json:"module"` Module string `json:"module"`
Why string `json:"why"` Why string `json:"why"`
Today string `json:"today,omitempty"`
} }
// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or // VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or