Compare commits

..
Author SHA1 Message Date
jochen 8b016cc62b A Go tools bundle is served by its binary (hq ADR 0193)
A bundle compiled to a binary has no entrypoints, and loads had to name one, so a Go bundle could
not be served. Its binary is what the runtime starts: loads names the binary, derived when the
module lists tools, and the runtime is told the binary's path, delivered like any tools bundle.
2026-10-03 22:27:01 +02:00
mesh-admin 473376259b Merge pull request 'The route proxy tells a backend the request was HTTPS, and for which name' (#245) from fix/the-route-proxy-says-the-request-was-https into main 2026-10-03 20:23:02 +00:00
jochen e1293fb0ad The route proxy tells a backend the request was HTTPS, and for which name
NewSingleHostReverseProxy sets only X-Forwarded-For, so a backend that writes its own addresses saw
the plain hop from the proxy: Gitea's Go import tag named an http clone URL and go get refused the
SDK's module path. The proxy now sets X-Forwarded-Proto, -Host and -For from the request it received,
and keeps the Host header as it was.
2026-10-03 22:22:51 +02:00
mesh-admin 82481099b7 Merge pull request 'The controller announces as the tool runtimes do, and answers $SRV.STATS (hq ADR 0197)' (#242) from fix/0197-the-controller-announces-as-the-runtimes-do into main 2026-10-03 20:18:48 +00:00
jochen b127f005c3 The controller announces as the tool runtimes do, and answers $SRV.STATS (hq ADR 0197)
Endpoints named <seat>__<verb> with the metadata the console identifies them by (kind, module,
tool, seat, scope); $SRV.STATS answered with its identity and endpoints, nothing counted. Grants:
STATS beside PING and INFO, and the tool runtime may answer under its own name, since it announces
everything it carries as one service — the bus lets it answer each request once.
2026-10-03 22:18:27 +02:00
jochen 58b4fcb8c8 A bundle stands on the toolchain it is compiled in (hq issue 211)
A manifest names its toolchain by language, not in build.on, so the planner did not know a bundle
depends on the module that publishes its toolchain and built the two in one tier: the bundle
against the old toolchain, recorded as built from the new commit. The edge is read from the
manifest, so it holds before any build recorded it, and a toolchain that moves rebuilds every
bundle compiled in it.
2026-10-03 22:18:20 +02:00
mesh-admin 796f6410c1 Merge pull request 'Discovery from what answers: grants, the controller announces its seat, JSON lists (hq ADR 0195, 0197)' (#241) from feat/node-and-module-lists-as-json into main 2026-10-03 20:11:29 +00:00
jochen e67c58cd98 Every serving principal may answer the services discovery for what it serves; the controller announces its seat (hq ADR 0197)
Grants: a principal that serves tools subscribes $SRV.PING/$SRV.INFO and those questions under
each name it serves — its own and no other's; the tool runtime and people may ask. The controller
answers discovery for the mesh-controller seat in NATS's services format, one endpoint per verb it
serves, with the seat's description and schema. module list --json says which modules declare tools,
so the console expects an announcement only from those.
2026-10-03 22:11:00 +02:00
jochen 85873b19e1 node list and module list answer --json, and the nodes and modules verbs use it (hq ADR 0195)
The console's discovery reads the machines and the modules; parsing a printed column breaks when it
is reworded. Both now answer JSON on --json, as status and seats do, and the seat verbs ask for it.
2026-10-03 21:55:35 +02:00
mesh-admin 2ebbb79937 Merge pull request 'A runtime compiled to a binary runs itself (hq ADR 0193)' (#240) from feat/0193-a-runtime-compiled-to-a-binary into main 2026-10-03 19:24:24 +00:00
jochen 9204190445 A runtime compiled to a binary runs itself (hq ADR 0193)
A compiled bundle records the binary it is (BinaryOf, shared by the builder and the composer), and
the node's runtime, when it is one, is run as ./<binary> from its own unpacked bundle rather than by
an interpreter and an entrypoint.
2026-10-03 21:24:12 +02:00
jschoubben 06ea2168d8 Merge pull request 'The roster is the machines: each node's internal domain covers its routes (hq ADR 0191)' (#238) from fix/the-mesh-publishes-the-names-it-composed into main 2026-10-03 19:12:23 +00:00
mesh-admin 2b149dd43e Merge pull request 'Beside every TypeScript entrypoint the builder writes an executable launcher; the runtime is told it (hq ADR 0193)' (#239) from feat/0193-a-launcher-beside-every-typescript-entrypoint into main 2026-10-03 19:08:22 +00:00
jochen 17dba2a34c Beside every TypeScript entrypoint the builder writes an executable launcher; the runtime is told it (hq ADR 0193)
The runtime knows no language: the build makes each served entrypoint executable. For a TypeScript
bundle that is <entry>.serve.mjs, which imports the entrypoint and serves what it registered over
MCP on stdio through the bundle's own SDK. The build records its launchers on the bundle, and the
composer names the launcher where a build wrote one and the entrypoint where it did not, so bundles
built before this keep serving until they are rebuilt.
2026-10-03 21:07:07 +02:00
jschoubben 11e4bc0ba1 The roster is the machines: each node's internal domain covers its routes (hq ADR 0191)
The roster published routed names — public ones first, then (in this PR's first take) internal ones
told apart by suffix. Neither is needed: a node has one internal domain and every route on it is a
name under it, answered by the resolver's per-node wildcard; a node's public domains are public
DNS's. routeNamesInTheMesh and NamesServed are removed, and a test pins .Names to the machines.
2026-10-03 16:10:16 +02:00
jschoubben e56f3aa1cb The roster publishes a route's internal name, never its public one (hq ADR 0191)
NamesServed read a route's public `name` and plan.go then filtered by suffix — telling the mesh's
names from public ones by their spelling, when the mesh composed both itself. It now publishes the
`internal-name` it composed under the serving node (ADR 0151); the suffix filter is gone.
2026-10-03 15:39:05 +02:00
mesh-admin f966693583 Merge pull request 'A file a tools bundle's words name restarts the runtime when it changes (hq ADR 0192)' (#237) from feat/0192-a-named-file-restarts-the-runtime into main 2026-10-03 13:33:57 +00:00
jochen 5acc763992 A file a tools bundle's words name restarts the runtime when it changes (hq ADR 0192)
The tool containers were restarted when their configuration file changed; the runtime now is
too, for every file a module's words name exactly — configuration and own secret alike.
2026-10-03 15:33:44 +02:00
mesh-admin 4f009fff83 Merge pull request 'A tools bundle is given its words, composed per machine; what they name is the account's to read (hq ADR 0192)' (#236) from feat/0192-a-bundles-env into main 2026-10-03 13:32:38 +00:00
jochen f27e31954f A tools bundle is given its words, composed per machine; what they name is the account's to read (hq ADR 0192)
build.artifacts[].env on a bundle: words and values written with ${dir:…} and ${port:…} only,
refused when a value carries any other reference (a secret's content, a binding) or names a word
the runtime sets for itself, and on any artifact that is not a bundle. Resolved per machine like a
container's environment and handed to the runtime as MESH_TOOL_ENV, module by module, in the unit
so a change restarts it. Every file and directory of the module a word names, or that holds one, is
owned by the account the runtime runs as where it says no owner, since a tool reads as that account.
2026-10-03 15:32:03 +02:00
jschoubben 62650cd48c Merge pull request 'The mesh answers only its own names privately; a public name resolves publicly (hq ADR 0191)' (#235) from fix/the-mesh-resolves-only-its-own-names into main 2026-10-03 13:16:14 +00:00
jschoubben 408f6dbad9 The mesh answers only its own names privately; a public name resolves publicly (hq ADR 0191)
Every routed public name was published into each machine's hosts region at its serving node's
private address. ace's resolver also answers its LAN, so a phone there got the control-node's
tunnel address for the mail server and could not connect. Routes have internal names under the
serving node (ADR 0151), so only names under the mesh suffix are published now.
2026-10-03 15:14:01 +02:00
mesh-admin eae0577567 Merge pull request 'Issues 203 and 206: an assignment issues its credential; the controller owns a worker's shape; the build seat's holder follows the controller' (#233) from fix/issues-203-206 into main 2026-10-03 09:49:54 +00:00
mesh-admin de26918c52 Merge pull request 'A declaration is numbered when it is composed, and a send is recorded even by a sender being replaced (hq issue 204)' (#232) from fix/issue-204 into main 2026-10-03 09:44:42 +00:00
mesh-admin e01d18e548 Merge pull request 'An idle build machine's empty fetch is asked again, not read as the end (hq ADR 0190)' (#234) from fix/an-empty-fetch-is-not-the-end into main 2026-10-03 09:41:52 +00:00
jochen 59166b1031 An idle build machine's empty fetch is asked again, not read as the end (hq ADR 0190)
A fetch on a context without a deadline waits the client's own while and reports the deadline
passed — the client's, not ours — and the loop read it as "stop": every idle build agent exited
clean every half minute and was restarted by its supervisor, a crash loop with nothing in the log
to say why. Only our own context ending ends the machine; an empty fetch, however it is reported,
is asked again.
2026-10-03 11:41:23 +02:00
jochen c294949f2a A worker of the wrong type on a history-keeping stream is re-made to deliver from now on, never from the start (hq issue 207)
Left for a hand, the hand re-made it with the server's default — everything the stream holds — and
on 2026-10-03 that replayed every build ask since 1 October into the catalogue. Re-made with
deliver-new instead: nothing acknowledged comes back; what was in flight is said and asked again.
2026-10-03 11:07:29 +02:00
jochen f86f6a74f0 A declaration is numbered when it is composed, and a send is recorded even by a sender being replaced (hq issue 204)
On 2026-10-02 a runtime assigned and applied on two machines was undone two seconds later by a
declaration that had the assignments of a minute earlier. Every path composes from the records at
compose time and holds the machines it sends — but the number went on at SEND time, after
composing, so a declaration composed before an assignment changed and sent after a newer one
carried the higher number, and the host, which rightly refuses a lower number, took the older
content as the mesh's newest word. The record of that send was never written either: it is written
after the declaration is away, on the sender's context, and the controller sending it was being
replaced in that very second — status read "applied, current" over a machine just told otherwise.

Now the number is taken before the composition reads anything, in every path, so what was composed
earlier is numbered lower however late it goes out and the host's refusal does what it is for; and
what was sent is written down on a context that outlives the sender, bounded, so a dying controller
still records what it told a machine. The `declare` command — a declaration a person sends by hand —
records its send too. Proven: compositions in one order and sends in the other keep the numbers in
composition order; a send is recorded after the sender's context is cancelled.
2026-10-03 04:02:36 +02:00
32 changed files with 1221 additions and 435 deletions
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"context"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
)
// A declaration composed earlier is numbered lower than one composed later, whatever order the two
// are sent in (novox/hq issue 204). The number used to be taken at send time, after composing, so a
// declaration composed before an assignment changed and sent after a newer one carried the higher
// number — and the machine, which refuses a lower number, took the older content as the mesh's
// newest word. Taken before the composition reads anything, the order of numbers is the order of
// compositions, and the host's refusal does what it is for.
func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *testing.T) {
allot := numbered()
var composed []string
compose := func(stamp string) func(string) (sendable, error) {
return func(node string) (sendable, error) {
composed = append(composed, stamp)
return sendable{Resources: []map[string]any{{"id": node + "." + stamp}}}, nil
}
}
// Composed first — before an assignment changed — and sent last.
stale, _ := composeEach([]string{"anchor"}, allot, compose("before"))
// Composed after the change, sent first.
fresh, _ := composeEach([]string{"anchor"}, allot, compose("after"))
if stale[0].declared.Sequence != 1 || fresh[0].declared.Sequence != 2 {
t.Fatalf("the numbers do not follow the compositions: before=%d after=%d",
stale[0].declared.Sequence, fresh[0].declared.Sequence)
}
// Sent in the other order, the numbers do not change — so the machine that has applied the
// fresh one (2) refuses the stale one (1) when it arrives late.
if !(stale[0].declared.Sequence < fresh[0].declared.Sequence) {
t.Fatal("a declaration composed earlier must carry the lower number, however late it is sent")
}
if len(composed) != 2 || composed[0] != "before" {
t.Fatalf("compositions happened in an unexpected order: %v", composed)
}
}
// The number is taken before the first read of the composition, not after it: an allotter that
// fails leaves nothing composed for that machine, and the others are still composed.
func TestTheNumberIsTakenBeforeComposingAndItsFailureIsARefusal(t *testing.T) {
calls := 0
allot := func(node string) (int64, error) {
if node == "anchor" {
return 0, context.DeadlineExceeded
}
return 7, nil
}
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
calls++
if node == "anchor" {
t.Fatal("anchor was composed although its number could not be taken")
}
return sendable{}, nil
})
if calls != 1 || len(sending) != 1 || sending[0].node != "laptop" || sending[0].declared.Sequence != 7 {
t.Fatalf("laptop should be composed with its number and anchor refused: %v / %v", sending, refusals)
}
if len(refusals) != 1 {
t.Fatalf("anchor's failed number should be a refusal naming it: %v", refusals)
}
}
// What was sent is written down even when the sender's context is already cancelled (issue 204): a
// controller replaced mid-send had told the machine and never recorded it, so status read "applied,
// current" over a machine that had just been sent something else.
func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
inv := inventory.ForTest(t)
ctx, cancel := context.WithCancel(t.Context())
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
cancel() // the sender is going away: its context is cancelled between the send and the record
body := []byte(`{"declaration":1,"resources":[]}`)
digest, err := recordSent(ctx, inv, "anchor", body)
if err != nil {
// NodeByName on the cancelled context may itself refuse; the record must still be possible
// through the detached context, so look the node up again on a live one.
t.Fatalf("recording a send after cancellation failed: %v", err)
}
outstanding, err := inv.Outstanding(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if outstanding != digest || digest != digestOf(body) {
t.Fatalf("the send was not recorded: outstanding %q, sent %q", outstanding, digest)
}
}
+54
View File
@@ -147,6 +147,40 @@ func moduleCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
// **The same list, for something other than a person** (novox/hq ADR 0195): what each module
// is, where it runs, whether it is current, and what it says of itself.
if len(args) > 1 && args[1] == "--json" {
type listed struct {
Module string `json:"module"`
Version string `json:"version"`
Built string `json:"built,omitempty"`
Head string `json:"head,omitempty"`
Current bool `json:"current"`
Provided bool `json:"provided,omitempty"`
// Tools says whether the module answers tools anywhere it runs: a list of its own,
// a bundle the runtime serves, or a seat's verbs it claims (novox/hq ADR 0197) —
// what the console checks the bus's answers against.
Tools bool `json:"tools"`
On []string `json:"on"`
Provides []string `json:"provides,omitempty"`
Requires []string `json:"requires,omitempty"`
Claims []string `json:"claims,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
out := make([]listed, 0, len(entries))
for _, e := range entries {
m := e.Manifest
l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head,
Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided,
On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires,
Capabilities: m.Capabilities, Tools: declaresTools(m)}
for _, c := range m.Claims {
l.Claims = append(l.Claims, c.At()+"/"+c.Name)
}
out = append(out, l)
}
return printJSON(out)
}
if len(entries) == 0 {
fmt.Println("this mesh knows about no modules yet")
return nil
@@ -733,3 +767,23 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
}
return out, nil
}
// declaresTools is whether a module answers tools wherever it runs (novox/hq ADR 0197): it names
// tools of its own, its build delivers a bundle the node's runtime serves, or it claims a seat
// whose verbs it serves. A module with none is never expected to announce anything.
func declaresTools(m catalogue.Manifest) bool {
if len(m.Tools) > 0 {
return true
}
for _, b := range m.Bundles {
if len(b.Loads) > 0 {
return true
}
}
for _, c := range m.Claims {
if len(c.Serves) > 0 {
return true
}
}
return false
}
+26
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"os"
"reflect"
"strings"
"testing"
@@ -303,3 +304,28 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
}
}
// The roster is the machines and nothing else (novox/hq ADR 0191): each node's internal domain covers
// every route on it, and a node's public domains are public DNS's. A routed name in `.Names` was a
// private answer for a public name, handed by a resolver serving a LAN to a phone that could not use it.
func TestTheRosterNamesOnlyTheMachines(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
gens, err := generators(ctx, open)
if err != nil {
t.Fatal(err)
}
for _, node := range []string{"anchor", "laptop"} {
plan, settings, err := planFor(ctx, open, node)
if err != nil {
t.Fatal(err)
}
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(with.Names, with.Machines) {
t.Fatalf("%s's roster names more than the machines:\n names %v\n machines %v", node, with.Names, with.Machines)
}
}
}
+26
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -44,6 +45,21 @@ func nodeCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
// **The same list, for something other than a person** — the console's discovery reads it
// (novox/hq ADR 0195), and a reader that parses a printed column breaks when it is reworded.
if len(args) > 1 && args[1] == "--json" {
type listed struct {
Name string `json:"name"`
Heard string `json:"heard"`
Mode string `json:"mode"`
ID string `json:"id"`
}
out := make([]listed, 0, len(nodes))
for _, n := range nodes {
out = append(out, listed{Name: n.Name, Heard: heardFrom(n), Mode: modeOf(n), ID: n.ID})
}
return printJSON(out)
}
if len(nodes) == 0 {
// Said rather than printed as nothing: an empty list and a failed read must never
// look the same, and this command answering "none" is only honest because getting
@@ -500,3 +516,13 @@ func orNotReported(s string) string {
}
return s
}
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
func printJSON(v any) error {
body, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
+7 -83
View File
@@ -645,23 +645,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
}
}
// And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
// to serve and knows nothing about what they mean.
// Kept apart from the machines, because a fact about the machines must not be handed the names
// the mesh merely serves (novox/hq 04-ISSUES/111).
// **The roster is the machines and nothing else** (novox/hq ADR 0191). Each node has one internal
// domain, `<node>.internal`, and every route on it is a name under that domain (ADR 0151), which
// the resolver answers with one wildcard per machine — so no route needs a line of its own. A
// node's public domains are the operator's and public DNS answers them; the mesh gives no private
// answer for any of them. The roster once carried every routed name, public ones included, and a
// resolver that also serves a LAN handed a phone a tunnel address for the mail server.
// `.Names` and `.Machines` stay two fields so a module's template keeps rendering (issue 111).
machines := make(map[string]string, len(names))
for name, at := range names {
machines[name] = at
}
routes, err := routeNamesInTheMesh(ctx, open)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
for name, at := range routes {
names[name] = at
}
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
@@ -740,76 +734,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
}, record, nil
}
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
// ADR 0066).
//
// **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
// ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
// composed on the consumer's node (from its label and that node's public domain) and served by the
// node answering the consumer's route requirement; this gathers both.
//
// It reads route names off resolutions rather than a table because there is no table: a route is a
// contribution, computed from what each node runs. Name-agnostic — a contribution counts as a
// routed name only because it carried a label the mesh composed, never because the mesh knows what
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
// the rest their names.
//
// **A node that could not be READ is a different matter and is raised.** Skipping one states, to
// every machine at once, that its names do not exist — and since the roster is part of every
// container's identity, that withdraws them and replaces every container (novox/hq 04-ISSUES/152,
// 151). So every failure here says which machine and which read, because the alternative is a
// mesh-wide refusal with nothing named in it.
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
inv := open.inventory
places, err := inv.Overlays(ctx)
if err != nil {
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
}
address := map[string]string{}
for _, p := range places {
if strings.TrimSpace(p.Address) != "" {
address[p.Name] = p.Address
}
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
}
// Every machine's resolution first, then the names across them at once: which node serves a
// name is a question about the graph — the consumer on one machine, the provider on another —
// and answered wrongly by looking at one contribution at a time (novox/hq issue 178).
plans := map[string]catalogue.Resolution{}
settings := map[string]catalogue.SettingsBy{}
for _, n := range nodes {
plan, layers, err := planFor(ctx, open, n.Name)
switch {
case unresolvable(err):
// Their set does not compose, so they serve no names. Passed over, so one machine's
// broken set does not cost the rest theirs.
continue
case err != nil:
// The mesh could not be asked. Returning the roster without this machine's names would
// state that they do not exist — to every machine, and indistinguishably from the
// operator having withdrawn them (novox/hq 04-ISSUES/152).
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
}
plans[n.Name], settings[n.Name] = plan, layers
}
served, err := catalogue.NamesServed(plans, settings)
if err != nil {
return nil, err
}
out := map[string]string{}
for name, node := range served {
if at := address[node]; at != "" {
out[name] = at
}
}
return out, nil
}
// 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
+71 -37
View File
@@ -151,6 +151,12 @@ func serve(ctx context.Context) error {
return err
}
defer stopServing()
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return err
}
defer stopAnnouncing()
return server.Serve(ctx)
}
@@ -205,6 +211,12 @@ func declare(ctx context.Context, args []string) error {
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
return err
}
// Written down like every other send (novox/hq issue 204): a declaration a person sent by hand
// is still what the machine was last told, and status must not read it as current for the one
// the mesh would compose.
if _, err := recordSent(ctx, inv, node, raw); err != nil {
return err
}
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
return nil
}
@@ -348,7 +360,7 @@ func pushCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
sending, refusals := composeEach(asked, func(node string) (sendable, error) {
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
plan, settings, err := planFor(held, open, node)
if err != nil {
return sendable{}, err
@@ -370,12 +382,8 @@ func pushCommand(ctx context.Context, args []string) error {
sentDigest := map[string]string{}
defer release()
for _, s := range sending {
// Numbered under the hold, one higher than the last, before the body exists — the number is
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
// (novox/hq 04-ISSUES/107).
if err := number(ctx, inv, &s); err != nil {
return err
}
// The number is inside the signed bytes, so a replayed older declaration cannot borrow a
// newer one's (novox/hq 04-ISSUES/107); it was taken when the composition began (issue 204).
body, err := s.declared.Body()
if err != nil {
return err
@@ -385,14 +393,10 @@ func pushCommand(ctx context.Context, args []string) error {
}
// After it is away, not before. A digest recorded for something that failed to send would
// make the machine look current for a declaration it never received.
record, err := inv.NodeByName(ctx, s.node)
digest, err := recordSent(ctx, inv, s.node, body)
if err != nil {
return err
}
digest := digestOf(body)
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
return err
}
sentDigest[s.node] = digest
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
}
@@ -476,11 +480,7 @@ func pushCommand(ctx context.Context, args []string) error {
15*time.Second); err != nil {
return err
}
record, err := inv.NodeByName(ctx, s.node)
if err != nil {
return err
}
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
return err
}
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
@@ -569,17 +569,31 @@ type readyNode struct {
//
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
// across two machines that must agree — and dropped here, where it never did.
func composeEach(names []string,
func composeEach(names []string, allot func(node string) (int64, error),
compose func(node string) (sendable, error)) ([]readyNode, []string) {
var sending []readyNode
var refusals []string
for _, name := range names {
// **Numbered before it is composed, not before it is sent** (novox/hq issue 204). The
// number says where this declaration stands against every other the mesh composed for the
// machine, and the host refuses one lower than the last it applied. Taken at send time, as
// it was, a declaration composed a minute ago — before an assignment changed — went out with
// a number higher than one composed after the change and sent before it, and the machine
// took the older content as the newer word: on 2026-10-02 a runtime assigned and applied on
// two machines was undone two seconds later by exactly that. Taken here, before the first
// read, what was composed earlier is numbered lower whatever order the sends happen in.
seq, err := allot(name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue
}
declared, err := compose(name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue
}
declared.Sequence = seq
if len(declared.Resources) == 0 {
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
// nothing may have HELD something before — the broker opening a placement gave it,
@@ -607,13 +621,10 @@ func sendRound(ctx context.Context, open *stores, names []string,
return nil, err
}
defer release()
sending, refused := composeEach(names, func(node string) (sendable, error) {
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) {
return compose(held, node)
})
for _, s := range sending {
if err := number(ctx, open.inventory, &s); err != nil {
return refused, err
}
body, err := s.declared.Body()
if err != nil {
return refused, err
@@ -670,6 +681,12 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
var sending []readyNode
var refusals []string
for _, name := range names {
// Numbered before composing, for the reason composeEach gives (novox/hq issue 204).
seq, err := allot(ctx, inv, name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue
}
plan, settings, err := planFor(ctx, open, name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
@@ -681,6 +698,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue
}
declared.Sequence = seq
reportLeftOut(name, declared)
sending = append(sending, readyNode{name, declared})
}
@@ -696,9 +714,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
defer server.Close()
for _, s := range sending {
if err := number(ctx, inv, &s); err != nil {
return err
}
body, err := s.declared.Body()
if err != nil {
return err
@@ -706,11 +721,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
return err
}
record, err := inv.NodeByName(ctx, s.node)
if err != nil {
return err
}
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
return err
}
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
@@ -951,15 +962,38 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
}
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
record, err := inv.NodeByName(ctx, s.node)
// allotting is allot over one inventory, in the shape composeEach takes.
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) {
return func(node string) (int64, error) { return allot(ctx, inv, node) }
}
// allot takes the next sequence for a machine — the number its next declaration carries.
func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, error) {
record, err := inv.NodeByName(ctx, node)
if err != nil {
return err
return 0, err
}
seq, err := inv.NextSequence(ctx, record.ID)
return inv.NextSequence(ctx, record.ID)
}
// recordSent writes down what a machine was just sent, and returns the digest.
//
// **On a context that outlives the caller's** (novox/hq issue 204). The record is written after the
// declaration is away, so a send that failed is never recorded as current — and a controller being
// replaced mid-send had its context cancelled between the two, so the machine was told and the mesh
// never wrote it down: status read "applied, current" over a machine that had just been sent
// something else. What was sent was sent; the record of it must not depend on the sender living
// another second. Bounded, so a store that is away does not hold a dying process open for ever.
func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body []byte) (string, error) {
kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
record, err := inv.NodeByName(kept, node)
if err != nil {
return err
return "", err
}
s.declared.Sequence = seq
return nil
digest := digestOf(body)
if err := inv.RecordSent(kept, record.ID, digest); err != nil {
return "", err
}
return digest, nil
}
+8 -2
View File
@@ -17,7 +17,7 @@ import (
// the wrong machine no longer refuses the whole node), applied one level up.
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
sending, refusals := composeEach(
[]string{"anchor", "home-server", "laptop"},
[]string{"anchor", "home-server", "laptop"}, numbered(),
func(node string) (sendable, error) {
if node == "anchor" {
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
@@ -43,7 +43,7 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
// (novox/hq issue 127): it may have held something before, and only sending the empty
// declaration tells it to drop what the mesh owned. It is never a refusal.
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
sending, refusals := composeEach([]string{"spare"},
sending, refusals := composeEach([]string{"spare"}, numbered(),
func(string) (sendable, error) { return sendable{}, nil })
if len(sending) != 1 || len(refusals) != 0 {
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
@@ -74,3 +74,9 @@ func TestASkippedMachineIsStillAnError(t *testing.T) {
}
}
}
// numbered is an allotter for tests: one higher per call, as the inventory's is per machine.
func numbered() func(string) (int64, error) {
var n int64
return func(string) (int64, error) { n++; return n, nil }
}
+11
View File
@@ -115,3 +115,14 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
}
}
// novox/hq 04-ISSUES/211: a merge moving the toolchain and a bundle compiled in it builds the
// bundle a tier after the toolchain, not beside it.
func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) {
edges := []inventory.Edge{{From: "node-tools", To: "mesh-tools", Kind: inventory.EdgeStandsOn}}
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"},
[]string{"mesh-tools", "node-tools"}, edges)
if len(p.Tiers) != 2 || p.Tiers[0][0] != "mesh-tools" || p.Tiers[1][0] != "node-tools" {
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
}
}
+1 -54
View File
@@ -2,13 +2,12 @@ package main
import (
"context"
"strings"
"testing"
)
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
// things, and only the first may be passed over when something is gathered across every machine
// (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
// (novox/hq 04-ISSUES/152). These pin that distinction where the gatherers rely on it.
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
open := aMesh(t)
@@ -45,55 +44,3 @@ func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
}
}
func TestOneIncoherentNodeDoesNotCostTheRestTheirNames(t *testing.T) {
open := aMesh(t)
one, two := rivals()
register(t, open, one)
register(t, open, two)
for _, m := range []string{one.Module, two.Module} {
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
t.Fatal(err)
}
}
// laptop cannot compose. That is laptop's problem and nobody else's: the roster is still
// answerable, and anchor keeps whatever it serves.
if _, err := routeNamesInTheMesh(t.Context(), open); err != nil {
t.Fatalf("one node's broken set cost the whole mesh its roster: %v", err)
}
}
func TestARosterIsNeverReturnedWithNamesItCouldNotRead(t *testing.T) {
open := aMesh(t)
stopped, cancel := context.WithCancel(t.Context())
cancel()
names, err := routeNamesInTheMesh(stopped, open)
if err == nil {
t.Fatalf("a roster was composed from a store that could not be read: %v", names)
}
// The failure must be raised, not turned into an absence. A roster missing a machine's names
// is indistinguishable, on every machine that receives it, from the operator withdrawing them —
// and because the roster is part of every container's identity, it replaces all of them.
if names != nil {
t.Fatalf("a partial roster was returned beside the error: %v", names)
}
}
// Kept so the reason survives the next person reading it: the message the gatherer raises must say
// which machine could not be read, or the operator is left with a mesh-wide failure and no name.
func TestTheRaisedFailureNamesTheMachineItCouldNotRead(t *testing.T) {
open := aMesh(t)
stopped, cancel := context.WithCancel(t.Context())
cancel()
_, err := routeNamesInTheMesh(stopped, open)
if err == nil {
t.Fatal("no failure was raised")
}
if !strings.Contains(err.Error(), "cannot be read") {
t.Fatalf("the failure does not say the mesh could not be read: %v", err)
}
}
+47 -2
View File
@@ -6,8 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nats-io/nats.go/micro"
"os"
"os/exec"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -66,14 +68,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
case "status":
return []string{"status", "--json"}, nil
case "nodes":
return []string{"node", "list"}, nil
return []string{"node", "list", "--json"}, nil
case "node":
if err := need("node"); err != nil {
return nil, err
}
return []string{"node", "show", str("node")}, nil
case "modules":
return []string{"module", "list"}, nil
return []string{"module", "list", "--json"}, nil
case "seats":
return []string{"seats", "--json"}, nil
case "builds":
@@ -379,3 +381,46 @@ func splitCommandLine(line string) ([]string, error) {
}
return words, nil
}
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
about := map[string]catalogue.Verb{}
for _, s := range catalogue.SeatsWithAProtocol() {
if s.Name == catalogue.ControllerSeatName {
for _, v := range s.Serves {
about[v.Name] = v
}
}
}
verbs := make([]string, 0, len(handlers))
for verb := range handlers {
verbs = append(verbs, verb)
}
sort.Strings(verbs)
var endpoints []micro.EndpointInfo
for _, verb := range verbs {
schema, _ := json.Marshal(about[verb].Input)
// The same shape every tool runtime announces in (node-tools' announce package): the name is
// `<seat>__<verb>`, as the protocol's characters allow; the metadata is what identifies it.
endpoints = append(endpoints, micro.EndpointInfo{
Name: catalogue.ControllerSeatName + "__" + verb,
Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb),
QueueGroup: "seat." + catalogue.ControllerSeatName,
Metadata: map[string]string{
"kind": "seat", "module": catalogue.ControllerSeatName, "tool": verb,
"seat": catalogue.ControllerSeatName, "scope": "mesh", "interchangeable": "false",
"description": about[verb].Description, "schema": string(schema),
},
})
}
return micro.Info{
ServiceIdentity: micro.ServiceIdentity{
Name: catalogue.ControllerSeatName, ID: "controller", Version: "0.1.0",
Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"},
},
Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat",
Endpoints: endpoints,
}
}
+47
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"fmt"
"github.com/novox/mesh-controller/internal/link"
"strings"
"testing"
@@ -249,3 +251,48 @@ func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
}
}
}
// novox/hq ADR 0195: the console's discovery reads the machines and the modules; they answer as JSON,
// as status and seats do, so nothing parses a printed column.
func TestTheNodesAndModulesVerbsAnswerAsJSON(t *testing.T) {
for verb, want := range map[string]string{"nodes": "[node list --json]", "modules": "[module list --json]"} {
argv, err := argvFor(verb, map[string]any{})
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(argv) != want {
t.Errorf("%s runs %v, want %s", verb, argv, want)
}
}
}
// novox/hq ADR 0197: the controller announces exactly the verbs it serves, each on the subject and
// queue it serves it on, with the seat's own description and schema, in NATS's services format.
func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
handlers, _, err := seatToolHandlers()
if err != nil {
t.Fatal(err)
}
info := seatAnnouncement(handlers)
if info.Name != catalogue.ControllerSeatName || info.ID == "" || info.Version == "" {
t.Fatalf("the service is not named for the seat: %+v", info.ServiceIdentity)
}
if len(info.Endpoints) != len(handlers) {
t.Fatalf("%d endpoints announced for %d verbs served", len(info.Endpoints), len(handlers))
}
for _, e := range info.Endpoints {
verb := e.Metadata["tool"]
if _, served := handlers[verb]; !served || e.Name != catalogue.ControllerSeatName+"__"+verb {
t.Errorf("%s (%s) is announced and not served under that name", e.Name, verb)
}
if e.Metadata["kind"] != "seat" || e.Metadata["seat"] != catalogue.ControllerSeatName {
t.Errorf("%s is not announced as the seat's verb: %v", e.Name, e.Metadata)
}
if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, verb) || e.QueueGroup != "seat."+catalogue.ControllerSeatName {
t.Errorf("%s is announced on %s/%s, not where it is served", e.Name, e.Subject, e.QueueGroup)
}
if e.Metadata["description"] == "" || e.Metadata["schema"] == "" || e.Metadata["scope"] != "mesh" {
t.Errorf("%s is announced without its description, schema or scope: %v", e.Name, e.Metadata)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// A backend behind the proxy learns the client used TLS and which name it asked for, so the addresses
// it writes into its own pages are the ones a client can use (2026-10-03: a forge's Go import tag
// named an http clone URL, and Go refused the module path).
func TestABackendIsToldTheRequestWasHTTPSAndForWhichName(t *testing.T) {
var proto, host, fwdHost, fwdFor string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proto, host, fwdHost, fwdFor = r.Header.Get("X-Forwarded-Proto"), r.Host, r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Forwarded-For")
}))
defer backend.Close()
where, _ := url.Parse(backend.URL)
req := httptest.NewRequest(http.MethodGet, "https://git.example.org/novox/mesh-sdk/go?go-get=1", nil)
req.TLS = &tls.ConnectionState{}
req.Host = "git.example.org"
req.RemoteAddr = "192.0.2.7:51000"
towards(where).ServeHTTP(httptest.NewRecorder(), req)
if proto != "https" || fwdHost != "git.example.org" || host != "git.example.org" || fwdFor != "192.0.2.7" {
t.Errorf("the backend was told proto=%q host=%q forwarded-host=%q for=%q", proto, host, fwdHost, fwdFor)
}
}
+15 -1
View File
@@ -273,7 +273,7 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) {
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
continue
}
r.to = httputil.NewSingleHostReverseProxy(where)
r.to = towards(where)
if r.insecure {
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
@@ -1060,3 +1060,17 @@ func asPort(v any) (int, bool) {
}
return 0, false
}
// towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and
// -For**, set from the request this proxy received. A backend that builds its own addresses — a forge
// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this
// proxy and writes `http://`, though every client reached it over TLS: Go refused the forge's module
// path for exactly that on 2026-10-03, its import tag naming an http clone URL.
// The standard library's NewSingleHostReverseProxy sets only X-Forwarded-For.
func towards(where *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(where)
pr.Out.Host = pr.In.Host
pr.SetXForwarded()
}}
}
+1 -1
View File
@@ -244,7 +244,7 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
j.note("consumer %s on %s changes from %s to %s delivery on a stream that keeps its history: "+
"re-made to deliver from now on, so nothing this one acknowledged comes back (novox/hq issue "+
"207); %d ask(s) it had not acknowledged are not carried over and must be asked again",
c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+have.NumAckPending)
c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+uint64(have.NumAckPending))
want.DeliverPolicy = nats.DeliverNewPolicy
} else {
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
+47
View File
@@ -236,6 +236,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
// which this package mirrors rather than reads, and a verb the seat does not declare is a
// subject nothing publishes.
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
sub = append(sub, announcing(ControllerSeat)...)
// The two events it reacts to, and its ack subject on the stream they arrive from
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
@@ -271,6 +273,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{}, err
}
pub = append(pub, invoked...)
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
// itself, its replies to the asker's own inbox.
pub = append(pub, discovering()...)
case KindEnrolment:
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
@@ -327,6 +332,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
// away — no other principal may subscribe this namespace, and a caller's authority is
// still granted per tool, by name, on the publish side.
sub = append(sub, own+".tool.>")
// It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it
// holds a verb of, answered by the runtime that serves them.
announced := []string{p.Module}
for _, s := range p.Holds {
if len(s.Serves) > 0 {
announced = append(announced, s.Name)
}
}
sub = append(sub, announcing(announced...)...)
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
// directly from the stream and followed live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module))
@@ -413,11 +427,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
// module's own principal has, for the same reason: the tools a module serves are what its
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
// this node, as the holder's own principal would be granted them.
var serves []string
for _, d := range p.Carries {
if !safeSubject.MatchString(d.Module) {
return Permissions{}, fmt.Errorf(
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
}
serves = append(serves, d.Module)
for _, s := range d.Holds {
serves = append(serves, s.Name)
}
own := "mesh.mod." + d.Module
sub = append(sub, own+".tool.>")
// A tool that emits an event is the module's code and emits under the module's name
@@ -444,6 +463,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{}, err
}
pub = append(pub, invoked...)
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
// discovery for each module and seat it carries, and the console it is asks the bus.
// One service per runtime process, named for the runtime: the bus lets a principal answer each
// request once, so the runtime announces everything it carries under its own name.
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
pub = append(pub, discovering()...)
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
sub = unique(sub)
@@ -765,3 +790,25 @@ func invokedSubjects(invokes []string) ([]string, error) {
}
return out, nil
}
// announcing is what a principal that serves tools subscribes to answer the NATS services
// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of
// each name it serves — its own and no other's, so it cannot answer for a service it is not.
func announcing(names ...string) []string {
out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"}
for _, n := range names {
if !safeSubject.MatchString(n) {
continue
}
for _, verb := range []string{"PING", "INFO", "STATS"} {
out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>")
}
}
return out
}
// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services
// protocol's discovery requests, whose replies come to its own inbox.
func discovering() []string {
return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"}
}
+13 -7
View File
@@ -233,7 +233,9 @@ func TestAPersonReachesNothingButTools(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
Invokes: []string{"*"}, PasswordHash: "x"})
for _, p := range perms.Publish {
if !strings.Contains(p, ".tool.") {
// A tool call, or asking what answers (novox/hq ADR 0197) — a question every service
// answers about itself, which claims nothing and controls nothing.
if !strings.Contains(p, ".tool.") && !strings.HasPrefix(p, "$SRV.") {
t.Errorf("a person may publish %q, which is not a tool call", p)
}
}
@@ -423,13 +425,17 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
if _, needed := ConsumerFor(p); needed {
t.Error("a consumer would be made for the runtime, which consumes nothing")
}
// Each subject once: the file is read as the mesh's authority model.
seen := map[string]bool{}
for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) {
if seen[s] {
t.Errorf("%s is granted twice", s)
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
// (novox/hq ADR 0197) — because subscribing and publishing are two different grants.
for _, list := range [][]string{perms.Subscribe, perms.Publish} {
seen := map[string]bool{}
for _, s := range list {
if seen[s] {
t.Errorf("%s is granted twice", s)
}
seen[s] = true
}
seen[s] = true
}
}
+4 -4
View File
@@ -25,7 +25,7 @@ accounts {
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
@@ -38,17 +38,17 @@ accounts {
} }
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
]
+52 -1
View File
@@ -587,6 +587,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
}
// **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime
// knows no language; for one that runs through an interpreter the build writes the launcher.
launchers, err := writeLaunchers(compiled, chain, a)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
}
say("bundle", "compiled, packing")
body, err := pack(compiled)
if err != nil {
@@ -598,7 +604,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
if err != nil {
return catalogue.Built{}, err
}
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil
case catalogue.ArtifactPackage:
// Built and published on a public base, to the mesh's package registry, by version
@@ -1165,6 +1171,9 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
// the package it is built from, which is what a compiler would have chosen anyway.
func binaryName(a catalogue.Artifact) string {
if name := catalogue.BinaryOf(a); name != "" {
return name
}
if name := strings.TrimSpace(a.Binary); name != "" {
return name
}
@@ -1173,3 +1182,45 @@ func binaryName(a catalogue.Artifact) string {
}
return a.Name
}
// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is
// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the
// bundle's package.json says.
const launcherSuffix = ".serve.mjs"
// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that
// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's
// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its
// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for
// a language whose build is already executable.
func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) {
if chain.Language != "typescript" {
return nil, nil
}
out := map[string]string{}
for _, entry := range a.Entrypoints {
if !strings.HasSuffix(entry, ".js") {
continue
}
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
body := "#!/usr/bin/env node\n" +
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
"await import(\"./" + filepath.Base(entry) + "\");\n" +
"await serveRegisteredOverStdio();\n"
path := filepath.Join(root, filepath.FromSlash(launcher))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
return nil, err
}
// WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set.
if err := os.Chmod(path, 0o755); err != nil {
return nil, err
}
out[entry] = launcher
}
return out, nil
}
+49
View File
@@ -0,0 +1,49 @@
package builder
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no
// language — so a TypeScript bundle carries a launcher beside each entrypoint.
func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) {
root := t.TempDir()
chain, err := ToolchainFor("typescript")
if err != nil {
t.Fatal(err)
}
got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle,
Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}})
if err != nil {
t.Fatal(err)
}
if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" {
t.Fatalf("launchers: %v", got)
}
path := filepath.Join(root, "tools", "index.serve.mjs")
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o755 {
t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm())
}
body, _ := os.ReadFile(path)
for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} {
if !strings.Contains(string(body), want) {
t.Errorf("the launcher lacks %q:\n%s", want, body)
}
}
// A compiled language's build is executable already: no launcher.
goChain, _ := ToolchainFor("go")
none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"})
if err != nil || len(none) != 0 {
t.Errorf("a Go bundle was given launchers: %v %v", none, err)
}
}
+88 -1
View File
@@ -2,6 +2,7 @@ package catalogue
import (
"fmt"
"path"
"sort"
"strings"
)
@@ -28,6 +29,9 @@ type Built struct {
Reference string
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
Digest string
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
Launchers map[string]string
}
// Resolve fills a manifest's resources in from what was built.
@@ -83,6 +87,12 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
@@ -92,7 +102,8 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
out.Bundles = append(out.Bundles, Bundle{
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads,
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
Binary: BinaryOf(a),
})
}
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
@@ -203,6 +214,12 @@ func (b *Build) problems(module string) []string {
// A bundle's source is the module's own directory by definition, and what it needs to say
// is which compiler — because the mesh chooses that, and cannot choose for a module that
// has not said.
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
"serves is given words (novox/hq ADR 0192); a container says its own environment",
module, a.Name, a.Kind))
}
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
// **Except for a language that compiles to a binary, where it names which one**
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
@@ -222,6 +239,7 @@ func (b *Build) problems(module string) []string {
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
"for it", module, a.Name))
}
problems = append(problems, bundleEnvProblems(module, a)...)
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
// not an entrypoint is a file the bundle does not contain, and the runtime would
// fail to import it on every machine rather than here.
@@ -230,6 +248,11 @@ func (b *Build) problems(module string) []string {
for _, e := range a.Entrypoints {
found = found || e == load
}
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
// (novox/hq ADR 0193).
if bin := BinaryOf(a); bin != "" {
found = load == bin
}
if !found {
problems = append(problems, fmt.Sprintf(
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
@@ -360,3 +383,67 @@ func versionOf(digest string) string {
}
return hex
}
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
var bundleEnvWords = map[string]bool{
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
RuntimeOperatorHome: true, RuntimeToolEnv: true,
}
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
// a value is a path or a constant written with the references a container's environment may use
// for a place or a port, and never a secret's content or another module's binding — a secret
// reaches a tool as a file whose path is named.
func bundleEnvProblems(module string, a Artifact) []string {
if len(a.Env) == 0 {
return nil
}
var problems []string
for _, word := range sortedKeys(a.Env) {
value := a.Env[word]
if bundleEnvWords[word] {
problems = append(problems, fmt.Sprintf(
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
module, a.Name, word))
}
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
if strings.Contains(rest, "${") {
problems = append(problems, fmt.Sprintf(
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
"named by its path, never as its content (novox/hq ADR 0192)",
module, a.Name, word, value))
}
}
return problems
}
func copyWords(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
// language that does not compile to one. The builder writes the binary under this name, and the
// composer runs it by it, so both ask here.
func BinaryOf(a Artifact) string {
if !compilesToABinary(a.Language) {
return ""
}
if name := strings.TrimSpace(a.Binary); name != "" {
return name
}
if from := strings.Trim(a.From, "./"); from != "" {
return path.Base(from)
}
return a.Name
}
+24
View File
@@ -921,6 +921,30 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
}
owner[fmt.Sprint(process["id"])] = RuntimeModule
out = append(out, process)
// What each module's bundles are given is read as the account the runtime runs as.
words := map[string]map[string]string{}
for _, m := range r.Modules {
w, err := bundleWords(m, with)
if err != nil {
return nil, err
}
words[m.Module] = w
}
// And a file a module's words name is one the runtime is restarted for when it changes.
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
restarts, _ := process["restart-on"].([]any)
seen := map[string]bool{}
for _, id := range restarts {
seen[fmt.Sprint(id)] = true
}
for _, id := range named {
if !seen[id] {
restarts = append(restarts, id)
seen[id] = true
}
}
process["restart-on"] = restarts
}
}
if with.Adopted {
// First, before anything a module declares: what the mesh needs reachable, then its guard.
+13
View File
@@ -602,6 +602,14 @@ type Bundle struct {
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
// run rather than loaded.
Loads []string `json:"loads,omitempty"`
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
Env map[string]string `json:"env,omitempty"`
// Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq
// ADR 0193). A bundle built before them has none, and is served as it was built.
Launchers map[string]string `json:"launchers,omitempty"`
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
Binary string `json:"binary,omitempty"`
}
// Build says how to produce this module's artifacts from its source.
@@ -748,6 +756,11 @@ type Artifact struct {
// module's tools and nothing else is the ordinary case and should not have to say the same
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
Loads []string `json:"loads,omitempty"`
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
Env map[string]string `json:"env,omitempty"`
}
// Kinds an artifact may be.
-124
View File
@@ -1,124 +0,0 @@
package catalogue
import (
"sort"
"strings"
)
// Which machine serves each routed name (novox/hq ADR 0066, issue 178).
//
// A routed name is a label the mesh composed for a consumer's endpoint, and it is *served* by the
// provider that answers requests for it — the proxy the consumer's route reaches. The same name is
// composed into every labelled contribution the consumer makes, because a provider that must know
// the consumer's public name (an identity provider composing a redirect) is told it the same way
// (04-ISSUES/122). Attributing the name to whichever of those providers a map happened to yield
// last sent a public name to the identity provider's machine on one plan and to the proxy's on the
// next (forge issue 227), and the whole names region flipped with it.
//
// **The terminus serves the name.** Among the providers a name reaches, the one that serves it is
// the one that is not itself routed: a provider that contributes a labelled name of its own to some
// requirement is published through another provider, and is a consumer of names, not their end.
// Name-agnostic — nothing here knows what "route" means — and structural: it reads the graph the
// modules declared. Deterministic: names, requirements and nodes are walked in order, so two
// plans of one mesh yield one region.
// NamesServed is every routed name across the mesh and the node that serves it, from every node's
// resolution and settings. A name several termini claim goes to the first node in name order, so
// the answer is stable; a name nothing terminal claims is left out.
func NamesServed(plans map[string]Resolution, settings map[string]SettingsBy) (map[string]string, error) {
nodes := make([]string, 0, len(plans))
for n := range plans {
nodes = append(nodes, n)
}
sort.Strings(nodes)
out := map[string]string{}
for _, node := range nodes {
plan := plans[node]
all, err := plan.contributions(settings[node], nil, nil)
if err != nil {
return nil, err
}
requirements := make([]string, 0, len(all))
for to := range all {
requirements = append(requirements, to)
}
sort.Strings(requirements)
for _, to := range requirements {
for _, given := range all[to] {
if given.Node != "" {
// Said from another machine; that machine's own resolution carries it.
continue
}
// A routed name, and only that: a contribution the mesh composed a name for from a
// label it was given. A grant that happens to carry a `name` of its own — a database
// name — carries no label and is left alone.
if _, labelled := given.Values["label"]; !labelled {
continue
}
name, _ := given.Values["name"].(string)
if name == "" {
continue
}
serving := servingNodeOf(plan, to, given.From, node)
if !servesNames(plans[serving], to) {
continue
}
name = strings.ToLower(name)
if held, taken := out[name]; !taken || serving < held {
out[name] = serving
}
}
}
}
return out, nil
}
// servingNodeOf is the node answering one consumer's requirement: whoever the plan needs it from,
// or this same node when the provider is beside the consumer.
func servingNodeOf(plan Resolution, requirement, consumer, self string) string {
for _, need := range plan.Needs {
if need.Name == requirement && need.For == consumer && need.From != "" {
return need.From
}
}
return self
}
// servesNames says whether the module providing a requirement on a node is a terminus: it is not
// itself published under a labelled name through some other provider. A node whose plan is not
// known (it did not resolve) serves nothing.
func servesNames(plan Resolution, requirement string) bool {
for _, m := range plan.Modules {
if !offers(m, requirement) {
continue
}
return !contributesALabel(m)
}
return false
}
func offers(m Manifest, requirement string) bool {
for _, o := range m.Offers() {
if o == requirement {
return true
}
}
return false
}
func contributesALabel(m Manifest) bool {
for _, values := range m.Contributes {
if _, labelled := values["label"]; labelled {
return true
}
}
for _, locals := range m.ContributesMany {
for _, values := range locals {
if _, labelled := values["label"]; labelled {
return true
}
}
}
return false
}
-99
View File
@@ -1,99 +0,0 @@
package catalogue
import (
"testing"
)
// The mesh of forge issue 227 (novox/hq issue 178): a dashboard on the home server contributes its
// label to the route its proxy serves AND to the identity provider on the control node, which must
// know the dashboard's public name to compose a redirect. Both contributions carry the composed
// name; only the proxy serves it.
func twoNodesOneName(t *testing.T) (map[string]Resolution, map[string]SettingsBy) {
t.Helper()
catalogue := shelf(
Manifest{Module: "route-adapter", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/adapter/mesh.json"}},
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
Manifest{Module: "keycloak", Version: "1", Provides: FromAnywhere("oidc-client"),
Serves: map[string]map[string]any{"oidc-client": {"token-path": "/token"}},
Receives: map[string]string{"oidc-client": "/etc/keycloak/clients.json"},
Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page"}},
// Published through the proxy itself: the identity provider is routed, not a router.
Contributes: map[string]map[string]any{"route": {"label": "login", "endpoint": "web", "port": 8080}}},
Manifest{Module: "grafana", Version: "1",
Listens: []Listening{{Port: 3000, From: FromMesh, Why: "dashboards"}},
Contributes: map[string]map[string]any{
"route": {"label": "grafana", "endpoint": "web", "port": 3000},
"oidc-client": {"label": "grafana", "endpoint": "web", "port": 3000, "callback": "/login"},
}},
)
home := withDomain("home.example")
home.Name, home.At = "home-server", "home-server.internal"
control := withDomain("control.example")
control.Name, control.At = "anchor", "anchor.internal"
onHome, err := Resolve(catalogue, []string{"grafana", "route-adapter"}, home, World{
Offered: map[string][]Provider{"oidc-client": {{Node: "anchor", At: "anchor.internal", Module: "keycloak"}}},
})
if err != nil {
t.Fatal(err)
}
onControl, err := Resolve(catalogue, []string{"keycloak", "route-proxy"}, control, World{})
if err != nil {
t.Fatal(err)
}
return map[string]Resolution{"home-server": onHome, "anchor": onControl},
map[string]SettingsBy{"home-server": {}, "anchor": {}}
}
func TestANameResolvesToTheNodeWhoseProxyServesIt(t *testing.T) {
plans, settings := twoNodesOneName(t)
// Many times, because the fault was map order: one plan said one node, the next the other.
for i := 0; i < 25; i++ {
served, err := NamesServed(plans, settings)
if err != nil {
t.Fatal(err)
}
if served["grafana.home.example"] != "home-server" {
t.Fatalf("run %d: the dashboard's name is served by %q, and its proxy is on the home server: %v",
i, served["grafana.home.example"], served)
}
if served["login.control.example"] != "anchor" {
t.Fatalf("run %d: the identity provider's own name is served by its proxy on the control node: %v", i, served)
}
if _, leaked := served["grafana.control.example"]; leaked {
t.Fatalf("a name composed for the identity provider's benefit is not one it serves: %v", served)
}
}
}
// A module that is routed several times names each route (ADR 0094's sibling for contributes);
// every one of them is a name the mesh must resolve, and none reached the names region before.
func TestEveryRouteOfAModuleWithSeveralIsANameServed(t *testing.T) {
catalogue := shelf(
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
Manifest{Module: "photos", Version: "1",
Listens: []Listening{{Port: 8102, From: FromMesh, Why: "web"}, {Port: 9102, From: FromMesh, Why: "api"}},
ContributesMany: map[string]map[string]map[string]any{"route": {
"site": {"label": "photos", "endpoint": "web", "port": 8102},
"api": {"label": "photos-api", "endpoint": "api", "port": 9102},
}}},
)
node := withDomain("control.example")
node.Name, node.At = "anchor", "anchor.internal"
plan, err := Resolve(catalogue, []string{"photos", "route-proxy"}, node, World{})
if err != nil {
t.Fatal(err)
}
served, err := NamesServed(map[string]Resolution{"anchor": plan}, map[string]SettingsBy{"anchor": {}})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"photos.control.example", "photos-api.control.example"} {
if served[name] != "anchor" {
t.Fatalf("%s is not served by its proxy: %v", name, served)
}
}
}
+4 -4
View File
@@ -28,10 +28,10 @@ import (
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every
// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is
// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told
// the suffix is its own wants only the machines.
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
type RosterFile struct {
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
// than discovered as a daemon that reads nothing.
+123 -9
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -82,6 +83,11 @@ const (
RuntimeBrokerFile = "MESH_BROKER_FILE"
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
// environment and hands each module's words to that module's bundles alone. In the unit, so a
// change to any module's words changes the process and restarts it.
RuntimeToolEnv = "MESH_TOOL_ENV"
)
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
@@ -123,14 +129,22 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
RuntimeModule, r.Node, len(runtime.Bundles))
}
bundle := runtime.Bundles[0]
if len(bundle.Entrypoints) != 1 {
return nil, fmt.Errorf(
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
}
interpreter, err := interpreterFor(bundle.Language)
if err != nil {
return nil, err
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
var run []any
if bundle.Binary != "" {
run = []any{"./" + bundle.Binary}
} else {
if len(bundle.Entrypoints) != 1 {
return nil, fmt.Errorf(
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
}
interpreter, err := interpreterFor(bundle.Language)
if err != nil {
return nil, err
}
run = []any{interpreter, bundle.Entrypoints[0]}
}
credential, declared := runtime.OwnSecrets["broker"]
if !declared {
@@ -146,15 +160,28 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
// would be told to load files that were never delivered.
var served []string
var restartOn []string
given := map[string]map[string]string{}
for _, m := range r.Modules {
if with.Adopted && m.Filtering != nil {
continue
}
words, err := bundleWords(m, with)
if err != nil {
return nil, err
}
if len(words) > 0 {
given[m.Module] = words
}
for _, b := range m.Bundles {
if len(b.Loads) == 0 {
continue
}
for _, load := range b.Loads {
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
if launcher, has := b.Launchers[load]; has {
load = launcher
}
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
}
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
@@ -168,10 +195,18 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
RuntimeToolModules: strings.Join(served, ","),
RuntimeBrokerFile: credential.Path,
}
if len(given) > 0 {
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
body, err := json.Marshal(given)
if err != nil {
return nil, err
}
env[RuntimeToolEnv] = string(body)
}
process := map[string]any{
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
"source": bundle.Source, "digest": bundle.Digest,
"run": []any{interpreter, bundle.Entrypoints[0]},
"run": run,
"env": env,
"restart-on": toAny(restartOn),
}
@@ -249,3 +284,82 @@ func ToolContainerOnTheRuntime(m Manifest, against []string) string {
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
}
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
// directories and its ${port:…} to the port this machine gave it — the same resolution a
// container's environment gets. Two bundles of one module naming one word differently is refused:
// the runtime hands a module's words to all its bundles.
func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
var out map[string]string
dirs := dirsFor(m, with)
for _, b := range m.Bundles {
if len(b.Loads) == 0 || len(b.Env) == 0 {
continue
}
for _, word := range sortedKeys(b.Env) {
value, err := dirFill(b.Env[word], dirs, m.Module)
if err != nil {
return nil, err
}
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
return nil, err
}
if out == nil {
out = map[string]string{}
}
if was, had := out[word]; had && was != value {
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
}
out[word] = value
}
}
return out, nil
}
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
// is owned by the account — a tool reads its configuration and its secret as the account, and a
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
//
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
// restarted for, as the container the tools came from was restarted when its configuration changed.
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
var named []string
if len(words) == 0 {
return nil
}
for _, resource := range out {
module := owner[fmt.Sprint(resource["id"])]
mine := words[module]
if len(mine) == 0 {
continue
}
kind := fmt.Sprint(resource["type"])
if kind != "file" && kind != "directory" {
continue
}
path, _ := resource["path"].(string)
if path == "" {
continue
}
for _, value := range mine {
if kind == "file" && value == path {
named = append(named, fmt.Sprint(resource["id"]))
}
}
if _, said := resource["owner"]; said || account == "" {
continue
}
for _, value := range mine {
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
resource["owner"] = account
break
}
}
}
sort.Strings(named)
return named
}
+222
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"encoding/json"
"fmt"
"strings"
"testing"
@@ -210,3 +211,224 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
}
})
}
// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as
// a container's environment, hands it to the runtime as the module's words, and makes what the
// words name readable by the account the runtime runs as.
func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{
RuntimeModule: {"broker": "sealed-credential"},
"dash": {"token": "sealed-token"},
}}
dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}},
Resources: []map[string]any{
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
{"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"},
{"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"},
},
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
Entrypoints: []string{"tools/index.js"},
Env: map[string]string{
"DASH_CONFIG_FILE": "${dir:mesh-state}/config.json",
"DASH_TOKEN_FILE": "${dir:mesh-state}/token",
"DASH_URL": "http://127.0.0.1:${port:3000}",
"DASH_ADMIN": "mesh-admin",
}}}}}
if problems := dash.Build.problems(dash.Module); len(problems) > 0 {
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
}
dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
if err != nil {
t.Fatal(err)
}
other := aToolsModule(t, "nftables", "tools/index.js")
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
dir := fileNamed(out, "dash.mesh-state")
if dir == nil {
t.Fatalf("no directory: %v", ids(out))
}
at := fmt.Sprint(dir["path"])
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
env := process["env"].(map[string]string)
var given map[string]map[string]string
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
}
want := map[string]string{
"DASH_CONFIG_FILE": at + "/config.json",
"DASH_TOKEN_FILE": at + "/token",
"DASH_URL": "http://127.0.0.1:3000",
"DASH_ADMIN": "mesh-admin",
}
if fmt.Sprint(given["dash"]) != fmt.Sprint(want) {
t.Errorf("dash is given %v, want %v", given["dash"], want)
}
if _, has := given["nftables"]; has {
t.Errorf("a module that declares no words was given some: %v", given)
}
// What the words name is the account's to read; nothing else of the module's is touched.
for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} {
if r := fileNamed(out, id); r == nil || r["owner"] != "ops" {
t.Errorf("%s is not the account's to read: %v", id, r)
}
}
if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil {
t.Errorf("a file no word names was given an owner: %v", r)
}
// A file a word names restarts the runtime when it changes, as it restarted the tool container.
restarts := fmt.Sprint(process["restart-on"])
for _, want := range []string{"dash.config", "dash." + NeedID("token")} {
if !strings.Contains(restarts, want) {
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
}
}
if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") {
t.Errorf("the runtime restarts for something no word names as a file: %s", restarts)
}
t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) {
out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
if r := fileNamed(out, "dash.config"); r["owner"] != nil {
t.Errorf("re-owned with no account: %v", r)
}
})
t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) {
changed := dash
changed.Bundles = append([]Bundle(nil), dash.Bundles...)
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) {
t.Error("the runtime's process is the same after a module's words changed, so it would not restart")
}
})
}
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
{Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
}}}
said := strings.Join(m.Build.problems(m.Module), "\n")
for _, want := range []string{
`"tools" gives DASH_TOKEN the value "${secret:token}"`,
`"tools" gives DASH_PEER the value "${bound:db:url}"`,
`"tools" gives itself ` + RuntimeBrokerFile,
`"runtime" is a "image" and says what it is given`,
} {
if !strings.Contains(said, want) {
t.Errorf("not refused: %s\nsaid:\n%s", want, said)
}
}
}
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
// build from before launchers — so the move needs no flag day.
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
Entrypoints: []string{"tools/index.js"}}}}}
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
if err != nil {
t.Fatal(err)
}
older := aToolsModule(t, "nftables", "tools/index.js")
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
if env[RuntimeToolModules] != want {
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
}
}
// novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle.
func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
System: "arch", From: "cmd/node-tools"}}}}
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
if err != nil {
t.Fatal(err)
}
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), goRuntime}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
if fmt.Sprint(process["run"]) != "[./node-tools]" {
t.Errorf("a Go runtime is run as %v, want its own binary", process["run"])
}
env := process["env"].(map[string]string)
if env[RuntimeToolModules] == "" || process["user"] != "ops" {
t.Errorf("the Go runtime is not told what to serve or whose it is: %v %v", env, process["user"])
}
}
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
System: "arch", From: "cmd/lamp-tools"}}}}
if p := lamp.Build.problems("lamp"); len(p) != 0 {
t.Fatalf("a Go tools bundle was refused: %v", p)
}
lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" {
t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads)
}
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
if fileNamed(out, "lamp."+BundleID("tools")) == nil {
t.Errorf("the Go bundle is not delivered: %v", ids(out))
}
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" {
t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules])
}
// An artifact may say it explicitly; naming anything but the binary is refused.
said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools",
Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}}
if p := said.Build.problems("lamp"); len(p) != 0 {
t.Errorf("loads naming the binary was refused: %v", p)
}
said.Build.Artifacts[0].Loads = []string{"tools/index.js"}
if p := said.Build.problems("lamp"); len(p) == 0 {
t.Error("a Go bundle loading a file it does not contain was admitted")
}
}
+16
View File
@@ -5,6 +5,7 @@ import (
"sort"
"strings"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue"
)
@@ -96,6 +97,21 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
add(name, on.Module, EdgeDeclared)
}
}
// **A bundle stands on the toolchain it is compiled in** (novox/hq 04-ISSUES/211). A
// manifest names its toolchain by language, not in `build.on`, so the edge was implicit
// and a merge that moved the toolchain and a bundle together built both in one tier —
// the bundle against the toolchain as it was, recorded as built from the new commit. Read
// from the manifest, so it holds before any build has recorded what it stood on; and a
// toolchain that moves rebuilds every bundle compiled in it, which is what a toolchain
// carrying a bundle's dependencies requires.
for _, a := range e.Manifest.Build.Artifacts {
if a.Kind != catalogue.ArtifactBundle {
continue
}
if chain, err := builder.ToolchainFor(a.Language); err == nil {
add(name, chain.Base, EdgeStandsOn)
}
}
}
for _, ref := range against[name] {
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
+38
View File
@@ -62,3 +62,41 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
}
}
}
// novox/hq 04-ISSUES/211: a bundle stands on the toolchain it is compiled in, so a merge moving both
// builds the toolchain first — read from the manifest, before any build recorded it.
func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) {
entries := []Entry{
{Manifest: catalogue.Manifest{Module: "mesh-tools"}, Source: Source{Repository: "novox/mesh-tools"}},
{Manifest: catalogue.Manifest{Module: "mesh-tools-go"}, Source: Source{Repository: "novox/mesh-tools-go"}},
{Manifest: catalogue.Manifest{Module: "node-tools", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}},
Source: Source{Repository: "novox/mesh-tools"}},
{Manifest: catalogue.Manifest{Module: "nftables", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}},
Source: Source{Repository: "novox/mesh-catalog"}},
{Manifest: catalogue.Manifest{Module: "photos", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile"}}}},
Source: Source{Repository: "novox/photos"}},
}
edges := dependenciesOf(entries, nil, nil)
has := func(from, to string) bool {
for _, e := range edges {
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
return true
}
}
return false
}
if !has("nftables", "mesh-tools") {
t.Errorf("a TypeScript bundle does not stand on the TypeScript toolchain: %v", edges)
}
if !has("node-tools", "mesh-tools-go") {
t.Errorf("a Go bundle does not stand on the Go toolchain: %v", edges)
}
for _, e := range edges {
if e.From == "photos" && e.Kind == EdgeStandsOn {
t.Errorf("an image stands on a toolchain it is not compiled in: %v", e)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package link
import (
"encoding/json"
"fmt"
"log"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/micro"
)
// What answers announces itself (novox/hq ADR 0197). A holder that serves a seat's verbs answers the
// NATS services protocol's discovery — `$SRV.PING` and `$SRV.INFO`, and each by its service's name
// and instance — with exactly what it serves, in NATS's own format, so the console and the standard
// `nats micro` commands learn what exists from what answers rather than from a roster.
// DiscoverySubjects are where one service instance is asked to say what it is.
func DiscoverySubjects(name, id string) []string {
var out []string
for _, verb := range []string{"PING", "INFO", "STATS"} {
out = append(out, "$SRV."+verb, "$SRV."+verb+"."+name, "$SRV."+verb+"."+name+"."+id)
}
return out
}
// Announce answers discovery for one service until stopped. The answer is fixed at the call: a holder
// whose verbs change announces again. Every instance answers, so there is no queue group.
func (b OverNATS) Announce(info micro.Info, logger *log.Logger) (func(), error) {
info.Type = micro.InfoResponseType
infoBody, err := json.Marshal(info)
if err != nil {
return nil, err
}
pingBody, err := json.Marshal(micro.Ping{ServiceIdentity: info.ServiceIdentity, Type: micro.PingResponseType})
if err != nil {
return nil, err
}
// Statistics the protocol asks for; the controller keeps none per verb, so it answers its
// identity and its endpoints with nothing counted — an honest zero, not a refusal.
stats := micro.Stats{ServiceIdentity: info.ServiceIdentity, Type: micro.StatsResponseType}
for _, e := range info.Endpoints {
stats.Endpoints = append(stats.Endpoints, &micro.EndpointStats{Name: e.Name, Subject: e.Subject, QueueGroup: e.QueueGroup})
}
statsBody, err := json.Marshal(stats)
if err != nil {
return nil, err
}
var subs []*nats.Subscription
done := make(chan struct{})
stop := func() {
close(done)
for _, s := range subs {
_ = s.Unsubscribe()
}
}
for _, subject := range DiscoverySubjects(info.Name, info.ID) {
subject := subject
body := infoBody
switch {
case len(subject) >= 9 && subject[:9] == "$SRV.PING":
body = pingBody
case len(subject) >= 10 && subject[:10] == "$SRV.STATS":
body = statsBody
}
bind := func() (*nats.Subscription, error) {
return b.Conn.Subscribe(subject, func(msg *nats.Msg) {
if err := msg.Respond(body); err != nil && logger != nil {
logger.Printf("%s: could not answer: %v", subject, err)
}
})
}
sub, err := bind()
if err != nil {
stop()
return nil, fmt.Errorf("announcing %s on %s: %w", info.Name, subject, err)
}
subs = append(subs, sub)
go keepBound(sub, bind, subject, done, logger)
}
return stop, nil
}
+8 -2
View File
@@ -193,9 +193,15 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
// machine with nothing to build, and is asked again.
fetched, err := sub.Fetch(1, nats.Context(ctx))
switch {
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
case ctx.Err() != nil:
// Ours ended: the machine is being stopped.
return nil
case errors.Is(err, nats.ErrTimeout):
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
// **An empty queue, not the end.** A fetch on a context without a deadline waits the
// client's own while and then says the deadline passed — the client's, not ours. Read
// as "stop", every idle build machine exited clean every half minute and was started
// again by its supervisor, which looked like a crash loop with nothing in the log to
// say why (2026-10-03, the first build agents). Asked again.
continue
case err != nil:
if sub.IsValid() {
+3 -4
View File
@@ -169,10 +169,9 @@ func (g *Generator) Graph() Graph { return g.graph }
//
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
// - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
// routed name through its resolver finds the machine serving it; machines with no address yet
// are already left out of the set. A routed name is one alias, itself — a machine has a bare
// name beside its full one, a routed name has nothing beside it (issue 157).
// - `.Names` is the machines (novox/hq ADR 0191): a route's internal name is under its node's
// internal domain and the resolver answers it by wildcard, and a public name is public DNS's.
// Machines with no address yet are already left out of the set.
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"