Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905f3363c9 | ||
|
|
9f9d9b3b25 | ||
|
|
bde4b61b3b | ||
|
|
d07018f3c5 | ||
|
|
729a5f9e6c | ||
|
|
773b561f5e | ||
|
|
ca7e81e964 | ||
|
|
08bb56f1d3 | ||
|
|
1a44d281c2 | ||
|
|
1c8fe65601 | ||
|
|
8eb6c3e94a | ||
|
|
9d4d847dc4 | ||
|
|
bea1a1c513 | ||
|
|
b1df688c62 | ||
|
|
21d38c9b0e | ||
|
|
1f86ed4135 | ||
|
|
689dd060b0 | ||
|
|
99c4c4ef04 | ||
|
|
e5e1666f91 | ||
|
|
bd58d1c3ef | ||
|
|
d134c89a7c | ||
|
|
a9307d9f33 | ||
|
|
5eb1c9c2b7 | ||
|
|
37b8edeec6 |
@@ -95,7 +95,7 @@ func showFiltering(f inventory.Filtering, adopted bool) {
|
||||
case fw.Active:
|
||||
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
||||
case fw.RetiredBy == "removed":
|
||||
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0175)\n", fw.Kind)
|
||||
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0180)\n", fw.Kind)
|
||||
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
||||
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
||||
case fw.RetiredBy != "":
|
||||
|
||||
@@ -522,6 +522,8 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
||||
recorded := inventory.Source{
|
||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||
BuiltFrom: result.Commit, Head: result.Commit,
|
||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||
Against: kept.Against,
|
||||
}
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
|
||||
@@ -557,7 +557,7 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
||||
|
||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
||||
Username: user, Kind: inventory.BusModule, Node: node, Module: m.Module,
|
||||
Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -577,6 +577,16 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
||||
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
||||
}
|
||||
|
||||
// busKindOf is what a module's bus user is recorded as: the node's tool runtime where the module is
|
||||
// the runtime (novox/hq ADR 0175), a module otherwise. The username is the same either way — the
|
||||
// runtime is issued through this same path — and the kind is what a reader of the records sees.
|
||||
func busKindOf(module string) string {
|
||||
if module == catalogue.RuntimeModule {
|
||||
return inventory.BusNodeTools
|
||||
}
|
||||
return inventory.BusModule
|
||||
}
|
||||
|
||||
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
||||
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
||||
// so the move can issue every module against a bus whose address it worked out itself
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -191,7 +192,7 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
||||
t.Fatalf("the source records as %+v", from)
|
||||
}
|
||||
// A manifest with no provenance at all is legitimate: fixing something in a hurry.
|
||||
if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) {
|
||||
if from, err := whereItComesFrom("", "", "", "", false); err != nil || !reflect.DeepEqual(from, inventory.Source{}) {
|
||||
t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
|
||||
@@ -131,10 +131,17 @@ func serve(ctx context.Context) error {
|
||||
|
||||
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
||||
// from the store's row, so what the seat declares is what is answered.
|
||||
handlers, err := seatToolHandlers()
|
||||
handlers, behind, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(behind) > 0 {
|
||||
// Said once, loudly, and then served anyway (novox/hq ADR 0185): the mesh keeps answering
|
||||
// while whatever put an older control plane here is undone.
|
||||
fmt.Printf("this control plane is behind the %s row: it cannot run %s. "+
|
||||
"Those answer the reason when called; everything else is served as usual\n",
|
||||
catalogue.ControllerSeatName, strings.Join(behind, ", "))
|
||||
}
|
||||
bus, isNATS := server.Bus().(link.OverNATS)
|
||||
if !isNATS {
|
||||
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
||||
|
||||
@@ -346,7 +346,9 @@ func rolloutMint(ctx context.Context, again bool) error {
|
||||
}
|
||||
machines++
|
||||
|
||||
case broker.KindModule:
|
||||
case broker.KindModule, broker.KindNodeTools:
|
||||
// The runtime is minted and delivered exactly as a module is (novox/hq ADR 0175): it is
|
||||
// issued as the module it stands for, to that module's `broker` secret.
|
||||
if p.Module == "mesh-controller" {
|
||||
// The control plane is a module too, and its `broker` secret is the old bus's
|
||||
// credential it is still using while this runs. Writing the new bus's blob there
|
||||
@@ -365,7 +367,7 @@ func rolloutMint(ctx context.Context, again bool) error {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusModule, Node: p.Node, Module: p.Module})
|
||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: busKindOf(p.Module), Node: p.Node, Module: p.Module})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
return nil
|
||||
}
|
||||
switch verb {
|
||||
case "command":
|
||||
// The generic verb: the command line as given, split as a shell would split it, with
|
||||
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
||||
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
||||
if err := need("command"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv, err := splitCommandLine(str("command"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("command names no command")
|
||||
}
|
||||
return argv, nil
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
@@ -215,13 +230,15 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
||||
}
|
||||
|
||||
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
||||
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
||||
// cannot run is said at start rather than at the first call.
|
||||
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||
// store's row, so a verb the row does not carry is not served. A verb it carries that this binary
|
||||
// cannot run is named at start and answers the reason when called — never a refusal to serve, which
|
||||
// would take the whole control plane down for one word (novox/hq ADR 0185).
|
||||
func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||
if !known {
|
||||
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||
return nil, nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||
}
|
||||
var behind []string
|
||||
handlers := map[string]link.ToolHandler{}
|
||||
for _, v := range seat.Serves {
|
||||
verb := v.Name
|
||||
@@ -232,8 +249,27 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||
continue
|
||||
}
|
||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
||||
catalogue.ControllerSeatName, verb, err)
|
||||
// **A row ahead of this binary is not a reason to go silent.**
|
||||
//
|
||||
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
||||
// this build does not know means the row was widened by a newer one — the ordinary
|
||||
// state of a roll-out, and of a push that put an older control plane back. Refusing to
|
||||
// serve at all made that transient fatal: on 2026-10-02 one unknown verb took the whole
|
||||
// mesh off the bus for ten minutes, and the way back was a human running the binary by
|
||||
// hand, because the thing that would have repaired it is the thing that was down
|
||||
// (novox/hq 04-ISSUES/201, ADR 0185).
|
||||
//
|
||||
// So the verbs this binary knows are served, and this one answers the reason instead of
|
||||
// nothing: a caller gets a sentence naming the fault, and everything else keeps working
|
||||
// — including the push that replaces this binary with the one whose verb it is.
|
||||
behind = append(behind, verb)
|
||||
reason := err
|
||||
handlers[verb] = func(context.Context, json.RawMessage) (any, error) {
|
||||
return nil, fmt.Errorf("%s is in this mesh's %s row and the control plane running "+
|
||||
"here cannot run it: %w. It is a verb of a newer build; this one is behind",
|
||||
verb, catalogue.ControllerSeatName, reason)
|
||||
}
|
||||
continue
|
||||
}
|
||||
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
args := map[string]any{}
|
||||
@@ -249,7 +285,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||
return runVerb(ctx, argv)
|
||||
}
|
||||
}
|
||||
return handlers, nil
|
||||
return handlers, behind, nil
|
||||
}
|
||||
|
||||
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
||||
@@ -289,3 +325,57 @@ func sampleArguments(v catalogue.Verb) map[string]any {
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
||||
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
||||
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
||||
func splitCommandLine(line string) ([]string, error) {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord := false
|
||||
quote := rune(0)
|
||||
runes := []rune(line)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if r == '\'' {
|
||||
quote = 0
|
||||
} else {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case quote == '"':
|
||||
if r == '"' {
|
||||
quote = 0
|
||||
} else if r == '\\' && i+1 < len(runes) {
|
||||
i++
|
||||
cur.WriteRune(runes[i])
|
||||
} else {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
inWord = true
|
||||
case r == '\\' && i+1 < len(runes):
|
||||
i++
|
||||
cur.WriteRune(runes[i])
|
||||
inWord = true
|
||||
case r == ' ' || r == '\t' || r == '\n':
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
cur.Reset()
|
||||
inWord = false
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||
}
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -130,10 +131,13 @@ func TestActsDoNotBlockTheCall(t *testing.T) {
|
||||
|
||||
// What `tools` answers is the seats' records, with each verb's schema.
|
||||
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
||||
handlers, err := seatToolHandlers()
|
||||
handlers, behind, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(behind) != 0 {
|
||||
t.Fatalf("this build cannot run %v of its own seat's verbs", behind)
|
||||
}
|
||||
if len(handlers) != len(catalogue.ControllerVerbs) {
|
||||
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
||||
}
|
||||
@@ -171,3 +175,77 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||
t.Fatalf("a plain line: %v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||
t.Fatal("an empty line was accepted")
|
||||
}
|
||||
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
||||
t.Fatal("an unclosed quote was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A verb in the row that this binary cannot run does not take the control plane off the bus: the
|
||||
// rest are served, the unknown one answers the reason, and the start-up names it (novox/hq ADR
|
||||
// 0185). One unknown word cost the mesh ten minutes of silence on 2026-10-02, recoverable only by
|
||||
// a person running the binary by hand — the push that would have repaired it needs the control
|
||||
// plane that was down.
|
||||
func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
|
||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||
if !known {
|
||||
t.Fatal("no controller seat")
|
||||
}
|
||||
// The row as a newer control plane would have written it: every verb this build knows, and one
|
||||
// it does not.
|
||||
widened := seat
|
||||
widened.Serves = append(append([]catalogue.Verb{}, seat.Serves...),
|
||||
catalogue.Verb{Name: "teleport", Description: "a verb from a build that does not exist yet"})
|
||||
rows := catalogue.DefaultSeats()
|
||||
for i := range rows {
|
||||
if rows[i].Name == catalogue.ControllerSeatName {
|
||||
rows[i] = widened
|
||||
}
|
||||
}
|
||||
catalogue.UseSeats(rows)
|
||||
t.Cleanup(func() { catalogue.UseSeats(catalogue.DefaultSeats()) })
|
||||
|
||||
handlers, behind, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
t.Fatalf("a row with one unknown verb refused to serve at all: %v", err)
|
||||
}
|
||||
if len(behind) != 1 || behind[0] != "teleport" {
|
||||
t.Fatalf("the verbs this build cannot run were reported as %v", behind)
|
||||
}
|
||||
if len(handlers) != len(widened.Serves) {
|
||||
t.Fatalf("%d handlers for %d verbs in the row", len(handlers), len(widened.Serves))
|
||||
}
|
||||
for _, known := range []string{"status", "nodes", "push"} {
|
||||
if handlers[known] == nil {
|
||||
t.Errorf("%s is not served although this build knows it", known)
|
||||
}
|
||||
}
|
||||
_, err = handlers["teleport"](context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("the unknown verb answered as though it had run")
|
||||
}
|
||||
for _, want := range []string{"teleport", "cannot run it", "behind"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the answer does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +732,11 @@ func handler(held *table) http.Handler {
|
||||
// And since a host may now be routed only on some paths, those are a third thing:
|
||||
// saying "no route for this name" while listing that very name as served is a
|
||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||
// **Said in the log as well as to the client.** A name this mesh does not serve, asked
|
||||
// for from outside, is what a scanner does, and the machine's intrusion prevention reads
|
||||
// this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the
|
||||
// jail's filter expects it.
|
||||
log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if !hidden && held.routed(r.Host) {
|
||||
|
||||
@@ -234,3 +234,13 @@ func admitsSubject(pattern, subject []string) bool {
|
||||
}
|
||||
return len(pattern) == len(subject)
|
||||
}
|
||||
|
||||
// The two packages name the runtime module separately — the broker's types stay free of the
|
||||
// catalogue's on purpose — so this is what holds them to one string. A rename that reached only one
|
||||
// side would compose a runtime principal for a module nobody assigns, silently, and leave the one
|
||||
// that is assigned with a module's own grants.
|
||||
func TestTheBrokerAndTheCatalogueAgreeOnTheRuntimeModule(t *testing.T) {
|
||||
if RuntimeModule != catalogue.RuntimeModule {
|
||||
t.Fatalf("the broker calls the runtime %q and the catalogue %q", RuntimeModule, catalogue.RuntimeModule)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -144,12 +144,18 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
// HolderConsumerFor is the worker a seat's holder gets on that seat's work queue.
|
||||
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
||||
//
|
||||
// **A queue group even though the seat guarantees one holder.** The seat is *authority* — who may
|
||||
// be the telegram sender — and the queue group is *delivery*. Tie delivery to the seat and the
|
||||
// day somebody allows two holders for throughput, every message is processed twice with nothing
|
||||
// reporting it. Kept separate, relaxing one changes nothing about the other.
|
||||
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
||||
// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*,
|
||||
// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a
|
||||
// holder per machine, and all of them take from this one consumer, so the work is shared without
|
||||
// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the
|
||||
// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by
|
||||
// a holder that has just become free. Which is also what ends the race issue 186 describes — asks
|
||||
// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth
|
||||
// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive
|
||||
// (stillWorking); the ack wait is for a holder that died.
|
||||
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
|
||||
if len(seat.Accepts) == 0 {
|
||||
return Consumer{}, false
|
||||
@@ -158,18 +164,13 @@ func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool)
|
||||
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
|
||||
Stream: seatStreamName(seat.Name),
|
||||
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
|
||||
Queue: "holders",
|
||||
AckWaitSeconds: 60,
|
||||
MaxDeliver: 5,
|
||||
// **One in flight.** A holder works one ask at a time, so the server hands it one at a
|
||||
// time: with the default of many, every ask behind the one being worked was delivered,
|
||||
// left unacknowledged for the length of the work, redelivered after the ack wait, and
|
||||
// after the fifth time dropped — on 2026-10-01 twenty-six of forty-three builds asked in
|
||||
// two minutes were never built, and the queue read as empty (novox/hq issue 186).
|
||||
MaxAckPending: 1,
|
||||
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
||||
"crash mid-work redelivers rather than loses; one in flight, so a queue of asks is a "+
|
||||
"queue and not a race against the ack wait", module, node, seat.Name),
|
||||
// As many in flight as there are holders working, which pulling bounds by itself: a holder
|
||||
// fetches one and fetches again only after it acknowledged. The server's default stands.
|
||||
Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+
|
||||
"and acknowledges after the work is done, so a crash mid-work redelivers rather than "+
|
||||
"loses and an idle holder is the one that takes the next ask", module, node, seat.Name),
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -88,15 +88,20 @@ func TestAModuleThatConsumesNothingGetsNoConsumer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The seat is authority and the queue group is delivery. Tie them together and the day somebody
|
||||
// allows two holders, every message is processed twice with nothing reporting it.
|
||||
func TestAHoldersWorkerUsesAQueueGroupAnyway(t *testing.T) {
|
||||
// The seat is authority and the worker is delivery (novox/hq ADR 0190): one worker per seat, shared
|
||||
// by every holder and pulled from, so a second holder takes the next ask rather than a copy of the
|
||||
// same one — which is what a queue group used to guard, and what pulling one durable gives outright.
|
||||
func TestAHoldersWorkerIsOneSharedByItsHolders(t *testing.T) {
|
||||
c, ok := HolderConsumerFor("one", "telegram", telegramSeat())
|
||||
if !ok {
|
||||
t.Fatal("the holder of a seat with inbound work got no worker")
|
||||
}
|
||||
if c.Queue == "" {
|
||||
t.Fatal("the worker is not in a queue group, so a second holder would double-process")
|
||||
two, _ := HolderConsumerFor("two", "telegram", telegramSeat())
|
||||
if c.Name != two.Name || c.Stream != two.Stream {
|
||||
t.Fatal("two holders got two workers, so each would process every ask")
|
||||
}
|
||||
if c.Push || c.Queue != "" {
|
||||
t.Fatal("the worker is pushed, so the server would hand an ask to a busy holder")
|
||||
}
|
||||
if c.Stream != "SEAT_TELEGRAM_SENDER" {
|
||||
t.Fatalf("the worker reads %q, not the seat's own stream", c.Stream)
|
||||
@@ -154,15 +159,23 @@ func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
|
||||
has(t, perms.Subscribe, c.Filters[0])
|
||||
}
|
||||
|
||||
// A holder works one ask at a time, so the server hands it one at a time (novox/hq issue 186):
|
||||
// asks queued behind the one being worked wait in the stream rather than being delivered,
|
||||
// left to expire and dropped after the fifth redelivery.
|
||||
func TestAHoldersWorkerTakesOneAskAtATime(t *testing.T) {
|
||||
c, found := HolderConsumerFor("anchor", "builder", DeclaredSeat{Name: "mesh-build-machine", Accepts: []string{"build"}})
|
||||
// Every holder of a seat shares one worker and pulls from it (novox/hq ADR 0190): no queue group
|
||||
// and no delivery subject, because a push consumer hands the next ask to whichever subscriber the
|
||||
// server picks, busy or not; and no cap of one in flight, because pulling bounds the asks in flight
|
||||
// by the holders that are free — which is what ended the race of issue 186, where asks delivered
|
||||
// behind the one being worked expired and were dropped.
|
||||
func TestAHoldersWorkerIsPulledByEveryHolder(t *testing.T) {
|
||||
c, found := HolderConsumerFor("anchor", "build-agent", DeclaredSeat{Name: "node-build-agent", Accepts: []string{"build"}})
|
||||
if !found {
|
||||
t.Fatal("a seat that accepts work has no worker")
|
||||
}
|
||||
if c.MaxAckPending != 1 {
|
||||
t.Fatalf("the worker may have %d asks in flight; one, so a queue is a queue", c.MaxAckPending)
|
||||
if c.Queue != "" || c.Push {
|
||||
t.Fatalf("the worker is pushed (queue %q, push %v); a holder pulls when it is free", c.Queue, c.Push)
|
||||
}
|
||||
if c.MaxAckPending != 0 {
|
||||
t.Fatalf("the worker caps asks in flight at %d; pulling bounds them by the holders working", c.MaxAckPending)
|
||||
}
|
||||
if c.Name != "SEAT_NODE_BUILD_AGENT_worker" || c.Stream != "SEAT_NODE_BUILD_AGENT" {
|
||||
t.Fatalf("the worker is %s on %s; one per seat, shared by its holders", c.Name, c.Stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,3 +73,37 @@ func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
||||
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
||||
}
|
||||
|
||||
// The runtime arriving on a machine changes nothing about what each module is issued (to-be 38 WP2):
|
||||
// the memberships are composed as before and the runtime reads several of them. What the machine's
|
||||
// user list gains is one runtime principal, and loses nothing but the runtime module's own.
|
||||
func TestTheRuntimeArrivingLeavesEveryMembershipAsItWas(t *testing.T) {
|
||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
||||
three := []Declared{
|
||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
||||
{Module: "zsh", Serves: []string{"execute"}},
|
||||
{Module: "systemd", Serves: []string{"units"}},
|
||||
}
|
||||
before := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": three}}
|
||||
after := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{
|
||||
"anchor": append(append([]Declared{}, three...), Declared{Module: RuntimeModule}),
|
||||
}}
|
||||
for _, d := range three {
|
||||
was := MembershipFor("anchor", d, PlacementsOf(before, nil))
|
||||
is := MembershipFor("anchor", d, PlacementsOf(after, nil))
|
||||
if !reflect.DeepEqual(was, is) {
|
||||
t.Errorf("%s's membership changed when the runtime arrived:\n%+v\n%+v", d.Module, was, is)
|
||||
}
|
||||
}
|
||||
users, err := Users(after)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kinds := map[Kind]int{}
|
||||
for _, p := range users {
|
||||
kinds[p.Kind]++
|
||||
}
|
||||
if kinds[KindNodeTools] != 1 || kinds[KindModule] != 3 || kinds[KindNode] != 1 || kinds[KindController] != 1 {
|
||||
t.Errorf("the machine's users are %v; one runtime, the three modules, the host and the controller", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
+99
-10
@@ -34,8 +34,20 @@ const (
|
||||
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
||||
// and no ability to answer anything, because a person asks.
|
||||
KindPerson Kind = "person"
|
||||
// KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per
|
||||
// node, on the host side, serving every assigned module's tools and every held seat's verbs.
|
||||
// Its authority is the union of what the modules it carries would each have had for their
|
||||
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
||||
KindNodeTools Kind = "node-tools"
|
||||
)
|
||||
|
||||
// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is
|
||||
// assigned, the mesh composes one runtime principal for the machine in place of that module's own,
|
||||
// and the per-module containers that served tools until then stop being the way tools reach a node.
|
||||
// Mirrored in the catalogue package, which the agreement test holds to the same string; one
|
||||
// constant, so a rename is one edit and the two packages cannot drift.
|
||||
const RuntimeModule = "node-tools"
|
||||
|
||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||
// emits (novox/hq ADR 0118, design 29 §5).
|
||||
type Seat struct {
|
||||
@@ -74,6 +86,13 @@ type Principal struct {
|
||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||
Watches []Seat
|
||||
|
||||
// Carries are the modules whose tools this principal serves, for a KindNodeTools principal
|
||||
// (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its
|
||||
// serving authority is the union of theirs — each module's own tool namespace and each held
|
||||
// seat's verbs on this node — derived from the same declarations the modules' own principals
|
||||
// are, so the runtime can serve nothing a module could not have served for itself.
|
||||
Carries []Declared
|
||||
|
||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||
@@ -91,10 +110,10 @@ type Principal struct {
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// meshSeatsTheControllerUses are the roles the mesh's own flows submit work to. Named rather than
|
||||
// seatsTheControllerAsks are the roles the mesh's own flows submit work to. Named rather than
|
||||
// derived from the seat set: the controller is not a module and declares no `uses`, so its side of a
|
||||
// seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable.
|
||||
var meshSeatsTheControllerUses = []string{"mesh-build-machine"}
|
||||
var seatsTheControllerAsks = []string{"node-build-agent"}
|
||||
|
||||
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
|
||||
// controller may answer an enrolment is derived from the same constant the user is named from.
|
||||
@@ -112,7 +131,10 @@ func (p Principal) Username() string {
|
||||
switch p.Kind {
|
||||
case KindPerson:
|
||||
return "person." + p.Module
|
||||
case KindModule:
|
||||
case KindModule, KindNodeTools:
|
||||
// The runtime is named exactly as the module it stands for would have been: the mesh
|
||||
// issues its credential through the same path a module's takes (`module issue`), and
|
||||
// that path knows the node and the module, not the kind.
|
||||
return p.Node + "." + p.Module
|
||||
case KindNode:
|
||||
return "node." + p.Node
|
||||
@@ -186,7 +208,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
||||
// build is the one today: the controller asks, and reads the answer from the seat's event
|
||||
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
|
||||
for _, seat := range meshSeatsTheControllerUses {
|
||||
// A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to
|
||||
// the role, and whichever machine holding it is idle takes it.
|
||||
for _, seat := range seatsTheControllerAsks {
|
||||
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
||||
}
|
||||
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
||||
@@ -346,13 +370,17 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
|
||||
// 3. Seats it holds: full participation.
|
||||
for _, s := range p.Holds {
|
||||
// Taking work from the role's queue: the worker consumer it binds (asked about,
|
||||
// delivered on, acknowledged), each on the seat's own stream. The first machine to
|
||||
// take work over the new bus was refused the asking (2026-09-28).
|
||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
||||
// so what it needs is MSG.NEXT on that worker and nothing delivered to it. The first
|
||||
// machine to take work over the new bus was refused the asking (2026-09-28).
|
||||
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
||||
stream := seatStreamName(s.Name)
|
||||
sub = append(sub, "_DELIVER."+worker, "_DELIVER."+worker+".>")
|
||||
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+stream+"."+worker,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker,
|
||||
"$JS.ACK."+stream+"."+worker+".>")
|
||||
for _, a := range s.Accepts {
|
||||
sub = append(sub, seatSubject(s, "accept", a))
|
||||
}
|
||||
@@ -375,6 +403,48 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, seatToolSubject(s, t, "*"))
|
||||
}
|
||||
}
|
||||
|
||||
case KindNodeTools:
|
||||
// **One process serves what every module on the machine would have served for itself**
|
||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||
// module's own principal has, for the same reason: the tools a module serves are what its
|
||||
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
|
||||
// this node, as the holder's own principal would be granted them.
|
||||
for _, d := range p.Carries {
|
||||
if !safeSubject.MatchString(d.Module) {
|
||||
return Permissions{}, fmt.Errorf(
|
||||
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
|
||||
}
|
||||
own := "mesh.mod." + d.Module
|
||||
sub = append(sub, own+".tool.>")
|
||||
// A tool that emits an event is the module's code and emits under the module's name
|
||||
// (ADR 0042); the runtime carrying that code may publish what the module declared it
|
||||
// emits, and nothing it did not.
|
||||
for _, e := range d.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
for _, s := range d.Holds {
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, read
|
||||
// directly from the stream and followed live. This node's and no other's — the one token
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*")
|
||||
// And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any
|
||||
// node, as the console already could — the runtime is the console's serving mode.
|
||||
invoked, err := invokedSubjects([]string{"*"})
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
|
||||
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
|
||||
sub = unique(sub)
|
||||
pub = unique(pub)
|
||||
}
|
||||
|
||||
if p.Kind == KindPerson {
|
||||
@@ -382,6 +452,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// consumer, because nothing is delivered to a person — they ask and are answered.
|
||||
sub = append(sub, p.inbox())
|
||||
}
|
||||
if p.Kind == KindNodeTools {
|
||||
// Its reply space, so the answers to what its tools call come back to it. No ack subject
|
||||
// for the same reason a person has none: nothing is delivered to it.
|
||||
sub = append(sub, p.inbox())
|
||||
}
|
||||
|
||||
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
|
||||
// Its own reply space, and nothing wider.
|
||||
@@ -403,7 +478,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||
// person are never asked anything, and are granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController,
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -625,6 +700,20 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// unique is a sorted list with each subject once. Two carried modules holding seats with the same
|
||||
// verb, or the runtime module itself carried beside the others, would otherwise write a grant twice
|
||||
// — harmless to the server, and noise in a file that is read as the mesh's authority model.
|
||||
func unique(values []string) []string {
|
||||
sort.Strings(values)
|
||||
out := values[:0]
|
||||
for i, v := range values {
|
||||
if i == 0 || v != values[i-1] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func quoted(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -371,3 +371,94 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime's authority is the union of what the modules it carries would have been granted for
|
||||
// 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
|
||||
// consumes, because it reacts to nothing.
|
||||
func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
||||
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
||||
{Module: "zsh", Emits: []string{"shell.opened"}, Consumes: []string{"shop.order.placed"}},
|
||||
{Module: RuntimeModule},
|
||||
}}
|
||||
perms, err := PermissionsFor(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"mesh.mod.nftables.tool.>", "mesh.mod.zsh.tool.>", "mesh.mod." + RuntimeModule + ".tool.>",
|
||||
"mesh.seat.node-packet-filter.tool.rules.anchor", "mesh.seat.node-packet-filter.tool.reload.anchor",
|
||||
"mesh.assignment.anchor.*",
|
||||
"_INBOX.anchor." + RuntimeModule + ".>",
|
||||
} {
|
||||
if !contains(perms.Subscribe, want) {
|
||||
t.Errorf("the runtime may not subscribe %s: %v", want, perms.Subscribe)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"mesh.mod.*.tool.>", "mesh.seat.*.tool.>",
|
||||
"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.*",
|
||||
"mesh.mod.zsh.event.shell.opened",
|
||||
} {
|
||||
if !contains(perms.Publish, want) {
|
||||
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
||||
}
|
||||
}
|
||||
// Nothing of what a carried module consumes, and no consumer of its own to ack.
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||
t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") {
|
||||
t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s)
|
||||
}
|
||||
}
|
||||
if !perms.AllowResponses {
|
||||
t.Error("the runtime answers what it is asked, and may not reply")
|
||||
}
|
||||
if _, needed := ConsumerFor(p); needed {
|
||||
t.Error("a consumer would be made for the runtime, which consumes nothing")
|
||||
}
|
||||
// Each subject once: the file is read as the mesh's authority model.
|
||||
seen := map[string]bool{}
|
||||
for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) {
|
||||
if seen[s] {
|
||||
t.Errorf("%s is granted twice", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A node-scoped seat's work is shared (novox/hq ADR 0190): its holder on any machine subscribes the
|
||||
// seat's one work subject, with no node in it, so holders on several machines read one queue. The
|
||||
// node token belongs to a seat's tools, which are asked of one machine (design 33 §4), not to its work.
|
||||
func TestANodeSeatsWorkSubjectCarriesNoNode(t *testing.T) {
|
||||
seat := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Serves: []string{"status"}}
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "build-agent", Holds: []Seat{seat}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build")
|
||||
hasNot(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build.anchor")
|
||||
// And its tools still carry the machine.
|
||||
has(t, perms.Subscribe, "mesh.seat.node-build-agent.tool.status.anchor")
|
||||
// The controller asks the role, not a machine.
|
||||
controller, err := PermissionsFor(Principal{Kind: KindController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, controller.Publish, "mesh.seat.node-build-agent.accept.>")
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ var ControllerFollows = []string{
|
||||
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
|
||||
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
||||
// catalogue.
|
||||
seatEventSubject("mesh-build-machine", "built"),
|
||||
seatEventSubject("node-build-agent", "built"),
|
||||
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
||||
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.merged"),
|
||||
|
||||
+4
-4
@@ -24,8 +24,8 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
||||
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.>"] }
|
||||
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-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
|
||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
@@ -37,8 +37,8 @@ accounts {
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||
} }
|
||||
{ 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.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||
|
||||
@@ -62,13 +62,33 @@ func Users(r Records) ([]Principal, error) {
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
out = append(out, Principal{Kind: KindNode, Node: node})
|
||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
||||
// principal — a module still serving tools from its own container holds its own
|
||||
// credential until it moves, and the two serve side by side in the meantime.
|
||||
runtimeHere := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == RuntimeModule {
|
||||
runtimeHere = true
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
continue
|
||||
}
|
||||
out = append(out, Principal{
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
})
|
||||
}
|
||||
if runtimeHere {
|
||||
out = append(out, Principal{
|
||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
||||
Carries: append([]Declared(nil), r.Assigned[node]...),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
|
||||
@@ -245,3 +245,54 @@ func TestAUserListIsComposedBeforeAnythingMovesOntoTheBus(t *testing.T) {
|
||||
t.Errorf("the composed list does not contain the machine running the bus")
|
||||
}
|
||||
}
|
||||
|
||||
// Where the runtime module is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own (novox/hq ADR 0175, to-be 38). Every other module keeps its own: a module
|
||||
// still serving tools from its own container holds its own credential until it moves.
|
||||
func TestTheRuntimeModuleBecomesTheMachinesRuntimePrincipal(t *testing.T) {
|
||||
r := someRecords()
|
||||
r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: RuntimeModule})
|
||||
users, err := Users(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runtime *Principal
|
||||
for i := range users {
|
||||
p := &users[i]
|
||||
if p.Node == "one" && p.Module == RuntimeModule {
|
||||
if p.Kind == KindModule {
|
||||
t.Fatalf("%s on one was composed as an ordinary module beside the runtime", RuntimeModule)
|
||||
}
|
||||
runtime = p
|
||||
}
|
||||
}
|
||||
if runtime == nil || runtime.Kind != KindNodeTools {
|
||||
t.Fatalf("one runs %s and got no runtime principal: %v", RuntimeModule, namesOf(t, r))
|
||||
}
|
||||
if runtime.Username() != "one."+RuntimeModule {
|
||||
t.Errorf("the runtime is named %q; `module issue` names it as the module it stands for", runtime.Username())
|
||||
}
|
||||
carried := map[string]bool{}
|
||||
for _, d := range runtime.Carries {
|
||||
carried[d.Module] = true
|
||||
}
|
||||
if !carried["telegram"] || !carried[RuntimeModule] {
|
||||
t.Errorf("the runtime carries %v; it carries every module on its node", carried)
|
||||
}
|
||||
// And the other node, where the runtime is not assigned, is exactly as before.
|
||||
for _, p := range users {
|
||||
if p.Node == "two" && p.Kind == KindNodeTools {
|
||||
t.Fatal("two runs no runtime and was given a runtime principal")
|
||||
}
|
||||
}
|
||||
// A module serving its own tools beside the runtime keeps its own principal.
|
||||
found := false
|
||||
for _, p := range users {
|
||||
if p.Kind == KindModule && p.Node == "one" && p.Module == "telegram" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("telegram lost its own principal when the runtime arrived on its node")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,6 +968,26 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if chain.Dependencies != "" {
|
||||
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
||||
// A second run in the same image rather than a shell wrapped around the compiler: the
|
||||
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
||||
// plain command beside it. Refused by name when the image carries no such directory — an
|
||||
// older toolchain image — because a bundle packed without its dependencies starts nowhere
|
||||
// and says so three layers away from here.
|
||||
copying := []string{
|
||||
"run", "--rm",
|
||||
"--volume", tree + ":" + within,
|
||||
"--workdir", within,
|
||||
base,
|
||||
"sh", "-c",
|
||||
`test -d "$1" || { echo "the toolchain image carries no $1: it predates the mesh shipping a bundle's dependencies, rebuild $2 first" >&2; exit 1; }; cp -a "$1/." "$3/"`,
|
||||
"dependencies", chain.Dependencies, chain.Base, out,
|
||||
}
|
||||
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
||||
return "", fmt.Errorf("copying the %s dependencies a bundle runs with: %w", chain.Language, err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(tree, out), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
||||
if !strings.HasPrefix(digest, "sha256:") {
|
||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||
}
|
||||
|
||||
// **And what it runs with, from the image it was compiled in** (novox/hq to-be 38 WP3). A
|
||||
// second run in the same toolchain image copies the toolchain's runtime directory — the
|
||||
// `"type": "module"` package.json and the pruned node_modules — into the output's root, and
|
||||
// refuses by name when the image carries none rather than packing a bundle that starts nowhere.
|
||||
var copied string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "/app/runtime") {
|
||||
copied = line
|
||||
}
|
||||
}
|
||||
if copied == "" {
|
||||
t.Fatalf("the bundle's dependencies were not copied in after the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if !strings.Contains(copied, "mesh-tools/build@sha256:") || !strings.Contains(copied, "predates") ||
|
||||
!strings.Contains(copied, Out("code")) {
|
||||
t.Fatalf("the copy does not run in the same toolchain, refuse an older image by name, or land in the artifact's output: %s", copied)
|
||||
}
|
||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||
t.Fatal("the dependencies were copied before the compile wrote its output")
|
||||
}
|
||||
}
|
||||
|
||||
// A language whose bundle carries its own dependencies copies nothing in: a Go binary is static.
|
||||
func TestOnlyALanguageWithARuntimeDirectoryCopiesDependenciesIn(t *testing.T) {
|
||||
ts, _ := ToolchainFor("typescript")
|
||||
if ts.Dependencies != "/app/runtime" {
|
||||
t.Fatalf("typescript bundles run with %q", ts.Dependencies)
|
||||
}
|
||||
for _, language := range []string{"go", "python"} {
|
||||
chain, _ := ToolchainFor(language)
|
||||
if chain.Dependencies != "" {
|
||||
t.Fatalf("%s copies %q into every bundle, and its bundles carry their own", language, chain.Dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Refused before anything is built, naming what to build first.** A base the mesh has not built
|
||||
@@ -145,9 +180,13 @@ func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) {
|
||||
t.Fatalf("a module with two bundles did not build: %v", err)
|
||||
}
|
||||
|
||||
// Compiled into two different places.
|
||||
// Compiled into two different places. Only the compile lines: the copy of each bundle's
|
||||
// dependencies names the same directory again, deliberately.
|
||||
var outputs []string
|
||||
for _, line := range r.ran {
|
||||
if !strings.Contains(line, "--outDir") {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Fields(line) {
|
||||
if strings.HasPrefix(part, ".mesh-build/") {
|
||||
outputs = append(outputs, part)
|
||||
|
||||
@@ -57,6 +57,23 @@ type Toolchain struct {
|
||||
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
||||
// produced (novox/hq 04-ISSUES/161).
|
||||
LinkerFlags []string
|
||||
// Dependencies is a directory inside the toolchain image whose contents a bundle in this
|
||||
// language runs with, copied whole into the compiled output's root after the compile.
|
||||
//
|
||||
// **A bundle that compiles is not yet a bundle that runs.** The compiler resolves `import
|
||||
// "nats"` from the toolchain image's own node_modules and the pack takes only what the compiler
|
||||
// wrote, so what a machine unpacked could not find a single dependency — and no TypeScript bundle
|
||||
// had ever run live to show it (novox/hq to-be 38 WP3). For TypeScript the directory holds a
|
||||
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
||||
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
||||
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
|
||||
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
|
||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
||||
//
|
||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
||||
// that starts nowhere: the image predates this and must be rebuilt first.
|
||||
Dependencies string
|
||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
||||
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
||||
//
|
||||
@@ -107,14 +124,21 @@ var toolchains = []Toolchain{
|
||||
// symlinks to a launcher that requires its library relatively — and the base image's own
|
||||
// assembly resolves them away, leaving a launcher whose relative require points nowhere.
|
||||
// Every module's hand-written Dockerfile had to know this. Now none of them does.
|
||||
// **Rooted at the module, so an entrypoint lands where it is named.** Without a root the
|
||||
// compiler takes the common directory of the files it is given: a module compiling only
|
||||
// `tools/index.ts` had its output at `index.js`, and the entrypoint it declared —
|
||||
// `tools/index.js`, "named as it will be found" — named a file the bundle did not
|
||||
// contain. The runtime that loads bundles by their declared entrypoints (novox/hq ADR
|
||||
// 0175) is what made this visible.
|
||||
Compile: []string{
|
||||
"node", "/app/node_modules/typescript/bin/tsc",
|
||||
"--module", "NodeNext", "--moduleResolution", "NodeNext",
|
||||
"--target", "ES2022",
|
||||
"--target", "ES2022", "--rootDir", ".",
|
||||
},
|
||||
OutputFlag: "--outDir",
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".ts",
|
||||
OutputFlag: "--outDir",
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".ts",
|
||||
Dependencies: "/app/runtime",
|
||||
},
|
||||
{
|
||||
Language: "go",
|
||||
|
||||
@@ -67,6 +67,31 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
out := m
|
||||
out.Build = nil
|
||||
out.Resources = nil
|
||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
||||
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
|
||||
// build compare equal.
|
||||
out.Bundles = nil
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
}
|
||||
out.Bundles = append(out.Bundles, Bundle{
|
||||
Name: a.Name, Source: made.Reference, Digest: made.Digest,
|
||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||
Loads: loads,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
named, _ := r["artifact"].(string)
|
||||
if named == "" {
|
||||
@@ -191,6 +216,20 @@ func (b *Build) problems(module string) []string {
|
||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||
"for it", module, a.Name))
|
||||
}
|
||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
||||
// fail to import it on every machine rather than here.
|
||||
for _, load := range a.Loads {
|
||||
found := false
|
||||
for _, e := range a.Entrypoints {
|
||||
found = found || e == load
|
||||
}
|
||||
if !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
||||
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
||||
}
|
||||
}
|
||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||
|
||||
@@ -508,10 +508,27 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, fmt.Errorf(
|
||||
"%s needs a secret called %q and none was made for it", m.Module, name)
|
||||
}
|
||||
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
||||
// The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175,
|
||||
// to-be 38 WP3): its process is composed `user: <account>` where the node has one, and a
|
||||
// root-owned 0600 file is one that process cannot read. Composed here rather than said in
|
||||
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
|
||||
// account has nothing to resolve it to, and then the runtime runs as root and the file
|
||||
// stays root's.
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||
}))
|
||||
}
|
||||
// This module's tools bundles, where the machine runs the node's tool runtime (novox/hq
|
||||
// ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's
|
||||
// own resources for the same reason: the runtime's process names the files inside these
|
||||
// and is restarted when one changes, so they are on the machine before it is.
|
||||
if r.runtimeHere() {
|
||||
first = append(first, bundleArchives(m)...)
|
||||
}
|
||||
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
||||
// the module's own resources, and so before the container that mounts them: the host must
|
||||
// find each present — refusing clearly if the operator has not provided it — before it
|
||||
@@ -885,6 +902,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, fact)
|
||||
}
|
||||
}
|
||||
// The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every
|
||||
// bundle delivered above and holding the credential sealed above, so both exist before it starts
|
||||
// — the order written here is the order the machine applies.
|
||||
if r.runtimeHere() {
|
||||
process, err := r.runtimeProcess(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
}
|
||||
if with.Adopted {
|
||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||
// The order a machine applies is the order written here.
|
||||
|
||||
@@ -102,8 +102,11 @@ func accountHomeOf(account, home string) string {
|
||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}.
|
||||
for _, field := range []string{"path", "owner", "content"} {
|
||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||
s, ok := resource[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -572,6 +572,36 @@ type Manifest struct {
|
||||
// a module that could ask for it could read every credential on the bus — and the claim on
|
||||
// `mesh-broker` is what authorises it, checked from this manifest alone.
|
||||
BusUsers string `json:"bus-users,omitempty"`
|
||||
|
||||
// Bundles are this module's compiled bundles as the build produced them: what each is called,
|
||||
// where it is, what it hashes to, what language it is in and which files a tool runtime loads
|
||||
// from it (novox/hq ADR 0175, to-be 38).
|
||||
//
|
||||
// **Derived, never written.** The manifest in a repository says `build.artifacts`; the manifest
|
||||
// the mesh holds says what came out, the way a resource naming an artifact comes to name a
|
||||
// digest. Kept here because a tools bundle is referenced by no resource of the module's own —
|
||||
// the node's runtime loads it, and the runtime is composed by the mesh — so without this the
|
||||
// resolved manifest would carry no trace of the one artifact the runtime needs. A repository
|
||||
// manifest that writes this beside a build is refused: it would be stating the build's output
|
||||
// by hand.
|
||||
Bundles []Bundle `json:"bundles,omitempty"`
|
||||
}
|
||||
|
||||
// Bundle is one compiled bundle after it exists, as the resolved manifest carries it.
|
||||
type Bundle struct {
|
||||
Name string `json:"name"`
|
||||
// Source is where a machine fetches it, kept without the store's address like every reference
|
||||
// the mesh records (artifacts.go); Digest is what it must hash to.
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
// Language is what it was compiled from, which is what says how it is run.
|
||||
Language string `json:"language,omitempty"`
|
||||
// Entrypoints are the compiled files it was built around, relative to its root.
|
||||
Entrypoints []string `json:"entrypoints,omitempty"`
|
||||
// Loads are the entrypoints a node's tool runtime imports from it: what the artifact said, or
|
||||
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
|
||||
// run rather than loaded.
|
||||
Loads []string `json:"loads,omitempty"`
|
||||
}
|
||||
|
||||
// Build says how to produce this module's artifacts from its source.
|
||||
@@ -708,6 +738,16 @@ type Artifact struct {
|
||||
// somebody adds a helper. An empty list is a bundle that is run rather than loaded — a
|
||||
// provisioner or a step, named by whatever runs it.
|
||||
Entrypoints []string `json:"entrypoints,omitempty"`
|
||||
|
||||
// Loads are the entrypoints of this bundle the node's tool runtime loads (novox/hq ADR 0175,
|
||||
// to-be 38): the module's tool code, each file registering its tools as it is imported. A
|
||||
// subset of Entrypoints, for a bundle that also carries things that are RUN — a daemon, a
|
||||
// step, a report — and must not have them imported into the runtime.
|
||||
//
|
||||
// Absent means every entrypoint, for a module that declares `tools`: a bundle holding the
|
||||
// module's tools and nothing else is the ordinary case and should not have to say the same
|
||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
||||
Loads []string `json:"loads,omitempty"`
|
||||
}
|
||||
|
||||
// Kinds an artifact may be.
|
||||
@@ -1333,6 +1373,15 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
//
|
||||
// Refused here because the alternative is a build that never returns, on a mesh new enough
|
||||
// that nobody is watching it yet.
|
||||
if m.Build != nil && len(m.Bundles) > 0 {
|
||||
// The output of a build, written beside the build that produces it (ADR 0175). A resource
|
||||
// naming a digest beside an `artifact` would be the same mistake, and is caught the same way:
|
||||
// what the mesh derives, a repository does not state.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s writes `bundles` beside its build. The mesh derives that from what the build "+
|
||||
"produced; a manifest states `build.artifacts` and nothing about what came out",
|
||||
m.Module))
|
||||
}
|
||||
if m.Build != nil && len(m.Build.Artifacts) > 0 {
|
||||
for _, o := range m.Offers() {
|
||||
if o != ArtifactStoreProvision {
|
||||
@@ -1667,6 +1716,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, m.undeclaredMounts()...)
|
||||
problems = append(problems, m.unknownDirRefs()...)
|
||||
problems = append(problems, m.unknownAccessRefs()...)
|
||||
problems = append(problems, m.jailProblems()...)
|
||||
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
@@ -1773,6 +1823,44 @@ var facilitiesOf = map[string][]string{
|
||||
"virtualisation": {"/var/lib/incus/unix.socket"},
|
||||
}
|
||||
|
||||
// jailProblems is every jail this module declares that the machine's intrusion prevention would
|
||||
// refuse (novox/hq ADR 0179).
|
||||
//
|
||||
// **Because one bad pattern stops every jail, not its own.** fail2ban expands `<HOST>` into a named
|
||||
// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the
|
||||
// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one
|
||||
// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern
|
||||
// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several
|
||||
// patterns, one per line, as fail2ban's own filters are written.
|
||||
func (m Manifest) jailProblems() []string {
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, j := range m.Jails {
|
||||
switch {
|
||||
case strings.TrimSpace(j.Name) == "":
|
||||
problems = append(problems, m.Module+" declares a jail with no name")
|
||||
case seen[j.Name]:
|
||||
problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name))
|
||||
}
|
||||
seen[j.Name] = true
|
||||
if strings.TrimSpace(j.Failregex) == "" {
|
||||
problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like")
|
||||
}
|
||||
for _, line := range strings.Split(j.Failregex, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
if n := strings.Count(line, "<HOST>"); n > 1 {
|
||||
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %d times in one pattern; "+
|
||||
"fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+
|
||||
"keeps no bans at all — write one pattern per shape, each naming <HOST> once",
|
||||
m.Module, strconv.Quote(j.Name), n))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||||
func (m Manifest) undeclaredMounts() []string {
|
||||
declared := map[string]bool{}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
||||
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
||||
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
||||
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
||||
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
||||
if err := machineInto(login, facts, "zsh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if login["name"] != "ops" {
|
||||
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
||||
}
|
||||
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
||||
"scope": "user", "user": "${machine:account}"}
|
||||
if err := machineInto(watcher, facts, "i3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if watcher["user"] != "ops" {
|
||||
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
||||
}
|
||||
// A machine with no operator account refuses rather than writing the literal.
|
||||
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
||||
map[string]string{"address": "10.0.0.1"}, "zsh")
|
||||
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
||||
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
||||
// ADR 0177): every verb described, with a schema, taking a scope.
|
||||
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
||||
seat, ok := SeatNamed("node-service-manager")
|
||||
if !ok {
|
||||
t.Fatal("node-service-manager is not a seat the mesh defines")
|
||||
}
|
||||
if seat.Scope != ScopeNode {
|
||||
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
||||
}
|
||||
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}
|
||||
var got []string
|
||||
for _, v := range seat.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Description == "" || v.Input == nil {
|
||||
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if _, has := props["scope"]; !has {
|
||||
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("the seat serves %v, not %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38).
|
||||
//
|
||||
// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned
|
||||
// module's tools and every held seat's verbs, on the host side, from the bundles each module's build
|
||||
// produced — and no module needs a container to reach the bus with its tools. The name is a constant
|
||||
// rather than a manifest field because a rule turns on it: the composer places the runtime's process
|
||||
// where this module is, and registration refuses the old pattern once this module exists.
|
||||
|
||||
// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package,
|
||||
// which composes a principal of its own for it; the agreement test there holds the two to one string.
|
||||
const RuntimeModule = "node-tools"
|
||||
|
||||
// BundleRoot is where a machine keeps the tools bundles the mesh delivers to it: under the mesh's
|
||||
// own directory, beside the daemons the host unpacks there, and never where a package manager also
|
||||
// writes. One directory per module, one per bundle beneath it, at a path that does not move with
|
||||
// the version — so the runtime's process names each entrypoint once and is restarted, not
|
||||
// recomposed, when a bundle changes.
|
||||
const BundleRoot = "/var/lib/mesh/bundles"
|
||||
|
||||
// BundleID names the archive resource that delivers one of a module's bundles; prefixed with the
|
||||
// module like every resource of its own.
|
||||
func BundleID(bundle string) string { return "bundle-" + bundle }
|
||||
|
||||
// BundlePath is where one module's bundle is unpacked on a machine.
|
||||
func BundlePath(module, bundle string) string { return BundleRoot + "/" + module + "/" + bundle }
|
||||
|
||||
// runtimeHere says whether this node's set includes the runtime module, which is what decides
|
||||
// whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned,
|
||||
// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads
|
||||
// is bytes nobody reads.
|
||||
func (r Resolution) runtimeHere() bool {
|
||||
for _, m := range r.Modules {
|
||||
if m.Module == RuntimeModule {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something
|
||||
// from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its
|
||||
// tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads
|
||||
// nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the
|
||||
// process that runs it, and not again here.
|
||||
//
|
||||
// The source is the kept reference; the per-resource pass that follows routes it through the
|
||||
// artifact store as this network reaches it now, as it does every image and archive the mesh built.
|
||||
func bundleArchives(m Manifest) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": BundleID(b.Name), "type": "archive",
|
||||
"source": b.Source, "digest": b.Digest,
|
||||
"path": BundlePath(m.Module, b.Name),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with
|
||||
// the runtime module like a resource of its own, because that module is what the host sees it as.
|
||||
func RuntimeProcessID() string { return "runtime" }
|
||||
|
||||
// RuntimeToolModules is the variable the runtime reads the modules it serves from: one
|
||||
// `<module>=<entrypoint>` per file it loads, comma-separated — several entries may name one module.
|
||||
// RuntimeBrokerFile is where it reads the node's credential; RuntimeOperatorAccount and
|
||||
// RuntimeOperatorHome are the machine's operator account and home, handed to every tool's
|
||||
// environment (to-be 38 WP1), and absent on a machine with no account.
|
||||
const (
|
||||
RuntimeToolModules = "MESH_TOOL_MODULES"
|
||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||
)
|
||||
|
||||
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
||||
// bundle's entrypoint after it. The one thing the composer takes from a language, and said here
|
||||
// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3).
|
||||
func interpreterFor(language string) (string, error) {
|
||||
switch language {
|
||||
case "typescript":
|
||||
return "node", nil
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with",
|
||||
RuntimeModule, language, language)
|
||||
}
|
||||
|
||||
// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175,
|
||||
// to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which
|
||||
// modules it serves and from which files, where its credential is, and who the machine's operator
|
||||
// is — and restarted when any bundle it loads or the credential it holds changes.
|
||||
//
|
||||
// Composed from the placed manifests, so the credential's path is where this node puts it. The
|
||||
// runtime runs as the operator's account when the machine has one, which is what lets a tool that
|
||||
// needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as
|
||||
// root, and the two operator words are not set.
|
||||
func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
var runtime *Manifest
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == RuntimeModule {
|
||||
runtime = &r.Modules[i]
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(runtime.Bundles) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+
|
||||
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)",
|
||||
RuntimeModule, r.Node, len(runtime.Bundles))
|
||||
}
|
||||
bundle := runtime.Bundles[0]
|
||||
if len(bundle.Entrypoints) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
||||
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
||||
}
|
||||
interpreter, err := interpreterFor(bundle.Language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credential, declared := runtime.OwnSecrets["broker"]
|
||||
if !declared {
|
||||
return nil, fmt.Errorf(
|
||||
"%s declares no own secret named broker, and the node's credential is delivered there: "+
|
||||
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}",
|
||||
RuntimeModule)
|
||||
}
|
||||
|
||||
// What it serves, and from which files: every module on this machine that composes here, in
|
||||
// name order, each bundle it loads from in the order the manifest gave. A module left out of
|
||||
// the declaration — a filter on an adopted machine — is left out of this too, or the runtime
|
||||
// would be told to load files that were never delivered.
|
||||
var served []string
|
||||
var restartOn []string
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, load := range b.Loads {
|
||||
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||
}
|
||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||
}
|
||||
}
|
||||
sort.Strings(served)
|
||||
restartOn = append(restartOn, RuntimeModule+"."+NeedID("broker"))
|
||||
sort.Strings(restartOn)
|
||||
|
||||
env := map[string]string{
|
||||
RuntimeToolModules: strings.Join(served, ","),
|
||||
RuntimeBrokerFile: credential.Path,
|
||||
}
|
||||
process := map[string]any{
|
||||
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
||||
"source": bundle.Source, "digest": bundle.Digest,
|
||||
"run": []any{interpreter, bundle.Entrypoints[0]},
|
||||
"env": env,
|
||||
"restart-on": toAny(restartOn),
|
||||
}
|
||||
if r.Account != "" {
|
||||
env[RuntimeOperatorAccount] = r.Account
|
||||
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
||||
process["user"] = r.Account
|
||||
}
|
||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
||||
// built; refused with the same words when there is no store to route through.
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return process, nil
|
||||
}
|
||||
|
||||
func toAny(in []string) []any {
|
||||
out := make([]any, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was
|
||||
// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image
|
||||
// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175).
|
||||
const (
|
||||
RuntimeImageModule = "mesh-tools"
|
||||
RuntimeImageArtifact = "runtime"
|
||||
)
|
||||
|
||||
// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools
|
||||
// are served from a container built on the tool runtime's image — or nothing when it is not. Judged
|
||||
// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood
|
||||
// on when it is a built one, because a resolved manifest carries no build. The gate itself is
|
||||
// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused.
|
||||
//
|
||||
// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container
|
||||
// (a module's service may well be one); building on the runtime's image (a service written against
|
||||
// the SDK may). All three is a container whose purpose is tools, which the runtime now serves.
|
||||
func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
||||
if len(m.Tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
container := false
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "container" {
|
||||
container = true
|
||||
}
|
||||
}
|
||||
if !container {
|
||||
return ""
|
||||
}
|
||||
onTheRuntime := false
|
||||
if m.Build != nil {
|
||||
for _, on := range m.Build.On {
|
||||
if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact {
|
||||
onTheRuntime = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range against {
|
||||
path, kept := InArtifactStore(Recorded(ref))
|
||||
if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") {
|
||||
onTheRuntime = true
|
||||
}
|
||||
}
|
||||
if !onTheRuntime {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s declares tools and a container built on %s's %s image — a container whose purpose is "+
|
||||
"serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+
|
||||
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
||||
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The packet-filter manifest as it was the day the runtime was decided (novox/hq ADR 0175): tools,
|
||||
// served from a container built on the tool runtime's image, with NET_ADMIN so the container could
|
||||
// reach the filter. The exact pattern to-be 38 WP4 moves it off, and the one the gate refuses.
|
||||
const thePacketFilterAsItWas = `{
|
||||
"module": "nftables",
|
||||
"version": "1",
|
||||
"capabilities": ["firewall", "container-runtime"],
|
||||
"claims": [{"name": "node-packet-filter", "scope": "node", "serves": ["rules", "reload", "remove"]}],
|
||||
"filtering": {"into": "/etc/nftables.conf"},
|
||||
"resources": [
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "package", "type": "package", "package": "nftables"},
|
||||
{"id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service",
|
||||
"content": "[Unit]\nDescription=The mesh's packet filter\n[Service]\nType=oneshot\nExecStart=nft -f /etc/nftables.conf\n", "mode": "0644"},
|
||||
{"id": "load", "type": "service", "unit": "mesh-filter.service", "state": "running", "boot": "enabled",
|
||||
"restart-on": ["unit"], "reload-on": ["filtering"]},
|
||||
{"id": "runtime", "type": "container", "name": "mesh-nftables", "network": "host",
|
||||
"capabilities": ["NET_ADMIN"],
|
||||
"volumes": ["${dir:mesh-state}/broker:/run/secrets/broker:ro", "/etc/nftables.conf:/etc/nftables.conf:ro"],
|
||||
"env": {"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_FILTER_FILE": "/etc/nftables.conf"},
|
||||
"artifact": "runtime"}
|
||||
],
|
||||
"tools": ["firewall_rules"],
|
||||
"own-secrets": {"broker": "${dir:mesh-state}/broker"},
|
||||
"build": {
|
||||
"on": [
|
||||
{"arg": "BUILD_BASE", "module": "mesh-tools", "artifact": "build"},
|
||||
{"arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime"}
|
||||
],
|
||||
"artifacts": [{"name": "runtime", "kind": "image", "from": "Dockerfile"}]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(thePacketFilterAsItWas))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// From the repository: the manifest says what it builds on.
|
||||
why := ToolContainerOnTheRuntime(m, nil)
|
||||
if why == "" {
|
||||
t.Fatal("the packet filter's tool container was not recognised from its build")
|
||||
}
|
||||
for _, word := range []string{"nftables", "mesh-tools", "runtime", "ADR 0175", "bundle"} {
|
||||
if !strings.Contains(why, word) {
|
||||
t.Errorf("the refusal does not say %q: %s", word, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Built: the manifest carries no build, and what it stood on says the same.
|
||||
built, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: ArtifactStoreScheme + "nftables/runtime@" + digest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stoodOn := []string{"anchor.internal:5100/mesh-tools/build@" + digest, "anchor.internal:5100/mesh-tools/runtime@" + digest}
|
||||
if ToolContainerOnTheRuntime(built, stoodOn) == "" {
|
||||
t.Error("the packet filter's tool container was not recognised from what its build stood on")
|
||||
}
|
||||
if ToolContainerOnTheRuntime(built, nil) != "" {
|
||||
t.Error("a built manifest with no record of its base was judged to be on the runtime")
|
||||
}
|
||||
|
||||
// Each of the three alone is an ordinary module.
|
||||
bundle := m
|
||||
bundle.Resources = m.Resources[:len(m.Resources)-1]
|
||||
if ToolContainerOnTheRuntime(bundle, nil) != "" {
|
||||
t.Error("a module with tools and no container is the pattern the runtime serves, and was refused")
|
||||
}
|
||||
service := m
|
||||
service.Tools = nil
|
||||
if ToolContainerOnTheRuntime(service, nil) != "" {
|
||||
t.Error("a service built against the SDK, declaring no tools, was refused")
|
||||
}
|
||||
elsewhere := m
|
||||
elsewhere.Build = &Build{On: []BuildsOn{{Arg: "NODE_BASE", Image: "node@" + digest}},
|
||||
Artifacts: m.Build.Artifacts}
|
||||
if ToolContainerOnTheRuntime(elsewhere, nil) != "" {
|
||||
t.Error("a tool container on a public base was refused as though it were on the runtime's")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The node's tool runtime (novox/hq ADR 0175, to-be 38): where the runtime module is assigned, a
|
||||
// machine is sent every assigned module's tools bundle as an archive, and the runtime's own process
|
||||
// loading them. Where it is not, the machine is sent exactly what it was sent before.
|
||||
|
||||
var bundleDigest = "sha256:" + strings.Repeat("b", 64)
|
||||
|
||||
// aToolsModule is a module whose tools come as a compiled bundle and nothing else — the shape every
|
||||
// module takes once its tool container goes (to-be 38 WP4).
|
||||
func aToolsModule(t *testing.T, name string, entrypoints ...string) Manifest {
|
||||
t.Helper()
|
||||
m := Manifest{Module: name, Version: "1", Tools: []string{"status"},
|
||||
Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: entrypoints},
|
||||
}}}
|
||||
resolved, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + name + "/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// theRuntime is the runtime module as the catalogue holds it: its own bundle, run rather than
|
||||
// loaded, and its broker secret to receive the node's credential in.
|
||||
func theRuntime(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"src/main.js"}}}}}
|
||||
resolved, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) {
|
||||
m := aToolsModule(t, "nftables", "tools/index.js")
|
||||
if len(m.Bundles) != 1 {
|
||||
t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles))
|
||||
}
|
||||
b := m.Bundles[0]
|
||||
if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" ||
|
||||
b.Source != ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest ||
|
||||
len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" {
|
||||
t.Errorf("the bundle is carried as %+v", b)
|
||||
}
|
||||
// A repository manifest may not write what the build derives.
|
||||
raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` +
|
||||
`"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") {
|
||||
t.Errorf("a manifest stating its build's output by hand was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) {
|
||||
store := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
zsh := aToolsModule(t, "zsh", "tools/index.js", "tools/more.js")
|
||||
|
||||
t.Run("with the runtime, one archive per tools bundle", func(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh, theRuntime(t)}}
|
||||
out, err := r.Declaration(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := fileNamed(out, "nftables."+BundleID("tools"))
|
||||
if archive == nil {
|
||||
t.Fatalf("nftables' tools bundle was not delivered: %v", ids(out))
|
||||
}
|
||||
if archive["type"] != "archive" || archive["digest"] != bundleDigest ||
|
||||
archive["path"] != BundleRoot+"/nftables/tools" {
|
||||
t.Errorf("delivered as %v", archive)
|
||||
}
|
||||
if archive["source"] != "http://anchor.internal:5101/v2/nftables/tools/blobs/"+bundleDigest {
|
||||
t.Errorf("fetched from %v, not through the store as this network reaches it", archive["source"])
|
||||
}
|
||||
if fileNamed(out, "zsh."+BundleID("tools")) == nil {
|
||||
t.Errorf("zsh's tools bundle was not delivered: %v", ids(out))
|
||||
}
|
||||
// The runtime's own bundle is run, not loaded: its process delivers it, not an archive.
|
||||
if fileNamed(out, RuntimeModule+"."+BundleID("runtime")) != nil {
|
||||
t.Error("the runtime's own bundle was delivered as an archive beside its process")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without the runtime, nothing changes", func(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh}}
|
||||
out, err := r.Declaration(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range ids(out) {
|
||||
if strings.Contains(id, BundleID("")) {
|
||||
t.Errorf("%s was delivered to a machine running no runtime to load it", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ids(out []map[string]any) []string {
|
||||
var names []string
|
||||
for _, r := range out {
|
||||
names = append(names, r["id"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// One process per machine runs the runtime from its own bundle, told what it serves and from where,
|
||||
// where its credential is, and who the operator is — restarted when any of that changes.
|
||||
func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
// A bundle carrying a daemon beside its tools says which files the runtime loads.
|
||||
showcase := Manifest{Module: "showcase", Version: "1", Tools: []string{"greet"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "code", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"daemon/index.js", "tools/index.js"}, Loads: []string{"tools/index.js"}}}}}
|
||||
showcase, err := showcase.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "showcase/code/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{nftables, showcase, theRuntime(t)}}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatalf("no runtime process was composed: %v", ids(out))
|
||||
}
|
||||
if process["type"] != "process" || process["name"] != RuntimeModule || process["digest"] != bundleDigest ||
|
||||
process["source"] != "http://anchor.internal:5101/v2/"+RuntimeModule+"/runtime/blobs/"+bundleDigest {
|
||||
t.Errorf("the runtime's process is %v", process)
|
||||
}
|
||||
if fmt.Sprint(process["run"]) != "[node src/main.js]" {
|
||||
t.Errorf("the runtime is run as %v; its bundle's one entrypoint, by its language's interpreter", process["run"])
|
||||
}
|
||||
env := process["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] != "nftables="+BundleRoot+"/nftables/tools/tools/index.js,"+
|
||||
"showcase="+BundleRoot+"/showcase/code/tools/index.js" {
|
||||
t.Errorf("the runtime is told to serve %q: every loaded file, by module, and nothing a bundle runs", env[RuntimeToolModules])
|
||||
}
|
||||
if env[RuntimeBrokerFile] != "/var/lib/mesh/"+RuntimeModule+"/broker" {
|
||||
t.Errorf("the runtime reads its credential at %q, not where the module's own secret is placed", env[RuntimeBrokerFile])
|
||||
}
|
||||
if env[RuntimeOperatorAccount] != "ops" || env[RuntimeOperatorHome] != "/home/ops" || process["user"] != "ops" {
|
||||
t.Errorf("the operator is not handed to the runtime: %v as %v", env, process["user"])
|
||||
}
|
||||
// The credential the process reads belongs to the account it runs as, or it could not read it
|
||||
// (to-be 38 WP3); other modules' secrets are left as their manifests say.
|
||||
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != "ops" {
|
||||
t.Errorf("the runtime's credential is not the account's to read: %v", credential)
|
||||
}
|
||||
restarts := fmt.Sprint(process["restart-on"])
|
||||
for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), RuntimeModule + "." + NeedID("broker")} {
|
||||
if !strings.Contains(restarts, want) {
|
||||
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||
}
|
||||
}
|
||||
// After every bundle and the credential, so both exist before it starts.
|
||||
names := ids(out)
|
||||
if names[len(names)-1] != RuntimeModule+"."+RuntimeProcessID() {
|
||||
t.Errorf("the runtime's process is not last: %v", names)
|
||||
}
|
||||
|
||||
t.Run("a machine with no account runs it as root without the operator words", func(t *testing.T) {
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{nftables, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
env := process["env"].(map[string]string)
|
||||
if _, set := env[RuntimeOperatorAccount]; set {
|
||||
t.Error("an operator account was named on a machine that has none")
|
||||
}
|
||||
if _, set := process["user"]; set {
|
||||
t.Error("a user was set on a machine with no account")
|
||||
}
|
||||
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != nil {
|
||||
t.Errorf("the runtime's credential was given an owner on a machine with no account: %v", credential)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a runtime module built wrong is refused by name", func(t *testing.T) {
|
||||
two := Manifest{Module: RuntimeModule, Version: "1", OwnSecrets: OwnSecrets{"broker": {Path: "/b"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"a.js", "b.js"}}}}}
|
||||
resolved, err := two.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "x/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Resolution{Node: "anchor", Modules: []Manifest{resolved}}.Declaration(with)
|
||||
if err == nil || !strings.Contains(err.Error(), "entrypoint") {
|
||||
t.Errorf("a runtime bundle with two entrypoints was composed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -96,10 +96,35 @@ var defaultSeats = []Seat{
|
||||
// A build says what it does as it does it (novox/hq ADR 0157): `started` when work is taken,
|
||||
// `log.<build id>` for every line, `built` for the outcome. The log's tail token is the build's
|
||||
// id, so a reader follows one build by subject alone.
|
||||
// **Node-scoped, and every holder takes from one queue** (novox/hq ADR 0190): a build is asked of
|
||||
// the role, and whichever machine holding the seat is idle pulls it. One holder per machine is
|
||||
// what the scope says; sharing the work is what a seat's queue has always done.
|
||||
{Name: "node-build-agent", Scope: ScopeNode,
|
||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
||||
// **Retired by ADR 0190, kept while a manifest still claims it.** The one build machine's seat.
|
||||
// A claim to a seat the mesh no longer defines is refused, and the module holding this one is
|
||||
// assigned on a live machine until build-agent replaces it — removing the row first would make
|
||||
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
|
||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||
// 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
|
||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
||||
Serves: []Verb{
|
||||
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
||||
"holding now, and the totals since the jail started; one jail's detail when named.",
|
||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
||||
"that holds it and when the ban ends.",
|
||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
||||
"operator's act on the live ban list, which the mesh never writes itself.",
|
||||
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})},
|
||||
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
||||
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})},
|
||||
}},
|
||||
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
||||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
||||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
||||
@@ -119,6 +144,12 @@ var defaultSeats = []Seat{
|
||||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
||||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})},
|
||||
}},
|
||||
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||
// served by the node tools runtime (ADR 0175).
|
||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||
Serves: serviceManagerVerbs()},
|
||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||
// model change, not a rename, so it stays until that is built.
|
||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||
@@ -392,3 +423,36 @@ func SeatsWithAProtocol() []Seat {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
||||
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
||||
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
||||
// caller asks for a user unit the way it asks for a system one.
|
||||
func serviceManagerVerbs() []Verb {
|
||||
scoped := func(more map[string]string, required []string) map[string]any {
|
||||
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
||||
for k, v := range more {
|
||||
props[k] = v
|
||||
}
|
||||
return schema(props, required)
|
||||
}
|
||||
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
||||
return []Verb{
|
||||
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
||||
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)},
|
||||
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "restart", Description: "Restart one unit.",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
||||
Input: scoped(unit, []string{"unit"})},
|
||||
{Name: "journal", Description: "The last lines of one unit's journal.",
|
||||
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
if len(Seats()) != 15 {
|
||||
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
||||
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 17 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,13 @@ var ControllerVerbs = []Verb{
|
||||
"node": "one machine; the whole mesh when absent",
|
||||
"clear": "\"true\" to remove the layer instead of setting it",
|
||||
}, []string{"module"})},
|
||||
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||
}, []string{"command"})},
|
||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
||||
Input: schema(map[string]string{
|
||||
|
||||
@@ -42,6 +42,9 @@ const (
|
||||
BusModule = "module"
|
||||
BusEnrolment = "enrolment"
|
||||
BusPerson = "person"
|
||||
// BusNodeTools is a machine's tool runtime (novox/hq ADR 0175): named like the module it
|
||||
// stands for, recorded as what it is.
|
||||
BusNodeTools = "node-tools"
|
||||
)
|
||||
|
||||
// MintBusPassword makes a bus password and records its hash under a username, replacing whatever was
|
||||
|
||||
@@ -42,6 +42,11 @@ type Source struct {
|
||||
// Seen is when the source was last looked at — by a build, by hand, or by the forge saying it
|
||||
// moved. What a late report of an older move is judged against.
|
||||
Seen time.Time
|
||||
// Against is every artifact the build this manifest came from stood on, as recorded. Part of a
|
||||
// module's provenance like the commit is, and what tells a built manifest's base when the manifest
|
||||
// itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over
|
||||
// by hand, which carries its `build.on` itself.
|
||||
Against []string
|
||||
}
|
||||
|
||||
// Current reports whether what the mesh holds is what the source last had.
|
||||
@@ -61,6 +66,32 @@ func (s Source) Current() bool {
|
||||
// gains a requirement, a claim, a resource. What matters is that the change is visible the next
|
||||
// time a node is resolved, which it is.
|
||||
func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, from Source) error {
|
||||
// **Once the node's tool runtime is in the catalogue, the pattern it retires may not spread**
|
||||
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
||||
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
||||
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
||||
// returning by habit. **Not refused for a module already registered in that shape**: the
|
||||
// catalogue holds some thirty of them the day the runtime arrives, each moves to a bundle in
|
||||
// its own change (to-be 38 WP4 onward), and a gate that refused every rebuild of every unmoved
|
||||
// module in the meantime would stop the whole pipeline to make a point the record already makes.
|
||||
// Before the runtime exists the pattern is accepted as it always was.
|
||||
if m.Module != catalogue.RuntimeModule {
|
||||
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
||||
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime {
|
||||
already, err := i.registeredInThatShape(ctx, m.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !already {
|
||||
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -89,6 +120,36 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
return err
|
||||
}
|
||||
|
||||
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
|
||||
// the runtime's image — judged from the manifest it holds and what that module's newest build stood
|
||||
// on, the same two things the gate judges a new registration by. False for a module the catalogue
|
||||
// does not hold.
|
||||
func (i *Inventory) registeredInThatShape(ctx context.Context, name string) (bool, error) {
|
||||
held, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stored, has := held[name]
|
||||
if !has {
|
||||
return false, nil
|
||||
}
|
||||
against, err := i.BuiltAgainst(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return catalogue.ToolContainerOnTheRuntime(stored, against[name]) != "", nil
|
||||
}
|
||||
|
||||
// hasModule is whether the catalogue holds a module of that name.
|
||||
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
|
||||
var one int
|
||||
err := i.store.Pool().QueryRow(ctx, `select 1 from module where name = $1`, name).Scan(&one)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SourceMoved records that a module's source has a newer commit than the mesh has built.
|
||||
//
|
||||
// This is the whole of noticing. Nothing here builds anything — it writes down that the two
|
||||
|
||||
@@ -685,3 +685,61 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
|
||||
t.Fatalf("a hand-registered manifest erased where the module comes from: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
||||
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
||||
// to-be 38 WP2.4) — for a module new to the catalogue or one that had moved away from it; a module
|
||||
// already standing in that shape is rebuilt as before, so the catalogue's pipeline keeps running
|
||||
// while each moves (WP3's amendment). Before the runtime, it is accepted as it always was — so a
|
||||
// mesh converts in the order the design says and nothing is refused before there is anything to
|
||||
// move to.
|
||||
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
filter := catalogue.Manifest{Module: "nftables", Version: "1", Tools: []string{"firewall_rules"},
|
||||
Resources: []map[string]any{{"id": "runtime", "type": "container", "name": "mesh-nftables"}}}
|
||||
stoodOn := []string{catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("d", 64)}
|
||||
|
||||
// Before the runtime exists the old pattern is accepted as it always was — and built, which is
|
||||
// how the catalogue comes to know what the module stood on.
|
||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
||||
t.Fatalf("before the runtime exists the old pattern is accepted: %v", err)
|
||||
}
|
||||
built := aBuild("nf1", "nftables", "")
|
||||
built.Against = stoodOn
|
||||
if err := inv.RecordBuild(ctx, built); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1"}
|
||||
if err := inv.RegisterModule(ctx, runtime, Source{Repository: "/r"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// **A module already registered in that shape is rebuilt without complaint** (to-be 38 WP2.4 as
|
||||
// amended by WP3): some thirty of them stand the day the runtime arrives, and each moves in its
|
||||
// own change. The gate is against the pattern spreading, not against the pipeline running.
|
||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
||||
t.Fatalf("a rebuild of a module that already had the pattern was refused: %v", err)
|
||||
}
|
||||
// A module new to the catalogue in that shape is refused, naming the record.
|
||||
newcomer := filter
|
||||
newcomer.Module = "lamp"
|
||||
err := inv.RegisterModule(ctx, newcomer, Source{Repository: "/r", Against: stoodOn})
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0175") {
|
||||
t.Fatalf("a new module in the old pattern was registered beside the runtime: %v", err)
|
||||
}
|
||||
// And a module that had moved its tools to a bundle may not come back to a container.
|
||||
moved := filter
|
||||
moved.Resources = nil
|
||||
if err := inv.RegisterModule(ctx, moved, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
||||
t.Fatalf("a module whose tools are a bundle was refused: %v", err)
|
||||
}
|
||||
unbuilt := aBuild("nf2", "nftables", "")
|
||||
if err := inv.RecordBuild(ctx, unbuilt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn})
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0175") {
|
||||
t.Fatalf("a module that had moved returned to the old pattern unrefused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
|
||||
if r := repositoryKey(e.Source.Repository); r != "" {
|
||||
byRepository[r] = append(byRepository[r], name)
|
||||
}
|
||||
if e.Manifest.ClaimsSeat("mesh-build-machine") {
|
||||
if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") {
|
||||
builders = append(builders, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
||||
return Entry{Manifest: catalogue.Manifest{Module: name}, Source: Source{Repository: repository}}
|
||||
}
|
||||
builder := entry("builder", "http://forge/novox/mesh-catalog.git")
|
||||
builder.Manifest.Claims = []catalogue.Claim{{Name: "mesh-build-machine", Scope: catalogue.ScopeMesh}}
|
||||
builder.Manifest.Claims = []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}
|
||||
plugin := entry("shop-plugin", "http://forge/novox/mesh-catalog.git")
|
||||
plugin.Manifest.Build = &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "shop"}}}
|
||||
entries := []Entry{
|
||||
|
||||
+11
-2
@@ -19,8 +19,17 @@ import (
|
||||
// act on or a declaration a node reconciles toward; a build is a request that takes minutes and has
|
||||
// exactly one answer. Too long for request/reply, too particular to be an event.
|
||||
|
||||
// TheBuildMachine is the role a build is submitted to.
|
||||
const TheBuildMachine = "mesh-build-machine"
|
||||
// TheBuildMachine is the role a build is submitted to: node-scoped, held on every machine that
|
||||
// builds, and the work shared among them (novox/hq ADR 0190). The name stays for every caller; what
|
||||
// it names moved from the mesh's one build machine to whichever build agent is idle.
|
||||
//
|
||||
// **Switching a live mesh over, in order.** The old seat's stream and worker
|
||||
// (SEAT_MESH_BUILD_MACHINE, SEAT_MESH_BUILD_MACHINE_worker) stay on the bus until removed by hand,
|
||||
// and the builder module keeps draining them while it is assigned. From the moment a controller
|
||||
// with this name runs, new asks go to node-build-agent and wait in its stream until some machine
|
||||
// holds the seat. So: let the queued builds finish; roll this controller; register and assign
|
||||
// build-agent to the machines that build; unassign builder and forget it and its seat's stream.
|
||||
const TheBuildMachine = "node-build-agent"
|
||||
|
||||
// BuildWork is where a build request lands, and BuildOutcome is where its result does. Derived from
|
||||
// the seat, so both sides name the role and neither names the other.
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestTheOldBusAnnouncesABuildUnderBothNames(t *testing.T) {
|
||||
if KeyRoleBuilt != "built" {
|
||||
t.Fatalf("the role's event is %q, and a holder emits its verbs bare", KeyRoleBuilt)
|
||||
}
|
||||
if TheBuildMachine != "mesh-build-machine" {
|
||||
if TheBuildMachine != "node-build-agent" {
|
||||
t.Fatalf("the role is %q", TheBuildMachine)
|
||||
}
|
||||
// The two must differ, or one publish would serve both and this doubling would be pointless.
|
||||
|
||||
@@ -126,29 +126,31 @@ func (m *natsMachine) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Take binds to the role's worker and hands each request over, one at a time.
|
||||
// Take binds to the role's worker and pulls one request at a time, handing each over.
|
||||
//
|
||||
// **Bound, never created.** The work queue and the worker on it are the controller's to define
|
||||
// (design 25 §3), and a build machine reaches no part of the JetStream API — so a missing one is said
|
||||
// as the mesh's to answer rather than quietly created with whatever this client defaults to.
|
||||
//
|
||||
// **Pulled, one at a time, by whichever holder is free** (novox/hq ADR 0190). Every machine holding
|
||||
// the role binds this same worker; a machine asks for the next request only when it has finished
|
||||
// the last, so a slow machine never holds an ask an idle one could take, and a machine that took
|
||||
// five at once would run five container builds against one runtime and finish all of them slower
|
||||
// than the first.
|
||||
func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build)) error {
|
||||
worker, found := broker.HolderConsumerFor(m.on, "builder",
|
||||
worker, found := broker.HolderConsumerFor(m.on, "build-agent",
|
||||
broker.DeclaredSeat{Name: m.seat, Accepts: []string{"build"}})
|
||||
if !found {
|
||||
return fmt.Errorf("%s accepts no work, so there is nothing for this machine to take", m.seat)
|
||||
}
|
||||
|
||||
// One at a time, which the consumer's own ack-pending limit enforces rather than a prefetch
|
||||
// setting: a machine that took five requests at once would run five container builds against one
|
||||
// runtime and finish all of them slower than the first.
|
||||
work := make(chan *nats.Msg, 1)
|
||||
// **The consumer's own filter, not the one subject this machine cares about.** The client checks
|
||||
// what is asked for against the consumer's filter and refuses anything that is not the same —
|
||||
// "subject does not match consumer" — so subscribing `…accept.build` against a consumer filtered
|
||||
// on `…accept.>` is rejected even though it is narrower. Learned twice now, on two different
|
||||
// consumers, which is why it is written down here.
|
||||
filter := worker.Filters[0]
|
||||
sub, err := m.js.Context().ChanQueueSubscribe(filter, worker.Queue, work,
|
||||
sub, err := m.js.Context().PullSubscribe(filter, worker.Name,
|
||||
nats.Bind(worker.Stream, worker.Name), nats.ManualAck())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
@@ -159,13 +161,27 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
||||
m.sub = sub
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
case msg, ok := <-work:
|
||||
if !ok {
|
||||
return errors.New("the bus stopped delivering build work")
|
||||
}
|
||||
// One, and wait a while for it; an empty queue is a timeout, which is the normal state of a
|
||||
// machine with nothing to build, and is asked again.
|
||||
fetched, err := sub.Fetch(1, nats.Context(ctx))
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
return nil
|
||||
case errors.Is(err, nats.ErrTimeout):
|
||||
continue
|
||||
case err != nil:
|
||||
if sub.IsValid() {
|
||||
// A transient fault in asking — a reconnect, a slow server — is asked past rather
|
||||
// than ending the machine; one that outlasts the ack wait redelivers nothing lost.
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("the bus stopped delivering build work: %w", err)
|
||||
}
|
||||
for _, msg := range fetched {
|
||||
var request BuildRequest
|
||||
if err := json.Unmarshal(msg.Data, &request); err != nil {
|
||||
// Unreadable: terminated rather than retried, because the next attempt reads the same
|
||||
|
||||
@@ -48,7 +48,7 @@ func aBusWithTheBuildRole(t *testing.T) *broker.JetStream {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clean := func() {
|
||||
_ = js.Context().DeleteStream("SEAT_MESH_BUILD_MACHINE")
|
||||
_ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT")
|
||||
for _, s := range broker.MeshStreams() {
|
||||
_ = js.Context().PurgeStream(s.Name)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
|
||||
// And the work left the queue: a request a machine took and settled must not be given to another.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
info, err := js.Context().StreamInfo("SEAT_MESH_BUILD_MACHINE")
|
||||
info, err := js.Context().StreamInfo("SEAT_NODE_BUILD_AGENT")
|
||||
if err == nil && info.State.Msgs == 0 {
|
||||
return
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func TestNatsABuildWaitsForAMachineRatherThanFailing(t *testing.T) {
|
||||
if _, err := js.Context().Publish(BuildWork(), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := js.Context().StreamInfo("SEAT_MESH_BUILD_MACHINE")
|
||||
info, err := js.Context().StreamInfo("SEAT_NODE_BUILD_AGENT")
|
||||
if err != nil || info.State.Msgs != 1 {
|
||||
t.Fatalf("the work did not queue: %+v %v", info, err)
|
||||
}
|
||||
@@ -245,3 +245,83 @@ func TestNatsWorkAMachineDidNotAnswerGoesBackToTheQueue(t *testing.T) {
|
||||
func quietLog() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
var _ = quietLog
|
||||
|
||||
// Two machines holding the role share one queue (novox/hq ADR 0190): three asks, each machine takes
|
||||
// one and the third waits until one of them is done; an ask is never handed to a machine that is
|
||||
// busy; and a machine that stops mid-ask leaves its ask to the other.
|
||||
func TestNatsTwoMachinesShareTheWorkAndNeitherIsHandedMoreThanItCanTake(t *testing.T) {
|
||||
js := aBusWithTheBuildRole(t)
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
|
||||
for _, id := range []string{"w-1", "w-2", "w-3"} {
|
||||
body, _ := json.Marshal(BuildRequest{ID: id, Repository: "/r"})
|
||||
if _, err := js.Context().Publish(BuildWork(), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type taken struct{ machine, id string }
|
||||
took := make(chan taken, 8)
|
||||
release := map[string]chan struct{}{"anchor": make(chan struct{}), "laptop": make(chan struct{})}
|
||||
machines := map[string]BuildMachine{}
|
||||
for _, name := range []string{"anchor", "laptop"} {
|
||||
name := name
|
||||
m := MachineOverNATS(js, name)
|
||||
machines[name] = m
|
||||
defer m.Close()
|
||||
go func() {
|
||||
_ = m.Take(ctx, func(ctx context.Context, work Build) {
|
||||
took <- taken{name, work.Request().ID}
|
||||
<-release[name]
|
||||
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: name})
|
||||
_ = work.Done()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// Each machine took exactly one, and they are different asks.
|
||||
first := map[string]string{}
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case got := <-took:
|
||||
if _, twice := first[got.machine]; twice {
|
||||
t.Fatalf("%s was handed a second ask while busy with its first", got.machine)
|
||||
}
|
||||
first[got.machine] = got.id
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("only %d machine(s) took work; two idle holders should both have", len(first))
|
||||
}
|
||||
}
|
||||
if first["anchor"] == first["laptop"] {
|
||||
t.Fatalf("both machines took %q: the queue is not shared, it is copied", first["anchor"])
|
||||
}
|
||||
// The third waits: nobody is free.
|
||||
select {
|
||||
case got := <-took:
|
||||
t.Fatalf("%s was handed %s while both machines were busy", got.machine, got.id)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
// One finishes, and only then is the third taken — by that machine, the one that is free.
|
||||
close(release["anchor"])
|
||||
release["anchor"] = make(chan struct{})
|
||||
select {
|
||||
case got := <-took:
|
||||
if got.machine != "anchor" {
|
||||
t.Fatalf("the third ask went to %s, which is still busy", got.machine)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the third ask was never taken after a machine became free")
|
||||
}
|
||||
// A machine that stops mid-ask leaves its ask unacknowledged, and the ack wait brings it round
|
||||
// to whoever is left — the path TestNatsWorkAMachineDidNotAnswerGoesBackToTheQueue proves with
|
||||
// an explicit hand-back, because the real wait is a minute. Here: the laptop goes, anchor
|
||||
// finishes, and with nothing queued nothing more is taken by the machine that is left.
|
||||
machines["laptop"].Close()
|
||||
close(release["anchor"])
|
||||
select {
|
||||
case got := <-took:
|
||||
t.Fatalf("%s took %s; the queue should be empty", got.machine, got.id)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user