Files
mesh-controller/cmd/mesh-controller/plan.go
T
mesh-tools 2111b78fc5 Delete the controller's own composition: planFor, declarationWith, renderingFor, grantsFor, theRestOfTheMesh, generators, whoResolves and composedAndValidated, with what only they reached (issue 1389)
Every caller composes through mesh-sdk's go/compose since issues 1386 to 1388; the controller's second copy of the composition is dead code. The parent of this commit is fb556d6b, whose try (build-1792493691274712799, 17m28s, 2026-10-20 11:12 UTC) ran the equality test green on the live facts: every machine was sent and compared both ways (ADR 0453, issue 1384).

Deleted, 40 functions: in plan.go planFor, declarationWith, renderingFor, grantsFor, theRestOfTheMesh and their helpers (boundOfGrant, busCredentialIssued, carriedBuilds, carriedOn, composeBusUsers, engineJudgesRoot, engineReadsHealth, engineTakesHanded, takesHanded, givenUnderAnOldKey, issuedToHolder, keyFor, licencesFor, managerPublicKeyFor, movedProvider, providerMoved, providerModuleOf, sayLeftOut, seatsOn, wireBuilds, zonesInTheMesh); in network.go generators, whoResolves, namesInTheMesh, onThePrivateNetwork; composedAndValidated, adoptionFor, adoptionOf, engineOpensVaultBox, namedModules, localNetworksOf, keepRecorded, passSeatsOn, theMeshPassesSeatsOn, assignmentsOf. Choosing (Reading, Allocating, Foreseeing) is gone: coreBoxesFor and coreBoxesOf take a plain allocating flag. composeForPush no longer takes the private network it ignored. The seat pass of a send is performSeatPasses, over the passes the composition names.

Measured: cmd/mesh-controller non-test code 67,643 -> 65,821 lines (-1,822), its tests 57,691 -> 57,286, with the integration part of the equality test removed here because it calls the deleted functions. Not the issue's 3,000: that counts all of plan.go (2,192 lines), of which the callers, the verbs, the perform step and the sends remain (sendable, recorded builds, the held-back and ready-node logic, the plan verb, the gate's trial); and the 37 functions a name walk lists as unreachable are called through interfaces and method sets and are kept.

