Where the node-tools module is assigned, the machine's bus user list gains one principal of kind node-tools in place of that module's own: it may subscribe every carried module's tool namespace and every held seat's verbs on its node, read and follow every membership on its node, call any tool anywhere, answer what it is asked — and consume nothing, because tools are what it runs. Every other module keeps its own principal, so a module still serving tools from its container holds its own credential until it moves. Named exactly as the module it stands for, so `module issue` and `rollout mint` deliver its credential through the path a module's already takes, into node-tools' own `broker` secret. The runtime module's name is one constant in each of the broker and catalogue packages, held to one string by the agreement test, because a rule turns on it.
736 lines
27 KiB
Go
736 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/overlay"
|
|
)
|
|
|
|
// the catalogue: what exists, what is assigned, and how it is configured.
|
|
//
|
|
// 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.
|
|
|
|
// provided is what comes with the control plane rather than from a repository.
|
|
//
|
|
// WireGuard, the names, and the domain module over both. The first two are here because the code
|
|
// that works out their files is here:
|
|
// a peer list is derived from every machine at once, so it cannot be written in a manifest, and
|
|
// whatever computes it has to live wherever the whole picture is.
|
|
//
|
|
// **It is a module in every other respect** — assigned, unassigned, resolved, settled, and absent
|
|
// from a machine nobody gave it to.
|
|
func providedModules() []catalogue.Manifest {
|
|
var out []catalogue.Manifest
|
|
for _, raw := range []map[string]any{
|
|
// **Two used to be here and are gone**: one wrote the mesh's names into a hosts file, the
|
|
// other wrote the same machines as wildcards for a resolver to read. Neither ran software
|
|
// and neither could be swapped for anything, which is the test of whether a thing is a
|
|
// module at all (novox/hq ADR 0040). They existed because computed output needed somewhere
|
|
// to live, and now a module says where it wants it — `facts` in its own manifest.
|
|
overlay.Manifest(), overlay.DomainManifest(),
|
|
} {
|
|
var m catalogue.Manifest
|
|
b, _ := json.Marshal(raw)
|
|
_ = json.Unmarshal(b, &m)
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
var provided = providedModules()
|
|
|
|
func moduleCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("module add <file>, module check <file>..., module list, or module forget <name>")
|
|
}
|
|
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
|
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
|
if args[0] == "check" {
|
|
var paths []string
|
|
for _, a := range args[1:] {
|
|
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
|
under, err := manifestsUnder(a)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paths = append(paths, under...)
|
|
continue
|
|
}
|
|
paths = append(paths, a)
|
|
}
|
|
return moduleCheck(paths, os.Stdout)
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
set := flag.NewFlagSet("module add", flag.ContinueOnError)
|
|
repo := set.String("source", "", "where this module comes from")
|
|
ref := set.String("ref", "", "the branch followed there")
|
|
commit := set.String("commit", "", "the commit this manifest was read at")
|
|
// **Where inside the repository the module is** (novox/hq ADR 0069). A module is a
|
|
// repository *and* a directory, and a record that carries only the repository names a
|
|
// module.json at its root — so every later build of it looks in the wrong place and fails
|
|
// with "no module.json at its root". Nine modules on this mesh were registered that way
|
|
// and none of them could be rebuilt (2026-09-28).
|
|
path := set.String("path", "", "the module's directory inside that repository")
|
|
self := set.Bool("self", false, "the source is a path on the forge holding the git seat")
|
|
positionals, err := parseAround(set, args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) != 1 {
|
|
return errors.New("module add <manifest.json> [--source <repo> [--self] [--path P] " +
|
|
"--ref <branch> --commit <sha>]")
|
|
}
|
|
raw, err := os.ReadFile(positionals[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := catalogue.ParseManifest(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := namesNoInstallation(m); err != nil {
|
|
return err
|
|
}
|
|
if err := inv.RegisterModule(ctx, m, from); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s registered", m.Module)
|
|
if *commit != "" {
|
|
fmt.Printf(" from %s", short(*commit))
|
|
}
|
|
if *repo != "" && *path == "" {
|
|
// Said, not refused: a module really at the root is the ordinary case for a repository
|
|
// of its own. But a repository holding many modules and a record naming none of them is
|
|
// a module nothing can rebuild, and the person adding it is the one who knows which.
|
|
fmt.Printf("\n no directory inside %s, so it is built from that repository's root — "+
|
|
"`--path` if the module lives in a directory there", *repo)
|
|
}
|
|
if len(m.Provides) > 0 {
|
|
fmt.Printf(", providing %s", describeOffers(m.Provides))
|
|
}
|
|
fmt.Println()
|
|
for _, c := range m.Claims {
|
|
fmt.Printf(" claims %s, one per %s\n", c.Name, c.At())
|
|
}
|
|
return nil
|
|
|
|
case "list":
|
|
// The catalogue: what exists, where it came from, whether it is current, and who runs it.
|
|
// The provenance was recorded from the first build and nothing showed it, which made
|
|
// "is this current?" a question you could only answer by reading the database.
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) == 0 {
|
|
fmt.Println("this mesh knows about no modules yet")
|
|
return nil
|
|
}
|
|
var stale int
|
|
for _, e := range entries {
|
|
m := e.Manifest
|
|
fmt.Printf("%-18s %-8s", m.Module, m.Version)
|
|
|
|
switch {
|
|
case e.Provided:
|
|
fmt.Printf(" %-22s", "with the control plane")
|
|
case e.Source.Repository == "":
|
|
// Handed over by hand. Legitimate — it is how a module is fixed in a hurry — and
|
|
// worth saying, because nothing can rebuild it.
|
|
fmt.Printf(" %-22s", "handed over")
|
|
case !e.Source.Current():
|
|
stale++
|
|
fmt.Printf(" %-22s", "behind "+short(e.Source.BuiltFrom)+" < "+short(e.Source.Head))
|
|
default:
|
|
fmt.Printf(" %-22s", "built "+short(e.Source.BuiltFrom))
|
|
}
|
|
|
|
if len(e.On) > 0 {
|
|
fmt.Printf(" on %s", strings.Join(e.On, ", "))
|
|
} else {
|
|
fmt.Printf(" on nothing")
|
|
}
|
|
fmt.Println()
|
|
|
|
var says []string
|
|
if len(m.Provides) > 0 {
|
|
says = append(says, "provides "+describeOffers(m.Provides))
|
|
}
|
|
if len(m.Requires) > 0 {
|
|
says = append(says, "requires "+strings.Join(m.Requires, ", "))
|
|
}
|
|
for _, c := range m.Claims {
|
|
says = append(says, "claims "+c.At()+"/"+c.Name)
|
|
}
|
|
if len(m.Capabilities) > 0 {
|
|
says = append(says, "needs "+strings.Join(m.Capabilities, ", "))
|
|
}
|
|
if len(says) > 0 {
|
|
fmt.Printf(" %s\n", strings.Join(says, " · "))
|
|
}
|
|
}
|
|
if stale > 0 {
|
|
fmt.Printf("\n%d module(s) behind their source — `build --behind` to catch up\n", stale)
|
|
}
|
|
return nil
|
|
|
|
case "moved":
|
|
if len(args) != 3 {
|
|
return errors.New("module moved <name> <commit> — the source has a newer commit")
|
|
}
|
|
if err := inv.SourceMoved(ctx, args[1], args[2]); err != nil {
|
|
return err
|
|
}
|
|
from, err := inv.SourceOf(ctx, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if from.Current() {
|
|
fmt.Printf("%s is current at %s\n", args[1], short(from.Head))
|
|
return nil
|
|
}
|
|
fmt.Printf("%s is behind: the mesh holds %s and the source has %s\n",
|
|
args[1], short(from.BuiltFrom), short(from.Head))
|
|
fmt.Printf(" run `build %s` to catch up\n", from.Repository)
|
|
return nil
|
|
|
|
case "forget":
|
|
// **What goes with it is said before it goes** (novox/hq 04-ISSUES/017). The settings, the
|
|
// module's own secrets and the ports the mesh chose all cascade off the module row, so
|
|
// `forget` used to destroy them and report "forgotten" — an action succeeding into a state
|
|
// its own verify would reject, and a sealed secret is not recoverable afterwards.
|
|
set := flag.NewFlagSet("module forget", flag.ContinueOnError)
|
|
andHeld := set.Bool("and-what-it-holds", false,
|
|
"discard its settings, its own secrets and its ports along with it")
|
|
positionals, err := parseAround(set, args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) != 1 {
|
|
return errors.New("module forget <name> [--and-what-it-holds]")
|
|
}
|
|
if !*andHeld {
|
|
if err := inv.ForgetModule(ctx, positionals[0]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s forgotten\n", positionals[0])
|
|
return nil
|
|
}
|
|
held, err := inv.DiscardModule(ctx, positionals[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s forgotten\n", positionals[0])
|
|
for _, line := range held.Lines() {
|
|
// Said after the fact as well as before it: this is the only record that these
|
|
// existed, and the next person to ask why the module came back empty reads it here.
|
|
fmt.Printf(" discarded%s\n", strings.TrimPrefix(line, " "))
|
|
}
|
|
return nil
|
|
|
|
case "issue":
|
|
// A module's broker account, scoped by its emits and consumes (novox/hq ADR 0043) and
|
|
// sealed to the machine that will run it — the generic case the builder was the first of.
|
|
set := flag.NewFlagSet("module issue", flag.ContinueOnError)
|
|
forNode := set.String("node", "",
|
|
"the machine that will run it, so the credential is delivered instead of printed")
|
|
positionals, err := parseAround(set, args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) != 1 {
|
|
return errors.New("module issue <module> --node <machine>")
|
|
}
|
|
module := positionals[0]
|
|
if *forNode == "" {
|
|
return errors.New("module issue needs --node: a module's account is sealed to the " +
|
|
"machine that runs it, and the mesh cannot read it back to print")
|
|
}
|
|
|
|
shelf, err := inv.Catalogue(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, ok := shelf[module]
|
|
if !ok {
|
|
return fmt.Errorf("this mesh knows no module %q; `module add` it first", module)
|
|
}
|
|
if err := mayIssue(m); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Which bus this mesh is on. A module gets a credential for exactly one, and the two are
|
|
// made in entirely different ways: on the bus the mesh runs on today an account is a
|
|
// management call, and on the bus being built it is a row the next composition writes into
|
|
// the server's user list (novox/hq design 25 §4).
|
|
busAddress, err := broker.BusAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
|
|
|
default:
|
|
return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0])
|
|
}
|
|
}
|
|
|
|
func assignCommand(ctx context.Context, verb string, args []string) error {
|
|
if len(args) != 2 {
|
|
return fmt.Errorf("%s <node> <module>", verb)
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
|
|
// The act itself is in acts.go, so the command API refuses exactly what this refuses
|
|
// (novox/hq ADR 0035). What differs between the surfaces is how the answer is printed.
|
|
act := assign
|
|
if verb == "unassign" {
|
|
act = unassign
|
|
}
|
|
said, err := act(ctx, open, args[0], args[1])
|
|
if said != "" {
|
|
fmt.Println(said)
|
|
}
|
|
if err != nil {
|
|
fmt.Println()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func settingsCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("settings set <module> <file> [--node <node>], or settings clear <module> [--node <node>]")
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
set := flag.NewFlagSet("settings", flag.ContinueOnError)
|
|
node := set.String("node", "", "one machine, rather than the whole mesh")
|
|
positionals, err := parseAround(set, args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
where := "the whole mesh"
|
|
if *node != "" {
|
|
where = *node
|
|
}
|
|
|
|
switch args[0] {
|
|
case "set":
|
|
if len(positionals) != 2 {
|
|
return errors.New("settings set <module> <settings.json | {…}> [--node <node>]")
|
|
}
|
|
// A file, or the values themselves when they begin with `{` — which is how the mesh's own
|
|
// `settings` tool passes them, having no file to hand over (novox/hq issue 198).
|
|
var raw []byte
|
|
if strings.HasPrefix(strings.TrimSpace(positionals[1]), "{") {
|
|
raw = []byte(positionals[1])
|
|
} else if raw, err = os.ReadFile(positionals[1]); err != nil {
|
|
return err
|
|
}
|
|
var values map[string]any
|
|
if err := json.Unmarshal(raw, &values); err != nil {
|
|
return fmt.Errorf("%s is not a settings file: %w", positionals[1], err)
|
|
}
|
|
if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil {
|
|
return err
|
|
}
|
|
|
|
var keys []string
|
|
for k := range values {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
fmt.Printf("%s on %s: %s\n", positionals[0], where, strings.Join(keys, ", "))
|
|
fmt.Println(" run `push` to send it")
|
|
return nil
|
|
|
|
case "clear":
|
|
if len(positionals) != 1 {
|
|
return errors.New("settings clear <module> [--node <node>]")
|
|
}
|
|
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("settings has no %q; it has set and clear", args[0])
|
|
}
|
|
}
|
|
|
|
// describeOffers says what a module provides, and marks the ones answered from anywhere in the
|
|
// mesh — because "provides a database" and "provides a shell" are read the same way and mean
|
|
// entirely different things about where the answer has to be.
|
|
func describeOffers(offers []catalogue.Offer) string {
|
|
var out []string
|
|
for _, o := range offers {
|
|
if o.At() == catalogue.ScopeMesh {
|
|
out = append(out, o.Name+" (from anywhere in the mesh)")
|
|
continue
|
|
}
|
|
out = append(out, o.Name)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// pinCommand says which node a machine gets a provision from.
|
|
//
|
|
// Needed only when more than one could answer, and recordable before that -- a mesh with one
|
|
// database should not change where an existing machine gets its data the day a second one
|
|
// arrives.
|
|
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
|
if setting && len(args) != 4 {
|
|
return errors.New("pin <node> <provision> <from-node> <module>")
|
|
}
|
|
if !setting && len(args) != 2 {
|
|
return errors.New("unpin <node> <provision>")
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
if !setting {
|
|
if err := inv.UnpinProvision(ctx, args[0], args[1]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
|
return nil
|
|
}
|
|
// The provider's node may be this same machine: two modules beside the consumer can both
|
|
// answer a provision, and then the module is the whole question (novox/hq #258).
|
|
if err := inv.PinProvision(ctx, args[0], args[1], args[2], args[3]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s gets %s from %s/%s\n", args[0], args[1], args[2], args[3])
|
|
fmt.Printf(" run `push %s` to send it\n", args[0])
|
|
return nil
|
|
}
|
|
|
|
// theBrokerSeat is the mesh-scoped seat the broker module claims (novox/hq ADR 0079: a
|
|
// foundation seat is named after the server it guards).
|
|
const theBrokerSeat = "mesh-broker"
|
|
|
|
// brokerReachableAt is the broker's address as the given node can reach it.
|
|
//
|
|
// The genesis address (MESH_BROKER_ADDRESS) is the broker's public endpoint — right for a machine
|
|
// that can dial it, wrong across the mesh, where the path is the overlay. A node that is ON the
|
|
// overlay is given the overlay name of the node HOLDING the broker — the one assigned a module
|
|
// claiming the mesh-broker seat — because that is where the broker is, whatever else the topology
|
|
// says. Only when nothing holds the seat yet (genesis raised the broker as plumbing and no module
|
|
// has adopted it) does the hub stand in, which is where the foundation is by convention.
|
|
//
|
|
// "On the overlay" is what `whereEveryoneIs` answers — a machine that RESOLVED the networking
|
|
// module — not "has an address", which is true of every placed machine and says nothing about
|
|
// whether anything can reach it (novox/hq issue 059). A node not on the overlay — at genesis,
|
|
// before any `overlay place`, which is when the builder's account is issued — keeps the genesis
|
|
// address, so nothing about bring-up changes. This is issue 055, corrected by 059.
|
|
func brokerReachableAt(ctx context.Context, inv *inventory.Inventory, known broker.Broker, node string) (string, error) {
|
|
shelf, err := inv.Catalogue(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
|
if err != nil {
|
|
// No catalogue yet is genesis, and at genesis the genesis address is the right one.
|
|
return known.Address, nil
|
|
}
|
|
if onNetwork[node] == "" {
|
|
return known.Address, nil
|
|
}
|
|
|
|
// Where the broker is: the node whose assigned set includes a module claiming the seat.
|
|
holders := map[string]bool{}
|
|
for name, m := range shelf {
|
|
for _, c := range m.Claims {
|
|
if c.Name == theBrokerSeat && c.At() == catalogue.ScopeMesh {
|
|
holders[name] = true
|
|
}
|
|
}
|
|
}
|
|
brokerAt := ""
|
|
if len(holders) > 0 {
|
|
overlays, err := inv.Overlays(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, o := range overlays {
|
|
assigned, err := inv.Assigned(ctx, o.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, a := range assigned {
|
|
if holders[a] && onNetwork[o.Name] != "" {
|
|
brokerAt = onNetwork[o.Name]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if brokerAt == "" {
|
|
// Nothing holds the seat (or its node is not on the overlay): the hub, where the
|
|
// foundation is by convention — and only a hub that is itself on the overlay, or the
|
|
// name we hand out routes nowhere.
|
|
overlays, err := inv.Overlays(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, o := range overlays {
|
|
if o.Hub && onNetwork[o.Name] != "" {
|
|
brokerAt = onNetwork[o.Name]
|
|
}
|
|
}
|
|
}
|
|
if brokerAt == "" {
|
|
return known.Address, nil
|
|
}
|
|
// A portless genesis address is a working configuration (amqps defaults to 5671), and
|
|
// silently keeping the public address would disable this whole path — so the port defaults
|
|
// rather than the fix dissolving (novox/hq issue 059).
|
|
_, port, err := net.SplitHostPort(known.Address)
|
|
if err != nil {
|
|
port = "5671"
|
|
}
|
|
return net.JoinHostPort(brokerAt, port), nil
|
|
}
|
|
|
|
// mayIssue says whether a module can be given a broker account. The account is delivered as the
|
|
// module's own secret named broker; a module that declares none has nothing to read it with, and
|
|
// an account nothing reads is an orphan on the bus (novox/hq 04-ISSUES/078). Said before the
|
|
// account is made, not after.
|
|
func mayIssue(m catalogue.Manifest) error {
|
|
if _, reads := m.OwnSecrets["broker"]; !reads {
|
|
return fmt.Errorf("%s declares no own secret named broker, so there is nowhere to deliver "+
|
|
"an account; a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", m.Module)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// issueOnTheNewBus gives an assigned module its credential on the bus being built.
|
|
//
|
|
// **Three things differ from a management call, and each is the point of the move.** The credential
|
|
// is minted into the mesh's records and becomes usable at the next composition, so there is no
|
|
// server to be reachable for this to work. The password travels beside the address rather than inside
|
|
// it, because the runtime's contract already separates them and a credential embedded in a URL is one
|
|
// that leaks into every log line that prints a connection. And the module's durable consumer is
|
|
// derived from what it declared rather than declared by name, so a module cannot ask for delivery of
|
|
// something it did not say it consumes.
|
|
func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
|
node, busAddress string) error {
|
|
|
|
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
|
Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Where the module is told to find the bus, and what certificate it must present. The same pair
|
|
// a node is told, for the same reason: a mesh's bus presents its own certificate, in no public
|
|
// trust store, so an address alone fails at TLS.
|
|
known, err := broker.FromEnvironment()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot deliver a credential without knowing where the bus is: %w", err)
|
|
}
|
|
reachable, err := brokerReachableAt(ctx, inv, known, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
|
}
|
|
|
|
// busKindOf is what a module's bus user is recorded as: the node's tool runtime where the module is
|
|
// the runtime (novox/hq ADR 0175), a module otherwise. The username is the same either way — the
|
|
// runtime is issued through this same path — and the kind is what a reader of the records sees.
|
|
func busKindOf(module string) string {
|
|
if module == catalogue.RuntimeModule {
|
|
return inventory.BusNodeTools
|
|
}
|
|
return inventory.BusModule
|
|
}
|
|
|
|
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
|
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
|
// so the move can issue every module against a bus whose address it worked out itself
|
|
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
|
|
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
|
node, busAddress string, known broker.Broker, reachable, user, password string) error {
|
|
// The seats this module claims, with the verbs each promises (novox/hq ADR 0159): the runtime
|
|
// serves a claimed seat's verbs with its tools of the same name, and the bus admits only the
|
|
// holder's subscription — so the runtime tries each claim and the grant decides. Written here
|
|
// because this file is the one thing the mesh writes that the runtime reads before it speaks.
|
|
claims, err := claimsFor(ctx, inv, m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
held, err := json.Marshal(struct {
|
|
URL string `json:"url"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
Claims []seatClaimed `json:"claims,omitempty"`
|
|
}{
|
|
URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
|
|
Node: node, Module: m.Module, User: user, Password: password, Claims: claims,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := inv.AcceptSecretForModule(ctx, node, m.Module, "broker", string(held)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// And how it hears what it consumes. Derived from its declaration, and only when it declared
|
|
// something: a module that consumes nothing needs no consumer, and creating one would be a
|
|
// durable subscription nobody reads.
|
|
if consumer, needed := broker.ConsumerFor(broker.Principal{
|
|
Kind: broker.KindModule, Node: node, Module: m.Module,
|
|
Emits: m.Emits, Consumes: m.Consumes, Serves: m.Tools,
|
|
}); needed {
|
|
if busAddress == "" {
|
|
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push`, then "+
|
|
"`rollout mint` again is harmless)\n", m.Module)
|
|
} else {
|
|
js, err := broker.Dial(busAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
|
|
"how %s hears what it consumes: %w", m.Module, err)
|
|
}
|
|
defer js.Close()
|
|
if err := js.EnsureConsumer(consumer); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("bus user %s minted for %s, scoped to what it emits and consumes\n", user, m.Module)
|
|
fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n", node, node)
|
|
fmt.Printf(" and it works once the bus has been told: the user list is composed into the " +
|
|
"machine holding mesh-broker\n")
|
|
return nil
|
|
}
|
|
|
|
// whereItComesFrom is the provenance a module handed over by hand records, and what a record must
|
|
// say to be worth anything later.
|
|
//
|
|
// **A module is a repository and a directory inside it** (novox/hq ADR 0069). A record carrying only
|
|
// the repository names a module.json at its root, so every later build of it looks in the wrong
|
|
// place — nine modules on this mesh were registered that way and none of them could be rebuilt
|
|
// (2026-09-28). The directory cannot be checked from here, because the control plane does not clone;
|
|
// what can be checked is that the record is whole.
|
|
func whereItComesFrom(repository, ref, commit, path string, self bool) (inventory.Source, error) {
|
|
// Provenance together or not at all. A source with no commit cannot be compared against
|
|
// anything, so it would record where the module came from and still never be able to say the
|
|
// mesh is behind it — which is the one thing recording it is for.
|
|
if (repository == "") != (commit == "") {
|
|
return inventory.Source{}, errors.New("--source and --commit go together: a source with " +
|
|
"no commit cannot be compared against anything, and a commit with no source has " +
|
|
"nothing to be compared with")
|
|
}
|
|
// A directory or a forge with no repository is half a location, and the half it keeps is the
|
|
// half nothing can be found with.
|
|
if repository == "" && (path != "" || self) {
|
|
return inventory.Source{}, errors.New("--path and --self say where inside a source and " +
|
|
"which forge holds it, so they need --source: without one there is nothing for them " +
|
|
"to be part of")
|
|
}
|
|
from := inventory.Source{Repository: repository, Ref: ref, BuiltFrom: commit, Path: path}
|
|
if self {
|
|
if err := onASeat(repository); err != nil {
|
|
return inventory.Source{}, err
|
|
}
|
|
from.Seat = gitSeat
|
|
}
|
|
return from, nil
|
|
}
|
|
|
|
// namesNoInstallation is the mesh refusing a definition that names an installation, at the moment
|
|
// it would enter the catalogue (novox/hq ADR 0112, ADR 0155). `module check` says the same thing
|
|
// earlier, where the author is; this is the last moment the mesh can still say no, and a
|
|
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
|
|
// in the same words. A name meant on purpose is declared with its reason and passes.
|
|
func namesNoInstallation(m catalogue.Manifest) error {
|
|
named := catalogue.InstallationProblems(m)
|
|
if len(named) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s names an installation, and a definition names none — declare a name meant "+
|
|
"on purpose under %s with its reason, or take it out:\n - %s",
|
|
m.Module, catalogue.NamesOnPurpose, strings.Join(named, "\n - "))
|
|
}
|
|
|
|
// seatClaimed is one seat a module claims, as its runtime needs it: the name, the scope (a
|
|
// node-scoped seat's verb carries the machine, design 33 §4) and the verbs the seat promises.
|
|
type seatClaimed struct {
|
|
Seat string `json:"seat"`
|
|
Scope string `json:"scope"`
|
|
Serves []string `json:"serves,omitempty"`
|
|
}
|
|
|
|
// claimsFor joins a module's claims with the seats' protocols from the mesh's records.
|
|
func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest) ([]seatClaimed, error) {
|
|
if len(m.Claims) == 0 {
|
|
return nil, nil
|
|
}
|
|
seats, err := inv.Seats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byName := map[string]catalogue.Seat{}
|
|
for _, s := range seats {
|
|
byName[s.Name] = s
|
|
}
|
|
var out []seatClaimed
|
|
for _, c := range m.Claims {
|
|
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
|
|
if s, known := byName[c.Name]; known {
|
|
claimed.Scope = s.Scope
|
|
// The verbs the runtime serves for the seat: the claim's own when it names them
|
|
// (ADR 0160), else every verb the seat promises, which its tools then answer.
|
|
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
|
}
|
|
out = append(out, claimed)
|
|
}
|
|
return out, nil
|
|
}
|