From 0de30064156a60f14da6081c975b099764bd3ee3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 17:15:51 +0100 Subject: [PATCH] A module refused alone is held, composed as what runs of it, on the node that applies as in its answer: one rule for both paths (issue 1126) The applying path (frombus.Compose) composed with no culprit, so one module's refusal came back as the whole node's: novox's apply would have refused all its modules for de-spiegel's unset route names. composeEach is now the one rule Compose and Answer both follow: - a module whose stub changes the refusal is held (compose.Node.Held); in the applying path a stand-in for a module placed here holds that module; only a refusal of no single module refuses the node; - a held module is composed from what runs of it (compose.Carried of its own composition in Running) and carried in the node's own wherever that runs it, folded or leaving the fold, so the node's own apply never removes it (issue 1945 by a new path) and what the others read of it is what runs; - none of a held module's own composition is in what is applied; - a module whose composition differs when the held ones are composed as stubs is held too; the node's own differing refuses the node; - since a held module is composed from its new manifest, what reads that manifest is held with it: the providers of what it contributes to (the proxy its routes) and, when it listens, the filter's holder. Quick fix weighed: a culprit loop in Compose alone. Thorough, chosen: one shared function, since the kind met twice (seed-8 shadow, now the apply), and review rounds 1 and 2 found its leaks. --- go/compose/composed.go | 54 +++- go/compose/frombus/answer.go | 332 ++++++++++++++++++++++--- go/compose/frombus/answer_rows_test.go | 161 +++++++++++- go/compose/frombus/frombus.go | 28 ++- go/compose/frombus/frombus_test.go | 51 +++- go/compose/input.go | 6 + 6 files changed, 562 insertions(+), 70 deletions(-) diff --git a/go/compose/composed.go b/go/compose/composed.go index f89dec1..afa9a77 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -55,11 +55,19 @@ func CompositionsShadow(in Input, node string, fps map[string]string) (out map[s return compositions(in, node, fps, Foreseeing, false) } +// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user +// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses +// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). +func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { + return compositions(in, node, fps, mode, false) +} + func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { reading := in reading.Nodes = append([]Node(nil), in.Nodes...) found := false inOwn := map[string]bool{} + held := map[string]bool{} for i := range reading.Nodes { if reading.Nodes[i].Name == node { reading.Nodes[i].ReadsModules = true @@ -67,6 +75,9 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli for m, ok := range reading.Nodes[i].InOwn { inOwn[m] = ok } + for m, ok := range reading.Nodes[i].Held { + held[m] = ok + } } } // The private network's module is built by nobody: no fingerprint of the new form ever names it. @@ -88,15 +99,26 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli continue } runsOwn = n.RunsOwn + candidates := map[string]bool{} for m := range inOwn { - if !folds(m) { + candidates[m] = true + } + for m := range held { + candidates[m] = true + } + for m := range candidates { + // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a + // composition of its own (its want new, its names still unset): its stub is never what the node's own applies, + // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945). + heldHere := held[m] && RunsAnyOf(runsOwn, m) + if !folds(m) && !heldHere { continue } man, ok := n.Kept[m] if !ok { man, ok = reading.Catalogue[m] } - if !ok || !NamesBuild(man) { + if !ok || (!NamesBuild(man) && !held[m]) { continue } carried[m] = true @@ -231,6 +253,10 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli out[own] = c } for key, c := range out { + if held[c.Module] { + delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held) + continue + } if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { c.Data = len(m.DataItems()) > 0 out[key] = c @@ -243,6 +269,30 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli return out, skipped, stood, nil } +// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. +func RunsAnyOf(runs *delivery.Composed, module string) bool { + if runs == nil { + return false + } + for _, raw := range runs.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(raw, &head) != nil { + continue + } + owner := head.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(head.ID) + } + if owner == module { + return true + } + } + return false +} + // NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot // compose such a module folded into its own composition: the build of the old form it runs is held by the controller // alone (issue 1126). diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 0f0bfe9..c871d6b 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -2,6 +2,7 @@ package frombus import ( "context" + "encoding/json" "fmt" "regexp" "slices" @@ -10,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -70,21 +72,14 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) } } - composed, skipped, stood, err := compose.CompositionsShadow(in, s.Node, fps) - for err != nil { - module, bare := culprit(in, s.Node, fps, failed, err) - if module == "" { - // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. - if len(failed) == 0 { - return refuse(err) - } - failed[delivery.HaveNodeWord] = err.Error() - composed, skipped, stood = nil, nil, nil - break + composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed) + if err != nil { + // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. + if len(failed) == 0 { + return refuse(err) } - failed[module] = err.Error() - in = stubbed(in, s.Node, module, bare) - composed, skipped, stood, err = compose.CompositionsShadow(in, s.Node, fps) + failed[delivery.HaveNodeWord] = err.Error() + composed, skipped, stood = nil, nil, nil } // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it. @@ -244,12 +239,259 @@ func standsInFor(x, node string) (string, string) { return node, "" } +// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's +// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the +// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid +// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in +// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single +// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the +// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a +// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers +// every stand-in for the caller to judge (Answer). +// +// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it +// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others +// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own +// composition is in out: the node keeps what it last applied. **What depends on how it is held is held too:** a module +// whose composition differs when the held ones are composed as their stubs instead is held with them, and a node's own +// composition that differs (but for the held modules' own resources) refuses the node. And since a held module is +// composed from its new manifest, which may not be the build's that runs, what reads that manifest is held with it +// (dependents): the providers of what it contributes to (the proxy its routes), and, when it listens, the filter. +func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { + mode := compose.Foreseeing + if applied { + mode = compose.Reading + } + runs := map[string]*delivery.Composed{} + for i := range running { + if m := running[i].Module; m != "" { + runs[m] = &running[i] + } + } + src := map[string]catalogue.Manifest{} // each held module's manifest before it was held + bareOf := map[string]bool{} + var hold func(m, why string, bare bool) + hold = func(m, why string, bare bool) { + if _, done := failed[m]; !done { + failed[m] = why // a held module tried bare keeps the words it was first held for + } + first := false + if _, ok := src[m]; !ok { + man, ok := manifestOn(in, node, m) + if !ok { + return + } + src[m], first = man, true + } + bareOf[m] = bareOf[m] || bare + in = heldAs(in, node, m, src[m], bareOf[m], runs[m]) + if !first { + return + } + // 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). + for _, r := range dependents(in, node, src[m], failed) { + hold(r, "it reads what "+m+" contributes or listens on, and "+m+" is held", false) + } + } + for _, m := range sortedKeys(failed) { + hold(m, failed[m], false) + } + for { + out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) + if err != nil { + module, bare := culprit(in, node, fps, mode, failed, err) + if module == "" { + return nil, nil, nil, err + } + hold(module, err.Error(), bare) + continue + } + again := false + if applied && len(stood) > 0 { + placed := map[string]bool{} + for _, m := range assignedOn(in, node) { + placed[m] = true + } + of := map[string][]string{} + var nodes []string + for _, x := range stood { + if n, m := standsInFor(x, node); n == node && placed[m] { + of[m] = append(of[m], x) + } else { + nodes = append(nodes, x) + } + } + for _, m := range sortedKeys(of) { + if _, done := failed[m]; !done { + hold(m, "stands in for "+strings.Join(of[m], ", "), false) + again = true + } + } + if !again && len(nodes) > 0 { + return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", ")) + } + } + if !again && len(failed) > 0 { + dependent, own := dependsOnHeld(in, node, fps, mode, out, src, bareOf, failed) + if own { + return nil, nil, nil, fmt.Errorf("frombus: %s's own composition depends on how %s, held, is composed, and is not applied", + node, strings.Join(sortedKeys(failed), ", ")) + } + for _, m := range dependent { + hold(m, "what it composes of "+strings.Join(sortedKeys(failed), ", ")+", held, is not what runs of them", false) + again = true + } + } + if !again { + break + } + } + skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) + return out, skipped, stood, nil +} + +// dependsOnHeld composes in again with each held module as its stub rather than as what runs of it, and answers the +// modules, not held, whose composition differs between the two (sorted), and own when the node's own composition does +// but for the held modules' own resources. A composition of stubs that fails says nothing more: what runs composed. +func dependsOnHeld(in compose.Input, node string, fps map[string]string, mode compose.Mode, asRuns map[string]delivery.Composed, + src map[string]catalogue.Manifest, bareOf map[string]bool, failed map[string]string) (dependent []string, own bool) { + stubs := in + for _, m := range sortedKeys(failed) { + if man, ok := src[m]; ok { + stubs = heldAs(stubs, node, m, man, bareOf[m], nil) + } + } + asStubs, _, _, err := compose.CompositionsIn(stubs, node, fps, mode) + if err != nil { + return nil, false + } + isHeld := func(m string) bool { _, ok := failed[m]; return ok } + keys := map[string]bool{} + for k := range asRuns { + keys[k] = true + } + for k := range asStubs { + keys[k] = true + } + for _, k := range sortedKeys(keys) { + a, inA := asRuns[k] + b, inB := asStubs[k] + m := a.Module + if !inA { + m = b.Module + } + if m != "" && isHeld(m) { + continue + } + if m == "" { + a, b = withoutHeld(a, isHeld), withoutHeld(b, isHeld) + } + da, errA := delivery.ComposedDigestOf(a) + db, errB := delivery.ComposedDigestOf(b) + if inA == inB && errA == nil && errB == nil && da == db { + continue + } + if m == "" { + own = true + continue + } + dependent = append(dependent, m) + } + return dependent, own +} + +// withoutHeld is the node's own composition c without the resources of a held module (its id naming it) and without +// its filled paths, for comparing the rest. +func withoutHeld(c delivery.Composed, isHeld func(string) bool) delivery.Composed { + out := c + out.Filled = nil + out.Resources = nil + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + _ = json.Unmarshal(raw, &head) + owner := head.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(head.ID) + } + if owner != "" && isHeld(owner) { + continue + } + out.Resources = append(out.Resources, raw) + } + return out +} + +// dependents is, of the modules placed on node and not held, those that read what man says to them: each that provides +// what man contributes to (the proxy its routes), and, when man listens on a port, each that holds the filter (the +// openings). +func dependents(in compose.Input, node string, man catalogue.Manifest, failed map[string]string) []string { + to := map[string]bool{} + for p := range man.Contributes { + to[p] = true + } + for p := range man.ContributesMany { + to[p] = true + } + listens := len(man.Listens) > 0 + if len(to) == 0 && !listens { + return nil + } + var out []string + for _, m := range assignedOn(in, node) { + if _, held := failed[m]; held || m == man.Module { + continue + } + pm, ok := manifestOn(in, node, m) + if !ok { + continue + } + if listens && pm.Filtering != nil { + out = append(out, m) + continue + } + for _, o := range pm.Provides { + if to[o.Name] { + out = append(out, m) + break + } + } + } + return out +} + +// assignedOn is the modules placed on node, in placement order. +func assignedOn(in compose.Input, node string) []string { + for _, n := range in.Nodes { + if n.Name == node { + return n.Assigned + } + } + return nil +} + +// manifestOn is the manifest node composes module from: its Kept, else the catalogue's. +func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) { + for _, n := range in.Nodes { + if n.Name == node { + if man, ok := n.Kept[module]; ok { + return man, true + } + } + } + man, ok := in.Catalogue[module] + return man, ok +} + // culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub -// (compose.Stub) makes the composition succeed or fail otherwise; empty when none does (the error is not one module's). -// Modules already refused are passed over. A module whose stub still fails the same is tried bare (its stub contributing -// nothing, bare true): a refusal of what it contributes is its own, which the stub keeps (issue 1126, seed-8: de-spiegel's -// route names on novox, an unset required value, refused the whole node). -func culprit(in compose.Input, node string, fps map[string]string, failed map[string]string, err error) (string, bool) { +// (compose.Stub) makes the composition in mode succeed or fail otherwise; empty when none does (the error is not one +// module's). Modules already refused are passed over. A module whose stub still fails the same is tried bare (its stub +// contributing nothing, bare true): a refusal of what it contributes is its own, which the stub keeps (issue 1126, +// seed-8: de-spiegel's route names on novox, an unset required value, refused the whole node). +func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, bool) { var assigned []string for _, n := range in.Nodes { if n.Name == node { @@ -266,7 +508,7 @@ func culprit(in compose.Input, node string, fps map[string]string, failed map[st } } changes := func(m string, bare bool) bool { - _, _, _, again := compose.CompositionsShadow(stubbed(in, node, m, bare), node, fps) + _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, bare), node, fps, mode) return again == nil || again.Error() != err.Error() } for _, m := range order { @@ -277,8 +519,16 @@ func culprit(in compose.Input, node string, fps map[string]string, failed map[st return m, false } } + var kept map[string]catalogue.Manifest + for _, n := range in.Nodes { + if n.Name == node { + kept = n.Kept + } + } for _, m := range order { - if _, done := failed[m]; done { + // One already held is tried bare too, once: held for its build, its stub may still refuse (its own secret + // never issued, issue 1126). + if _, done := failed[m]; done && isBare(kept[m]) { continue } if changes(m, true) { @@ -288,31 +538,47 @@ func culprit(in compose.Input, node string, fps map[string]string, failed map[st return "", false } -// stubbed is in with module composed as a stub on node alone (compose.Stub, as node's Kept); bare, a stub that also -// contributes nothing to what it requires. +// isBare says a stub contributes nothing and declares no own secret (stubbed, bare). +func isBare(m catalogue.Manifest) bool { + return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0 +} + +// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node +// composes it from: a culprit's try. func stubbed(in compose.Input, node, module string, bare bool) compose.Input { + man, ok := manifestOn(in, node, module) + if !ok { + return in + } + return heldAs(in, node, module, man, bare, nil) +} + +// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of +// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; bare, contributing nothing to +// what it requires and declaring no own secret: none of a held module's own composition is applied, so what it has not +// been issued (restic's repository on a node it never ran on) is no refusal of the rest. +func heldAs(in compose.Input, node, module string, man catalogue.Manifest, bare bool, runs *delivery.Composed) compose.Input { out := in out.Nodes = append([]compose.Node(nil), in.Nodes...) for i := range out.Nodes { if out.Nodes[i].Name != node { continue } - man, ok := out.Nodes[i].Kept[module] - if !ok { - if man, ok = in.Catalogue[module]; !ok { - return in - } + if bare { + man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil } kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1) for k, v := range out.Nodes[i].Kept { kept[k] = v } - stub := compose.Stub(man) - if bare { - stub.Contributes, stub.ContributesMany = nil, nil - } - kept[module] = stub + kept[module] = compose.Carried(man, module, runs) out.Nodes[i].Kept = kept + held := make(map[string]bool, len(out.Nodes[i].Held)+1) + for k, v := range out.Nodes[i].Held { + held[k] = v + } + held[module] = true + out.Nodes[i].Held = held } return out } diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index b90e2a1..71ea788 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -111,7 +111,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's // answer took 7m46s, one compose per module), though here a stub would compose. - if m, _ := culprit(in, "n1", fps, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { + if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { t.Errorf("a refusal naming no placed module was laid on %s", m) } ownKey, _ := delivery.ComposedNodeKey("n1") @@ -129,7 +129,10 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { running = append(running, c) } scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running} - got, _, err := Compose(ctx, b, Mesh{}, scope, fps) + got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err) } @@ -162,9 +165,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) } // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. - if _, _, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err == nil || - !strings.Contains(err.Error(), "spiegel has not been built for this mesh") { - t.Errorf("composed spiegel without what runs: %v", err) + if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || + !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { + t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) } // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter @@ -217,7 +220,10 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } running = append(running, c) } - got, _, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatalf("the node does not compose itself with spiegel carried: %v", err) } @@ -278,7 +284,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is - // refused, in the config form's words, and the rest is judged. + // refused, in the config form's words, and proxy, which receives its routes (what it would compose lacks them: review + // of the apply, round 1); the rest is judged. spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") if err != nil { t.Fatal(err) @@ -302,11 +309,143 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) } } - if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) { - t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node not", a) + if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel", "proxy"}) { + t.Errorf("a module's own refusal: %+v, want de-spiegel refused with proxy, which receives its routes, the node not", a) } - if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "proxy" }) { - t.Errorf("proxy, which composes, is not judged beside de-spiegel's refusal: %+v", a) + if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }) { + t.Errorf("lemurs, which composes, is not judged beside de-spiegel's refusal: %+v", a) + } + + // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It + // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was + // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which + // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it + // removes nothing of it. + spiegelRuns := []json.RawMessage{ + json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), + json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), + json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} + if in, err = Input(ctx, b, Mesh{}, stub); err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} + in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { + m.Contributes, m.ContributesMany = nil, nil + return m + }(in.Nodes[0].Kept["de-spiegel"]) + if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { + t.Fatal(err) + } + running = nil + for _, c := range base { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) + } + running = append(running, c) + } + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) + if err != nil { + t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) + } + if len(held) != 2 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["proxy"], "de-spiegel") { + t.Errorf("held %v, want de-spiegel in its config form's words, and proxy for its routes", held) + } + lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fps["lemurs"]) + proxyKey, _ := delivery.ComposedKey("n1", "proxy", fps["proxy"]) + if _, ok := got[lemursKey]; !ok { + t.Errorf("lemurs, which composes, is not composed beside de-spiegel's refusal: %v", sortedKeys(got)) + } + if _, ok := got[proxyKey]; ok { + t.Errorf("proxy is composed without the routes of held de-spiegel: %v", sortedKeys(got)) + } + carries := func(c delivery.Composed, what string) { + t.Helper() + ownRes, _ := json.Marshal(c.Resources) + for _, raw := range spiegelRuns { + var r struct{ ID string } + _ = json.Unmarshal(raw, &r) + if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { + t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) + } + } + } + carries(got[ownKey], "folded") + // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, + // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried + // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its + // container, files and units (issue 1945 by a new path). + fps["de-spiegel"] = fp("7") + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { + t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) + } + spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) + if _, ok := got[spiegelKey]; ok { + t.Errorf("held de-spiegel's own composition is handed") + } + carries(got[ownKey], "leaving the fold") + + // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others + // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own + // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. A module whose + // composition depends on how site is held (here the filter) is held with it. + site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` + + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` + + `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) + b = mk("lemurs", overlay.Name, "site", "nft") + for m, raw := range map[string][]byte{"site": site, "nft": nft} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + for i := range p.Modules { + if p.Modules[i].Module == "site" { + p.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b.placements["n1"] = p + rec = b.records["n1"] + rec.OutwardLinks = []string{"eth0"} + b.records["n1"] = rec + fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} + siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ + json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), + json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + if err != nil || held["site"] == "" || held["nft"] == "" { + t.Fatalf("site, its own secret never issued: %v, held %v; want site held, and the filter, which depends on it", err, held) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { + t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) + } + } + } + // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its + // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the + // build that runs listens on 8080: the filter, the holder of what site listens on, is held with it, and nothing + // applied opens 9090. + moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) + b.manifests[delivery.ManifestDigest(moved)] = moved + b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} + for i := range p.Modules { + p.Modules[i].Ports = nil + } + b.placements["n1"] = p + siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + if err != nil || held["site"] == "" || held["nft"] == "" { + t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held, and the filter with it", err, held) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 9090") { + t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) + } + } } } diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 7101fa0..7632246 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -25,8 +25,8 @@ // **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), // for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet // read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one -// the composition stands in for it, and Compose refuses a composition that stands in for anything (compose.ErrStoodIn): -// a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is +// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in +// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is // always empty. package frombus @@ -379,25 +379,31 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module return in, nil } -// Compose is the node's compositions over the bus (ADR 0547 §2): Input for s, then compose.CompositionsApplied at the -// fingerprints fps gives each module: composed in the Reading mode as the controller composes them, and refused when it -// stands in for anything the input does not carry (compose.ErrStoodIn), since the node applies what it composed. -func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (map[string]delivery.Composed, []string, error) { - in, err := Input(ctx, r, mesh, s) +// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps +// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate +// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition +// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with +// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a +// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served +// elsewhere, a stand-in for the node's own) refuses the node, err. +func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }) if err != nil { - return nil, nil, err + return nil, nil, held, err } // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) if err != nil { - return nil, nil, err + return nil, nil, held, err } if len(asConsumer)+len(asProvider) > 0 { if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { - return nil, nil, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) + return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) } } - return compose.CompositionsApplied(in, s.Node, fps) + out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held) + return out, skipped, held, err } // fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index e02fa56..d15dbed 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -223,7 +223,10 @@ func TestInput(t *testing.T) { // The same composer over both: the compositions from the bus are the store's, byte for byte. fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), "media": "src2:" + strings.Repeat("c", 64)} - fromBus, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) + fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatal(err) } @@ -243,7 +246,10 @@ func TestInput(t *testing.T) { running = append(running, c) } laptop := mk().placements["laptop"] - asIs, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) + asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatal(err) } @@ -252,7 +258,10 @@ func TestInput(t *testing.T) { } moved := mk().placements["anchor"] moved.Decided.Endpoint = "anchor.example:51821" - changed, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatal(err) } @@ -289,7 +298,10 @@ func TestInput(t *testing.T) { } oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} - runs, _, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) + runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatal(err) } @@ -297,7 +309,10 @@ func TestInput(t *testing.T) { for _, c := range runs { runsNow = append(runsNow, c) } - objectsRun, _, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) + objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatal(err) } @@ -579,11 +594,21 @@ func TestInput(t *testing.T) { } return b } - if _, _, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err == nil || !strings.Contains(err.Error(), `restic needs a secret called "repository"`) { - t.Errorf("restic composed from the indexer's manifest: %v, want its own secret refused", err) + if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) { + t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held) + } else { + for key, c := range out { + if c.Module == "restic" { + // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one. + t.Errorf("held restic is composed as %s, want none of its own composition handed", key) + } + } } builtBus := movedMesh(true) - fromBuild, _, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) + fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } if err != nil { t.Fatalf("restic composed from its build: %v", err) } @@ -597,8 +622,8 @@ func TestInput(t *testing.T) { // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). unreferenced := movedMesh(true) unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} - if _, _, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err == nil || !strings.Contains(err.Error(), "the build of restic is done and waits for the reference of tools") { - t.Errorf("a done key with an unreferenced artifact: %v", err) + if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { + t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) } // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done // build of it is at the trunk's FP2: its build is not read. @@ -662,12 +687,12 @@ func TestInput(t *testing.T) { } return b } - if _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { + if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { t.Errorf("a provider's served setting nobody sets: %v", err) } served := withMailer(true) - if _, _, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil { - t.Errorf("laptop with what anchor's mailer serves: %v", err) + if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 { + t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held) } if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") { t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead) diff --git a/go/compose/input.go b/go/compose/input.go index 5b54123..5b9cba0 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -204,6 +204,12 @@ type Node struct { // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. RunsOwn *delivery.Composed + // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when + // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what + // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs + // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own + // never drops it. + Held map[string]bool } // Place is where a node is, as its placement says (inv.Overlay).