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 isfb556d6b, 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 at198be8217c, built fromfb556d6bby patch commits and compared with the author's worktree), so that this commit's parent isfb556d6b.
701 lines
27 KiB
Go
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
|
|
}
|