Tests dropped, each with the mesh-sdk go/compose test that holds its cases:
- seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo, TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims, TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone: rest_test.go TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant, TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone, TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant. TestACompositionPassesTheSeatAndWritesOneHandActByTheController and TestALostPassWritesNoActAndAFailedOneLeavesTheRecord stay as tests of the send's step (performSeatPasses); TestAThrowawayMeshPassesTheSeatInMemoryAndWritesNothing stays as TestAThrowawayMeshWritesNoHandActForAPass.
- seat_pass_test.go TestNoSeatIsPassedWhenAMachinesAssignmentsCouldNotBeRead: no counterpart, and none is needed: the adapter reads every machine's assignments for the Input and a machine whose read fails fails the Input, so no seat is passed on a partial read.
- network_test.go TestOnlyAMachineOnThePrivateNetworkIsNamed: network_test.go TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork.
- held_back_test.go TestASendCarriesTheCurrentBuildsAndALeftOutModuleKeepsItsOwn: node_test.go TestAModuleLeftOutKeepsTheBuildItWasLastSent (and compose_sdk_test.go's table, now literal).
- modules_wire_test.go TestTheBuildOfACarriedCommitIsTheAssignmentsOrTheOneBuiltFromIt: node_test.go TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem.
- compose_equal_integration_test.go: the old path it compares against is deleted; its last run is the parent's try.
Rewritten against mesh-sdk, same cases: issued_test.go (needs now come from compose.Resolve: Needed.IssuedToHolder and ProviderMoved), module_health_test.go and merge_gate_handing tests (the contract in the Input), provisioner_account_integration_test.go (the composition's refusal), one_resolution_integration_test.go (sayFound). Every test of the send, perform, the adapter and the verbs is kept; the tests that used planFor, declarationWith, generators, renderingFor or grantsFor as setup use resolveNode, composeSend, composeQuestion, renderingOf and grantsOf. compose_callers_test.go now fails when any deleted name is declared or called again.

Committed by copying the files of a staging branch (stage/r4b-1389 at 198be8217c, built from fb556d6b by patch commits and compared with the author's worktree), so that this commit's parent is fb556d6b.
2026-10-20 11:18:42 +00:00

701 lines
27 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// working out what one machine should be.
//
// Split out of main.go, which had reached 2,769 lines because appending was always the
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
// nothing in it was wrong, and no one edit was the one that should have been a new file.
// notResolvable marks the one failure in planFor that is a statement about the node: its assigned
// modules do not compose. Every other failure means the mesh could not be *asked* — the store was
// unreachable, a key could not be read — and says nothing about the node at all.
//
// The distinction exists because three callers gather something across every machine and must carry
// on when one machine's set is broken. Each of them read a plain error as "their set does not
// resolve", and so read a store that was briefly unreachable as a machine that runs nothing. On the
// roster of routed names that is not a degraded answer but a false one: it states, to every machine
// at once, that another machine's names do not exist. A control node spent hours replacing every
// container it ran, on a six-minute cycle, because each pass restarted the store this is read from,
// the read failed, one name left the roster, and the roster is part of every container's identity
// (novox/hq 04-ISSUES/152, and 04-ISSUES/151 for why a changed roster is a changed container).
//
// So: skip a node that cannot resolve, and never a node that could not be read.
type notResolvable struct{ err error }
func (n notResolvable) Error() string { return n.err.Error() }
func (n notResolvable) Unwrap() error { return n.err }
// unresolvable reports whether err is a node's own set failing to compose, rather than the mesh
// being unable to answer.
func unresolvable(err error) bool {
var n notResolvable
return errors.As(err, &n)
}
// leftOutKey carries where theRestOfTheMesh says a machine it leaves out, for an act that answers with
// its own resolution instead (novox/hq issue 237).
type leftOutKey struct{}
// leftOutSaidBy is ctx with theRestOfTheMesh's "left out" lines written to w rather than to standard
// error.
//
// **An act's answer is one resolution taken after its write** (novox/hq issue 237, decided 2026-10-11).
// `assign` read the whole mesh once before its write — to record the seats held by derivation — and that
// read said, on standard error, that the node did not resolve for lack of the seat the act was about to
// give it. The controller seat's verb answers with both streams, so the stale line came last, after the
// act's own "now has it held", and named as its remedy the assignment just made. An act gives io.Discard
// here: what the node resolves to after the write is said by its own planFor, and every other machine
// that does not resolve by blockedElsewhere, both read after the write.
func leftOutSaidBy(ctx context.Context, w io.Writer) context.Context {
return context.WithValue(ctx, leftOutKey{}, w)
}
// declarationFor is everything a node would be sent.
//
// One place, because there were three and one of them was written before credentials existed and
// silently produced a declaration missing them — a difference between what `plan` showed and what
// `plan --json` handed to anything reading it.
func declarationFor(ctx context.Context, open *stores, node string,
plan catalogue.Resolution, settings catalogue.SettingsBy) (sendable, error) {
// **A send, because `plan` is the send without the sending.** It is one machine, named by
// a person, who is asking what a push would do — so the port it shows and the secret it seals
// have to be the ones a push would use, and both are kept once chosen. Showing numbers that a
// later push would replace would make the command answer a question nobody asked.
//
// The line is not "a question may not write". It is who is asking and how often: this is a
// person, about one machine, on purpose. What may not write is the comparison the mesh runs
// over every machine to answer whether each is up to date, which is a question and makes nothing (composeQuestion).
return composeSend(ctx, open, node)
}
// boundToData is every binding of this machine's consumers to a provision that keeps their data
// (novox/hq ADR 0232), as a send records it. Not a consumer left out of the declaration: the machine
// is not told anything new about it, so nothing about where it is bound has been sent.
func boundToData(plan catalogue.Resolution, leftOut map[string]string) []inventory.Binding {
var out []inventory.Binding
seen := map[[2]string]bool{}
for _, n := range plan.Needs {
if !n.KeepsData || n.ByRecord || n.Module == "" {
continue
}
if _, left := leftOut[n.For]; left {
continue
}
key := [2]string{n.For, n.Name}
if seen[key] {
continue
}
seen[key] = true
out = append(out, inventory.Binding{Machine: plan.Node, Consumer: n.For, Provision: n.Name,
Provider: catalogue.Chosen{Node: n.From, Module: n.Module}})
}
return out
}
// sortedKeysOf is a map's keys, sorted — so what a declaration says it left out does not move
// for a reordering nobody made.
func sortedKeysOf(m map[string]string) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
func reportLeftOut(node string, declared sendable) {
for _, m := range declared.LeftOut {
fmt.Printf("%s: %s left out — what the machine holds for it is kept and its containers are "+
"untouched. %s\n", node, m, declared.leftOutWhy[m])
}
// And whom it serves nothing, because their identity overflows what the provision keeps (ADR
// 0225): the machine is sent everything else, and the consumer is named.
for _, o := range declared.withheld {
fmt.Printf("%s: %s\n", node, o)
}
// And whom it no longer serves because they are bound elsewhere (novox/hq issue 274).
for _, u := range declared.unbound {
fmt.Printf("%s: %s\n", node, u)
}
// And every contribution its holder could not render, which the machine is sent without (novox/hq
// ADR 0255).
for _, u := range declared.unplaced {
fmt.Printf("%s: %s\n", node, u)
}
// And each file only the provisioner's client fills, left out because the module is not handed here.
for _, u := range declared.handedOnly {
fmt.Printf("%s: %s\n", node, u)
}
}
// engineReadsBusy says whether a machine's node-engine reads a container's `busy` (novox/hq ADR 0335), by its
// own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
func engineReadsBusy(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
stated, had, err := inv.HealthOf(ctx, node)
if err != nil {
return false, err
}
return had && stated.Contract >= link.BusyContract, nil
}
// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 §4): the user
// the process of the module running it runs as, empty where no such module is on the machine.
func provisionerAccount(plan catalogue.Resolution) string {
for _, m := range plan.Modules {
if !runsTheProvisionerClient(m) {
continue
}
for _, r := range m.Resources {
if r["type"] == "process" {
if user, _ := r["user"].(string); user != "" && user != "root" {
return user
}
}
}
}
return ""
}
// busyNotSent is, for a node-engine that does not read a container's busy, each container whose module declares
// one: not sent it, so replaced at once while it may hold work (novox/hq ADR 0335). Nothing for one that reads it.
func busyNotSent(modules []catalogue.Manifest, reads bool) []string {
if reads {
return nil
}
var out []string
for _, m := range modules {
for _, r := range m.Resources {
if _, has := r[catalogue.BusyField]; has {
out = append(out, fmt.Sprintf("%s.%v declares busy, and this node-engine does not read it yet (its "+
"statement's contract is below %d): not sent, so the container is replaced at once, busy or not "+
"(novox/hq ADR 0335)", m.Module, r["id"], link.BusyContract))
}
}
}
return out
}
// certificateFor is what the mesh certifies about one machine's internal name.
//
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
// the machine and whether it is on the private network, `identity` holds the authority and the
// key that machine reported. The process holding both grants asks each for its part
// (novox/hq ADR 0008).
func certificateFor(ctx context.Context, open *stores, node string) (string, string, error) {
inv := open.inventory
ident, err := open.Identity(ctx)
if err != nil {
return "", "", err
}
record, err := inv.NodeByName(ctx, node)
if err != nil {
return "", "", err
}
serving, err := ident.ServingKeyOf(ctx, record.ID)
if err != nil {
return "", "", err
}
if serving == "" {
// The machine joined before it had one, or never reported it. Said plainly, because the
// remedy is on the machine and no amount of pushing from here will produce one.
return "", "", fmt.Errorf(
"%s wants a certificate and has never told the mesh what key it serves with; it "+
"joins again to report one", node)
}
// The name it is certified for. Only a machine on the private network has one — a certificate
// for a name nothing resolves is a certificate nothing can check.
//
// With the catalogue, not without it. Being on the private network is a conclusion about what
// a node resolves to, so a nil shelf resolves nothing and every machine looks like it is on no
// network — which refused every certificate the mesh was asked for, and said the machine was
// not on a network it plainly was.
shelf, err := inv.Catalogue(ctx)
if err != nil {
return "", "", err
}
where, err := whereEveryoneIs(ctx, inv, shelf)
if err != nil {
return "", "", err
}
name := where[node]
if name == "" {
return "", "", fmt.Errorf(
"%s wants a certificate and is not on the private network, so it has no name inside "+
"the mesh to be certified for", node)
}
issued, err := ident.Certify(ctx, node, name, serving)
if err != nil {
return "", "", err
}
authority, err := ident.EstablishAuthority(ctx)
if err != nil {
return "", "", err
}
return issued, authority.Certificate, nil
}
// listensLines is what a person is told about what this module would open, and why — the same
// `why` every listens entry already carries for the firewall it also feeds (novox/hq ADR 0007), so
// deciding whether to assign a module can see what it would open before it opens it, not only
// after. A module with nothing to listen on prints nothing extra, same as today.
func listensLines(m catalogue.Manifest) []string {
var out []string
for _, l := range m.Listens {
if l.Why == "" {
out = append(out, fmt.Sprintf(" listens %d/%s from %s", l.Port, l.At(), l.From))
continue
}
out = append(out, fmt.Sprintf(" listens %d/%s from %s — %s", l.Port, l.At(), l.From, l.Why))
}
return out
}
func planCommand(ctx context.Context, args []string) error {
// One reading of the mesh for every machine this looks at.
ctx = withInputCache(ctx)
set := flag.NewFlagSet("plan", flag.ContinueOnError)
// Because "one resource" does not tell you whether the settings landed. Being able to read
// the file before it is sent is the difference between believing a merge worked and knowing.
show := set.Bool("files", false, "print the files this node would be given")
// The declaration exactly as the node would receive it. For handing to something else --
// checking it against the host's own parser, most usefully, which is the only way to know
// that what the control plane emits is what the host accepts.
asJSON := set.Bool("json", false, "print the declaration this node would be sent")
// What a push would change, against what the machine was last sent (novox/hq ADR 0217): read
// before sending, so a change that removes, moves or recreates something is seen first.
asDiff := set.Bool("diff", false, "what a push would change on this node, against what it was last sent")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) != 1 {
return errors.New("plan <node> [--files] [--json] [--diff]")
}
args = positionals
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
plan, settings, err := resolveNode(ctx, open, args[0])
if err != nil {
return err
}
if len(plan.Modules) == 0 {
fmt.Printf("%s is assigned nothing\n", args[0])
return nil
}
// Which optional requirements are unbound (novox/hq ADR 0330 §7): beside the document on the
// error stream where the answer is the declaration itself, which carries no such field — the
// node-engine refuses an envelope key it does not know.
if *asDiff || *asJSON {
for _, line := range unboundLines(plan) {
fmt.Fprintf(os.Stderr, "%s:%s\n", args[0], line)
}
}
if *asDiff {
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
return err
}
body, err := declared.Body()
if err != nil {
return err
}
// A module left out is not in the body, so the diff alone would say "nothing would change" for
// a module just assigned whose settings cannot compose — success-shaped silence. Said first, with
// why, as push and the plain plan say it (novox/hq ADR 0163, rule 6).
reportLeftOut(args[0], declared)
return writePlanDiff(ctx, open.inventory, args[0], body)
}
if *asJSON {
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
return err
}
// The bytes a push would send, indented: one marshaller, so `plan --json` cannot show an
// envelope other than the one sent.
body, err := declared.Body()
if err != nil {
return err
}
var indented bytes.Buffer
if err := json.Indent(&indented, body, "", " "); err != nil {
return err
}
fmt.Println(indented.String())
return nil
}
// What the machine was last told, by whom and from which assignment generation (novox/hq issue 234),
// beside what it would be told now. A record that cannot be read is said, not taken for none.
if err := writeLastSend(ctx, os.Stdout, open.inventory, args[0]); err != nil {
fmt.Printf("what %s was last sent could not be read: %v\n", args[0], err)
}
// Which modules a push would leave out, and why — said before the plan, since the plan is of
// what the machine would be told (novox/hq ADR 0163, rule 6). Judged, never composed: `plan`
// without --json allocates nothing.
if record, err := open.inventory.NodeByName(ctx, args[0]); err == nil {
left := plan.LeftOut(settings, record.Adopted)
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
}
// And which of its modules no provider will grant, because the identity overflows the bound of
// what it requires (novox/hq ADR 0225) — said on the machine the remedy is for.
for _, o := range plan.Overflowing() {
fmt.Printf("%s: %s\n", args[0], o)
}
// And a setting that reaches nothing — refused where it is stored, and said here for one
// stored before its definition moved from under it.
for _, m := range plan.Modules {
for _, stray := range catalogue.UnusedSettings(m, settings[m.Module]) {
fmt.Printf("%s: a setting reaches nothing — %s\n", args[0], stray)
}
}
fmt.Printf("%s would run:\n", args[0])
for _, m := range plan.Modules {
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
for _, line := range listensLines(m) {
fmt.Println(line)
}
}
// What was assigned here and cannot run here. Said with the rest rather than as a refusal: it is
// one module on the wrong machine, the others still run, and the remedy is to move this one.
for _, u := range plan.Unhostable {
for _, c := range u.Missing {
fmt.Printf(" %-20s not applied — %s\n", u.Module, catalogue.WrongMachine(u.Module, c, args[0]))
}
}
for _, c := range plan.Claims {
fmt.Printf(" holds %s, one per %s\n", c.Claim, c.Scope)
}
// What this machine depends on that is not on it. Worth saying out loud: it is the only part
// of a node's set that stops working when a *different* machine goes away, and nothing else
// in this output would have told anybody that.
for _, n := range plan.Needs {
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
// And a served key the provider left out, because its machine composes no name it asks for
// (novox/hq ADR 0346 §3).
for _, line := range n.LeftOut {
fmt.Printf(" %s\n", line)
}
}
// What the providers on this machine leave out of what they serve, and why: said where the remedy
// is (novox/hq ADR 0346 §3).
for _, line := range plan.ServedLeftOut(settings) {
fmt.Printf("%s: %s\n", args[0], line)
}
for _, line := range unboundLines(plan) {
fmt.Println(line)
}
// And a container's busy that this machine's node-engine is too old to be sent (novox/hq ADR 0335): its
// container is replaced at once, as before. A statement that cannot be read is said as such.
if reads, err := engineReadsBusy(ctx, open.inventory, args[0]); err != nil {
fmt.Printf("%s: whether its node-engine reads a container's busy could not be read: %v\n", args[0], err)
} else {
for _, line := range busyNotSent(plan.Modules, reads) {
fmt.Printf("%s: %s\n", args[0], line)
}
}
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
return err
}
resources := declared.Resources
for module, layers := range settings {
for _, layer := range layers {
fmt.Printf(" %-20s settings from %s\n", module, layer.From)
}
}
// What each port reached from the local network resolved to on this machine (novox/hq ADR 0340 §8),
// or that it resolved to none.
for _, p := range declared.localNetwork {
fmt.Println(" " + p.Said())
}
fmt.Printf("\n%d resource(s)\n", len(resources))
if *show {
for _, r := range resources {
content, ok := r["content"].(string)
if !ok {
continue
}
fmt.Printf("\n--- %s ---\n%s", shownAs(r), content)
}
}
return nil
}
// shownAs is the heading `plan --show` puts over a resource's content.
//
// **A file written into says so.** Its content is the mesh's part of a file that is otherwise the
// machine's — the keys of a JSON document (novox/hq ADR 0102), the region of a hosts file (issue
// 128). Shown under a bare path it reads as the whole file, and a person checking what a take
// replaces would see a hosts file of a dozen lines where the machine keeps thirty.
func shownAs(r map[string]any) string {
heading := fmt.Sprintf("%v %v", r["id"], r["path"])
if into, ok := r["into"].(string); ok && into != "" {
heading += fmt.Sprintf(" (written into, %s)", into)
}
return heading
}
// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT
// node's ports on it: the port the node was given (novox/hq ADR 0100) over the one the mesh
// assigned over the manifest's own, settled with the node's settings layers.
//
// **The one derivation** for every reader of a provider's address — a consumer's binding, the
// artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable
// given ports are that node's refusal to report, not this reader's.
func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) {
ports, layers, err := portsGivenOn(ctx, inv, node, m)
if err != nil {
return nil, nil, err
}
serves := catalogue.ServedOn(m, provision, ports)
if len(serves) > 0 {
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
if err != nil {
return nil, nil, err
}
}
// The layers too, read once, for what is filled after (novox/hq ADR 0346).
return serves, layers, nil
}
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the
// node's settings layers for the module, read once for both.
func portsGivenOn(ctx context.Context, inv *inventory.Inventory, node string,
m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) {
ports, err := portsOn(ctx, inv, node, m.Module)
if err != nil {
return nil, nil, err
}
layers, err := inv.SettingsFor(ctx, node, m.Module)
if err != nil {
return nil, nil, err
}
if given, err := catalogue.GivenPorts(m, layers); err == nil {
for wanted, at := range given {
ports[wanted] = at
}
}
return ports, layers, nil
}
// portsOn is one module's assignments on one machine, by the port the software uses.
func portsOn(
ctx context.Context, inv *inventory.Inventory, node, module string,
) (map[int]int, error) {
all, err := inv.PortsFor(ctx, node)
if err != nil {
return nil, err
}
out := map[int]int{}
for _, a := range all {
if a.Module == module {
out[a.Wanted] = a.Machine
}
}
return out, nil
}
// busUserList is composeBusUsers without saying anything: the list, and the users left out of it
// for want of a credential. Asked on every send to decide whether the machine holding the bus must
// go first (novox/hq issue 249), where saying the same missing users each time would bury them.
func busUserList(ctx context.Context, inv *inventory.Inventory,
onThisNode []catalogue.Manifest) (string, []string, error) {
holdsTheBus := false
for _, m := range onThisNode {
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
holdsTheBus = true
}
}
if !holdsTheBus {
return "", nil, nil
}
records, err := inv.BusRecords(ctx)
if err != nil {
return "", nil, err
}
users, err := broker.Users(records)
if err != nil {
return "", nil, err
}
kept, err := inv.BusUsers(ctx)
if err != nil {
return "", nil, err
}
hashes := make(map[string]string, len(kept))
for name, u := range kept {
hashes[name] = u.PasswordHash
}
filled, missing := broker.WithPasswords(users, hashes)
if len(filled) == 0 {
return "", missing, fmt.Errorf(
"this machine runs the bus and not one user has a credential, so the composed list " +
"would refuse every connection in the mesh")
}
list, err := broker.ComposeAccounts(filled)
return list, missing, err
}
// providerModuleOf is which module answers a need on the providing node: the one in this node's
// own set when the provider is here, else the one the catalogue says offers it.
// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the
// mesh (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
if n.Module != "" {
if m, known := shelf[n.Module]; known {
return n.Module, m.IssuedOutsideOf(n.Name)
}
}
return catalogue.OffersIssuedOutside(shelf, n.Name)
}
// unheldLogger remembers what was last logged about each node's unmet seat dependencies, so the
// log says each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document.
// A test gives its own writer and memory, and no longer shares the process's (novox/hq issue 486).
//
// **The serving controller's log only.** planFor runs for every node on every push, assignment and
// status — `blockedElsewhere` alone resolves the whole mesh — and a one-shot command starts with an
// empty memory, so every node's report was "a change" and a push printed the whole mesh's list,
// burying the line about the node it acted on. A command says what concerns its own act instead
// (unheldChange, reportUnheldPushed); the full list is `status`'s.
type unheldLogger struct {
mu sync.Mutex
logged map[string]string
out io.Writer
}
func newUnheldLogger(out io.Writer) *unheldLogger {
return &unheldLogger{logged: map[string]string{}, out: out}
}
var (
unheldLog = newUnheldLogger(os.Stderr)
logUnheldChanges bool
)
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
// it, including when they become none — in the serving controller, and nowhere else.
func logUnheld(node string, unheld []catalogue.Unheld) {
if !logUnheldChanges {
return
}
unheldLog.log(node, unheld)
}
func (l *unheldLogger) log(node string, unheld []catalogue.Unheld) {
lines := make([]string, 0, len(unheld))
for _, u := range unheld {
lines = append(lines, u.String())
}
now := strings.Join(lines, "\n")
l.mu.Lock()
defer l.mu.Unlock()
before, seen := l.logged[node]
l.logged[node] = now
if (seen && before == now) || (!seen && now == "") {
return
}
if now == "" {
fmt.Fprintf(l.out, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node)
return
}
fmt.Fprintf(l.out, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
node, len(lines), strings.Join(lines, "\n "))
}
// unheldChange is what an act on one node changed about its unmet seat dependencies, judged over
// its assignments before and after (novox/hq ADR 0207): each dependency now unmet that was not —
// which includes every one of a module just assigned — and each now met that was not. Nothing about
// any other node, and nothing that was already true before the act.
func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string {
return unheldChangeTo(shelf, node, before, after, unheldOver(shelf, node, after))
}
// unheldOver is the node's unmet seat dependencies judged over a set of module names alone.
func unheldOver(shelf map[string]catalogue.Manifest, node string, names []string) []catalogue.Unheld {
var set []catalogue.Manifest
for _, n := range names {
if m, known := shelf[n]; known {
set = append(set, m)
}
}
return catalogue.UnheldDependencies(shelf, node, set, nil)
}
// unheldChangeTo is unheldChange with the node's dependencies as they now stand given: `now` is what
// the act's one resolution after its write says is unmet, and `after` the modules that resolution has
// on the node (novox/hq issue 237). A line about how things stand after the act is never judged from
// anything else, so it cannot disagree with `plan` for the node.
func unheldChangeTo(shelf map[string]catalogue.Manifest, node string, before, after []string,
now []catalogue.Unheld) []string {
key := func(u catalogue.Unheld) string { return u.Module + " " + u.Seat }
was := map[string]catalogue.Unheld{}
for _, u := range unheldOver(shelf, node, before) {
was[key(u)] = u
}
is := map[string]catalogue.Unheld{}
for _, u := range now {
is[key(u)] = u
}
var lines []string
for _, k := range sortedNames(is) {
if _, already := was[k]; !already {
lines = append(lines, "but "+is[k].String())
}
}
for _, k := range sortedNames(was) {
if _, still := is[k]; still {
continue
}
u := was[k]
if !slices.Contains(after, u.Module) {
continue // went with its module, which says nothing about the seat
}
lines = append(lines, fmt.Sprintf("and %s on %s now has %s held", u.Module, node, u.Seat))
}
return lines
}
func sortedNames[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}