Compare commits

..
Author SHA1 Message Date
jochen 60e8d01ac0 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:11 +02:00
45 changed files with 460 additions and 1922 deletions
-9
View File
@@ -530,15 +530,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
if result.Source != nil && result.Source.Seat != "" { if result.Source != nil && result.Source.Seat != "" {
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
} }
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
// the commit is built and recorded as what it was built from, and the module keeps following
// what it followed before — the repository's default branch for one new to the catalogue.
if followedBranch(result.Ref) == "" && result.Ref != "" {
recorded.Ref = ""
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
recorded.Ref = followedBranch(was.Ref)
}
}
if err := namesNoInstallation(manifest); err != nil { if err := namesNoInstallation(manifest); err != nil {
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
result.On, result.Repository, short(result.Commit), err) result.On, result.Repository, short(result.Commit), err)
-37
View File
@@ -63,40 +63,3 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
t.Fatalf("a failure is said in the builder's words: %v", err) t.Fatalf("a failure is said in the builder's words: %v", err)
} }
} }
// novox/hq 04-ISSUES/215: a build asked at a commit is recorded as built from that commit, and the
// module keeps following the branch it followed — a new one, the default branch.
func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
manifest, _ := json.Marshal(map[string]any{"module": "unifi", "version": "1"})
result := func(id, ref, commit string) link.BuildResult {
return link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
t.Fatal(err)
}
src, err := open.inventory.SourceOf(ctx, "unifi")
if err != nil {
t.Fatal(err)
}
if src.Ref != "main" || src.BuiltFrom != "9c97a8a1d2c3" {
t.Errorf("after a build at a commit the module follows %q, built from %q; want main, 9c97a8a1d2c3", src.Ref, src.BuiltFrom)
}
// One new to the catalogue, first built at a commit, follows the default branch.
other, _ := json.Marshal(map[string]any{"module": "letta", "version": "1"})
r := result("b-3", "deadbeef", "deadbeefcafe")
r.Manifest, r.Path = other, "modules/letta"
if _, _, err := takeIn(ctx, open.inventory, r); err != nil {
t.Fatal(err)
}
if src, _ := open.inventory.SourceOf(ctx, "letta"); src.Ref != "" {
t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref)
}
}
-93
View File
@@ -1,93 +0,0 @@
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,40 +147,6 @@ func moduleCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err 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 { if len(entries) == 0 {
fmt.Println("this mesh knows about no modules yet") fmt.Println("this mesh knows about no modules yet")
return nil return nil
@@ -767,23 +733,3 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
} }
return out, nil 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
}
+8 -40
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"os" "os"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -254,10 +253,11 @@ func theResolver(t *testing.T) catalogue.Manifest {
return m return m
} }
// The resolver is handed every machine on the private network as a wildcard, and is handed it again // The resolver is handed every machine on the private network as a wildcard, the same set and the
// when a machine leaves — through the module's own manifest asking for the fact, with no module of the // same source as the hosts file, and is handed it again when a machine leaves — through the
// mesh's own in between (hal dnsmasq-app conversion, novox/hq 08-connectivity). It is the mesh's one // module's own manifest asking for the fact, with no module of the mesh's own in between (hal
// resolver (ADR 0194), and the container runtime is given no resolver of its own (ADR 0196). // dnsmasq-app conversion, novox/hq 08-connectivity). The runtime on that machine is pointed at the
// machine's own address, where the resolver answers for its containers.
func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) { func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) {
open := aMesh(t) open := aMesh(t)
ctx := t.Context() ctx := t.Context()
@@ -265,12 +265,6 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil { if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203):
// no bus is known to this test, so it is minted here, or composing refuses the placeholder.
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "dnsmasq"}); err != nil {
t.Fatal(err)
}
zones := func() string { zones := func() string {
t.Helper() t.Helper()
for _, r := range composed(t, open, "anchor").Resources { for _, r := range composed(t, open, "anchor").Resources {
@@ -292,12 +286,11 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
t.Errorf("the resolver's machines lack %q:\n%s", want, first) t.Errorf("the resolver's machines lack %q:\n%s", want, first)
} }
} }
// The container runtime is given no resolver of its own (novox/hq ADR 0196): it copies its
// machine's, which name the mesh's resolver first. A `dns` key would be a second account of where a
// container asks, read only when the runtime starts.
for _, r := range composed(t, open, "anchor").Resources { for _, r := range composed(t, open, "anchor").Resources {
if r["id"] == "dnsmasq.runtime-dns" { if r["id"] == "dnsmasq.runtime-dns" {
t.Errorf("the resolver still writes the runtime's own dns: %v", r) if !strings.Contains(r["content"].(string), `"10.77.0.1"`) || r["into"] != "json" {
t.Errorf("the runtime is not pointed at this machine's own address, written into its file: %v", r)
}
} }
} }
@@ -310,28 +303,3 @@ 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) 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,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -45,21 +44,6 @@ func nodeCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err 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 { if len(nodes) == 0 {
// Said rather than printed as nothing: an empty list and a failed read must never // 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 // look the same, and this command answering "none" is only honest because getting
@@ -516,13 +500,3 @@ func orNotReported(s string) string {
} }
return s 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
}
-24
View File
@@ -211,27 +211,3 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
} }
} }
} }
// novox/hq 04-ISSUES/215: a module once built at a commit still follows its branch — a merge into it
// matches the module, and a plan re-asks the branch, not the old commit.
func TestAModuleBuiltAtACommitStillFollowsItsBranch(t *testing.T) {
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main"}
pinned := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a"}
if !sourceIs(pinned, m) {
t.Error("a module whose record names a commit is left out of a merge into its branch")
}
full := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a1d2c3b4a5f60718293a4b5c6d7e8f9012"}
if !sourceIs(full, m) {
t.Error("a full commit hash is read as a branch")
}
if got := followedBranch("9c97a8a"); got != "" {
t.Errorf("a plan would re-ask the old commit %q", got)
}
if got := followedBranch("release"); got != "release" {
t.Errorf("a branch is not followed as named: %q", got)
}
// A module that follows another branch is still not this merge's.
if sourceIs(inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "release"}, m) {
t.Error("a module following another branch was matched")
}
}
+51 -47
View File
@@ -645,24 +645,23 @@ func renderingFor(ctx context.Context, open *stores, node string,
} }
} }
// **The roster is the machines and nothing else** (novox/hq ADR 0191). Each node has one internal // And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
// domain, `<node>.internal`, and every route on it is a name under that domain (ADR 0151), which // `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
// the resolver answers with one wildcard per machine — so no route needs a line of its own. A // routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
// node's public domains are the operator's and public DNS answers them; the mesh gives no private // to serve and knows nothing about what they mean.
// answer for any of them. The roster once carried every routed name, public ones included, and a // Kept apart from the machines, because a fact about the machines must not be handed the names
// resolver that also serves a LAN handed a phone a tunnel address for the mail server. // the mesh merely serves (novox/hq 04-ISSUES/111).
// `.Names` and `.Machines` stay two fields so a module's template keeps rendering (issue 111).
machines := make(map[string]string, len(names)) machines := make(map[string]string, len(names))
for name, at := range names { for name, at := range names {
machines[name] = at machines[name] = at
} }
routes, err := routeNamesInTheMesh(ctx, open)
// And every zone a module in the mesh answers itself (novox/hq ADR 0199), for the mesh's resolver
// to forward.
zones, err := zonesInTheMesh(ctx, open)
if err != nil { if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err 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 // **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 // broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
@@ -733,7 +732,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
BusMembership: memberships[node], BusMembership: memberships[node],
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports, Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names, Certificate: certificate, Authority: authority, Mesh: private, Names: names,
Machines: machines, Zones: zones, Machines: machines,
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
@@ -741,17 +740,26 @@ func renderingFor(ctx context.Context, open *stores, node string,
}, record, nil }, record, nil
} }
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR // routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
// 0199): the zone settled from that node's settings, the node's private address, the port the // ADR 0066).
// answering listen is published on there.
// //
// Read across every machine's resolution, as the roster once read routed names: a node whose set does // **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
// not compose declares nothing and is passed over, so one broken machine does not cost the rest their // ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
// zones; a store that cannot be read is raised, naming the machine, because returning the zones // composed on the consumer's node (from its label and that node's public domain) and served by the
// without it would withdraw them from the resolver as if the operator had (novox/hq 04-ISSUES/152). // node answering the consumer's route requirement; this gathers both.
// What the mesh refuses about the zones together — one declared twice, one shadowing the mesh's //
// suffix or a node's public domain — is refused here, by name. // It reads route names off resolutions rather than a table because there is no table: a route is a
func zonesInTheMesh(ctx context.Context, open *stores) ([]catalogue.ZoneAt, error) { // 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 inv := open.inventory
places, err := inv.Overlays(ctx) places, err := inv.Overlays(ctx)
if err != nil { if err != nil {
@@ -763,47 +771,43 @@ func zonesInTheMesh(ctx context.Context, open *stores) ([]catalogue.ZoneAt, erro
address[p.Name] = p.Address address[p.Name] = p.Address
} }
} }
nodes, err := inv.Nodes(ctx) nodes, err := inv.Nodes(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err) return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
} }
var zones []catalogue.ZoneAt
var public []string // 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 { for _, n := range nodes {
plan, _, err := planFor(ctx, open, n.Name) plan, layers, err := planFor(ctx, open, n.Name)
switch { switch {
case unresolvable(err): 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 continue
case err != nil: case err != nil:
return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) // 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)
} }
if plan.PublicDomain != "" { plans[n.Name], settings[n.Name] = plan, layers
public = append(public, plan.PublicDomain)
} }
for _, m := range plan.Modules { served, err := catalogue.NamesServed(plans, settings)
if m.Zone == nil {
continue
}
at := address[n.Name]
if at == "" {
// Not on the private network yet: nothing could reach its answerer.
continue
}
published, layers, err := portsGivenOn(ctx, inv, n.Name, m)
if err != nil {
return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
}
z, err := catalogue.ZoneOn(m, layers, published, n.Name, at)
if err != nil { if err != nil {
return nil, err return nil, err
} }
zones = append(zones, *z) out := map[string]string{}
for name, node := range served {
if at := address[node]; at != "" {
out[name] = at
} }
} }
if problems := catalogue.ZonesProblems(zones, overlay.Suffix(), public); len(problems) > 0 { return out, nil
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
}
return zones, nil
} }
// certificateFor is what the mesh certifies about one machine's internal name. // certificateFor is what the mesh certifies about one machine's internal name.
+37 -71
View File
@@ -151,12 +151,6 @@ func serve(ctx context.Context) error {
return err return err
} }
defer stopServing() 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) return server.Serve(ctx)
} }
@@ -211,12 +205,6 @@ func declare(ctx context.Context, args []string) error {
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil { if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
return err 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)) fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
return nil return nil
} }
@@ -360,7 +348,7 @@ func pushCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) { sending, refusals := composeEach(asked, func(node string) (sendable, error) {
plan, settings, err := planFor(held, open, node) plan, settings, err := planFor(held, open, node)
if err != nil { if err != nil {
return sendable{}, err return sendable{}, err
@@ -382,8 +370,12 @@ func pushCommand(ctx context.Context, args []string) error {
sentDigest := map[string]string{} sentDigest := map[string]string{}
defer release() defer release()
for _, s := range sending { for _, s := range sending {
// The number is inside the signed bytes, so a replayed older declaration cannot borrow a // Numbered under the hold, one higher than the last, before the body exists — the number is
// newer one's (novox/hq 04-ISSUES/107); it was taken when the composition began (issue 204). // 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
}
body, err := s.declared.Body() body, err := s.declared.Body()
if err != nil { if err != nil {
return err return err
@@ -393,10 +385,14 @@ func pushCommand(ctx context.Context, args []string) error {
} }
// After it is away, not before. A digest recorded for something that failed to send would // 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. // make the machine look current for a declaration it never received.
digest, err := recordSent(ctx, inv, s.node, body) record, err := inv.NodeByName(ctx, s.node)
if err != nil { if err != nil {
return err return err
} }
digest := digestOf(body)
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
return err
}
sentDigest[s.node] = digest sentDigest[s.node] = digest
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources)) fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
} }
@@ -480,7 +476,11 @@ func pushCommand(ctx context.Context, args []string) error {
15*time.Second); err != nil { 15*time.Second); err != nil {
return err return err
} }
if _, err := recordSent(ctx, inv, s.node, body); err != nil { record, err := inv.NodeByName(ctx, s.node)
if err != nil {
return err
}
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
return err return err
} }
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources)) fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
@@ -569,31 +569,17 @@ type readyNode struct {
// //
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential // 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. // across two machines that must agree — and dropped here, where it never did.
func composeEach(names []string, allot func(node string) (int64, error), func composeEach(names []string,
compose func(node string) (sendable, error)) ([]readyNode, []string) { compose func(node string) (sendable, error)) ([]readyNode, []string) {
var sending []readyNode var sending []readyNode
var refusals []string var refusals []string
for _, name := range names { 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) declared, err := compose(name)
if err != nil { if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err)) refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue continue
} }
declared.Sequence = seq
if len(declared.Resources) == 0 { if len(declared.Resources) == 0 {
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to // 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, // nothing may have HELD something before — the broker opening a placement gave it,
@@ -621,10 +607,13 @@ func sendRound(ctx context.Context, open *stores, names []string,
return nil, err return nil, err
} }
defer release() defer release()
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) { sending, refused := composeEach(names, func(node string) (sendable, error) {
return compose(held, node) return compose(held, node)
}) })
for _, s := range sending { for _, s := range sending {
if err := number(ctx, open.inventory, &s); err != nil {
return refused, err
}
body, err := s.declared.Body() body, err := s.declared.Body()
if err != nil { if err != nil {
return refused, err return refused, err
@@ -681,12 +670,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
var sending []readyNode var sending []readyNode
var refusals []string var refusals []string
for _, name := range names { 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) plan, settings, err := planFor(ctx, open, name)
if err != nil { if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err)) refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
@@ -698,7 +681,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err)) refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
continue continue
} }
declared.Sequence = seq
reportLeftOut(name, declared) reportLeftOut(name, declared)
sending = append(sending, readyNode{name, declared}) sending = append(sending, readyNode{name, declared})
} }
@@ -714,6 +696,9 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
defer server.Close() defer server.Close()
for _, s := range sending { for _, s := range sending {
if err := number(ctx, inv, &s); err != nil {
return err
}
body, err := s.declared.Body() body, err := s.declared.Body()
if err != nil { if err != nil {
return err return err
@@ -721,7 +706,11 @@ 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 { if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
return err return err
} }
if _, err := recordSent(ctx, inv, s.node, body); err != nil { record, err := inv.NodeByName(ctx, s.node)
if err != nil {
return err
}
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
return err return err
} }
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources)) fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
@@ -962,38 +951,15 @@ 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). // number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
// allotting is allot over one inventory, in the shape composeEach takes. func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) { record, err := inv.NodeByName(ctx, s.node)
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 { if err != nil {
return 0, err return err
} }
return inv.NextSequence(ctx, record.ID) seq, err := 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 { if err != nil {
return "", err return err
} }
digest := digestOf(body) s.declared.Sequence = seq
if err := inv.RecordSent(kept, record.ID, digest); err != nil { return nil
return "", err
}
return digest, nil
} }
+2 -8
View File
@@ -17,7 +17,7 @@ import (
// the wrong machine no longer refuses the whole node), applied one level up. // the wrong machine no longer refuses the whole node), applied one level up.
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) { func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
sending, refusals := composeEach( sending, refusals := composeEach(
[]string{"anchor", "home-server", "laptop"}, numbered(), []string{"anchor", "home-server", "laptop"},
func(node string) (sendable, error) { func(node string) (sendable, error) {
if node == "anchor" { if node == "anchor" {
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`) 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 // (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. // declaration tells it to drop what the mesh owned. It is never a refusal.
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) { func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
sending, refusals := composeEach([]string{"spare"}, numbered(), sending, refusals := composeEach([]string{"spare"},
func(string) (sendable, error) { return sendable{}, nil }) func(string) (sendable, error) { return sendable{}, nil })
if len(sending) != 1 || len(refusals) != 0 { if len(sending) != 1 || len(refusals) != 0 {
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals) t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
@@ -74,9 +74,3 @@ 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 }
}
+1 -58
View File
@@ -272,8 +272,7 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
} }
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
fmt.Printf(" tier %d: ", p.Tier) fmt.Printf(" tier %d: ", p.Tier)
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215). if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
if err := buildOne(ctx, source, e.Source.Path, followedBranch(e.Source.Ref), 0); err != nil {
state.State = "failed" state.State = "failed"
state.Why = err.Error() state.Why = err.Error()
p.State = inventory.PlanFailed p.State = inventory.PlanFailed
@@ -400,24 +399,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
} }
return true, nil return true, nil
} }
// **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken
// in by whichever controller hears it, and a merge to the controller's own repository replaces
// the controller in its first tier: the build that produced the new one is recorded, and the
// plan never hears it. The record is the fact; a build recorded after the ask is that tier's
// outcome, whoever was listening.
recorded := map[string][]inventory.Build{}
for _, m := range tier {
if s := p.Modules[m]; s != nil && s.State == "asked" {
builds, err := inv.Builds(ctx, m, 5)
if err != nil {
return false, err
}
recorded[m] = builds
}
}
if settleFromRecords(p, tier, recorded) {
return true, nil
}
// Asked: wait for every build. // Asked: wait for every build.
var latest time.Time var latest time.Time
for _, m := range tier { for _, m := range tier {
@@ -774,41 +755,3 @@ func splitList(s string) []string {
} }
return out return out
} }
// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build
// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214).
// Newest first, as Builds answers: the first record after the ask is the outcome of that ask.
func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool {
changed := false
for _, m := range tier {
s := p.Modules[m]
if s == nil || s.State != "asked" || s.AskedAt == nil {
continue
}
var outcome *inventory.Build
for i := range recorded[m] {
b := recorded[m][i]
if b.At.Before(*s.AskedAt) {
break
}
outcome = &b
}
if outcome == nil {
continue
}
at := outcome.At
if outcome.Worked() {
s.State = "built"
s.BuiltAt = &at
s.Commit = outcome.Commit
} else {
s.State = "failed"
s.Why = outcome.Failed
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier)
}
fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID)
changed = true
}
return changed
}
-48
View File
@@ -115,51 +115,3 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
t.Fatalf("a cycle should be one tier of two, said: %v", tiers) 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)
}
}
// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
// the controller rebuilding itself — settles from the build records instead of waiting for ever.
func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
Modules: map[string]*inventory.PlanModule{
"mesh-controller": {State: "asked", AskedAt: &asked},
"builder": {State: "asked", AskedAt: &asked},
}}
records := map[string][]inventory.Build{
// Newest first, as Builds answers: the build after the ask is the outcome.
"mesh-controller": {
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
},
// Only a build from before the ask: not this ask's outcome.
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
}
if !settleFromRecords(&p, p.Tiers[0], records) {
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
}
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
}
if s := p.Modules["builder"]; s.State != "asked" {
t.Errorf("an ask with no record after it was settled: %+v", s)
}
// A failure recorded after the ask fails the plan, as hearing it would have.
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
}
}
+54 -1
View File
@@ -2,12 +2,13 @@ package main
import ( import (
"context" "context"
"strings"
"testing" "testing"
) )
// A node's own set failing to compose, and the mesh being unable to answer at all, are different // 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 // 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 gatherers rely on it. // (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) { func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
open := aMesh(t) open := aMesh(t)
@@ -44,3 +45,55 @@ func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err) 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)
}
}
+2 -47
View File
@@ -6,10 +6,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/nats-io/nats.go/micro"
"os" "os"
"os/exec" "os/exec"
"sort"
"strings" "strings"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
@@ -68,14 +66,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
case "status": case "status":
return []string{"status", "--json"}, nil return []string{"status", "--json"}, nil
case "nodes": case "nodes":
return []string{"node", "list", "--json"}, nil return []string{"node", "list"}, nil
case "node": case "node":
if err := need("node"); err != nil { if err := need("node"); err != nil {
return nil, err return nil, err
} }
return []string{"node", "show", str("node")}, nil return []string{"node", "show", str("node")}, nil
case "modules": case "modules":
return []string{"module", "list", "--json"}, nil return []string{"module", "list"}, nil
case "seats": case "seats":
return []string{"seats", "--json"}, nil return []string{"seats", "--json"}, nil
case "builds": case "builds":
@@ -381,46 +379,3 @@ func splitCommandLine(line string) ([]string, error) {
} }
return words, nil 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,8 +2,6 @@ package main
import ( import (
"context" "context"
"fmt"
"github.com/novox/mesh-controller/internal/link"
"strings" "strings"
"testing" "testing"
@@ -251,48 +249,3 @@ 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)
}
}
}
+1 -27
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"regexp"
"strings" "strings"
"time" "time"
@@ -284,14 +283,6 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
if isHistory(m.MergedAt, lastLookAt(entries, m)) { if isHistory(m.MergedAt, lastLookAt(entries, m)) {
packaging = nil packaging = nil
} }
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
// another branch is not part of this merge, and whoever is waiting for its change should read why.
for _, e := range entries {
if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) {
fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n",
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
}
}
touched := whatTheMergeTouched(from, entries, m) touched := whatTheMergeTouched(from, entries, m)
for _, e := range touched { for _, e := range touched {
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil { if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
@@ -354,24 +345,7 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool {
if !sameRepository(s.Repository, m) { if !sameRepository(s.Repository, m) {
return false return false
} }
ref := followedBranch(s.Ref) return s.Ref == "" || s.Ref == m.Base
return ref == "" || ref == m.Base
}
// commitRef is a ref that names a commit rather than a branch: what `build --ref <commit>` asks for.
var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A
// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a
// fix — recorded that commit as the module's ref; every merge after it then failed to match the
// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same
// old commit again. A commit recorded so is read as the repository's default branch, which is what
// the module followed before it; a branch is followed as named.
func followedBranch(ref string) string {
if commitRef.MatchString(strings.TrimSpace(ref)) {
return ""
}
return ref
} }
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it // sameRepository is whether a recorded repository is the one a merge names, in either spelling it
-29
View File
@@ -1,29 +0,0 @@
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)
}
}
+1 -15
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) log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
continue continue
} }
r.to = towards(where) r.to = httputil.NewSingleHostReverseProxy(where)
if r.insecure { if r.insecure {
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
} }
@@ -1060,17 +1060,3 @@ func asPort(v any) (int, bool) {
} }
return 0, false 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: "+ 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 "+ "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", "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+uint64(have.NumAckPending)) c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+have.NumAckPending)
want.DeliverPolicy = nats.DeliverNewPolicy want.DeliverPolicy = nats.DeliverNewPolicy
} else { } else {
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+ j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
+2 -65
View File
@@ -236,8 +236,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
// which this package mirrors rather than reads, and a verb the seat does not declare is a // which this package mirrors rather than reads, and a verb the seat does not declare is a
// subject nothing publishes. // subject nothing publishes.
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>") 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 // 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 // (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
@@ -273,9 +271,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{}, err return Permissions{}, err
} }
pub = append(pub, invoked...) 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: case KindEnrolment:
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
@@ -332,15 +327,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
// away — no other principal may subscribe this namespace, and a caller's authority is // away — no other principal may subscribe this namespace, and a caller's authority is
// still granted per tool, by name, on the publish side. // still granted per tool, by name, on the publish side.
sub = append(sub, own+".tool.>") 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 // Its own membership (ADR 0160): the one subject a runtime derives for itself, read
// directly from the stream and followed live. Nothing else's. // directly from the stream and followed live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module)) sub = append(sub, MembershipSubject(p.Node, p.Module))
@@ -427,16 +413,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
// module's own principal has, for the same reason: the tools a module serves are what its // 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 // 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. // this node, as the holder's own principal would be granted them.
var serves []string
for _, d := range p.Carries { for _, d := range p.Carries {
if !safeSubject.MatchString(d.Module) { if !safeSubject.MatchString(d.Module) {
return Permissions{}, fmt.Errorf( return Permissions{}, fmt.Errorf(
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module) "%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 own := "mesh.mod." + d.Module
sub = append(sub, own+".tool.>") sub = append(sub, own+".tool.>")
// A tool that emits an event is the module's code and emits under the module's name // A tool that emits an event is the module's code and emits under the module's name
@@ -463,30 +444,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{}, err return Permissions{}, err
} }
pub = append(pub, invoked...) pub = append(pub, invoked...)
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers // Nothing about consumers: it consumes nothing. A module's reactions to events are its
// discovery for each module and seat it carries, and the console it is asks the bus. // own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
// 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()...)
// **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's
// "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and
// the runtime is its bus — it reads the module's own durable consumer and acknowledges what
// the module's code took. Exactly the grants the module's own principal has for that consumer,
// on its name and no other's: asking about it, pulling from it, acknowledging it. The
// consumer is still the controller's to make, from the module's own principal.
for _, d := range p.Carries {
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
if _, consumes := ConsumerFor(own); !consumes {
continue
}
stream, durable := consumerStream(own), consumerDurable(own)
pub = append(pub,
"$JS.API.CONSUMER.INFO."+stream+"."+durable,
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
"$JS.ACK."+stream+"."+durable+".>")
}
sub = unique(sub) sub = unique(sub)
pub = unique(pub) pub = unique(pub)
} }
@@ -806,25 +765,3 @@ func invokedSubjects(invokes []string) ([]string, error) {
} }
return out, nil 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.>"}
}
+11 -28
View File
@@ -233,9 +233,7 @@ func TestAPersonReachesNothingButTools(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
Invokes: []string{"*"}, PasswordHash: "x"}) Invokes: []string{"*"}, PasswordHash: "x"})
for _, p := range perms.Publish { for _, p := range perms.Publish {
// A tool call, or asking what answers (novox/hq ADR 0197) — a question every service if !strings.Contains(p, ".tool.") {
// 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) t.Errorf("a person may publish %q, which is not a tool call", p)
} }
} }
@@ -378,7 +376,7 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs // their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
// on this node, every module's membership on this node, and a call to anything. Nothing it // on this node, every module's membership on this node, and a call to anything. Nothing it
// consumes, because it reacts to nothing. // consumes, because it reacts to nothing.
func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) { func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}} filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{ p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}}, {Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
@@ -408,46 +406,31 @@ func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) {
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish) t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
} }
} }
// It reads the consumer of every carried module that consumes — that module's, by its name, as // Nothing of what a carried module consumes, and no consumer of its own to ack.
// the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing. for _, s := range perms.Subscribe {
for _, want := range []string{ if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
"$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh", t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s)
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh",
"$JS.ACK.EVENTS.anchor_zsh.>",
} {
if !contains(perms.Publish, want) {
t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish)
} }
} }
for _, s := range perms.Publish { for _, s := range perms.Publish {
if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") { if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") {
t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s) t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s)
}
}
// It pulls; nothing is pushed to it, and it subscribes no event subject directly.
for _, s := range perms.Subscribe {
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
t.Errorf("the runtime was granted a delivery: %s", s)
} }
} }
if !perms.AllowResponses { if !perms.AllowResponses {
t.Error("the runtime answers what it is asked, and may not reply") t.Error("the runtime answers what it is asked, and may not reply")
} }
if _, needed := ConsumerFor(p); needed { if _, needed := ConsumerFor(p); needed {
t.Error("a consumer would be made for the runtime itself; it reads its modules' consumers, never one of its own") t.Error("a consumer would be made for the runtime, which consumes nothing")
} }
// Each subject once in each list: the file is read as the mesh's authority model. One subject may // Each subject once: the file is read as the mesh's authority model.
// 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{} seen := map[string]bool{}
for _, s := range list { for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) {
if seen[s] { if seen[s] {
t.Errorf("%s is granted twice", s) t.Errorf("%s is granted twice", s)
} }
seen[s] = true seen[s] = true
} }
}
} }
func contains(list []string, want string) bool { func contains(list []string, want string) bool {
+4 -4
View File
@@ -25,7 +25,7 @@ accounts {
users = [ users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { { 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.>"] } 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.>", "$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"] } 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"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
@@ -38,17 +38,17 @@ accounts {
} } } }
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { { 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"] } 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: ["$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"] } subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { { 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"] } 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: ["$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"] } subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { { 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"] } 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: ["$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.>"] } subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
] ]
+1 -52
View File
@@ -587,12 +587,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
if err != nil { if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err) 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") say("bundle", "compiled, packing")
body, err := pack(compiled) body, err := pack(compiled)
if err != nil { if err != nil {
@@ -604,7 +598,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
if err != nil { if err != nil {
return catalogue.Built{}, err return catalogue.Built{}, err
} }
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
case catalogue.ArtifactPackage: case catalogue.ArtifactPackage:
// Built and published on a public base, to the mesh's package registry, by version // Built and published on a public base, to the mesh's package registry, by version
@@ -1171,9 +1165,6 @@ 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 // 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. // the package it is built from, which is what a compiler would have chosen anyway.
func binaryName(a catalogue.Artifact) string { func binaryName(a catalogue.Artifact) string {
if name := catalogue.BinaryOf(a); name != "" {
return name
}
if name := strings.TrimSpace(a.Binary); name != "" { if name := strings.TrimSpace(a.Binary); name != "" {
return name return name
} }
@@ -1182,45 +1173,3 @@ func binaryName(a catalogue.Artifact) string {
} }
return a.Name 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
@@ -1,49 +0,0 @@
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)
}
}
+2 -3
View File
@@ -21,9 +21,8 @@ import (
// formats and gains no fields. // formats and gains no fields.
// //
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is: // **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are // `at`, `as` and `from` are facts about any provision at all, and everything else comes from what
// facts about any provision at all, and everything else comes from what the provider said it // the provider said it serves — whose keys are agreed by the requirement's name, not by this file.
// serves — whose keys are agreed by the requirement's name, not by this file.
// bound is where a module says a value from one of its bindings belongs: // bound is where a module says a value from one of its bindings belongs:
// ${bound:<provision>.<key>}. // ${bound:<provision>.<key>}.
@@ -232,34 +232,3 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
t.Fatal("one module on two machines shares an identity") t.Fatal("one module on two machines shares an identity")
} }
} }
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
// machine's private address beside its name, and only when the machine has one.
func TestABindingOffersTheProvidersAddress(t *testing.T) {
consumer := func() Resolution {
return Resolution{
Node: "workstation",
Modules: []Manifest{{
Module: "resolv-conf",
Requires: []string{"wildcard-resolution"},
Resources: []map[string]any{{
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
"content": "nameserver ${bound:wildcard-resolution:address}\n",
}},
}},
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
}
}
out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
t.Fatalf("the resolver is not named by its address: %q", got)
}
// A machine with no address yet: refused, never written with a blank where the address belongs.
if _, err := consumer().Declaration(Rendering{}); err == nil {
t.Fatal("a file naming an address the mesh does not have was composed")
}
}
+1 -118
View File
@@ -2,7 +2,6 @@ package catalogue
import ( import (
"fmt" "fmt"
"path"
"sort" "sort"
"strings" "strings"
) )
@@ -29,9 +28,6 @@ type Built struct {
Reference string Reference string
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one. // Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
Digest string 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. // Resolve fills a manifest's resources in from what was built.
@@ -87,12 +83,6 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
loads := append([]string(nil), a.Loads...) loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 { if a.Loads == nil && len(m.Tools) > 0 {
loads = append([]string(nil), a.Entrypoints...) 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 // **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 — // it reached it by, and a manifest carrying that address names an installation —
@@ -102,8 +92,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
out.Bundles = append(out.Bundles, Bundle{ out.Bundles = append(out.Bundles, Bundle{
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest, Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers), Loads: loads,
Binary: BinaryOf(a),
}) })
} }
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
@@ -214,12 +203,6 @@ 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 // 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 // is which compiler — because the mesh chooses that, and cannot choose for a module that
// has not said. // 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 { if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
// **Except for a language that compiles to a binary, where it names which one** // **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 // (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
@@ -239,7 +222,6 @@ func (b *Build) problems(module string) []string {
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+ "%s: %q is a bundle and says no language, so nothing can choose a compiler "+
"for it", module, a.Name)) "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 // 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 // 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. // fail to import it on every machine rather than here.
@@ -248,11 +230,6 @@ func (b *Build) problems(module string) []string {
for _, e := range a.Entrypoints { for _, e := range a.Entrypoints {
found = found || e == load 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 { if !found {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+ "%s: %q says the runtime loads %q, which is not among its entrypoints — "+
@@ -383,97 +360,3 @@ func versionOf(digest string) string {
} }
return hex 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
}
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
// naming the field that would deliver it.
func undeliveredBundles(m Manifest) []string {
if m.Build == nil || m.Module == RuntimeModule {
return nil
}
named := map[string]bool{}
for _, r := range m.Resources {
if a, ok := r["artifact"].(string); ok && a != "" {
named[a] = true
}
}
var problems []string
for _, a := range m.Build.Artifacts {
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
continue
}
problems = append(problems, fmt.Sprintf(
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
m.Module, a.Name))
}
return problems
}
+1 -39
View File
@@ -155,10 +155,6 @@ type Rendering struct {
// standing beside the machines and looking as real as they do. // standing beside the machines and looking as real as they do.
Machines map[string]string Machines map[string]string
// Zones is every zone a module in the mesh answers itself, where it is answered (novox/hq ADR
// 0199): the mesh's resolver forwards each one there.
Zones []ZoneAt
Settings SettingsBy Settings SettingsBy
Generators map[string]Generator Generators map[string]Generator
// Grants are the credentials this node must create, for the provisions it offers. Passed in // Grants are the credentials this node must create, for the provisions it offers. Passed in
@@ -736,16 +732,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
known[provision] = values known[provision] = values
} }
} }
// And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is
// what nearly every consumer wants; the one that cannot use it is a machine's resolver
// configuration, which must reach the resolver before it can resolve anything — the resolver's
// own name included. Absent when the machine has no address yet, so a file naming it is refused
// rather than written with a blank where an address belongs.
for _, values := range known {
if address := with.Machines[values["at"]]; address != "" {
values["address"] = address
}
}
// And what the module is called through each requirement it contributes to (novox/hq // And what the module is called through each requirement it contributes to (novox/hq
// 04-ISSUES/122) — the same composition its binding file carries. // 04-ISSUES/122) — the same composition its binding file carries.
for provision, values := range known { for provision, values := range known {
@@ -915,7 +901,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// plane's; making a name resolve is the module's software. Emitted as ordinary files under // plane's; making a name resolve is the module's software. Emitted as ordinary files under
// this module's name, so they are applied, reported and removed exactly as anything else // this module's name, so they are applied, reported and removed exactly as anything else
// it declares. // it declares.
given, err := FactsWithZonesInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix, with.Zones) given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -935,30 +921,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
} }
owner[fmt.Sprint(process["id"])] = RuntimeModule owner[fmt.Sprint(process["id"])] = RuntimeModule
out = append(out, process) 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 { if with.Adopted {
// First, before anything a module declares: what the mesh needs reachable, then its guard. // First, before anything a module declares: what the mesh needs reachable, then its guard.
-19
View File
@@ -540,10 +540,6 @@ type Manifest struct {
// `restart-on` names to restart when the roster changes. // `restart-on` names to restart when the roster changes.
Facts map[string]RosterFile `json:"facts,omitempty"` Facts map[string]RosterFile `json:"facts,omitempty"`
// Zone is the zone of names this module answers itself, and the listen that answers it (novox/hq
// ADR 0199). The mesh's resolver forwards the zone to it; nothing here names an address.
Zone *Zone `json:"zone,omitempty"`
// Certificate is where this module wants a certificate for its machine's name inside the // Certificate is where this module wants a certificate for its machine's name inside the
// mesh, and where the key that goes with it can be found. // mesh, and where the key that goes with it can be found.
// //
@@ -606,14 +602,6 @@ type Bundle struct {
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is // every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
// run rather than loaded. // run rather than loaded.
Loads []string `json:"loads,omitempty"` 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. // Build says how to produce this module's artifacts from its source.
@@ -760,11 +748,6 @@ type Artifact struct {
// module's tools and nothing else is the ordinary case and should not have to say the same // 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. // list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
Loads []string `json:"loads,omitempty"` 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. // Kinds an artifact may be.
@@ -1377,7 +1360,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
problems = append(problems, m.Build.problems(m.Module)...) problems = append(problems, m.Build.problems(m.Module)...)
problems = append(problems, undeliveredBundles(m)...)
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
// //
// Building publishes to the store, and the builder will not start without one. So a module // Building publishes to the store, and the builder will not start without one. So a module
@@ -1746,7 +1728,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
problems = append(problems, zoneProblems(m)...)
if len(problems) > 0 { if len(problems) > 0 {
sort.Strings(problems) sort.Strings(problems)
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
+124
View File
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,99 @@
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)
}
}
}
+23 -44
View File
@@ -48,12 +48,9 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
} }
for _, want := range []string{ for _, want := range []string{
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a // Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
// member cannot reach what the mesh's names point at. // address there (novox/hq issue 198).
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n", "\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
"\nno-hosts\n",
"\nconf-file=" + m.Facts["zones"].Path + "\n",
"\ndomain-needed\n", "\nbogus-priv\n", "\ndomain-needed\n", "\nbogus-priv\n",
"\nconf-file=" + m.Facts["node-zones"].Path + "\n", "\nconf-file=" + m.Facts["node-zones"].Path + "\n",
} { } {
@@ -76,15 +73,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
t.Errorf("the resolver listens on %s", taken) t.Errorf("the resolver listens on %s", taken)
} }
} }
// Never a directory or a file the operator keeps: a line written for one machine's programs would // And the file that decides what the machine asks names it there, alone.
// become an answer for every node (ADR 0199).
for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} {
if strings.Contains(config, never) {
t.Errorf("the mesh's resolver still reads or listens on %q", never)
}
}
// And the file that decides what the machine asks names the mesh's resolver first, by address,
// and a public one second, asked only when the first is silent (ADR 0196).
var resolv string var resolv string
for _, r := range catalogueManifest(t, "resolv-conf").Resources { for _, r := range catalogueManifest(t, "resolv-conf").Resources {
if r["path"] == "/etc/resolv.conf" { if r["path"] == "/etc/resolv.conf" {
@@ -97,16 +86,13 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver ")) nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver "))
} }
} }
if len(nameservers) != 2 || nameservers[0] != "${bound:wildcard-resolution:address}" || nameservers[1] != "1.1.1.1" { if len(nameservers) != 1 || nameservers[0] != "127.0.0.1" {
t.Errorf("resolv.conf names %v; the mesh's resolver by address first, a public one second", nameservers) t.Errorf("resolv.conf names %v; the predecessor's names the mesh's resolver alone at 127.0.0.1", nameservers)
} }
if !strings.Contains(resolv, "\noptions timeout:1 attempts:1") { // The split-DNS alternative points at the same address, or a machine that keeps
t.Errorf("the fallback is not reached after one short attempt:\n%s", resolv)
}
// The split-DNS alternative points at the same resolver, or a machine that keeps
// systemd-resolved in charge would route the mesh's suffix to nothing. // systemd-resolved in charge would route the mesh's suffix to nothing.
for _, r := range catalogueManifest(t, "resolved-split-dns").Resources { for _, r := range catalogueManifest(t, "resolved-split-dns").Resources {
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=${bound:wildcard-resolution:address}\n") { if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=127.0.0.1\n") {
t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content) t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content)
} }
} }
@@ -114,8 +100,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
// The resolver and what points the machine at it compose on one machine, and what arrives is the // The resolver and what points the machine at it compose on one machine, and what arrives is the
// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on // mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on
// that file, the machine pointed at the resolver by address, and the runtime given no resolver of // that file, and the runtime pointed at this machine's own address.
// its own but kept running across a restart (ADR 0196).
func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"}, got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"},
Node{Name: "anchor", At: "anchor.internal"}, World{}) Node{Name: "anchor", At: "anchor.internal"}, World{})
@@ -130,7 +115,6 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
// issue 111) — the resolver's zones read only the second, and in this scenario the two // issue 111) — the resolver's zones read only the second, and in this scenario the two
// happen to be the same map, since nothing routed is part of it. // happen to be the same map, since nothing routed is part of it.
Names: twoMachines, Machines: twoMachines, Suffix: "internal", Names: twoMachines, Machines: twoMachines, Suffix: "internal",
Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}},
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
}) })
@@ -160,29 +144,24 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
for _, id := range service["restart-on"].([]any) { for _, id := range service["restart-on"].([]any) {
reflects[id.(string)] = true reflects[id.(string)] = true
} }
if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] { if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] {
t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"]) t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
}
if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") {
t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z)
} }
// The runtime's own file, written into (novox/hq ADR 0102) with one key, by what decides how the // The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states:
// machine resolves: a restart keeps every container running. No `dns` — a container copies its // where containers resolve, and that a restart keeps them running — because the runtime reads
// machine's resolvers (ADR 0196), and the mesh's resolver is not written into the runtime twice. // `dns` only when it starts, and the one restart that needs is the operator's (issue 110).
if ids["dnsmasq.runtime-dns"] != nil { runtime := ids["dnsmasq.runtime-dns"]
t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"])
}
runtime := ids["resolv-conf.runtime-config"]
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
} }
var keys map[string]any var keys map[string]any
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil { if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
t.Fatalf("the runtime's keys are not JSON: %v", err) t.Fatalf("the runtime's keys are not JSON: %v", err)
} }
if len(keys) != 1 || keys["live-restore"] != true { dns, _ := keys["dns"].([]any)
t.Errorf("the runtime is given %v; live-restore and nothing else", keys) if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true {
t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys)
} }
// The runtime is reloaded when that file changes, and never restarted: a restart stops every // The runtime is reloaded when that file changes, and never restarted: a restart stops every
// container on the machine (ADR 0102), and a reload is what turns live-restore on. // container on the machine (ADR 0102), and a reload is what turns live-restore on.
@@ -192,10 +171,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
continue continue
} }
if _, restarts := r["restart-on"]; restarts { if _, restarts := r["restart-on"]; restarts {
t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
} }
for _, on := range asStrings(r["reload-on"]) { for _, on := range asStrings(r["reload-on"]) {
if on == "resolv-conf.runtime-config" { if on == "dnsmasq.runtime-dns" {
reloaded = true reloaded = true
} }
} }
@@ -205,8 +184,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
} }
resolv := ids["resolv-conf.resolv"] resolv := ids["resolv-conf.resolv"]
if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\nnameserver 1.1.1.1\n") { if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 127.0.0.1\n") {
t.Fatalf("the machine is not pointed at the resolver by address, with the public fallback: %v", resolv) t.Fatalf("the machine is not pointed at the resolver: %v", resolv)
} }
} }
+4 -31
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 // 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), // 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` and // `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not // name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq // how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told
// issue 111 distinction is kept as two fields so the templates that range either keep rendering. // the suffix is its own wants only the machines.
type RosterFile struct { type RosterFile struct {
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather // 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. // than discovered as a daemon that reads nothing.
@@ -61,16 +61,6 @@ type rosterView struct {
Suffix string Suffix string
Names []rosterEntry Names []rosterEntry
Machines []rosterEntry Machines []rosterEntry
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
Zones []rosterZone
}
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
type rosterZone struct {
Zone string
Address string
Port int
} }
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address, // rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
@@ -90,12 +80,6 @@ type rosterEntry struct {
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both // `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
// are given and the template chooses. // are given and the template chooses.
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) { func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
}
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
zones []ZoneAt) ([]map[string]any, error) {
if len(m.Facts) == 0 { if len(m.Facts) == 0 {
return nil, nil return nil, nil
} }
@@ -110,7 +94,6 @@ func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[
Suffix: strings.TrimPrefix(suffixOr(suffix), "."), Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
Names: entriesFrom(every, accounts, suffix), Names: entriesFrom(every, accounts, suffix),
Machines: entriesFrom(machines, accounts, suffix), Machines: entriesFrom(machines, accounts, suffix),
Zones: zonesFrom(zones),
} }
out := make([]map[string]any, 0, len(names)) out := make([]map[string]any, 0, len(names))
@@ -240,13 +223,3 @@ func sortedNames(addresses map[string]string) []string {
sort.Strings(out) sort.Strings(out)
return out return out
} }
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
func zonesFrom(zones []ZoneAt) []rosterZone {
out := make([]rosterZone, 0, len(zones))
for _, z := range zones {
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
}
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
return out
}
+1 -115
View File
@@ -1,7 +1,6 @@
package catalogue package catalogue
import ( import (
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@@ -83,11 +82,6 @@ const (
RuntimeBrokerFile = "MESH_BROKER_FILE" RuntimeBrokerFile = "MESH_BROKER_FILE"
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT" RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
RuntimeOperatorHome = "MESH_OPERATOR_HOME" 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 // interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
@@ -129,12 +123,6 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
RuntimeModule, r.Node, len(runtime.Bundles)) RuntimeModule, r.Node, len(runtime.Bundles))
} }
bundle := runtime.Bundles[0] bundle := runtime.Bundles[0]
// 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 { if len(bundle.Entrypoints) != 1 {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+ "%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
@@ -144,8 +132,6 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
run = []any{interpreter, bundle.Entrypoints[0]}
}
credential, declared := runtime.OwnSecrets["broker"] credential, declared := runtime.OwnSecrets["broker"]
if !declared { if !declared {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -160,28 +146,15 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
// would be told to load files that were never delivered. // would be told to load files that were never delivered.
var served []string var served []string
var restartOn []string var restartOn []string
given := map[string]map[string]string{}
for _, m := range r.Modules { for _, m := range r.Modules {
if with.Adopted && m.Filtering != nil { if with.Adopted && m.Filtering != nil {
continue 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 { for _, b := range m.Bundles {
if len(b.Loads) == 0 { if len(b.Loads) == 0 {
continue continue
} }
for _, load := range b.Loads { 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) served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
} }
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
@@ -195,18 +168,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
RuntimeToolModules: strings.Join(served, ","), RuntimeToolModules: strings.Join(served, ","),
RuntimeBrokerFile: credential.Path, 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{ process := map[string]any{
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule, "id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
"source": bundle.Source, "digest": bundle.Digest, "source": bundle.Source, "digest": bundle.Digest,
"run": run, "run": []any{interpreter, bundle.Entrypoints[0]},
"env": env, "env": env,
"restart-on": toAny(restartOn), "restart-on": toAny(restartOn),
} }
@@ -284,82 +249,3 @@ 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", "now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule) 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
}
-247
View File
@@ -1,7 +1,6 @@
package catalogue package catalogue
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@@ -211,249 +210,3 @@ 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 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying
// `loads`, listing `tools`, or a resource naming it admits it.
func TestABundleNothingDeliversIsRefused(t *testing.T) {
base := func() Manifest {
return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}}
}
if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") {
t.Fatalf("a bundle nothing delivers was admitted: %v", p)
}
loads := base()
loads.Build.Artifacts[0].Loads = []string{"tools/index.js"}
tools := base()
tools.Tools = []string{"baserow_list_rows"}
run := base()
run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}}
runtime := base()
runtime.Module = RuntimeModule
for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} {
if p := undeliveredBundles(m); len(p) != 0 {
t.Errorf("a bundle delivered by %s was refused: %v", name, p)
}
}
}
// 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")
}
}
-24
View File
@@ -107,31 +107,7 @@ var defaultSeats = []Seat{
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it. // that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
{Name: "mesh-build-machine", Scope: ScopeMesh, {Name: "mesh-build-machine", Scope: ScopeMesh,
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"}, Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
// **The mesh's one resolver** (novox/hq ADR 0194, 0196): every node's internal domain, held in one
// place, and every node and container asks it first. Delivers what a machine's resolver
// configuration requires, so that requirement resolves to the holder wherever it is placed.
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Decision: "novox/hq ADR 0194"},
// **Retired by ADR 0194, kept while a manifest still claims it** — the same reason as
// mesh-build-machine above: a machine still holds it until the mesh's resolver replaces it, and
// removing the row first would make that machine unresolvable. Deleted once nothing claims it.
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, {Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
// **A machine's /etc/hosts is one module's** (novox/hq ADR 0199): its holder writes the machine's
// own lines and keeps every other line as the operator's, changed through these three verbs on that
// machine alone. The controller holds none of it.
{Name: "node-hosts-file", Scope: ScopeNode, Decision: "novox/hq ADR 0199",
Serves: []Verb{
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
"the operator's, or the block of the module or tool that writes it.",
Input: schema(map[string]string{}, nil)},
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
"names": "the names for it, separated by spaces"}, []string{"address", "names"})},
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
[]string{"name"})},
}},
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
// four; the jails themselves are composed from the modules the machine runs (to-be 31). // four; the jails themselves are composed from the modules the machine runs (to-be 31).
+4 -4
View File
@@ -44,10 +44,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
delivered[s.Delivers] = s.Name delivered[s.Delivers] = s.Name
} }
} }
// Nineteen since mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199) — two fewer // Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
// once the retired mesh-build-machine and node-dns-resolver rows go, when no manifest claims either. // mesh-build-machine row goes, when no registered manifest claims it any more.
if len(Seats()) != 19 { if len(Seats()) != 17 {
t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+ t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
} }
} }
-117
View File
@@ -1,117 +0,0 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// Zones: names a module answers itself (novox/hq ADR 0199).
//
// The mesh's resolver holds each node's internal domain and nothing else (ADR 0191, 0194). A module
// whose names are its own — the lab's scenario machines, known only while a scenario runs — declares
// the zone it answers and the listen that answers it; the controller hands the resolver's holder every
// zone with the declaring node's private address and the port that listen is published on, and the
// holder forwards the zone there. **A definition names no address** (ADR 0112): the zone is a setting,
// the listen is the module's own, and where they are is the mesh's fact.
// Zone is the manifest's declaration that a module answers the names in one zone.
type Zone struct {
// Name is the zone: a label or a dotted name, normally `${setting:<key>}`, so the operator chooses
// it and the definition does not.
Name string `json:"name"`
// Listen names one of the module's listens: the DNS answerer for the zone.
Listen string `json:"listen"`
}
// ZoneAt is a declared zone where the mesh placed it: what the resolver's holder forwards, and where.
type ZoneAt struct {
Zone string
Node string
Module string
Address string
Port int
}
// zoneProblems is what is wrong with a module's zone declaration on its own, before any node.
func zoneProblems(m Manifest) []string {
if m.Zone == nil {
return nil
}
var problems []string
if strings.TrimSpace(m.Zone.Name) == "" {
problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module))
}
if !m.hasListen(m.Zone.Listen) {
problems = append(problems, fmt.Sprintf(
"%s declares zone %q answered by listen %q, and has no listen of that name",
m.Module, m.Zone.Name, m.Zone.Listen))
}
return problems
}
func (m Manifest) hasListen(name string) bool {
if name == "" {
return false
}
for _, l := range m.Listens {
if l.Name == name {
return true
}
}
return false
}
// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the
// port its answering listen is published on there. Nothing when the module declares no zone.
func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) {
if m.Zone == nil {
return nil, nil
}
settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers)
if err != nil {
return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err)
}
zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".")
var port int
for _, l := range m.Listens {
if l.Name == m.Zone.Listen {
port = l.Port
if at, given := published[l.Port]; given {
port = at
}
}
}
return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil
}
// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone
// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module
// may not shadow names the mesh's resolver or the public DNS answers.
func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string {
var problems []string
under := func(zone, domain string) bool {
domain = strings.Trim(strings.ToLower(domain), ".")
return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain))
}
seen := map[string]ZoneAt{}
for _, z := range zones {
if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) {
problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone",
z.Zone, other.Module, other.Node, z.Module, z.Node))
}
seen[z.Zone] = z
if under(z.Zone, suffix) {
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it",
z.Module, z.Node, z.Zone))
}
for _, d := range publicDomains {
if under(z.Zone, d) {
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it",
z.Module, z.Node, z.Zone, d))
}
}
}
sort.Strings(problems)
return problems
}
-78
View File
@@ -1,78 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199).
func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"lab","version":"1",
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
"zone":{"name":"${setting:zone}","listen":"web"}}`))
if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) {
t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err)
}
if _, err := ParseManifest([]byte(`{"module":"lab","version":"1",
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
"zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil {
t.Fatalf("a well-formed zone was refused: %v", err)
}
}
// The zone is the operator's (a setting) and the port is where this machine publishes the listen —
// neither is the definition's to state.
func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) {
m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"},
Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}}
z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}},
map[int]int{5353: 15353}, "workstation", "10.77.0.3")
if err != nil {
t.Fatal(err)
}
if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" {
t.Fatalf("the zone was placed as %+v", *z)
}
if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil {
t.Fatal("a zone nobody named was placed")
}
}
// One module answers a zone, and none may shadow the mesh's names or a public domain.
func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) {
one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53}
if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 {
t.Fatalf("one ordinary zone was refused: %v", p)
}
twice := one
twice.Node, twice.Module = "laptop", "other"
cases := map[string][]ZoneAt{
"declared by": {one, twice},
"mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}},
"public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}},
}
for want, zones := range cases {
p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n")
if !strings.Contains(p, want) {
t.Errorf("not refused for %q: %q", want, p)
}
}
}
// The resolver's template sees every zone with where it is answered, in zone order.
func TestTheResolversTemplateRangesTheZones(t *testing.T) {
m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": {
Path: "/etc/mesh-resolver/zones.conf",
Template: "{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}",
}}}
out, err := FactsWithZonesInto(m, Resolution{Node: "anchor"}, nil, nil, nil, "", []ZoneAt{
{Zone: "zeta", Address: "10.77.0.2", Port: 53},
{Zone: "incus", Address: "10.77.0.3", Port: 15353},
})
if err != nil {
t.Fatal(err)
}
if got := out[0]["content"]; got != "server=/incus/10.77.0.3#15353\nserver=/zeta/10.77.0.2#53\n" {
t.Fatalf("the resolver was told %q", got)
}
}
-16
View File
@@ -5,7 +5,6 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
) )
@@ -97,21 +96,6 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
add(name, on.Module, EdgeDeclared) 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] { for _, ref := range against[name] {
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok { if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
-38
View File
@@ -62,41 +62,3 @@ 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)
}
}
}
+2 -9
View File
@@ -44,15 +44,8 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) {
} }
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance // The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
// answers, and to the instance on one machine. Nothing else. // answers, and to the instance on one machine. Nothing else.
// And asking what answers (novox/hq ADR 0197), which claims nothing and calls nothing. if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
var tools []string perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
for _, s := range perms.Publish {
if !strings.HasPrefix(s, "$SRV.") {
tools = append(tools, s)
}
}
if len(tools) != 2 || tools[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
tools[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish) t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
} }
for _, s := range perms.Publish { for _, s := range perms.Publish {
-81
View File
@@ -1,81 +0,0 @@
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
}
+2 -8
View File
@@ -193,15 +193,9 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
// machine with nothing to build, and is asked again. // machine with nothing to build, and is asked again.
fetched, err := sub.Fetch(1, nats.Context(ctx)) fetched, err := sub.Fetch(1, nats.Context(ctx))
switch { switch {
case ctx.Err() != nil: case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
// Ours ended: the machine is being stopped.
return nil return nil
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout): case 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 continue
case err != nil: case err != nil:
if sub.IsValid() { if sub.IsValid() {
+4 -3
View File
@@ -169,9 +169,10 @@ 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. // - 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. // - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
// - `.Names` is the machines (novox/hq ADR 0191): a route's internal name is under its node's // - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
// internal domain and the resolver answers it by wildcard, and a public name is public DNS's. // routed name through its resolver finds the machine serving it; machines with no address yet
// Machines with no address yet are already left out of the set. // 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).
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + 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}}" "{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"