package frombus import ( "context" "fmt" "maps" "regexp" "slices" "sort" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/delivery" ) // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with // s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as // for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes // nothing; the proposal and the revision are the candidate's, said back in the answer. // // The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the // node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the // composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own // secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other // stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} refuse := func(err error) delivery.CandidateAnswer { a.Refused = err.Error() a.Verdict = delivery.CandidateRefused return a } trunk, err := r.Placements(ctx) if err != nil { return refuse(fmt.Errorf("frombus: placement: %w", err)) } if s.Running == nil { s.Running = running } // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and // below, a module the composition refuses, each composed as a stub so the rest is composed and compared. failed := map[string]string{} in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }) if err != nil { return refuse(err) } asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) if err != nil { return refuse(err) } // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet // issued under Added (review of L2b, round 1, finding 1). runs := map[string]bool{} for _, c := range running { if c.Module != "" { runs[c.Module] = true } } var consumed []compose.CredentialKey for _, k := range asConsumer { if runs[k.ConsumerModule] { consumed = append(consumed, k) } } asConsumer = consumed if len(asConsumer)+len(asProvider) > 0 { if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) } } composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps)) 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[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. runsOwn := false for _, c := range running { runsOwn = runsOwn || c.Module == "" } if !runsOwn { for key, c := range composed { if c.Module == "" { delete(composed, key) } } } if _, whole := failed[delivery.HaveNodeWord]; whole { // Nothing composed to compare: every refusal is said, and nothing else. for _, m := range sortedKeys(failed) { a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) } heldAsToday(&a, s.HeldToday) a.Verdict = delivery.VerdictOf(a) return a } // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). if len(s.InOwn) > 0 { var judged []delivery.Composed for _, c := range running { if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { judged = append(judged, c) } } running = judged } cmp := delivery.CompareComposedWith(composed, running, s.SameValue) // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. budget := MaxShown for i := range cmp.Differs { kept := cmp.Differs[i].Bodies[:0] for _, b := range cmp.Differs[i].Bodies { if n := len(b.Composed) + len(b.Running); n <= budget { budget -= n kept = append(kept, b) } else { kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) a.Note = "some differing resources are named without their bodies: the answer holds at most " + fmt.Sprint(MaxShown) + " bytes of them" } } cmp.Differs[i].Bodies = kept } // A refused module was composed as a stub: what it composed is no difference; its refusal is said. notFailed := func(m string) bool { _, ok := failed[m]; return !ok } a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. added := map[string]bool{} for node, p := range s.Placements { if p == nil { continue } was := map[string]bool{} for _, m := range trunk[node].Modules { was[m.Module] = true } for _, m := range p.Modules { if !was[m.Module] { added[node+"/"+m.Module] = true } } } unissued := map[string][]string{} refusals := map[string][]string{} for _, x := range stood { node, module := standsInFor(x, s.Node) switch { case node == s.Node && runs[module]: // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is // empty, and every module counts as the group's). refusals[module] = append(refusals[module], x) case module != "" && added[node+"/"+module]: if node == s.Node { unissued[module] = append(unissued[module], x) } // another node's addition: the module here that carries it differs, which the assigner judges case module == "" || node != s.Node: // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) default: refusals[module] = append(refusals[module], x) } } addedHere := map[string]delivery.ModuleAdded{} for _, m := range cmp.Added { addedHere[m.Module] = m } for _, m := range skipped { // placed, and no build given to compose it at addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} } for m, xs := range unissued { got := addedHere[m] got.Module = m got.Unissued = append(got.Unissued, xs...) addedHere[m] = got } for _, m := range sortedKeys(addedHere) { a.Added = append(a.Added, addedHere[m]) } for _, m := range sortedKeys(refusals) { if _, isAdded := addedHere[m]; isAdded { continue // a module the candidate adds here: what it lacks is said under Added } if _, isFailed := failed[m]; isFailed { continue // said below, as why it could not be composed } a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) } for _, m := range sortedKeys(failed) { a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) } sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) heldAsToday(&a, s.HeldToday) a.Verdict = delivery.VerdictOf(a) return a } // 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): the candidate keeps it 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). No // module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered. func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { if len(today) == 0 { return } kept := a.Refusals[:0] for _, r := range a.Refusals { if w, keep := today[r.Module]; keep { r.Today = w a.Held = append(a.Held, r) } else { kept = append(kept, r) } } a.Refusals = kept if len(a.Refusals) == 0 { a.Refusals = nil } } // maxCulprits bounds the modules one refusal is tried on, each a whole composition. const maxCulprits = 8 // MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). const MaxShown = 256 << 10 var ( grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) ) // standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port // `port module:port`, a grant `the grant of for on `, a need ` on needs ...`; // the module empty when the words name none (the certificate, the authority). func standsInFor(x, node string) (string, string) { if m := grantStandIn.FindStringSubmatch(x); m != nil { return m[2], m[1] } if m := needsStandIn.FindStringSubmatch(x); m != nil { return m[2], m[1] } if rest, ok := strings.CutPrefix(x, "port "); ok { if module, _, ok := strings.Cut(rest, ":"); ok { return node, module } } if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") { return node, module } 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. // // **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held // route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer, // a collector, the provider of what the held one contributes to and the filter where it listens each compose with what // the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the // build it runs where one is known (ran: a composition of its own running at another build than it is composed at), // else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision // (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account, // and the hold says so. func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string, ran func(module string) (catalogue.Manifest, bool)) (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, offering what it last offered left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut) hold := func(m, why, leave string) { if _, done := failed[m]; !done { failed[m] = why // a held module tried again keeps the words it was first held for } if _, ok := src[m]; !ok { man, ok := manifestOn(in, node, m) if !ok { return } if ran != nil { if was, ok := ran(m); ok { man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens } } src[m] = man } if leave != "" { if left[m] == nil { left[m] = map[string]bool{} } left[m][leave] = true if leave != leaveAll { failed[m] += "; composed without what it contributes to " + leave + ", which does not compose" } } in = heldAs(in, node, m, src[m], left[m], runs[m]) } for _, m := range sortedKeys(failed) { hold(m, failed[m], "") } for { out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) if err != nil { module, leave := culprit(in, node, fps, mode, failed, err) if module == "" { return nil, nil, nil, err } hold(module, err.Error(), leave) 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], ", "), "") 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 { break } } skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) return out, skipped, stood, nil } // leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out // what it contributes to that provision alone. const leaveAll = "*" // 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, and what of its offer to leave out: the // first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave // ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's // routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its // stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not // one module's). Modules already refused are passed over for their stub. func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) { assigned := assignedOn(in, node) // Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a // refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's // answer took 7m46s). var order []string for _, m := range assigned { if strings.Contains(err.Error(), m) && len(order) < maxCulprits { order = append(order, m) } } changes := func(m, leave string) bool { _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode) return again == nil || again.Error() != err.Error() } for _, m := range order { if _, done := failed[m]; done { continue } if changes(m, "") { return m, "" } } for _, m := range order { man, ok := manifestOn(in, node, m) if !ok { continue } // One already held is tried again too: held for its build, its stub may still refuse (its own secret never // issued, issue 1126). A provision named in the error is tried first, each at one more composition. var to []string for p := range man.Contributes { to = append(to, p) } for p := range man.ContributesMany { if _, both := man.Contributes[p]; !both { to = append(to, p) } } sort.SliceStable(to, func(i, j int) bool { return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j]) }) for _, p := range to { if changes(m, p) { return m, p } } if isBare(man) { continue } if changes(m, leaveAll) { return m, leaveAll } } return "", "" } // isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll). 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, leave left out of its offer: a culprit's try. func stubbed(in compose.Input, node, module, leave string) compose.Input { man, ok := manifestOn(in, node, module) if !ok { return in } var left map[string]bool if leave != "" { left = map[string]bool{leave: true} } return heldAs(in, node, module, man, left, 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; with what left names out of // its offer: a provision's contributions, or with leaveAll everything it contributes and its own secrets, none of a // held module's own composition being 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, left map[string]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 } if left[leaveAll] { man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil } else if len(left) > 0 { contributes := map[string]map[string]any{} for p, v := range man.Contributes { if !left[p] { contributes[p] = v } } many := maps.Clone(man.ContributesMany) for p := range left { delete(many, p) } man.Contributes, man.ContributesMany = contributes, many } kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1) for k, v := range out.Nodes[i].Kept { kept[k] = v } 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 }