Compare commits

...
Author SHA1 Message Date
jschoubben bf204f90f3 The console's build tool has the command's three shapes: a repository, a base (--on), everything behind
Rebuilding the forty-three modules that stand on the runtime image took forty-three tool calls
because the seat verb only knew a repository. `on` rebuilds every module built on a base, `behind`
rebuilds what is older than its source, both asked and not waited for, the daemon taking each result
in (issue 176). Nothing is required any more; a build naming nothing is refused by the command's usage.
2026-10-01 14:05:57 +02:00
mesh-admin 55c5c061ab Merge pull request 'A tool's grant covers the machine-addressed subject, and a credential names the seats its module claims (ADR 0159)' (#185) from feat/a-tool-call-names-the-machine into main 2026-10-01 12:01:29 +00:00
jschoubben ccae1ec303 gofmt 2026-10-01 13:58:41 +02:00
jschoubben 9fd5971212 A tool's grant covers the machine-addressed subject, and a credential names the seats its module claims (hq ADR 0159)
`invokes: [<module>.<tool>]` now grants `mesh.mod.<module>.tool.<tool>` and the same with the
machine as its last token, which is how a call reaches one machine's instance. The broker
credential the mesh writes carries `claims`: each seat the module claims, its scope, and the verbs
the seat promises, so the runtime serves them on the seat's subjects; the holder's grant, composed
from the holding, is what admits the subscription.
2026-10-01 13:58:23 +02:00
mesh-admin 05ccab2e4b Merge pull request 'The two seat commands appear in the usage text' (#94) from fix/seat-usage-lines into main 2026-10-01 11:17:51 +00:00
mesh-admin 05ae5e5040 Merge pull request 'A provider with one credential shares it with every consumer, remade for all at once (ADR 0158)' (#184) from feat/0158-a-provider-with-one-credential-shares-it into main 2026-10-01 10:27:08 +00:00
jschoubben 988250f37a A provider with one credential shares it with every consumer, remade for all at once (hq ADR 0158)
An offer may say `"credential": {"own": "<secret>"}`: the provider's own secret is the credential
every consumer of that provision receives, in the shape of a pair credential. The vault keeps one
value, sealed to the provider, to every consumer that holds the provision and to the operator, all
under one generation stamp; a consumer binding later, or `secret rotate` on the provider's secret,
makes a fresh value and seals it to every holder in one act, and the rotate command sends every
holding machine together. An accepted value is sealed to the consumers of the moment and never
remade: a consumer binding after it is refused with the way out (ADR 0113). The named own secret
must say how it is taken (issue 180), so the provider's start applies the file.

A need carries the shared secret's name from either side of the machine boundary; the plan mints a
consumer's copy from the provider's value. Registered manifests keep their bytes.
2026-10-01 12:26:44 +02:00
mesh-admin 6ca4ba68c8 Merge pull request 'A module's own secret rotates when its definition says the module reads it at start (hq 180, forge 231)' (#183) from feat/231-an-own-secret-rotates into main 2026-10-01 09:42:10 +00:00
jschoubben 1469f5ff82 A module's own secret rotates when its definition says the module reads it at start (hq 180)
`secret rotate <node> <module> <name>` makes the secret anew the way the first mint did, seals it
to the machine and the operator, and sends the machine, so the module starts again on the new value
— said in the log with who asked and when, never the value. Only for a secret whose definition says
`"taken": "at-start"`: an own secret is now a path, or {path, taken}, and a definition that says
nothing of how a secret is taken is refused with the word to write, because a credential rotated
under software that never reads it again is worse than one left alone (issue 179). `applied` is
refused by name until the staged form ADR 0114 decided is built; a value given to the mesh is
refused as ADR 0113 says. `rotate` is a verb on the controller's seat with two shapes — a pair
credential by provision, an own secret by machine, module and name — so the console can ask.
Registered manifests keep their bytes: a path alone is written back as a path.
2026-10-01 11:41:49 +02:00
mesh-admin 0e977399d4 Merge pull request 'The controller's own manifest places the mesh's files, now that the word is live (issue 174)' (#182) from feat/the-controllers-own-manifest-is-placed into main 2026-10-01 08:57:29 +00:00
jschoubben c462db105e The controller's own manifest places the mesh's files, now that the word is live (issue 174)
Held back one release (#177) because the controller that reads `place: "mesh"` could not be built
by the one that did not. It runs since 2026-10-01 00:06; the manifest names no host path now, and
resolves to exactly the paths it named (TestPlacedDirectoriesKeepTheirPaths over both).
2026-10-01 10:56:42 +02:00
mesh-admin 7273ca2f0f Merge pull request 'A routed name resolves to the node whose proxy serves it, never to a provider merely told it (hq 178, forge 227)' (#181) from fix/227-a-name-resolves-to-the-node-that-serves-it into main 2026-10-01 00:04:14 +00:00
jschoubben ad797d8742 A routed name resolves to the node whose proxy serves it, never to a provider merely told it (hq 178)
The names region attributed a composed name to whichever labelled contribution a map yielded last.
A dashboard contributes its label to its route and to the identity provider, which must know the
public name for a redirect; so on one plan grafana.<domain> pointed at the proxy's machine and on
the next at the identity provider's, and the whole region flipped with it (forge issue 227). And a
module routed several times contributed no name at all, because the single-value reading of its
contributions is empty for the many shape.

Now every node's resolution is read first and the names are attributed across them at once: the
terminus serves the name — the provider that is not itself published under a labelled name through
another — walked in order, so one mesh yields one region. Name-agnostic, structural, deterministic.
2026-10-01 02:03:50 +02:00
mesh-admin 5b39e95361 Merge pull request 'Two store-backed tests rotted because nothing runs the check (issue 177)' (#180) from fix/the-converged-declaration-guard into main 2026-09-30 23:37:40 +00:00
jschoubben 7e4a0ecf9a Two store-backed tests rotted because nothing runs the check (novox/hq issue 177)
The converged-declaration guard still expected `hosts` on a container after c978aa7 took it off
every container, and the adopted-anchor fixture reported no outward link after ADR 0140 made a
filter depend on one. Both failed under `make check` since 2026-09-28/30; the build does not run the
check, so the mesh never saw it. The guard is re-captured with the change named — a field an older
host never sees is the one change it permits — and the fixture reports a link as a real host does.
2026-10-01 01:37:18 +02:00
mesh-admin 7661f57833 Merge pull request 'A build is taken in where its outcome is heard, and the build tool answers at once (issue 176)' (#179) from fix/176-a-build-is-registered-where-it-is-heard into main 2026-09-30 23:27:29 +00:00
jschoubben 076e0ae259 A build is taken in where its outcome is heard, and the build tool answers at once (issue 176)
The console's `build` tool answered "no build machine answered within 0s", handed a forge path to
git as written, and a build heard afterwards was recorded and never registered: recording and
registration lived only in the waiting caller, and the tool did not wait.

Now one function takes a build's outcome in — records it, parses the manifest, refuses a definition
naming an installation, registers the module with its source as the seat and path the request
carried — and both the waiting command and the daemon that follows the role's `built` event call
it. `build --wait 0` asks and returns with the id; `builds --log <id>` follows it. The seat verb
says `--self` for a repository given without a scheme.
2026-10-01 01:27:04 +02:00
mesh-admin a96e2f0d78 Merge pull request 'A build says what it does on the bus, as it happens (ADR 0157)' (#178) from feat/a-build-says-what-it-does into main 2026-09-30 22:43:52 +00:00
jschoubben 17f7cb0d9c A build says what it does on the bus, as it happens (novox/hq ADR 0157)
The build-machine seat emits `started` and `log.<build id>` beside `built`. Every line the builder
speaks — each step, each command with its duration, and on failure the command's own output — goes
to stderr as before and onto the bus under the build's id, one subject per build, kept a week in
EVENTS with every other event. `builds --log <id>` reads it back from the stream with a consumer
that is gone when the reading is done, on the command line and as the controller's seat verb;
`builds` lists each build's id and `build` says the id it asked with.

Lines are core publishes with a sequence number, so a build is not slowed by an ack per line and a
gap is visible; `started` and `built` are awaited into the stream. The seat protocol widens
additively at the controller's next start; the holder's grant follows on the broker node's next
composition.
2026-10-01 00:43:07 +02:00
mesh-admin f6685ed22d Merge pull request 'An assignment places a module's directories and its accesses (hq 153)' (#176) from feat/153-an-assignment-places-directories-and-accesses into main 2026-09-30 22:03:33 +00:00
jschoubben 52c18f7a45 The path-preservation proof resolves access ids to their default paths too
An access named by id (issue 153) resolves to the path the definition still carries when the
assignment says nothing, and the proof compares that — the same rule as a placed directory.
2026-10-01 00:01:41 +02:00
jschoubben fa7415fcd0 Merge main into the branch: the assignment's placement sits beside the mesh's own place (issue 174) 2026-09-30 23:47:09 +02:00
mesh-admin f8947a806d Merge pull request 'The controller's own manifest names its paths for one more release' (#177) from fix/the-controllers-own-manifest-waits-a-release into main 2026-09-30 21:15:10 +00:00
jschoubben b98e503a61 The controller's own manifest names its paths for one more release
A manifest word ships one release after the code that reads it. The merged manifest already said
`place: "mesh"`, and the running controller, which does not know the word, refused its own build
result — so the controller that knows it could never be built. The literal paths come back here;
the conversion follows once this release runs.
2026-09-30 23:11:36 +02:00
jschoubben 5b832918df Merge pull request 'A setting reaches only what declares it, the mesh places its own files, registration refuses a name (issues 173, 174, ADR 0155)' (#175) from feat/the-mesh-places-its-own-files into main 2026-09-30 20:50:59 +00:00
jschoubben 17bbcc1596 An assignment places a module's directories and its accesses (hq 153)
A definition names no host path (ADR 0112); an adopted machine keeps its
data where the predecessor put it. Two settings, validated like endpoints:

  places:   {<directory id>: <path> | {path, owner}}
  accesses: {<access id>: <path>}

An access may now be declared by id (`{"id": "series", "mode": "read-write"}`)
and named in mounts, env and content as ${access:<id>}; the assignment
says where it is on this node, and an access nobody placed is refused by
name. A definition still carrying a path keeps it as the default the
assignment replaces. A placed directory takes the assignment's owner
where it says one. Resolved in composition, so the host receives paths
and owners exactly as before.
2026-09-30 22:42:50 +02:00
jschoubben 29be985c23 A served value or a contribution's may be the operator's: ${setting:…} fills there too, refused by name when unset
Issue 173's rule needs it: a mail provider serves its domain and an identity provider its issuer,
and neither is the definition's to state. Declared as ${setting:<key>}, filled from the layers after
the overrides; a key so asked for is not stray.
2026-09-30 22:34:43 +02:00
jschoubben 05d977666a A setting reaches only what declares it, the mesh places its own files, registration refuses a name
Three of novox/hq's group-4 leftovers, one branch.

Issue 173: a module's settings reached every route it contributed, every database it asked for and
every served fact its consumers read — a mail server's site name arrived at the proxy as a route
fact. A setting now overrides a key a contribution or served fact declares and adds none; a file
still merges any key, and a key nothing takes is named as stray instead of dropped silently.

Issue 174: the mesh's own files for a module — its bus credential, its merged config, its bindings —
were placed by the definition under /var/lib/mesh/<module>, 232 host paths in 50 definitions. A
directory may now say `place: "mesh"` and resolves to <root>/mesh/<module>; a directory beneath a
placed one may state its path as `${dir:<id>}/<rest>` and moves with it. The proof test resolves
both catalogues and compares: 48 definitions, no path moved. The controller's own manifest is
converted here; the catalogue in mesh-catalog.

ADR 0155: the installation check moves to registration. `module add` and a build's result both
refuse a definition that names an installation, in the check's words, with the way out; the build
stays recorded.
2026-09-30 22:29:28 +02:00
jschoubben e871991495 Merge pull request 'The catalogue-wide checks run against the sibling checkout by default' (#174) from fix/the-catalogue-checks-run-by-default into main 2026-09-30 20:10:44 +00:00
jschoubben f9e19814eb The catalogue-wide checks run against the checkout beside this one by default
A check that only ran when somebody remembered a variable was a check nobody ran (novox/hq issue
134). MESH_CATALOGUE still overrides; the checks skip only when no catalogue can be found.
2026-09-30 22:10:42 +02:00
jschoubben af31315a5f Merge pull request 'The controller takes one announcement at a time' (#173) from fix/one-announcement-at-a-time into main 2026-09-30 19:37:56 +00:00
jschoubben 474f68b34c The controller takes one announcement at a time
A merge's handler builds for minutes and keeps its own delivery alive; the announcements handed over
behind it timed out on the client and came back, and a merge that came back rebuilt what it had just
built, five times over (novox/hq issue 175). MaxAckPending 1 on the events consumer: the server holds
the rest.
2026-09-30 21:37:53 +02:00
jschoubben 1a724f20fe Merge pull request 'The seat rename survives a row seeded under the new name' (#172) from fix/the-seat-rename-survives-a-seeded-row into main 2026-09-30 19:34:37 +00:00
jschoubben 0da0bb2157 The seat rename survives a row seeded under the new name
A controller whose defaults carry the new name seeds it before the migration runs, and the first
form renamed into a duplicate key; the control node's prepare failed on every attempt (2026-09-30).
If the new row exists, the old row's holding moves to it and the old row goes; otherwise it is
renamed. The old name becomes an alias either way.
2026-09-30 21:34:34 +02:00
jschoubben 118e333ff8 Merge pull request 'The artifact store's seat is named for its scope: mesh-artifact-store' (#171) from feat/the-artifact-store-seat-is-named-for-its-scope into main
Reviewed-on: #171
2026-09-30 19:16:47 +00:00
jschoubben c1334f3f85 The artifact store's seat is named for its scope: mesh-artifact-store
ADR 0121 decided it and deferred it as a delivering-seat migration; ADR 0122's aliases made it one
update and one alias (migration 0048). The former name resolves to it forever (novox/hq ADR 0156,
issue 123).
2026-09-30 21:14:40 +02:00
jschoubben 70d379816c Merge pull request 'A converted definition resolves to the paths it named before' (#170) from feat/definitions-place-their-directories into main 2026-09-30 19:11:45 +00:00
jschoubben d8e7c13f6d A converted definition resolves to the paths it named before
The check novox/hq issue 119 asks for before a definition stops naming where its data lives: two
catalogue checkouts, every module in both resolved with the controller's own rule and compared whole.
2026-09-30 21:10:18 +02:00
jschoubben 1851a15e57 Merge pull request 'A definition names no installation: the check, an operator's value, a context on the git seat' (#169) from feat/a-definition-names-no-installation into main
Reviewed-on: #169
2026-09-30 18:36:17 +00:00
jschoubben d9dbc9a59a A definition names no installation: the check, an operator's value, a context on the git seat
InstallationProblems judges every value the mesh acts on for a name under a public top-level domain
or a public address, with prose, the world's registries, resolvers and certificate authorities
exempt, and a name a resource means on purpose declared with its reason (names-on-purpose). Run by
module check and a catalogue-wide test, not yet at registration, while the declared list shrinks.
${setting:<key>} fills a file from the assignment's settings and is refused when nothing set it.
A build context may live on the git seat; the request carries the seat's clone base (novox/hq ADR
0112, ADR 0155, issues 122 and 134).
2026-09-30 18:38:06 +02:00
jschoubben d5e1332dda Merge pull request 'A JSON verb's answer is its standard output alone' (#168) from fix/a-verbs-answer-is-its-stdout into main
Reviewed-on: #168
2026-09-30 16:20:14 +00:00
jschoubben 5ea0e87059 A JSON verb's answer is its standard output alone
status --json prints its warnings beside the document; parsed from both streams together the first
status asked through the console carried no answer as data. Output stays both streams, in order.
2026-09-30 18:18:43 +02:00
jschoubben 8e81266cdc Merge pull request 'A holder binds its seat's tools when it may, not only when it starts' (#167) from fix/a-holder-binds-when-it-may into main 2026-09-30 16:13:46 +00:00
jschoubben 70705ffe45 A holder binds its seat's tools when it may, not only when it starts
The grant is a line in the bus's user list the controller itself composes and a push delivers, so
the first controller to serve its seat started before the list named it and every subscription was
refused for good (2026-09-30). A refused subscription is retried until it holds.
2026-09-30 17:59:23 +02:00
jschoubben 91c4da8a82 Merge pull request 'The mesh's own verbs are the mesh-controller seat's tools' (#166) from feat/the-mesh-answers-for-itself into main
Reviewed-on: #166
2026-09-30 15:54:18 +00:00
jschoubben e9df5dccab The mesh's own verbs are the mesh-controller seat's tools
A seat's protocol lives in the store (migration 0047; seeded additively), a served verb carries its
description and schema, holding a mesh seat requires serving its verbs, a node-scoped seat's tool
carries the node, and the control plane serves status, nodes, node, modules, seats, builds, plan,
assign, unassign, push, build and tools on its seat by running the same commands (novox/hq ADR 0132,
ADR 0154, design 33). A grant of * reaches a role's tools; seat:<seat>.<verb> grants one.
2026-09-30 17:39:38 +02:00
mesh-admin 990ef27cd2 Merge pull request 'A module is told the name it is served under (hq 122)' (#149) from fix/122-a-module-is-told-its-own-name into main 2026-09-30 15:13:53 +00:00
jschoubben c585158836 The two seat commands appear in the usage text
Both existed and neither was listed: rename since ADR 0122, the handover since
ADR 0131. Found by asking the running binary for help and seeing only the list.
2026-09-27 23:45:59 +02:00
77 changed files with 4563 additions and 248 deletions
+23 -12
View File
@@ -148,20 +148,34 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// it either finishes or fails is indistinguishable from one that never arrived — which cost a long // it either finishes or fails is indistinguishable from one that never arrived — which cost a long
// diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that // diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that
// the handler said nothing until the end. // the handler said nothing until the end.
fmt.Fprintf(os.Stderr, "a build request arrived for %s\n", request.Repository) fmt.Fprintf(os.Stderr, "a build request arrived for %s (%s)\n", request.Repository, request.ID)
// **Everything a build says goes two ways**: to stderr, as always, and onto the bus as the
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
// watching, reads the same lines this container's log holds, live, and after the fact from the
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
say := func(step, message string) {
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
work.Say(step, message)
}
builder.Said = say
defer func() { builder.Said = nil }()
if err := work.Began(ctx); err != nil {
fmt.Fprintf(os.Stderr, "cannot say a build started: %v\n", err)
}
result := link.BuildResult{ result := link.BuildResult{
ID: request.ID, Repository: request.Repository, Path: request.Path, ID: request.ID, Repository: request.Repository, Path: request.Path,
Ref: request.Ref, On: on, Ref: request.Ref, On: on, Source: request.Source,
} }
fmt.Fprintf(os.Stderr, "building %s", request.Repository) what := "building " + request.Repository
if request.Path != "" { if request.Path != "" {
fmt.Fprintf(os.Stderr, " at %s", request.Path) what += " at " + request.Path
} }
if request.Ref != "" { if request.Ref != "" {
fmt.Fprintf(os.Stderr, " at %s", request.Ref) what += " on " + request.Ref
} }
fmt.Fprintln(os.Stderr) say("build", what)
npmrc, err := packagesFrom() npmrc, err := packagesFrom()
var built builder.Result var built builder.Result
@@ -171,16 +185,13 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// after a clone that then fails at npm ci. // after a clone that then fails at npm ci.
built, err = builder.Build(ctx, builder.Command, publisher, built, err = builder.Build(ctx, builder.Command, publisher,
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc, request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(), forgeFrom(), say, request.Seats)
func(step, message string) {
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
})
} }
if err != nil { if err != nil {
// A failure is a result. A build that fails and says nothing is indistinguishable from a // A failure is a result. A build that fails and says nothing is indistinguishable from a
// builder that is not running, and those want completely different responses. // builder that is not running, and those want completely different responses.
result.Failed = err.Error() result.Failed = err.Error()
fmt.Fprintf(os.Stderr, " failed: %v\n", err) say("failed", err.Error())
} else { } else {
manifest, marshalErr := json.Marshal(built.Manifest) manifest, marshalErr := json.Marshal(built.Manifest)
if marshalErr != nil { if marshalErr != nil {
@@ -197,7 +208,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
for _, r := range built.Read { for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
} }
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit)) say("built", built.Manifest.Module+" from "+short(built.Commit))
} }
} }
+4
View File
@@ -91,6 +91,10 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{ if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body), Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
Firewall: "ufw", Held: held, Reachable: reachable, Firewall: "ufw", Held: held, Reachable: reachable,
// A machine says which of its links face outside on every apply (novox/hq ADR 0140), and a
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
Outward: []string{"eth0"},
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+129 -41
View File
@@ -10,6 +10,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
@@ -175,10 +177,14 @@ func buildFrom(result link.BuildResult) inventory.Build {
func buildsCommand(ctx context.Context, args []string) error { func buildsCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("builds", flag.ContinueOnError) set := flag.NewFlagSet("builds", flag.ContinueOnError)
limit := set.Int("n", 20, "how many to show") limit := set.Int("n", 20, "how many to show")
logOf := set.String("log", "", "a build's id: print what the build machine said, line by line")
positionals, err := parseAround(set, args) positionals, err := parseAround(set, args)
if err != nil { if err != nil {
return err return err
} }
if *logOf != "" {
return buildLog(ctx, *logOf)
}
module := "" module := ""
if len(positionals) == 1 { if len(positionals) == 1 {
module = positionals[0] module = positionals[0]
@@ -219,8 +225,8 @@ func buildsCommand(ctx context.Context, args []string) error {
if !b.Worked() { if !b.Worked() {
outcome = "failed" outcome = "failed"
} }
fmt.Printf("%-18s %-14s %-10s %s\n", fmt.Printf("%-18s %-14s %-10s %s %s\n",
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04")) what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
fmt.Printf(" %s", b.Repository) fmt.Printf(" %s", b.Repository)
if b.Ref != "" { if b.Ref != "" {
fmt.Printf(" at %s", b.Ref) fmt.Printf(" at %s", b.Ref)
@@ -408,11 +414,14 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
Path: path, Path: path,
Ref: ref, Ref: ref,
Held: heldBy(ctx), Held: heldBy(ctx),
Seats: seatBases(ctx),
} }
fmt.Printf("asked for %s", source) fmt.Printf("asked for %s", source)
if source.Seat != "" { if source.Seat != "" {
fmt.Printf(" (%s)", repository) fmt.Printf(" (%s)", repository)
} }
// The id is how a person follows this build while it runs: `builds --log <id>`.
fmt.Printf(" as %s", request.ID)
if path != "" { if path != "" {
fmt.Printf(" at %s", path) fmt.Printf(" at %s", path)
} }
@@ -427,66 +436,91 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
} }
defer ask.Close() defer ask.Close()
if wait == 0 {
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
// controller takes it in — records the build, registers the module — whether or not anybody
// is still here. A tool call cannot hold a connection for the minutes a build takes; it
// follows the build by its id instead.
if err := ask.Ask(ctx, request); err != nil {
return err
}
fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+
"shows what came of it; the module is registered when the outcome comes\n", request.ID)
return nil
}
result, err := ask.Submit(ctx, request, wait) result, err := ask.Submit(ctx, request, wait)
if err != nil { if err != nil {
return err return err
} }
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
// nobody asked for, and the difference is the whole of whether somebody should be looking at
// something.
open, err := openStores(ctx) open, err := openStores(ctx)
if err != nil { if err != nil {
return err return err
} }
defer open.Close() defer open.Close()
inv := open.inventory manifest, kept, err := takeIn(ctx, open.inventory, result)
kept := buildFrom(result) if err != nil {
if err := inv.RecordBuild(ctx, kept); err != nil {
return err return err
} }
if result.Failed != "" {
// The builder's own words. Wrapping them in something about the control plane would put
// two explanations between a person and a build log.
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
}
// Said as recorded: what each artifact is, not where this builder happened to push it. // Said as recorded: what each artifact is, not where this builder happened to push it.
for _, made := range kept.Made { for _, made := range kept.Made {
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference) fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
} }
// Parsed with the same parser a hand-written manifest goes through. A second path would be a
// second thing to disagree about what a manifest is. The manifest as recorded, so the catalogue
// holds references by digest and path and every declaration composes the store's address in.
manifest, err := catalogue.ParseManifest(kept.Manifest)
if err != nil {
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
result.On, result.Repository, err)
}
// Recorded with where it came from, so "is this current?" is answerable without building it
// again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL
// just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put
// the forge's address back into every module built from it. The build log above keeps the URL,
// because that is what was cloned.
recorded := inventory.Source{
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
BuiltFrom: result.Commit, Head: result.Commit,
}
if source.Seat != "" {
recorded.Repository, recorded.Seat = source.Repository, source.Seat
}
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
return err
}
fmt.Printf("\n%s %s, built on %s from %s\n", fmt.Printf("\n%s %s, built on %s from %s\n",
manifest.Module, manifest.Version, result.On, short(result.Commit)) manifest.Module, manifest.Version, result.On, short(result.Commit))
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module) fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
return nil return nil
} }
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
// result reaches the catalogue whether or not the asker was still listening.
//
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
// nobody asked for, and the difference is the whole of whether somebody should be looking at
// something. Then parsed with the same parser a hand-written manifest goes through — a second path
// would be a second thing to disagree about what a manifest is — and registered with where it came
// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111),
// which the request carried and the outcome echoes. A definition naming an installation is refused
// here, where it would enter the catalogue; the build stays recorded and the refusal says which.
//
// Idempotent: the same outcome taken in twice registers the same module twice, which is one row
// written with the same values.
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
catalogue.Manifest, inventory.Build, error) {
kept := buildFrom(result)
if err := inv.RecordBuild(ctx, kept); err != nil {
return catalogue.Manifest{}, kept, err
}
if result.Failed != "" {
// The builder's own words. Wrapping them in something about the control plane would put
// two explanations between a person and a build log.
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
result.On, result.Repository, result.Failed)
}
manifest, err := catalogue.ParseManifest(kept.Manifest)
if err != nil {
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
result.On, result.Repository, err)
}
recorded := inventory.Source{
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
BuiltFrom: result.Commit, Head: result.Commit,
}
if result.Source != nil && result.Source.Seat != "" {
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
}
if err := namesNoInstallation(manifest); err != nil {
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
result.On, result.Repository, short(result.Commit), err)
}
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
return manifest, kept, err
}
return manifest, kept, nil
}
// buildAndShow builds and prints the manifest without recording anything. // buildAndShow builds and prints the manifest without recording anything.
func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
repository, err := cloneFrom(ctx, source) repository, err := cloneFrom(ctx, source)
@@ -513,7 +547,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
result, err := ask.Submit(ctx, link.BuildRequest{ result, err := ask.Submit(ctx, link.BuildRequest{
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()), ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
Repository: repository, Path: path, Ref: ref, Repository: repository, Path: path, Ref: ref,
Held: heldBy(ctx), Held: heldBy(ctx), Seats: seatBases(ctx),
}, wait) }, wait)
if err != nil { if err != nil {
return err return err
@@ -618,3 +652,57 @@ func askOver(_ *link.Server) (link.Builders, error) {
} }
return link.BuildsOverNATS(address) return link.BuildsOverNATS(address)
} }
// buildLog prints everything a build machine said about one build, read back from the bus.
//
// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own
// events under the build's id, retained with every other event; the mesh keeps no second copy. Read
// with a consumer of its own that is gone when this returns, so nothing accumulates in the server
// for the reading, and filtered by subject, so one build's lines are all that travel.
func buildLog(ctx context.Context, id string) error {
address, err := broker.BusAddress()
if err != nil {
return err
}
js, err := broker.Dial(address)
if err != nil {
return fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
}
defer js.Close()
sub, err := js.Context().PullSubscribe(link.BuildLog(id), "",
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
if err != nil {
return fmt.Errorf("cannot read %s from the bus: %w", link.BuildLog(id), err)
}
defer func() { _ = sub.Unsubscribe() }()
printed := 0
for {
batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second))
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("reading a build's log: %w", err)
}
for _, msg := range batch {
var line link.BuildLine
if err := json.Unmarshal(msg.Data, &line); err != nil {
fmt.Printf(" ? %s\n", string(msg.Data))
continue
}
at := line.At
if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil {
at = t.Local().Format("15:04:05")
}
fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message)
printed++
}
if len(batch) < 200 {
break
}
}
if printed == 0 {
fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+
"machine older than this that said nothing while building\n", id)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/link"
)
// A build's outcome is taken in the same way whoever hears it (novox/hq issue 176): recorded, and
// the module registered with its source as the seat and path when the request said so — never the
// URL. A definition naming an installation is recorded and not registered; a failure is recorded
// and said.
func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
})
if err != nil {
t.Fatal(err)
}
if m.Module != "shop" {
t.Fatalf("registered %q", m.Module)
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if _, held := shelf["shop"]; !held {
t.Fatal("the module a heard build produced is not in the catalogue")
}
src, err := open.inventory.SourceOf(ctx, "shop")
if err != nil || src.Seat != "git" || src.Repository != "novox/shop" || src.BuiltFrom != "abcdef0123" {
t.Fatalf("the source is the seat and the path, never the URL: %+v %v", src, err)
}
builds, err := open.inventory.Builds(ctx, "shop", 5)
if err != nil || len(builds) != 1 || builds[0].ID != "b-1" {
t.Fatalf("the build is not recorded once: %v %v", builds, err)
}
named, _ := json.Marshal(map[string]any{"module": "idp", "version": "1", "resources": []any{
map[string]any{"id": "server", "type": "container", "image": "x@sha256:aa",
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}}})
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{
ID: "b-2", Repository: "/r", On: "anchor", Commit: "0123456789", Manifest: named})
if err == nil || !strings.Contains(err.Error(), "does not register it") {
t.Fatalf("a definition naming an installation was taken in: %v", err)
}
if shelf, _ := open.inventory.Catalogue(ctx); shelf["idp"].Module != "" {
t.Fatal("the refused module was registered anyway")
}
if builds, _ := open.inventory.Builds(ctx, "idp", 5); len(builds) != 1 {
t.Fatalf("the refused build was not recorded: %v", builds)
}
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{ID: "b-3", Repository: "/r", On: "anchor", Failed: "no compiler"})
if err == nil || !strings.Contains(err.Error(), "no compiler") {
t.Fatalf("a failure is said in the builder's words: %v", err)
}
}
+13
View File
@@ -30,6 +30,7 @@ func moduleCheck(paths []string, out io.Writer) error {
"manifest of a repository together so the rules between them are checked too") "manifest of a repository together so the rules between them are checked too")
} }
shelf := catalogue.Shelf{} shelf := catalogue.Shelf{}
faulted := map[string]bool{}
failed := 0 failed := 0
for _, path := range paths { for _, path := range paths {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
@@ -50,6 +51,15 @@ func moduleCheck(paths []string, out io.Writer) error {
failed++ failed++
continue continue
} }
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
// catalogue-wide test, and at registration, which refuses in the same words.
if named := catalogue.InstallationProblems(m); len(named) > 0 {
for _, p := range named {
fmt.Fprintf(out, "%s: %s\n", path, p)
}
failed += len(named)
faulted[m.Module] = true
}
shelf[m.Module] = m shelf[m.Module] = m
} }
@@ -70,6 +80,9 @@ func moduleCheck(paths []string, out io.Writer) error {
sort.Strings(names) sort.Strings(names)
for _, name := range names { for _, name := range names {
m := shelf[name] m := shelf[name]
if faulted[name] {
continue
}
fmt.Fprintf(out, "%s: ok", name) fmt.Fprintf(out, "%s: ok", name)
if n := len(m.Tools); n > 0 { if n := len(m.Tools); n > 0 {
fmt.Fprintf(out, ", %d tool(s)", n) fmt.Fprintf(out, ", %d tool(s)", n)
+25
View File
@@ -5,6 +5,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/novox/mesh-controller/internal/catalogue"
"testing" "testing"
) )
@@ -67,3 +69,26 @@ func TestModuleCheckPassesTheCatalogue(t *testing.T) {
t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String()) t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String())
} }
} }
func TestRegistrationRefusesADefinitionNamingAnInstallation(t *testing.T) {
// novox/hq ADR 0155: the check moves to registration once the catalogue passes it. Both
// ways in — `module add` and a build's result — go through this, and a name declared on
// purpose passes with its reason.
named := catalogue.Manifest{Module: "idp", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "x@sha256:aa",
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
}}
err := namesNoInstallation(named)
if err == nil || !strings.Contains(err.Error(), "login.mesh-one.be") ||
!strings.Contains(err.Error(), catalogue.NamesOnPurpose) {
t.Fatalf("a definition naming an installation is refused with the name and the way out; got %v", err)
}
meant := catalogue.Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
catalogue.NamesOnPurpose: map[string]any{
"registry.mesh-one.be": "built outside the mesh until its repository is a build source here"}},
}}
if err := namesNoInstallation(meant); err != nil {
t.Fatalf("a name declared on purpose passes; got %v", err)
}
}
+18 -1
View File
@@ -172,6 +172,8 @@ func usage() {
upgrade <name> record ...record that they are behind, and send nothing upgrade <name> record ...record that they are behind, and send nothing
status [--json] what is wrong, what is quiet, and what is out of date status [--json] what is wrong, what is quiet, and what is out of date
seats [--json] every seat this mesh defines, what it delivers, and who holds it seats [--json] every seat this mesh defines, what it delivers, and who holds it
seat rename <from> <to> rename a seat; its former name still resolves (ADR 0122)
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
board [--listen ADDR] the same three questions, as a page that holds nothing board [--listen ADDR] the same three questions, as a page that holds nothing
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
assign <node> <module> put a module on a node assign <node> <module> put a module on a node
@@ -241,6 +243,21 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
} }
} }
// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or
// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module
// registered, the same as the waiting command does. Said either way, so the daemon's log tells what
// became of a build nobody was watching.
func (b builds) Built(ctx context.Context, result link.BuildResult) error { func (b builds) Built(ctx context.Context, result link.BuildResult) error {
return b.inv.RecordBuild(ctx, buildFrom(result)) manifest, _, err := takeIn(ctx, b.inv, result)
switch {
case err != nil && result.Failed != "":
fmt.Printf("%s: %v\n", result.ID, err)
return nil
case err != nil:
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
return nil
}
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
return nil
} }
+61 -1
View File
@@ -113,6 +113,9 @@ func moduleCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := namesNoInstallation(m); err != nil {
return err
}
if err := inv.RegisterModule(ctx, m, from); err != nil { if err := inv.RegisterModule(ctx, m, from); err != nil {
return err return err
} }
@@ -581,6 +584,14 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment. // (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
node, busAddress string, known broker.Broker, reachable, user, password string) error { node, busAddress string, known broker.Broker, reachable, user, password string) error {
// The seats this module claims, with the verbs each promises (novox/hq ADR 0159): the runtime
// serves a claimed seat's verbs with its tools of the same name, and the bus admits only the
// holder's subscription — so the runtime tries each claim and the grant decides. Written here
// because this file is the one thing the mesh writes that the runtime reads before it speaks.
claims, err := claimsFor(ctx, inv, m)
if err != nil {
return err
}
held, err := json.Marshal(struct { held, err := json.Marshal(struct {
URL string `json:"url"` URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"` Fingerprint string `json:"fingerprint,omitempty"`
@@ -588,9 +599,10 @@ func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
Module string `json:"module"` Module string `json:"module"`
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` Password string `json:"password"`
Claims []seatClaimed `json:"claims,omitempty"`
}{ }{
URL: "nats://" + reachable, Fingerprint: known.Fingerprint, URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
Node: node, Module: m.Module, User: user, Password: password, Node: node, Module: m.Module, User: user, Password: password, Claims: claims,
}) })
if err != nil { if err != nil {
return err return err
@@ -662,3 +674,51 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor
} }
return from, nil return from, nil
} }
// namesNoInstallation is the mesh refusing a definition that names an installation, at the moment
// it would enter the catalogue (novox/hq ADR 0112, ADR 0155). `module check` says the same thing
// earlier, where the author is; this is the last moment the mesh can still say no, and a
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
// in the same words. A name meant on purpose is declared with its reason and passes.
func namesNoInstallation(m catalogue.Manifest) error {
named := catalogue.InstallationProblems(m)
if len(named) == 0 {
return nil
}
return fmt.Errorf("%s names an installation, and a definition names none — declare a name meant "+
"on purpose under %s with its reason, or take it out:\n - %s",
m.Module, catalogue.NamesOnPurpose, strings.Join(named, "\n - "))
}
// seatClaimed is one seat a module claims, as its runtime needs it: the name, the scope (a
// node-scoped seat's verb carries the machine, design 33 §4) and the verbs the seat promises.
type seatClaimed struct {
Seat string `json:"seat"`
Scope string `json:"scope"`
Serves []string `json:"serves,omitempty"`
}
// claimsFor joins a module's claims with the seats' protocols from the mesh's records.
func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest) ([]seatClaimed, error) {
if len(m.Claims) == 0 {
return nil, nil
}
seats, err := inv.Seats(ctx)
if err != nil {
return nil, err
}
byName := map[string]catalogue.Seat{}
for _, s := range seats {
byName[s.Name] = s
}
var out []seatClaimed
for _, c := range m.Claims {
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
if s, known := byName[c.Name]; known {
claimed.Scope = s.Scope
claimed.Serves = catalogue.VerbNames(s.Serves)
}
out = append(out, claimed)
}
return out, nil
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
// A module that declares none is refused before the account exists, so the bus never carries an // A module that declares none is refused before the account exists, so the bus never carries an
// account nothing reads (novox/hq 04-ISSUES/078). // account nothing reads (novox/hq 04-ISSUES/078).
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) { func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: map[string]string{"password": "/run/password"}}) err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}})
if err == nil { if err == nil {
t.Fatal("a module with no broker own secret was issued an account") t.Fatal("a module with no broker own secret was issued an account")
} }
@@ -20,7 +20,7 @@ func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
t.Errorf("the refusal does not say %q: %v", want, err) t.Errorf("the refusal does not say %q: %v", want, err)
} }
} }
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: map[string]string{"broker": "/run/broker"}}); err != nil { if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}); err != nil {
t.Errorf("a module declaring its broker secret was refused: %v", err) t.Errorf("a module declaring its broker secret was refused: %v", err)
} }
} }
+41 -32
View File
@@ -163,7 +163,14 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
} }
continue continue
} }
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) var secret inventory.Secret
var err error
if n.SharedOwn != "" {
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
} else {
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
}
if err != nil { if err != nil {
// Said rather than skipped. A machine that resolves cleanly and receives no // Said rather than skipped. A machine that resolves cleanly and receives no
// credential is one that will fail to authenticate at some later, less obvious // credential is one that will fail to authenticate at some later, less obvious
@@ -731,9 +738,13 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err) return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
} }
out := map[string]string{} // Every machine's resolution first, then the names across them at once: which node serves a
// name is a question about the graph — the consumer on one machine, the provider on another —
// and answered wrongly by looking at one contribution at a time (novox/hq issue 178).
plans := map[string]catalogue.Resolution{}
settings := map[string]catalogue.SettingsBy{}
for _, n := range nodes { for _, n := range nodes {
plan, settings, err := planFor(ctx, open, n.Name) plan, layers, err := planFor(ctx, open, n.Name)
switch { switch {
case unresolvable(err): case unresolvable(err):
// Their set does not compose, so they serve no names. Passed over, so one machine's // Their set does not compose, so they serve no names. Passed over, so one machine's
@@ -745,38 +756,16 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
// operator having withdrawn them (novox/hq 04-ISSUES/152). // operator having withdrawn them (novox/hq 04-ISSUES/152).
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err) return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
} }
for _, m := range plan.Modules { plans[n.Name], settings[n.Name] = plan, layers
for to := range m.Contributes { }
values, asks, err := plan.ContributionsFrom(to, m.Module, settings) served, err := catalogue.NamesServed(plans, settings)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !asks { out := map[string]string{}
continue for name, node := range served {
} if at := address[node]; at != "" {
// A routed name, and only that: a contribution the mesh composed a name for from a out[name] = at
// label it was given. A grant that happens to carry a `name` of its own — a database
// name — carries no label and is left alone.
if _, labelled := values["label"]; !labelled {
continue
}
name, _ := values["name"].(string)
if name == "" {
continue
}
// The node that serves it: whoever answers this consumer's route requirement, or
// this same node when the proxy is beside the consumer.
serving := n.Name
for _, need := range plan.Needs {
if need.Name == to && need.For == m.Module {
serving = need.From
break
}
}
if at := address[serving]; at != "" {
out[strings.ToLower(name)] = at
}
}
} }
} }
return out, nil return out, nil
@@ -1356,3 +1345,23 @@ func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
} }
return nil return nil
} }
// providerModuleOf is which module answers a need on the providing node: the one in this node's
// own set when the provider is here, else the one the catalogue says offers it.
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
for _, m := range resolved.Modules {
if _, shared := m.SharedCredentialOf(n.Name); shared {
return m.Module
}
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return ""
}
for name, m := range shelf {
if _, shared := m.SharedCredentialOf(n.Name); shared {
return name
}
}
return ""
}
+17
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log"
"os" "os"
"sort" "sort"
"strings" "strings"
@@ -124,6 +125,22 @@ func serve(ctx context.Context) error {
return err return err
} }
// 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()
if err != nil {
return err
}
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")
}
stopServing, err := bus.ServeSeatTools(catalogue.ControllerSeatName, handlers, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return err
}
defer stopServing()
return server.Serve(ctx) return server.Serve(ctx)
} }
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/link"
)
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
//
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
// decisions in it. Running a fresh process rather than calling the function keeps two things true
// that calling it would not — every command opens and closes its own stores the way it does from a
// shell, and nothing a command prints to the process's standard output can leak into another call's
// answer. It also means a refusal is the same refusal in the same words, because it is the same
// output.
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
// command speaks JSON — the same as data.
type verbAnswer struct {
Output string `json:"output"`
OK bool `json:"ok"`
Answer any `json:"answer,omitempty"`
}
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
func argvFor(verb string, args map[string]any) ([]string, error) {
str := func(key string) string {
v, _ := args[key].(string)
return strings.TrimSpace(v)
}
need := func(keys ...string) error {
for _, k := range keys {
if str(k) == "" {
return fmt.Errorf("%s needs %q", verb, k)
}
}
return nil
}
switch verb {
case "status":
return []string{"status", "--json"}, nil
case "nodes":
return []string{"node", "list"}, nil
case "node":
if err := need("node"); err != nil {
return nil, err
}
return []string{"node", "show", str("node")}, nil
case "modules":
return []string{"module", "list"}, nil
case "seats":
return []string{"seats", "--json"}, nil
case "builds":
if id := str("log"); id != "" {
return []string{"builds", "--log", id}, nil
}
if m := str("module"); m != "" {
return []string{"builds", m}, nil
}
return []string{"builds"}, nil
case "plan":
if err := need("node"); err != nil {
return nil, err
}
return []string{"plan", str("node"), "--json"}, nil
case "assign", "unassign":
if err := need("node", "module"); err != nil {
return nil, err
}
return []string{verb, str("node"), str("module")}, nil
case "push":
// Sent and not waited for: the asker reads `status` for what the machine did, which is
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
// time out on every machine that takes a minute, and say nothing about the ones that did not.
if n := str("node"); n != "" {
return []string{"push", n, "--wait", "0"}, nil
}
return []string{"push", "--behind", "--wait", "0"}, nil
case "rotate":
if p := str("provision"); p != "" {
argv := []string{"rotate", p}
if c := str("consumer"); c != "" {
argv = append(argv, "--consumer", c)
}
return argv, nil
}
if str("node") != "" && str("module") != "" && str("secret") != "" {
return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil
}
// Half of either shape: the command says its usage, which names both shapes, and that is
// the answer the caller needs.
return []string{"rotate"}, nil
case "build":
// Three shapes, as the command has them: a repository, a base every module built on it
// is rebuilt from (`--on`), or everything behind its source (`--behind`). Asked, not
// waited for, the same as a single build.
if on := str("on"); on != "" {
return []string{"build", "--on", on, "--wait", "0"}, nil
}
if b := str("behind"); b != "" && b != "no" && b != "false" {
return []string{"build", "--behind", "--wait", "0"}, nil
}
if err := need("repository"); err != nil {
return nil, err
}
// Not waited for: a tool call cannot hold a connection for the minutes a build takes; the
// daemon takes the outcome in when it comes and the id follows the build (issue 176). A
// repository given without a scheme is a path on the forge holding the git seat.
argv := []string{"build", str("repository"), "--wait", "0"}
if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") {
argv = append(argv, "--self")
}
if p := str("path"); p != "" {
argv = append(argv, "--path", p)
}
if r := str("ref"); r != "" {
argv = append(argv, "--ref", r)
}
return argv, nil
}
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
}
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
// runVerb runs this binary with the given command line and gathers what it said.
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
self, err := os.Executable()
if err != nil {
return verbAnswer{}, err
}
cmd := exec.CommandContext(ctx, self, argv...)
// The same environment: the stores' credentials, the bus, the broker — everything a command run
// from a shell in this container would have, because it is that.
cmd.Env = os.Environ()
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
// prints its warnings beside the document, and a JSON parsed from the two together parsed
// nothing (2026-09-30, the first status asked through the console had no `answer`).
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
if jsonVerbs[argv[0]] && runErr == nil {
var parsed any
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
answer.Answer = parsed
}
}
var exit *exec.ExitError
if runErr != nil && !errors.As(runErr, &exit) {
// Not the command refusing — the command not running at all, which is this process's fault.
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
}
return answer, nil
}
// 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) {
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
if !known {
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
}
handlers := map[string]link.ToolHandler{}
for _, v := range seat.Serves {
verb := v.Name
if verb == "tools" {
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
return seatTools(), nil
}
continue
}
if _, err := argvFor(verb, map[string]any{"node": "x", "module": "x", "repository": "x"}); err != nil {
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
catalogue.ControllerSeatName, verb, err)
}
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
args := map[string]any{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
}
}
argv, err := argvFor(verb, args)
if err != nil {
return nil, err
}
return runVerb(ctx, argv)
}
}
return handlers, nil
}
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
func seatTools() map[string]any {
var seats []map[string]any
for _, s := range catalogue.SeatsWithAProtocol() {
if len(s.Serves) == 0 {
continue
}
var tools []map[string]any
for _, v := range s.Serves {
tools = append(tools, map[string]any{
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
})
}
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
}
return map[string]any{"seats": seats}
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the
// schema names and no other (novox/hq ADR 0154, ADR 0035).
func TestEveryDeclaredVerbHasACommandLine(t *testing.T) {
for _, v := range catalogue.ControllerVerbs {
if v.Name == "tools" {
continue
}
args := map[string]any{}
props, _ := v.Input["properties"].(map[string]any)
for name := range props {
args[name] = "x"
}
argv, err := argvFor(v.Name, args)
if err != nil {
t.Errorf("%s: %v", v.Name, err)
continue
}
if argv[0] == "" {
t.Errorf("%s: empty command", v.Name)
}
}
}
// `builds` given a build's id reads that build's log from the bus rather than listing builds
// (novox/hq ADR 0157).
func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) {
argv, err := argvFor("builds", map[string]any{"log": "build-17"})
if err != nil {
t.Fatal(err)
}
if strings.Join(argv, " ") != "builds --log build-17" {
t.Fatalf("builds with a log id became %q", strings.Join(argv, " "))
}
}
// The build tool takes a repository as a URL or as its path on the forge holding the git seat, and
// says which it was given, so the command reads the path as a seat source rather than handing it to
// git as written (novox/hq issue 176). And it never waits: the id follows the build.
func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) {
argv, _ := argvFor("build", map[string]any{"repository": "novox/mesh-catalog", "path": "modules/x"})
if line := strings.Join(argv, " "); !strings.Contains(line, "--self") || !strings.Contains(line, "--wait 0") {
t.Fatalf("a forge path is a seat source, not waited for; got %q", line)
}
argv, _ = argvFor("build", map[string]any{"repository": "https://example.tld/o/r.git"})
if line := strings.Join(argv, " "); strings.Contains(line, "--self") {
t.Fatalf("a URL is cloned as given; got %q", line)
}
}
// `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a
// module's own secret by machine, module and name.
func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
argv, _ := argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace"})
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
t.Fatalf("a pair credential: %v", argv)
}
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
t.Fatalf("an own secret: %v", argv)
}
argv, _ = argvFor("rotate", map[string]any{"node": "ace"})
if strings.Join(argv, " ") != "rotate" {
t.Fatalf("half an own secret falls to the command's usage: %v", argv)
}
}
// A required argument missing is refused in the verb's own words, before anything runs.
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
t.Fatalf("node without a machine was accepted: %v", err)
}
if _, err := argvFor("upgrade", map[string]any{}); err == nil {
t.Fatal("a verb the seat does not serve was accepted")
}
}
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
func TestActsDoNotBlockTheCall(t *testing.T) {
argv, _ := argvFor("push", map[string]any{"node": "one"})
if strings.Join(argv, " ") != "push one --wait 0" {
t.Fatalf("push waits: %v", argv)
}
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
t.Fatalf("build: %v", argv)
}
}
// What `tools` answers is the seats' records, with each verb's schema.
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
handlers, err := seatToolHandlers()
if err != nil {
t.Fatal(err)
}
if len(handlers) != len(catalogue.ControllerVerbs) {
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
}
answer := seatTools()
seats, _ := answer["seats"].([]map[string]any)
var found bool
for _, s := range seats {
if s["seat"] == catalogue.ControllerSeatName {
found = true
tools, _ := s["tools"].([]map[string]any)
if len(tools) != len(catalogue.ControllerVerbs) || tools[0]["input"] == nil {
t.Fatalf("the controller seat's tools are not listed in full: %v", tools)
}
}
}
if !found {
t.Fatal("the mesh-controller seat is not in the listing")
}
}
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
// printed beside the document does not take the document away. The test binary stands in for the
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
jsonVerbs["-test.run"] = true
t.Cleanup(func() { delete(jsonVerbs, "-test.run") })
answer, err := runVerb(t.Context(), []string{"-test.run", "TestAnswerEcho", "-test.v"})
if err != nil {
t.Fatal(err)
}
if !answer.OK {
t.Fatalf("the command failed: %s", answer.Output)
}
if !strings.Contains(answer.Output, "PASS") {
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
}
}
// The build tool has the command's three shapes (ADR 0157's follow-up, 2026-10-01): a repository, a
// base whose dependents are rebuilt, or everything behind its source — each asked, not waited for.
func TestTheBuildToolRebuildsWhatStandsOnABase(t *testing.T) {
argv, _ := argvFor("build", map[string]any{"on": "mesh-tools"})
if strings.Join(argv, " ") != "build --on mesh-tools --wait 0" {
t.Fatalf("a base: %v", argv)
}
argv, _ = argvFor("build", map[string]any{"behind": "yes"})
if strings.Join(argv, " ") != "build --behind --wait 0" {
t.Fatalf("behind: %v", argv)
}
if _, err := argvFor("build", map[string]any{}); err == nil {
t.Fatal("a build naming nothing was accepted")
}
}
+61 -1
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
@@ -38,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
} }
switch args[0] { switch args[0] {
case "accept": case "accept":
case "rotate":
return secretRotate(ctx, args[1:])
case "recover": case "recover":
return secretRecover(ctx, args[1:]) return secretRecover(ctx, args[1:])
case "export": case "export":
@@ -101,7 +104,8 @@ func secretCommand(ctx context.Context, args []string) error {
return nil return nil
} }
const secretUsage = "secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" + const secretUsage = "secret rotate <node> <module> <name>\n" +
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" + "secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]" "secret export [--out <file>]"
@@ -359,3 +363,59 @@ func valueFor(node, module, name, from string) (string, error) {
return line, nil return line, nil
} }
} }
// secretRotate makes a module's own secret anew and sends the machine, so the module starts again on
// the new value (novox/hq ADR 0114, issue 180). A pair credential rotates with `rotate <provision>`;
// this is the secret with one party. Said in the log with who asked and when, never the value.
func secretRotate(ctx context.Context, args []string) error {
rest, _ := split(args)
if len(rest) != 3 {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if err := open.inventory.RotateModuleSecret(ctx, node, module, name); err != nil {
var refused inventory.ErrNotRotatable
if errors.As(err, &refused) {
return fmt.Errorf("not rotated: %s", refused.Why)
}
return err
}
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
// A shared credential (ADR 0158) has as many holders as the provision has consumers, and all
// of them are sent in one act, so no machine is left reading a value the provider no longer takes.
machines, err := open.inventory.SharedHolders(ctx, node, module, name)
if err != nil {
return err
}
if len(machines) == 0 {
machines = []string{node}
}
if len(machines) == 1 {
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
} else {
fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", "))
}
if err := sendTo(ctx, open, machines); err != nil {
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
"one until the machines next apply. Fix the cause and run `push --behind`", err)
}
return nil
}
// whoAsked names the caller for the log: the account the command runs as, which for a tool call
// through the console is the mesh's own.
func whoAsked() string {
if u := os.Getenv("SUDO_USER"); u != "" {
return u
}
if u := os.Getenv("USER"); u != "" {
return u
}
return "the mesh"
}
+6 -1
View File
@@ -47,7 +47,12 @@ func composed(t *testing.T, open *stores, node string) sendable {
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random: // aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
// what changes this string is a change to what a converged machine is sent, which is the thing an // what changes this string is a change to what a converged machine is sent, which is the thing an
// older host would refuse. // older host would refuse.
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"hosts":["anchor.internal:10.77.0.1"],"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}` //
// Re-captured 2026-10-01 (novox/hq issue 177): c978aa7 took `hosts` off every container — a
// machine's own resolver knows the mesh's names now — and left this string carrying it, so the
// guard failed for a day and nothing ran it. A field an older host never sees is the one change
// this guard permits; a field it would refuse is the one it exists to catch.
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) { func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
open := aMesh(t) open := aMesh(t)
+39 -4
View File
@@ -82,6 +82,16 @@ func cloneFrom(ctx context.Context, source buildSource) (string, error) {
// serves no scheme or port has nothing to compose from — a default port here would be the forge's // serves no scheme or port has nothing to compose from — a default port here would be the forge's
// address guessed, which is the thing this exists to stop. // address guessed, which is the thing this exists to stop.
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) { func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
base, err := seatBase(world, seatName)
if err != nil {
return "", err
}
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
return fmt.Sprintf("%s/%s.git", base, path), nil
}
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
func seatBase(world catalogue.World, seatName string) (string, error) {
seat, known := catalogue.SeatNamed(seatName) seat, known := catalogue.SeatNamed(seatName)
if !known || seat.Delivers == "" { if !known || seat.Delivers == "" {
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName) return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
@@ -94,9 +104,9 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
} }
} }
if holder == nil { if holder == nil {
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+ return "", fmt.Errorf("nobody holds the %s seat, so nothing can be cloned from this mesh's "+
"forge — assign a module that claims it, or build from the repository's URL without --self", "forge — assign a module that claims it, or build from the repository's URL without --self",
seat.Name, repository) seat.Name)
} }
var provider *catalogue.Provider var provider *catalogue.Provider
for i, p := range world.Offered[seat.Delivers] { for i, p := range world.Offered[seat.Delivers] {
@@ -118,8 +128,33 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q", return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
holder.Module, holder.Node, seat.Name, seat.Delivers) holder.Module, holder.Node, seat.Name, seat.Delivers)
} }
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git") return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil }
// seatBases is the clone base of every seat a recipe's context may name, for a build request
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
// name it at all, and if it does the builder refuses with the seat's name.
func seatBases(ctx context.Context) map[string]string {
open, err := openStores(ctx)
if err != nil {
return nil
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return nil
}
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
if err != nil {
return nil
}
bases := map[string]string{}
for _, seatName := range []string{gitSeat} {
if base, err := seatBase(world, seatName); err == nil {
bases[seatName] = base
}
}
return bases
} }
// servedPort is a served port as text, however the manifest and the node's settings carried it. // servedPort is a served port as text, however the manifest and the node's settings carried it.
+1 -1
View File
@@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
// An event published under a seat's name is real even though no module declares it as its own. // An event published under a seat's name is real even though no module declares it as its own.
if bad := Disagreements(nil, if bad := Disagreements(nil,
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}}, []AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
[]DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 { []DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad) t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
} }
} }
+7
View File
@@ -40,6 +40,13 @@ type Consumer struct {
AckWaitSeconds int AckWaitSeconds int
// MaxDeliver before the message is dead-lettered; zero for the mesh's default. // MaxDeliver before the message is dead-lettered; zero for the mesh's default.
MaxDeliver int MaxDeliver int
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
// the server's default, which is many. **One, for a consumer handled one at a time**
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
MaxAckPending int
Why string Why string
} }
+15 -1
View File
@@ -42,7 +42,8 @@ func TestInvokingGrantsNothingButTheCall(t *testing.T) {
if strings.Contains(p, ".event.") { if strings.Contains(p, ".event.") {
t.Errorf("a module that only invokes may publish %q, an event it never declared", p) t.Errorf("a module that only invokes may publish %q, an event it never declared", p)
} }
if strings.HasPrefix(p, "mesh.seat.") { // A role's tools are tools (ADR 0132); a role's work queue and events are not.
if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") {
t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p) t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p)
} }
} }
@@ -90,3 +91,16 @@ func TestADeclaredInvokeReachesTheComposedUser(t *testing.T) {
} }
has(t, perms.Publish, "mesh.mod.*.tool.>") has(t, perms.Publish, "mesh.mod.*.tool.>")
} }
// A module's tool is addressed two ways (novox/hq ADR 0159): to whichever instance answers, and to
// the instance on one machine. A grant for the tool covers both and nothing wider.
func TestInvokingAToolMayAddressTheMachineToo(t *testing.T) {
got, err := invokedSubjects([]string{"postgres.postgres_query"})
if err != nil {
t.Fatal(err)
}
want := []string{"mesh.mod.postgres.tool.postgres_query", "mesh.mod.postgres.tool.postgres_query.*"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("the grant is %v, want %v", got, want)
}
}
+1
View File
@@ -179,6 +179,7 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
AckPolicy: nats.AckExplicitPolicy, AckPolicy: nats.AckExplicitPolicy,
AckWait: time.Duration(c.AckWaitSeconds) * time.Second, AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
MaxDeliver: c.MaxDeliver, MaxDeliver: c.MaxDeliver,
MaxAckPending: c.MaxAckPending,
DeliverGroup: c.Queue, DeliverGroup: c.Queue,
DeliverSubject: "", DeliverSubject: "",
Description: c.Why, Description: c.Why,
+45 -5
View File
@@ -40,6 +40,10 @@ const (
// emits (novox/hq ADR 0118, design 29 §5). // emits (novox/hq ADR 0118, design 29 §5).
type Seat struct { type Seat struct {
Name string Name string
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
// subject, because one subject reaching six machines' holders is not an address
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
Scope string
Accepts []string Accepts []string
Emits []string Emits []string
Serves []string Serves []string
@@ -197,6 +201,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
// the new bus was refused the publish (2026-09-28). // the new bus was refused the publish (2026-09-28).
pub = append(pub, "mesh.mod.*.tool.>") pub = append(pub, "mesh.mod.*.tool.>")
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
// which this package mirrors rather than reads, and a verb the seat does not declare is a
// subject nothing publishes.
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
// The two events it reacts to, and its ack subject on the stream they arrive from // The two events it reacts to, and its ack subject on the stream they arrive from
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the // (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
// controller a subscriber to every event in the mesh, and its permission list would stop // controller a subscriber to every event in the mesh, and its permission list would stop
@@ -343,7 +354,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, seatSubject(s, "event", e)) pub = append(pub, seatSubject(s, "event", e))
} }
for _, t := range s.Serves { for _, t := range s.Serves {
sub = append(sub, seatSubject(s, "tool", t)) sub = append(sub, seatToolSubject(s, t, p.Node))
} }
} }
@@ -355,7 +366,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, seatSubject(s, "accept", a)) pub = append(pub, seatSubject(s, "accept", a))
} }
for _, t := range s.Serves { for _, t := range s.Serves {
pub = append(pub, seatSubject(s, "tool", t)) pub = append(pub, seatToolSubject(s, t, "*"))
} }
} }
} }
@@ -405,6 +416,18 @@ func seatSubject(s Seat, kind, verb string) string {
return "mesh.seat." + s.Name + "." + kind + "." + verb return "mesh.seat." + s.Name + "." + kind + "." + verb
} }
// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
func seatToolSubject(s Seat, verb, node string) string {
base := seatSubject(s, "tool", verb)
if s.Scope == "node" && node != "" {
return base + "." + node
}
return base
}
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are // consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
// two functions because conflating them was a real bug. // two functions because conflating them was a real bug.
// //
@@ -615,15 +638,32 @@ func invokedSubjects(invokes []string) ([]string, error) {
var out []string var out []string
for _, t := range invokes { for _, t := range invokes {
if t == "*" { if t == "*" {
out = append(out, "mesh.mod.*.tool.>") // Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool
// like any other, addressed to the seat instead of a module.
out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>")
continue
}
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
// A role's tool, `seat:<seat>.<verb>`. Both address shapes, because the grant is
// written without knowing the seat's scope: a mesh seat's verb is flat and a node
// seat's carries the machine (design 33 §4).
seat, verb, ok := strings.Cut(rest, ".")
if !ok || seat == "" || verb == "" {
return nil, fmt.Errorf(
"%q does not name a role's tool: one invokes seat:<seat>.<verb>", t)
}
out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*")
continue continue
} }
module, tool, ok := strings.Cut(t, ".") module, tool, ok := strings.Cut(t, ".")
if !ok || module == "" || tool == "" { if !ok || module == "" || tool == "" {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"%q does not name a tool: one invokes <module>.<tool>, or * for every one", t) "%q does not name a tool: one invokes <module>.<tool>, seat:<seat>.<verb>, or * for every one", t)
} }
out = append(out, "mesh.mod."+module+".tool."+tool) // Both ways a module's tool is addressed (novox/hq ADR 0159): to whichever instance
// answers, and to the instance on one machine, which is the same subject with the machine
// as its last token.
out = append(out, "mesh.mod."+module+".tool."+tool, "mesh.mod."+module+".tool."+tool+".*")
} }
return out, nil return out, nil
} }
+12
View File
@@ -71,6 +71,18 @@ func TestHoldingASeatIsTheMirrorOfUsingIt(t *testing.T) {
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send") hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
} }
// A build machine may say everything about a build as it happens (novox/hq ADR 0157): that it
// started, and every line under the build's own id — the seat's `log.*` becomes a publish over
// one token, so a reader follows one build by subject and the holder can name no other subject.
func TestTheBuildMachineMaySayWhatItDoesUnderTheBuildsId(t *testing.T) {
seat := Seat{Name: "mesh-build-machine", Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}}
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "builder",
Holds: []Seat{seat}, PasswordHash: "x"})
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.started")
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.log.*")
hasNot(t, perms.Publish, "mesh.seat.mesh-build-machine.event.>")
}
// Without an ack permission a durable consumer never really consumes: every message it receives is // Without an ack permission a durable consumer never really consumes: every message it receives is
// redelivered forever, refused by the permission list it already has (design 25 §4). // redelivered forever, refused by the permission list it already has (design 25 §4).
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) { func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
+57
View File
@@ -0,0 +1,57 @@
package broker
import "testing"
// A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one
// node-scoped seat derive two addresses, and a user of the seat may publish any node's.
func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) {
seat := Seat{Name: "node-dns-resolver", Scope: "node", Serves: []string{"lookup"}}
one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
has(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.one")
has(t, two.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup")
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
has(t, user.Publish, "mesh.seat.node-dns-resolver.tool.lookup.*")
}
// A mesh-scoped seat's tool stays flat: nothing about it changes.
func TestAMeshSeatsToolIsAddressedToTheSeatAlone(t *testing.T) {
seat := Seat{Name: "git", Scope: "mesh", Serves: []string{"list_repos"}}
holder, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", Holds: []Seat{seat}, PasswordHash: "x"})
has(t, holder.Subscribe, "mesh.seat.git.tool.list_repos")
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
has(t, user.Publish, "mesh.seat.git.tool.list_repos")
}
// The controller serves its own seat's verbs and may answer them (novox/hq ADR 0154).
func TestTheControllerServesItsSeatsToolsAndMayAnswer(t *testing.T) {
perms, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, perms.Subscribe, "mesh.seat.mesh-controller.tool.>")
if !perms.AllowResponses {
t.Fatal("the controller serves tools and may not answer one")
}
}
// A grant to every tool reaches a role's tools too, and a role's tool is granted by name.
func TestAGrantReachesARolesTools(t *testing.T) {
all, _ := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", Invokes: []string{"*"}, PasswordHash: "x"})
has(t, all.Publish, "mesh.seat.*.tool.>")
one, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller.status"}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, one.Publish, "mesh.seat.mesh-controller.tool.status")
hasNot(t, one.Publish, "mesh.seat.mesh-controller.tool.push")
hasNot(t, one.Publish, "mesh.mod.*.tool.>")
if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller"}, PasswordHash: "x"}); err == nil {
t.Fatal("a role grant naming no verb was accepted")
}
}
+11 -3
View File
@@ -79,7 +79,7 @@ func MeshStreams() []Stream {
"n-1 by construction (issue 107)", "n-1 by construction (issue 107)",
}, },
{ {
Name: "EVENTS", Name: EventsStream,
// A seat's own events ride here too: they are 1:many like any event, and the // A seat's own events ride here too: they are 1:many like any event, and the
// `event` token keeps them clear of both the seat's work queue (`accept`) and its // `event` token keeps them clear of both the seat's work queue (`accept`) and its
// tools (`tool`), which must not be persisted. // tools (`tool`), which must not be persisted.
@@ -220,6 +220,9 @@ func seatEventSubject(seat, verb string) string {
return "mesh.seat." + seat + ".event." + verb return "mesh.seat." + seat + ".event." + verb
} }
// EventsStream holds every module's and every role's events, a build's log among them.
const EventsStream = "EVENTS"
// MeshConsumers is what the controller consumes, in the order a person reads it. // MeshConsumers is what the controller consumes, in the order a person reads it.
// //
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's, // **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
@@ -244,8 +247,13 @@ func MeshConsumers() []Consumer {
Push: true, Push: true,
AckWaitSeconds: 30, AckWaitSeconds: 30,
MaxDeliver: 5, MaxDeliver: 5,
Why: "the two events the mesh's own controller reacts to; after max-deliver it " + // One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
"dead-letters, because an announcement it cannot act on will not become actionable", // announcement handed over behind it must wait on the server, not time out on the
// client and come back to be acted on again.
MaxAckPending: 1,
Why: "the two events the mesh's own controller reacts to, one at a time; after " +
"max-deliver it dead-letters, because an announcement it cannot act on will not " +
"become actionable",
}, },
} }
} }
+11
View File
@@ -260,3 +260,14 @@ func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
seen[subject] = c.Name + " on " + c.Stream seen[subject] = c.Name + " on " + c.Stream
} }
} }
// The controller's events consumer is handed one announcement at a time (novox/hq issue 175): a
// merge's handler builds for minutes, and what is queued behind it must wait on the server rather
// than time out on the client and be acted on twice.
func TestTheControllerTakesOneAnnouncementAtATime(t *testing.T) {
for _, c := range MeshConsumers() {
if c.Stream == "EVENTS" && c.Name == ControllerName && c.MaxAckPending != 1 {
t.Fatalf("the events consumer may have %d outstanding; one announcement at a time", c.MaxAckPending)
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ accounts {
users = [ users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.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"] } publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "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"] } 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.>"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
+61 -11
View File
@@ -90,7 +90,13 @@ type GitCredential struct {
// records — reachable, unreferenced, and indistinguishable from something in use. // records — reachable, unreferenced, and indistinguishable from something in use.
func Build(ctx context.Context, run Runner, publish Publisher, func Build(ctx context.Context, run Runner, publish Publisher,
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
forge GitCredential, log Log) (Result, error) { forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
// that hand none — tests of everything but contexts — read as they did.
var seatBases map[string]string
if len(seats) > 0 {
seatBases = seats[0]
}
say := logging(log) say := logging(log)
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref)) say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
@@ -209,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts { for _, a := range artifacts {
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say) made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
if err != nil { if err != nil {
say("artifact", "%s FAILED: %v", a.Name, err) say("artifact", "%s FAILED: %v", a.Name, err)
return Result{}, err return Result{}, err
@@ -246,14 +252,18 @@ func logging(log Log) func(step, format string, args ...any) {
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact // module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
// name so two artifacts of one module naming different contexts do not collide. // name so two artifacts of one module naming different contexts do not collide.
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string, func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) { from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact) url, err := contextURL(from, seats)
if err != nil {
return "", err
}
say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact)
dir := filepath.Join(workspace, "context-"+artifact) dir := filepath.Join(workspace, "context-"+artifact)
if err := os.RemoveAll(dir); err != nil { if err := os.RemoveAll(dir); err != nil {
return "", err return "", err
} }
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil { if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err) return "", fmt.Errorf("cannot clone %s: %w", url, err)
} }
if from.Ref != "" { if from.Ref != "" {
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil { if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
@@ -264,6 +274,23 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentia
return dir, nil return dir, nil
} }
// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's
// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155).
// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge
// would be the literal this removes, one layer down.
func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) {
if from.Seat == "" {
return from.Repository, nil
}
base, told := seats[from.Seat]
if !told || base == "" {
return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+
"base for that seat — nothing holds it in this mesh, or the control plane predates the word",
from.Repository, from.Seat)
}
return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil
}
// cloneWith is a git invocation that may offer a stored credential. // cloneWith is a git invocation that may offer a stored credential.
// //
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly // The first `-c credential.helper=` clears every helper the environment might carry, so exactly
@@ -416,7 +443,8 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
func one(ctx context.Context, run Runner, publish Publisher, func one(ctx context.Context, run Runner, publish Publisher,
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string, module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) { held map[string]string, npmrc string, seats map[string]string,
say func(step, format string, args ...any)) (catalogue.Built, error) {
switch a.Kind { switch a.Kind {
case catalogue.ArtifactUpstream: case catalogue.ArtifactUpstream:
@@ -493,7 +521,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
recipePath := a.From recipePath := a.From
buildDir := tree buildDir := tree
if a.Context != nil { if a.Context != nil {
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say) cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say)
if err != nil { if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err) return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
} }
@@ -691,6 +719,21 @@ func short(commit string) string {
return commit return commit
} }
// Said is where the lines Command speaks go, beside the build's own Log: what runs, how long it
// took, and that it failed. Nil prints them to stderr, as a build machine with nobody listening
// should. The machine sets it per build so every line reaches the bus too (novox/hq ADR 0157) —
// the step is "run", and the message is the line as it has always been printed.
var Said Log
func tell(step, format string, args ...any) {
message := fmt.Sprintf(format, args...)
if Said == nil {
fmt.Fprintf(os.Stderr, " %s\n", message)
return
}
Said(step, message)
}
// Command is a Runner that actually runs things. // Command is a Runner that actually runs things.
func Command(ctx context.Context, dir, name string, args ...string) (string, error) { func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line // **Every command is echoed before it runs**, with where. On a build that hangs, the last line
@@ -698,16 +741,23 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is // nothing" and "git clone is waiting on a network that will not answer". Silent on success is
// what made an empty workspace unreadable. // what made an empty workspace unreadable.
started := timeNow() started := timeNow()
fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " ")) tell("run", "$ (%s) %s %s", short(filepath.Base(dir)), name, strings.Join(args, " "))
cmd := exec.CommandContext(ctx, name, args...) cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir cmd.Dir = dir
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started)) tell("run", "! %s %s failed after %s", name, args[0], since(started))
// The command's own output is part of what a reader needs — the compiler's error, the
// clone's refusal — and a line per output line keeps it readable on the bus.
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
tell("output", "%s", line)
}
}
return string(out), fmt.Errorf("%s %s: %w\n%s", return string(out), fmt.Errorf("%s %s: %w\n%s",
name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
} }
fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started)) tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started))
return string(out), nil return string(out), nil
} }
+26
View File
@@ -0,0 +1,26 @@
package builder
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A context on a seat is cloned from the base the mesh sent, joined to the repository's path; a
// context by URL is itself; a seat the mesh sent no base for is refused by name (novox/hq ADR 0155).
func TestAContextOnASeatIsClonedFromTheBaseTheMeshSent(t *testing.T) {
seats := map[string]string{"git": "http://forge.example.tld:3000"}
got, err := contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, seats)
if err != nil || got != "http://forge.example.tld:3000/org/controller.git" {
t.Fatalf("got %q, %v", got, err)
}
got, err = contextURL(catalogue.ArtifactContext{Repository: "https://elsewhere.example/x.git"}, seats)
if err != nil || got != "https://elsewhere.example/x.git" {
t.Fatalf("a URL context was changed: %q, %v", got, err)
}
_, err = contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, nil)
if err == nil || !strings.Contains(err.Error(), "git seat") {
t.Fatalf("a seat with no base was not refused by name: %v", err)
}
}
@@ -0,0 +1,18 @@
package catalogue
import "testing"
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
// aliases loaded, the former name resolves to the seat.
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
was := aliases
t.Cleanup(func() { aliases = was })
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
seat, known := SeatNamed("the-artifact-store")
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
}
if _, known := SeatNamed("mesh-artifact-store"); !known {
t.Fatal("the seat is not in the set under its name")
}
}
@@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
} }
// A second one, on any other machine, is refused — and the refusal names the seat. // A second one, on any other machine, is refused — and the refusal names the seat.
elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh, elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
Node: "anchor", Module: "distribution"}}} Node: "anchor", Module: "distribution"}}}
other := workstation() other := workstation()
other.Name = "laptop" other.Name = "laptop"
@@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
"second time and every consumer elsewhere would refuse to choose") "second time and every consumer elsewhere would refuse to choose")
} }
if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err) t.Fatalf("refused without naming the seat: %v", err)
} }
} }
+1 -1
View File
@@ -59,7 +59,7 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
for _, pair := range []struct{ seat, delivers string }{ for _, pair := range []struct{ seat, delivers string }{
{"git", "git"}, {"git", "git"},
{"npm-package-registry", "npm-package-registry"}, {"npm-package-registry", "npm-package-registry"},
{"the-artifact-store", "artifact-store"}, {"mesh-artifact-store", "artifact-store"},
{"mesh-store", "postgres-database"}, {"mesh-store", "postgres-database"},
{"mesh-broker", "mesh-bus"}, {"mesh-broker", "mesh-bus"},
} { } {
+45 -4
View File
@@ -3,6 +3,7 @@ package catalogue
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -10,11 +11,24 @@ import (
// //
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that // Not a fixture: the point is whether the manifests as written are accepted by the control plane that
// will read them, and a copy of one manifest proves nothing about the other seventy-one. // will read them, and a copy of one manifest proves nothing about the other seventy-one.
func TestEveryCatalogueManifestParses(t *testing.T) { // catalogueRoot is the catalogue these checks run over: MESH_CATALOGUE when set, else the checkout
root := os.Getenv("MESH_CATALOGUE") // beside this one, the way the main layout has it. A check that only ran when somebody remembered a
if root == "" { // variable was a check nobody ran (novox/hq issue 134, 2026-09-30); it skips only when there is no
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this") // catalogue to be found at all.
func catalogueRoot(t *testing.T) string {
t.Helper()
if root := os.Getenv("MESH_CATALOGUE"); root != "" {
return root
} }
sibling := filepath.Join("..", "..", "..", "mesh-catalog")
if _, err := os.Stat(filepath.Join(sibling, "modules")); err != nil {
t.Skip("no catalogue beside this checkout and MESH_CATALOGUE unset")
}
return sibling
}
func TestEveryCatalogueManifestParses(t *testing.T) {
root := catalogueRoot(t)
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil || len(found) == 0 { if err != nil || len(found) == 0 {
t.Fatalf("no manifests under %s: %v", root, err) t.Fatalf("no manifests under %s: %v", root, err)
@@ -45,3 +59,30 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing") t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
} }
} }
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
// definition names a domain or a public address the mesh acts on, and every value that must for now
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
root := catalogueRoot(t)
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil || len(found) == 0 {
t.Fatalf("no manifests under %s: %v", root, err)
}
var named []string
for _, p := range found {
raw, err := os.ReadFile(p)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
m, err := ParseManifest(raw)
if err != nil {
t.Errorf("%s: %v", p, err)
continue
}
named = append(named, InstallationProblems(m)...)
}
if len(named) > 0 {
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
}
}
+21
View File
@@ -197,6 +197,27 @@ func TestARouteCanBeSetPerMesh(t *testing.T) {
} }
} }
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
// it, arrived in every route it contributed. A setting overrides a key the contribution
// declares and adds none — the provider reads the contribution as a contract.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
out, err := got.Declaration(Rendering{Settings: SettingsBy{
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
}})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if given[0].Values["host"] != "dashboard" {
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
}
if _, leaked := given[0].Values["sitename"]; leaked {
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
}
}
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
// It would create a file nobody ever writes to, on a machine where nothing asked for it. // It would create a file nobody ever writes to, on a machine where nothing asked for it.
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1", _, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
+32 -4
View File
@@ -465,7 +465,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
"%s needs a secret called %q and none was made for it", m.Module, name) "%s needs a secret called %q and none was made for it", m.Module, name)
} }
first = append(first, ownedBy(m.SecretsOwner, map[string]any{ first = append(first, ownedBy(m.SecretsOwner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed, "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
})) }))
} }
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
@@ -473,9 +473,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
// find each present — refusing clearly if the operator has not provided it — before it // find each present — refusing clearly if the operator has not provided it — before it
// starts anything that depends on it. The mesh creates, chowns and reconciles none of it; // starts anything that depends on it. The mesh creates, chowns and reconciles none of it;
// an `access` resource says only *this path must exist, and this module reaches it*. // an `access` resource says only *this path must exist, and this module reaches it*.
for _, a := range m.Accesses { // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id
// where the operator said, the definition's default otherwise, refused where neither.
accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module])
if err != nil {
return nil, err
}
for _, a := range accesses {
first = append(first, map[string]any{ first = append(first, map[string]any{
"id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(), "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode,
}) })
} }
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
@@ -670,6 +676,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
} }
// And where this node places the directories the module declared without a path // And where this node places the directories the module declared without a path
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
// — and, on an adopted machine, where the assignment says they already are, with the
// owner the data already has (novox/hq issue 153). Malformed placements are refused here.
placed, err := Places(m, with.Settings[m.Module])
if err != nil {
return nil, err
}
dirs := dirsFor(m, with) dirs := dirsFor(m, with)
// And the machine underneath, which no binding of its own can tell it. // And the machine underneath, which no binding of its own can tell it.
thisMachine := machineFacts(r, with.Names, with.MeshRange) thisMachine := machineFacts(r, with.Names, with.MeshRange)
@@ -696,6 +708,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
// Said in the catalogue, not on the machine: the host parses strictly and knows no // Said in the catalogue, not on the machine: the host parses strictly and knows no
// such field, and the reason is for a reader of the manifest. // such field, and the reason is for a reader of the manifest.
delete(copied, SecretsInEnvironment) delete(copied, SecretsInEnvironment)
delete(copied, NamesOnPurpose)
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
// definition may not carry because it is true of one installation only. Filled from
// the same layers a mergeable file takes, and refused when no layer set it.
if err := settingInto(copied, with.Settings[m.Module], m.Module); err != nil {
return nil, err
}
// **Placed before anything reads a path.** A pathless directory receives the path // **Placed before anything reads a path.** A pathless directory receives the path
// this node resolves for it, and every ${dir:…} — in paths, mounts, content and // this node resolves for it, and every ${dir:…} — in paths, mounts, content and
// environment — becomes that path, so what follows sees only concrete places // environment — becomes that path, so what follows sees only concrete places
@@ -703,6 +722,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
if err := dirInto(copied, dirs, m.Module); err != nil { if err := dirInto(copied, dirs, m.Module); err != nil {
return nil, err return nil, err
} }
// The operator's data the same way: ${access:…} becomes where this node keeps it,
// and a placed directory takes the owner the assignment said (issue 153).
if err := accessInto(copied, accessPaths, m.Module); err != nil {
return nil, err
}
ownerInto(copied, placed)
// **After settings, and that is the whole reason it is here.** A module's file // **After settings, and that is the whole reason it is here.** A module's file
// content is where a setting lands, so a placeholder may only exist once the setting // content is where a setting lands, so a placeholder may only exist once the setting
// has been put in — filling secrets first would look at content that is not yet what // has been put in — filling secrets first would look at content that is not yet what
@@ -1154,7 +1179,10 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
// module wrote another into its configuration. // module wrote another into its configuration.
func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) ( func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) (
map[string]any, error) { map[string]any, error) {
values, err := settle(raw, layers, nil, what) // Overridden, not merged: a setting changes a key the contribution declares and adds none.
// The provider reads the contribution as a contract, and a setting made for one of this
// module's files is no part of it (novox/hq 04-ISSUES/173).
values, err := overridden(raw, layers, what)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", what, err) return nil, fmt.Errorf("%s: %w", what, err)
} }
+63 -12
View File
@@ -20,6 +20,12 @@ import (
// declared with the path as the exception it is, and everything else in the module names it by // declared with the path as the exception it is, and everything else in the module names it by
// id — so moving it later is one line, not a search. // id — so moving it later is one line, not a search.
// //
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
// `${dir:<id>}` and states no path.
//
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always // **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing // has; nothing new reaches it and it learns no field. Which also means a resolved path changing
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126). // is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
@@ -27,6 +33,15 @@ import (
// defaultDataRoot is where module data lands when a node states no root of its own. // defaultDataRoot is where module data lands when a node states no root of its own.
const defaultDataRoot = "/var/lib" const defaultDataRoot = "/var/lib"
// The two places a pathless directory may name, beside its own id.
const (
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
// assignment, which every other placed thing of the module sits beneath.
placeOwn = "."
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
placeMesh = "mesh"
)
// dirRef is how a module names one of its placed directories: ${dir:<id>}. // dirRef is how a module names one of its placed directories: ${dir:<id>}.
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`) var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
@@ -40,27 +55,54 @@ func dataRoot(with Rendering) string {
// dirsFor is every placed directory of a module, id → the path it resolves to on this node. // dirsFor is every placed directory of a module, id → the path it resolves to on this node.
// //
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module> — // A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
// to-be 27's one directory per assignment, which every other placed thing sits beneath. At most // saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
// one makes sense; nothing enforces one, because two ids resolving to one path is a mistake the // saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
// module's own files make visible immediately. // one, because two ids resolving to one path is a mistake the module's own files make visible
// immediately.
//
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
// filled after the directories it can name are resolved; one level, because a directory beneath
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
func dirsFor(m Manifest, with Rendering) map[string]string { func dirsFor(m Manifest, with Rendering) map[string]string {
dirs := map[string]string{} dirs := map[string]string{}
var beneath []map[string]any
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
// malformed; here an invalid setting simply places nothing.
placed, _ := Places(m, with.Settings[m.Module])
for _, r := range m.Resources { for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" { if fmt.Sprint(r["type"]) != "directory" {
continue continue
} }
id := fmt.Sprint(r["id"]) id := fmt.Sprint(r["id"])
if p, said := placed[id]; said {
dirs[id] = p.Path
continue
}
if path, stated := r["path"].(string); stated && path != "" { if path, stated := r["path"].(string); stated && path != "" {
if strings.HasPrefix(path, "${dir:") {
beneath = append(beneath, r)
continue
}
dirs[id] = strings.TrimRight(path, "/") dirs[id] = strings.TrimRight(path, "/")
continue continue
} }
if place, said := r["place"].(string); said && place == "." { switch place, _ := r["place"].(string); place {
case placeOwn:
dirs[id] = dataRoot(with) + "/" + m.Module dirs[id] = dataRoot(with) + "/" + m.Module
continue case placeMesh:
} dirs[id] = dataRoot(with) + "/mesh/" + m.Module
default:
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
} }
}
for _, r := range beneath {
path := strings.TrimRight(r["path"].(string), "/")
// A reference to no directory is left as written and refused where the resource is
// placed (dirInto), with the message that names what exists.
filled, _ := dirFill(path, dirs, m.Module)
dirs[fmt.Sprint(r["id"])] = filled
}
return dirs return dirs
} }
@@ -119,9 +161,17 @@ func placedManifest(m Manifest, with Rendering) (Manifest, error) {
if m.Secrets, err = fillMap(m.Secrets); err != nil { if m.Secrets, err = fillMap(m.Secrets); err != nil {
return m, err return m, err
} }
if m.OwnSecrets, err = fillMap(m.OwnSecrets); err != nil { if len(m.OwnSecrets) > 0 {
own := make(OwnSecrets, len(m.OwnSecrets))
for name, s := range m.OwnSecrets {
filled, err := dirFill(s.Path, dirs, m.Module)
if err != nil {
return m, err return m, err
} }
own[name] = OwnSecret{Path: filled, Taken: s.Taken}
}
m.OwnSecrets = own
}
if m.Grants, err = fillMap(m.Grants); err != nil { if m.Grants, err = fillMap(m.Grants); err != nil {
return m, err return m, err
} }
@@ -244,10 +294,11 @@ func (m Manifest) unknownDirRefs() []string {
"%s states both path and place on %v — a stated path IS the placement", "%s states both path and place on %v — a stated path IS the placement",
m.Module, r["id"])) m.Module, r["id"]))
} }
if place != "." { if place != placeOwn && place != placeMesh {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s says place %q on %v, and the only place is %q — the assignment's own root", "%s says place %q on %v, and the places are %q — the assignment's own root — and "+
m.Module, place, r["id"], ".")) "%q — where the mesh keeps what it writes for the module",
m.Module, place, r["id"], placeOwn, placeMesh))
} }
} }
seen := map[string]bool{} seen := map[string]bool{}
@@ -292,7 +343,7 @@ func (m Manifest) unknownDirRefs() []string {
} }
maps := map[string]map[string]string{ maps := map[string]map[string]string{
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
"own-secrets": m.OwnSecrets, "grants": m.Grants, "own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
} }
for field, entries := range maps { for field, entries := range maps {
for _, value := range entries { for _, value := range entries {
+77 -4
View File
@@ -164,7 +164,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
}, },
Binds: map[string]string{"route": "${dir:state}/route.json"}, Binds: map[string]string{"route": "${dir:state}/route.json"},
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"}, Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
OwnSecrets: map[string]string{"admin-key": "${dir:state}/admin-key.secret"}, OwnSecrets: OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"}, Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
} }
placed, err := placedManifest(m, Rendering{}) placed, err := placedManifest(m, Rendering{})
@@ -177,7 +177,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" { if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
t.Fatalf("secrets are placed; got %v", placed.Secrets) t.Fatalf("secrets are placed; got %v", placed.Secrets)
} }
if placed.OwnSecrets["admin-key"] != "/var/lib/photos/admin-key.secret" { if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" {
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets) t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
} }
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" { if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
@@ -216,8 +216,48 @@ func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
wrong := Manifest{Module: "x", Resources: []map[string]any{ wrong := Manifest{Module: "x", Resources: []map[string]any{
{"id": "d", "type": "directory", "place": "sub/dir"}, {"id": "d", "type": "directory", "place": "sub/dir"},
}} }}
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the only place is "."`) { if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
t.Fatalf("a place that is not the root refuses; got %v", got) t.Fatalf("a place that is neither root refuses; got %v", got)
}
}
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
m := Manifest{Module: "umami",
Resources: []map[string]any{
{"id": "mesh-state", "type": "directory", "place": "mesh"},
{"id": "state", "type": "directory", "place": "."},
{"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
},
OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
Binds: map[string]string{"route": "${dir:state}/route.json"},
}
if got := m.unknownDirRefs(); len(got) != 0 {
t.Fatalf("place %q is a place; got %v", "mesh", got)
}
dirs := dirsFor(m, Rendering{})
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
}
dirs = dirsFor(m, Rendering{DataRoot: "/srv"})
if dirs["mesh-state"] != "/srv/mesh/umami" {
t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs)
}
placed, err := placedManifest(m, Rendering{})
if err != nil {
t.Fatal(err)
}
if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" {
t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets)
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirsFor(m, Rendering{}), m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" {
t.Fatalf("the mount's host side is placed; got %v", container["volumes"])
} }
} }
@@ -250,3 +290,36 @@ func shallowCopy(resource map[string]any) map[string]any {
} }
return copied return copied
} }
func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
// An adopted layout keeps a subdirectory the predecessor made under the mesh's directory
// (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one,
// it moves with it — a node's root moves both, and the definition names no host path.
m := Manifest{Module: "gitea", Resources: []map[string]any{
{"id": "mesh-state", "type": "directory", "place": "mesh"},
{"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"},
{"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:runtime-state}:/data"}},
}}
if got := m.unknownDirRefs(); len(got) != 0 {
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
}
dirs := dirsFor(m, Rendering{DataRoot: "/srv"})
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
}
sub := shallowCopy(m.Resources[1])
if err := dirInto(sub, dirs, m.Module); err != nil {
t.Fatal(err)
}
if sub["path"] != "/srv/mesh/gitea/state" {
t.Fatalf("the directory resource itself is resolved; got %v", sub["path"])
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirs, m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" {
t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"])
}
}
+1 -1
View File
@@ -419,7 +419,7 @@ func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) { func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
r := Resolution{Modules: []Manifest{{ r := Resolution{Modules: []Manifest{{
Module: "networking", Computed: "mesh-network", Module: "networking", Computed: "mesh-network",
OwnSecrets: map[string]string{"key": "/var/lib/mesh/key"}, OwnSecrets: OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
}}} }}}
out, err := r.Declaration(Rendering{ out, err := r.Declaration(Rendering{
Needed: map[string]map[string]string{"networking": {"key": "sealed"}}, Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
+227
View File
@@ -0,0 +1,227 @@
package catalogue
import (
"encoding/json"
"fmt"
"net"
"regexp"
"sort"
"strings"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
//
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
// naming the installation they were written in. This is the check.
//
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
// people to ignore the report. What is judged is every other string value: a name under a public
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
// its host is the runtime's, true on every machine that runs it.
//
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
// server's config names the federation's public directory; an application built outside the mesh
// is pulled from the registry that built it, until the mesh builds it. The resource carries
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
// per name — so a reader sees which names a definition means to carry and why, a name the map does
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
// each name mapped to its reason. The host never sees it.
const NamesOnPurpose = "names-on-purpose"
// prose is every key whose value the mesh never reads.
var prose = map[string]bool{"why": true, "description": true}
// Registries the world runs, which an image may name because an image reference must say where it
// is pulled from. Anything else in an image reference is a registry of some installation.
var worldsRegistries = map[string]bool{
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
"codeberg.org": true, "cgr.dev": true,
}
// Services the world runs that a definition may name as a policy default, the way it may name a
// public resolver: the public certificate authorities' ACME directories. Anything else a served
// fact or a file names is somebody's installation.
var worldsServices = map[string]bool{
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
"acme.zerossl.com": true,
}
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
var worldsResolvers = map[string]bool{
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
}
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
var hostname = regexp.MustCompile(
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
`internal|example|tld|test|invalid)`)
// address is a dotted quad.
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
func isName(b byte) bool {
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
func standalone(re *regexp.Regexp, value string) []string {
var out []string
for _, span := range re.FindAllStringIndex(value, -1) {
if span[0] > 0 && isName(value[span[0]-1]) {
continue
}
if span[1] < len(value) && isName(value[span[1]]) {
continue
}
out = append(out, value[span[0]:span[1]])
}
return out
}
// InstallationProblems is every value of a definition that names an installation, in the
// definition's own words: where it is, and what it names.
func InstallationProblems(m Manifest) []string {
raw, err := json.Marshal(m)
if err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var problems []string
// The module's own name is a value too: a module named after the domain it serves is a
// definition that can only be installed there (issue 134).
for _, name := range namesIn(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
}
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
for _, name := range namesIn(value) {
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
continue
}
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
}
for _, ip := range addressesIn(value) {
if meant[ip] {
continue
}
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
}
})
sort.Strings(problems)
return problems
}
// walk visits every string in the tree with its path, the names the enclosing resource means to
// name (with a reason), and whether it is an image reference.
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
switch v := node.(type) {
case map[string]any:
if declared, has := v[NamesOnPurpose].(map[string]any); has {
widened := map[string]bool{}
for name := range meant {
widened[name] = true
}
for name, reason := range declared {
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
widened[strings.ToLower(name)] = true
}
}
meant = widened
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if prose[k] || k == NamesOnPurpose || (at == "" && k == "module") {
continue
}
child := at + "." + k
if at == "" {
child = k
}
if s, isString := v[k].(string); isString {
visit(child, s, meant, k == "image")
continue
}
walk(v[k], child, meant, visit)
}
case []any:
for i, item := range v {
child := fmt.Sprintf("%s[%d]", at, i)
if s, isString := item.(string); isString {
visit(child, s, meant, false)
continue
}
walk(item, child, meant, visit)
}
}
}
// namesIn is every hostname in a value that could belong to an installation.
func namesIn(value string) []string {
var out []string
for _, found := range standalone(hostname, value) {
name := strings.ToLower(found)
switch {
case strings.HasSuffix(name, ".docker.internal"):
// The container runtime's alias for its own host: every machine running it has one.
case worldsServices[name]:
// A public authority named as a policy default, true of any mesh that wants it.
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
name == "example.com", name == "example.net", name == "example.org",
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
// Documentation names, which is what a definition's own example should use.
default:
out = append(out, name)
}
}
return out
}
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
// unspecified address, a documentation range, or a resolver the world runs.
func addressesIn(value string) []string {
var out []string
for _, found := range standalone(address, value) {
ip := net.ParseIP(found)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
continue
}
out = append(out, found)
}
return out
}
func documentation(ip net.IP) bool {
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package catalogue
import (
"strings"
"testing"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
// reason a reader sees.
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
m := Manifest{Module: "idp", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
got := strings.Join(InstallationProblems(m), "\n")
for _, want := range []string{
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
"idp names the public address 51.15.22.9 at resources[1].content",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
if strings.Contains(got, mustNot) {
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
}
}
}
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
m := Manifest{Module: "resolver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("the world's names were reported: %v", got)
}
}
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
// The federation's public directory in a homeserver's config: the world's, said so, and a name
// the map does not cover is still reported.
m := Manifest{Module: "homeserver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
}}
got := InstallationProblems(m)
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
bare := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
}}
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
t.Fatalf("an image on an installation's registry was not named: %v", got)
}
excepted := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
}}
if got := InstallationProblems(excepted); len(got) != 0 {
t.Fatalf("a declared exception was still reported: %v", got)
}
}
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
{Name: "server", Kind: "image", From: "Dockerfile",
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
}}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("a context on a seat was reported: %v", got)
}
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
if got := InstallationProblems(m); len(got) != 1 {
t.Fatalf("a context by URL was not reported: %v", got)
}
}
+174 -18
View File
@@ -91,8 +91,13 @@ const (
// If the path is absent when a machine applies, the host refuses clearly rather than creating it: // If the path is absent when a machine applies, the host refuses clearly rather than creating it:
// the mesh does not own it, so conjuring it would be a lie the host then acts on. // the mesh does not own it, so conjuring it would be a lie the host then acts on.
type Access struct { type Access struct {
// Path is the absolute path on the machine, as the operator provides it. // ID is the name the module gives this access, which the assignment places
Path string `json:"path"` // (`accesses: {<id>: <path>}`, novox/hq ADR 0112, issue 153) and the module's mounts name as
// ${access:<id>}. The shape a definition should use: it names no path of any machine.
ID string `json:"id,omitempty"`
// Path is the absolute path on the machine. A definition carrying one names an installation;
// tolerated as the default the assignment may replace, for accesses declared before ids.
Path string `json:"path,omitempty"`
// Mode is "read" or "read-write". Absent narrows to read. // Mode is "read" or "read-write". Absent narrows to read.
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
} }
@@ -122,6 +127,38 @@ type Offer struct {
Name string `json:"name"` Name string `json:"name"`
// Scope defaults to the node, which is where most things must be to be usable. // Scope defaults to the node, which is where most things must be to be usable.
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
// Credential, when set, says this provision's credential is one of the provider's own secrets,
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
// cannot give each consumer a login of its own. The named secret must say how it is taken.
Credential *OfferCredential `json:"credential,omitempty"`
}
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
type OfferCredential struct {
Own string `json:"own"`
}
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
// and whether it names one.
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
for _, o := range m.Provides {
if o.Name == provision && o.Credential != nil && o.Credential.Own != "" {
return o.Credential.Own, true
}
}
return "", false
}
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
func (m Manifest) ProvisionsSharing(own string) []string {
var out []string
for _, o := range m.Provides {
if o.Credential != nil && o.Credential.Own == own {
out = append(out, o.Name)
}
}
sort.Strings(out)
return out
} }
// At is this offer's scope, with the default applied. // At is this offer's scope, with the default applied.
@@ -142,24 +179,28 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
var full struct { var full struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
Credential *OfferCredential `json:"credential,omitempty"`
} }
if err := json.Unmarshal(raw, &full); err != nil { dec := json.NewDecoder(bytes.NewReader(raw))
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err) dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
} }
o.Name, o.Scope = full.Name, full.Scope o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
return nil return nil
} }
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
// went through the mesh comes out looking like the one that went in. // went through the mesh comes out looking like the one that went in.
func (o Offer) MarshalJSON() ([]byte, error) { func (o Offer) MarshalJSON() ([]byte, error) {
if o.Scope == "" { if o.Scope == "" && o.Credential == nil {
return json.Marshal(o.Name) return json.Marshal(o.Name)
} }
return json.Marshal(struct { return json.Marshal(struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"` Scope string `json:"scope,omitempty"`
}{o.Name, o.Scope}) Credential *OfferCredential `json:"credential,omitempty"`
}{o.Name, o.Scope, o.Credential})
} }
// Manifest is everything a module says about itself. // Manifest is everything a module says about itself.
@@ -252,8 +293,8 @@ type Manifest struct {
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118). // module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
Tools []string `json:"tools,omitempty"` Tools []string `json:"tools,omitempty"`
// Invokes are the tools this module calls, each `<module>.<tool>`, or the single entry `*` for // Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
// every tool on the mesh (novox/hq ADR 0152). // or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
// //
// **A grant, and only a grant.** The bus lets this module publish exactly those tool subjects // **A grant, and only a grant.** The bus lets this module publish exactly those tool subjects
// and nothing beside them — no event, no subscription, no seat. A module that declares none // and nothing beside them — no event, no subscription, no seat. A module that declares none
@@ -385,7 +426,16 @@ type Manifest struct {
// module running on three machines has three passwords and the mesh can read none of them. A // module running on three machines has three passwords and the mesh can read none of them. A
// manifest carrying one instead would put the same secret on every machine that ever runs the // manifest carrying one instead would put the same secret on every machine that ever runs the
// module, in a file anybody can read, for ever. // module, in a file anybody can read, for ever.
OwnSecrets map[string]string `json:"own-secrets,omitempty"` //
// **And how the module takes it** (novox/hq ADR 0114, issue 180): `"admin": "<path>"` says
// where and nothing else; `"admin": {"path": "<path>", "taken": "at-start"}` says the module
// reads the file when it starts, so the mesh may rotate it by making a new value and starting
// the module again; `"taken": "applied"` says the module's own code applies it to a backend
// that takes it only once, so a rotation must be staged beside the current value — the form the
// mesh does not build yet, and refuses by name. A secret that says neither is not rotated by
// the mesh: the one fault worse than an unrotated credential is a rotated one the software
// never saw.
OwnSecrets OwnSecrets `json:"own-secrets,omitempty"`
// SecretsOwner is who the files holding this module's secrets belong to on the machine — // SecretsOwner is who the files holding this module's secrets belong to on the machine —
// `uid:gid`, or a name — when its process is not root. // `uid:gid`, or a name — when its process is not root.
@@ -549,8 +599,14 @@ type BuildsOn struct {
type ArtifactContext struct { type ArtifactContext struct {
// Repository is cloned fresh, the same way the module's own repository is — a working tree // Repository is cloned fresh, the same way the module's own repository is — a working tree
// nothing has touched, so what was built is reproducible from the two commits named rather // nothing has touched, so what was built is reproducible from the two commits named rather
// than from whatever a previous build happened to leave behind. // than from whatever a previous build happened to leave behind. A URL, or — with Seat — a
// path on that seat's holder, `<owner>/<name>`.
Repository string `json:"repository"` Repository string `json:"repository"`
// Seat is the seat the repository lives on: `git` for this mesh's own forge (novox/hq ADR 0111,
// ADR 0155). A context written as a URL names one installation's forge and can be built
// nowhere else; a path on the seat is composed by the mesh that builds it, whichever forge
// holds the seat there.
Seat string `json:"seat,omitempty"`
// Ref is the branch, tag or commit of that repository to build. Empty means its own default // Ref is the branch, tag or commit of that repository to build. Empty means its own default
// branch — the same meaning an empty module ref already has. // branch — the same meaning an empty module ref already has.
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
@@ -1122,6 +1178,23 @@ func ParseManifest(raw []byte) (Manifest, error) {
if !name.MatchString(p) { if !name.MatchString(p) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
} }
if offer.Credential != nil {
own, declared := m.OwnSecrets[offer.Credential.Own]
switch {
case offer.Credential.Own == "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential that names no own secret", m.Module, p))
case !declared:
problems = append(problems, fmt.Sprintf(
"%s provides %q with its own secret %q as the credential, and declares no such secret",
m.Module, p, offer.Credential.Own))
case own.Taken == "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with its own secret %q as the credential every consumer receives, so "+
"the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)",
m.Module, p, offer.Credential.Own))
}
}
if instead, generic := engineGeneric[p]; generic { if instead, generic := engineGeneric[p]; generic {
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing // A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing
// the role means a requirement for it matches any engine, resolves as satisfied, and // the role means a requirement for it matches any engine, resolves as satisfied, and
@@ -1408,14 +1481,20 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
} }
for name, where := range m.OwnSecrets { for name, own := range m.OwnSecrets {
if !placedOrAbsolute(where) { if !placedOrAbsolute(own.Path) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, where)) "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path))
} }
if name == "" { if name == "" {
problems = append(problems, m.Module+" needs a secret with no name") problems = append(problems, m.Module+" needs a secret with no name")
} }
if own.Taken != "" && own.Taken != TakenAtStart && own.Taken != TakenApplied {
problems = append(problems, fmt.Sprintf(
"%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+
"or %q (applied by the module's own code to a backend that takes it once)",
m.Module, name, own.Taken, TakenAtStart, TakenApplied))
}
} }
localOf := map[string]string{} localOf := map[string]string{}
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
@@ -1519,7 +1598,16 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
for _, a := range m.Accesses { for _, a := range m.Accesses {
if !strings.HasPrefix(a.Path, "/") { if a.ID == "" && a.Path == "" {
problems = append(problems, fmt.Sprintf(
"%s declares an access with neither an id nor a path — an id, which the assignment places",
m.Module))
}
if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") {
problems = append(problems, fmt.Sprintf(
"%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID))
}
if a.Path != "" && !strings.HasPrefix(a.Path, "/") {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s accesses %q, which is not an absolute path", m.Module, a.Path)) "%s accesses %q, which is not an absolute path", m.Module, a.Path))
} }
@@ -1551,6 +1639,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
// the time it sees the mount it is being asked to create the directory, which it can do. // the time it sees the mount it is being asked to create the directory, which it can do.
problems = append(problems, m.undeclaredMounts()...) problems = append(problems, m.undeclaredMounts()...)
problems = append(problems, m.unknownDirRefs()...) problems = append(problems, m.unknownDirRefs()...)
problems = append(problems, m.unknownAccessRefs()...)
for i, r := range m.Resources { for i, r := range m.Resources {
id, _ := r["id"].(string) id, _ := r["id"].(string)
@@ -1669,8 +1758,8 @@ func (m Manifest) undeclaredMounts() []string {
claim(fmt.Sprint(r["path"])) claim(fmt.Sprint(r["path"]))
} }
} }
for _, where := range m.OwnSecrets { for _, own := range m.OwnSecrets {
claim(where) claim(own.Path)
} }
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) { for _, f := range m.SecretFiles(to) {
@@ -1795,6 +1884,7 @@ func invokeProblems(m Manifest) []string {
if t == "*" { if t == "*" {
continue continue
} }
t = strings.TrimPrefix(t, "seat:")
module, tool, named := strings.Cut(t, ".") module, tool, named := strings.Cut(t, ".")
if !named || !name.MatchString(module) || !toolName.MatchString(tool) { if !named || !name.MatchString(module) || !toolName.MatchString(tool) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
@@ -1804,3 +1894,69 @@ func invokeProblems(m Manifest) []string {
} }
return problems return problems
} }
// How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or
// applied by its own code to a backend that takes it once.
const (
TakenAtStart = "at-start"
TakenApplied = "applied"
)
// OwnSecret is where one of a module's own secrets lands, and how the module takes it.
type OwnSecret struct {
Path string
Taken string
}
// OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming
// the path and how it is taken; written back the way it was read, so a manifest the mesh holds
// keeps its bytes.
type OwnSecrets map[string]OwnSecret
func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
var entries map[string]json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return err
}
out := make(OwnSecrets, len(entries))
for name, body := range entries {
var path string
if err := json.Unmarshal(body, &path); err == nil {
out[name] = OwnSecret{Path: path}
continue
}
var long struct {
Path string `json:"path"`
Taken string `json:"taken,omitempty"`
}
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&long); err != nil {
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\"}: %w", name, err)
}
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken}
}
*o = out
return nil
}
func (o OwnSecrets) MarshalJSON() ([]byte, error) {
entries := make(map[string]any, len(o))
for name, s := range o {
if s.Taken == "" {
entries[name] = s.Path
continue
}
entries[name] = map[string]string{"path": s.Path, "taken": s.Taken}
}
return json.Marshal(entries)
}
// Paths is each own secret's path by name — the shape every placement and file walk reads.
func (o OwnSecrets) Paths() map[string]string {
out := make(map[string]string, len(o))
for name, s := range o {
out[name] = s.Path
}
return out
}
+124
View File
@@ -0,0 +1,124 @@
package catalogue
import (
"sort"
"strings"
)
// Which machine serves each routed name (novox/hq ADR 0066, issue 178).
//
// A routed name is a label the mesh composed for a consumer's endpoint, and it is *served* by the
// provider that answers requests for it — the proxy the consumer's route reaches. The same name is
// composed into every labelled contribution the consumer makes, because a provider that must know
// the consumer's public name (an identity provider composing a redirect) is told it the same way
// (04-ISSUES/122). Attributing the name to whichever of those providers a map happened to yield
// last sent a public name to the identity provider's machine on one plan and to the proxy's on the
// next (forge issue 227), and the whole names region flipped with it.
//
// **The terminus serves the name.** Among the providers a name reaches, the one that serves it is
// the one that is not itself routed: a provider that contributes a labelled name of its own to some
// requirement is published through another provider, and is a consumer of names, not their end.
// Name-agnostic — nothing here knows what "route" means — and structural: it reads the graph the
// modules declared. Deterministic: names, requirements and nodes are walked in order, so two
// plans of one mesh yield one region.
// NamesServed is every routed name across the mesh and the node that serves it, from every node's
// resolution and settings. A name several termini claim goes to the first node in name order, so
// the answer is stable; a name nothing terminal claims is left out.
func NamesServed(plans map[string]Resolution, settings map[string]SettingsBy) (map[string]string, error) {
nodes := make([]string, 0, len(plans))
for n := range plans {
nodes = append(nodes, n)
}
sort.Strings(nodes)
out := map[string]string{}
for _, node := range nodes {
plan := plans[node]
all, err := plan.contributions(settings[node], nil, nil)
if err != nil {
return nil, err
}
requirements := make([]string, 0, len(all))
for to := range all {
requirements = append(requirements, to)
}
sort.Strings(requirements)
for _, to := range requirements {
for _, given := range all[to] {
if given.Node != "" {
// Said from another machine; that machine's own resolution carries it.
continue
}
// A routed name, and only that: a contribution the mesh composed a name for from a
// label it was given. A grant that happens to carry a `name` of its own — a database
// name — carries no label and is left alone.
if _, labelled := given.Values["label"]; !labelled {
continue
}
name, _ := given.Values["name"].(string)
if name == "" {
continue
}
serving := servingNodeOf(plan, to, given.From, node)
if !servesNames(plans[serving], to) {
continue
}
name = strings.ToLower(name)
if held, taken := out[name]; !taken || serving < held {
out[name] = serving
}
}
}
}
return out, nil
}
// servingNodeOf is the node answering one consumer's requirement: whoever the plan needs it from,
// or this same node when the provider is beside the consumer.
func servingNodeOf(plan Resolution, requirement, consumer, self string) string {
for _, need := range plan.Needs {
if need.Name == requirement && need.For == consumer && need.From != "" {
return need.From
}
}
return self
}
// servesNames says whether the module providing a requirement on a node is a terminus: it is not
// itself published under a labelled name through some other provider. A node whose plan is not
// known (it did not resolve) serves nothing.
func servesNames(plan Resolution, requirement string) bool {
for _, m := range plan.Modules {
if !offers(m, requirement) {
continue
}
return !contributesALabel(m)
}
return false
}
func offers(m Manifest, requirement string) bool {
for _, o := range m.Offers() {
if o == requirement {
return true
}
}
return false
}
func contributesALabel(m Manifest) bool {
for _, values := range m.Contributes {
if _, labelled := values["label"]; labelled {
return true
}
}
for _, locals := range m.ContributesMany {
for _, values := range locals {
if _, labelled := values["label"]; labelled {
return true
}
}
}
return false
}
+99
View File
@@ -0,0 +1,99 @@
package catalogue
import (
"testing"
)
// The mesh of forge issue 227 (novox/hq issue 178): a dashboard on the home server contributes its
// label to the route its proxy serves AND to the identity provider on the control node, which must
// know the dashboard's public name to compose a redirect. Both contributions carry the composed
// name; only the proxy serves it.
func twoNodesOneName(t *testing.T) (map[string]Resolution, map[string]SettingsBy) {
t.Helper()
catalogue := shelf(
Manifest{Module: "route-adapter", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/adapter/mesh.json"}},
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
Manifest{Module: "keycloak", Version: "1", Provides: FromAnywhere("oidc-client"),
Serves: map[string]map[string]any{"oidc-client": {"token-path": "/token"}},
Receives: map[string]string{"oidc-client": "/etc/keycloak/clients.json"},
Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page"}},
// Published through the proxy itself: the identity provider is routed, not a router.
Contributes: map[string]map[string]any{"route": {"label": "login", "endpoint": "web", "port": 8080}}},
Manifest{Module: "grafana", Version: "1",
Listens: []Listening{{Port: 3000, From: FromMesh, Why: "dashboards"}},
Contributes: map[string]map[string]any{
"route": {"label": "grafana", "endpoint": "web", "port": 3000},
"oidc-client": {"label": "grafana", "endpoint": "web", "port": 3000, "callback": "/login"},
}},
)
home := withDomain("home.example")
home.Name, home.At = "home-server", "home-server.internal"
control := withDomain("control.example")
control.Name, control.At = "anchor", "anchor.internal"
onHome, err := Resolve(catalogue, []string{"grafana", "route-adapter"}, home, World{
Offered: map[string][]Provider{"oidc-client": {{Node: "anchor", At: "anchor.internal", Module: "keycloak"}}},
})
if err != nil {
t.Fatal(err)
}
onControl, err := Resolve(catalogue, []string{"keycloak", "route-proxy"}, control, World{})
if err != nil {
t.Fatal(err)
}
return map[string]Resolution{"home-server": onHome, "anchor": onControl},
map[string]SettingsBy{"home-server": {}, "anchor": {}}
}
func TestANameResolvesToTheNodeWhoseProxyServesIt(t *testing.T) {
plans, settings := twoNodesOneName(t)
// Many times, because the fault was map order: one plan said one node, the next the other.
for i := 0; i < 25; i++ {
served, err := NamesServed(plans, settings)
if err != nil {
t.Fatal(err)
}
if served["grafana.home.example"] != "home-server" {
t.Fatalf("run %d: the dashboard's name is served by %q, and its proxy is on the home server: %v",
i, served["grafana.home.example"], served)
}
if served["login.control.example"] != "anchor" {
t.Fatalf("run %d: the identity provider's own name is served by its proxy on the control node: %v", i, served)
}
if _, leaked := served["grafana.control.example"]; leaked {
t.Fatalf("a name composed for the identity provider's benefit is not one it serves: %v", served)
}
}
}
// A module that is routed several times names each route (ADR 0094's sibling for contributes);
// every one of them is a name the mesh must resolve, and none reached the names region before.
func TestEveryRouteOfAModuleWithSeveralIsANameServed(t *testing.T) {
catalogue := shelf(
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
Manifest{Module: "photos", Version: "1",
Listens: []Listening{{Port: 8102, From: FromMesh, Why: "web"}, {Port: 9102, From: FromMesh, Why: "api"}},
ContributesMany: map[string]map[string]map[string]any{"route": {
"site": {"label": "photos", "endpoint": "web", "port": 8102},
"api": {"label": "photos-api", "endpoint": "api", "port": 9102},
}}},
)
node := withDomain("control.example")
node.Name, node.At = "anchor", "anchor.internal"
plan, err := Resolve(catalogue, []string{"photos", "route-proxy"}, node, World{})
if err != nil {
t.Fatal(err)
}
served, err := NamesServed(map[string]Resolution{"anchor": plan}, map[string]SettingsBy{"anchor": {}})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"photos.control.example", "photos-api.control.example"} {
if served[name] != "anchor" {
t.Fatalf("%s is not served by its proxy: %v", name, served)
}
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func needy() Manifest { func needy() Manifest {
return Manifest{ return Manifest{
Module: "postgres", Version: "1", Module: "postgres", Version: "1",
OwnSecrets: map[string]string{"superuser": "/var/lib/mesh/postgres/superuser"}, OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/mesh/postgres/superuser"}},
Resources: []map[string]any{ Resources: []map[string]any{
{"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"}, {"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"},
}, },
@@ -74,7 +74,7 @@ func TestANeedIsAnAbsolutePath(t *testing.T) {
func TestAModuleMayNeedSeveralThings(t *testing.T) { func TestAModuleMayNeedSeveralThings(t *testing.T) {
// A password and a token, say. Telling them apart is the module's business, not the mesh's. // A password and a token, say. Telling them apart is the module's business, not the mesh's.
m := needy() m := needy()
m.OwnSecrets["replication"] = "/var/lib/mesh/postgres/replication" m.OwnSecrets["replication"] = OwnSecret{Path: "/var/lib/mesh/postgres/replication"}
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{}) got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{})
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{ out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{
"postgres": {"superuser": "b25l", "replication": "dHdv"}, "postgres": {"superuser": "b25l", "replication": "dHdv"},
@@ -0,0 +1,54 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// An own secret says how the module takes it (novox/hq ADR 0114, issue 180): a path alone says
// nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes
// it was given either way.
func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"broker":"/var/lib/mesh/idp/broker",
"admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"},
"session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`))
if err != nil {
t.Fatal(err)
}
if m.OwnSecrets["broker"] != (OwnSecret{Path: "/var/lib/mesh/idp/broker"}) {
t.Fatalf("a path alone is a path and nothing more: %+v", m.OwnSecrets["broker"])
}
if m.OwnSecrets["admin"].Taken != TakenApplied || m.OwnSecrets["session"].Taken != TakenAtStart {
t.Fatalf("the word was not kept: %+v", m.OwnSecrets)
}
// Written back the way it was read, so a registered manifest keeps its bytes.
out, err := json.Marshal(m.OwnSecrets)
if err != nil {
t.Fatal(err)
}
var again OwnSecrets
if err := json.Unmarshal(out, &again); err != nil {
t.Fatal(err)
}
if len(again) != 3 || again["admin"].Taken != TakenApplied || again["broker"].Taken != "" {
t.Fatalf("the round trip changed the secrets: %s", out)
}
if !strings.Contains(string(out), `"broker":"/var/lib/mesh/idp/broker"`) {
t.Fatalf("a path alone is written back as a path: %s", out)
}
}
func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`))
if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) {
t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err)
}
_, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`))
if err == nil {
t.Fatal("an unknown field on an own secret was accepted")
}
}
+180
View File
@@ -0,0 +1,180 @@
package catalogue
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition
// stops naming where its data lives: a converted manifest, resolved on a node with the default root,
// names exactly the paths the manifest before it named. Data that a service is using must not move
// because a definition stopped saying where it was.
//
// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now.
// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared
// whole — not only the directories, but every string a directory's id was written into. Both
// sides are resolved, so a manifest converted in two steps (issue 119, then issue 174) is judged
// against the paths it named, not the text it used to name them with.
func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) {
before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE")
if before == "" || after == "" {
t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this")
}
found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json"))
compared := 0
for _, path := range found {
module := filepath.Base(filepath.Dir(path))
old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json"))
if err != nil {
continue // new since; nothing to keep
}
now, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(old) == string(now) {
continue
}
m, err := ParseManifest(now)
if err != nil {
t.Errorf("%s: %v", module, err)
continue
}
earlier, err := ParseManifest(old)
if err != nil {
t.Errorf("%s before: %v", module, err)
continue
}
var was, is any
if err := json.Unmarshal(old, &was); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(now, &is); err != nil {
t.Fatal(err)
}
// Both sides resolved: the manifest before may itself already place some directories
// (issue 119's conversion), and what must not move is the path a machine sees.
was = resolvedTree(was, dirsFor(earlier, Rendering{}), module, t)
resolved := resolvedTree(is, dirsFor(m, Rendering{}), module, t)
// An access named by id resolves to the path the definition still carries as its default
// (issue 153) — the same rule as a placed directory: an assignment that says nothing moves
// nothing.
was = accessesResolved(was, earlier)
resolved = accessesResolved(resolved, m)
if !reflect.DeepEqual(was, resolved) {
wasJSON, _ := json.MarshalIndent(was, "", " ")
isJSON, _ := json.MarshalIndent(resolved, "", " ")
t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s",
module, firstDifference(string(wasJSON), string(isJSON)), "")
}
compared++
}
t.Logf("%d converted manifest(s) resolve to the paths they named before", compared)
}
// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless
// directory given the path it resolves to, and the placement word removed.
func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any {
switch v := node.(type) {
case map[string]any:
out := map[string]any{}
for k, child := range v {
if k == "place" {
continue
}
out[k] = resolvedTree(child, dirs, module, t)
}
if out["type"] == "directory" {
if _, has := out["path"]; !has {
if id, ok := out["id"].(string); ok {
out["path"] = dirs[id]
}
}
}
return out
case []any:
out := make([]any, len(v))
for i, child := range v {
out[i] = resolvedTree(child, dirs, module, t)
}
return out
case string:
filled, err := dirFill(v, dirs, module)
if err != nil {
t.Error(err)
}
return filled
}
return node
}
func firstDifference(a, b string) string {
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
for i := range al {
if i >= len(bl) || al[i] != bl[i] {
from := i - 2
if from < 0 {
from = 0
}
to := i + 3
if to > len(al) {
to = len(al)
}
bt := i + 3
if bt > len(bl) {
bt = len(bl)
}
return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n")
}
}
return "(the difference is beyond the shorter document)"
}
// accessesResolved fills every ${access:<id>} with the default path the definition carries for that
// access, and drops the id, so a manifest that names its accesses is compared by the paths a machine
// with no placement receives.
func accessesResolved(node any, m Manifest) any {
defaults := map[string]string{}
for _, a := range m.Accesses {
if a.ID != "" && a.Path != "" {
defaults[a.ID] = a.Path
}
}
var walk func(any) any
walk = func(n any) any {
switch v := n.(type) {
case map[string]any:
out := map[string]any{}
for k, child := range v {
if k == "id" {
if _, isAccess := v["mode"]; isAccess && v["type"] == nil {
if _, hasPath := v["path"]; hasPath {
continue
}
}
}
out[k] = walk(child)
}
return out
case []any:
out := make([]any, len(v))
for i, child := range v {
out[i] = walk(child)
}
return out
case string:
return accessRef.ReplaceAllStringFunc(v, func(ref string) string {
if p, ok := defaults[accessRef.FindStringSubmatch(ref)[1]]; ok {
return p
}
return ref
})
}
return n
}
return walk(node)
}
+382
View File
@@ -0,0 +1,382 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
//
// A definition names no host path. It declares the directories it owns by id, and the operator's
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
// concrete paths exactly as it always has and learns no field.
//
// {"places": {"config": "/services/sonarr/config",
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
// "accesses": {"series": "/storage/media/series",
// "downloads": "/storage/downloads"}}
//
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
// the manifest's), removed when empty and no longer declared. A placed access is still the
// operator's: mounted, never created, chowned or removed.
// PlacesSetting is the settings key that places a module's declared directories, by id.
const PlacesSetting = "places"
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
const AccessesSetting = "accesses"
// Placement is what an assignment says about one of a module's directories.
type Placement struct {
// Path is where the directory is on this machine. Absolute.
Path string
// Owner is "uid:gid" when the assignment overrides the manifest's — the predecessor's data is
// owned by whoever it ran as, and that is one machine's fact.
Owner string
}
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
// Places reads where this node places the module's directories, by directory id.
//
// It refuses an id the module declares no directory for, a path that is not absolute, and an
// owner that is not uid:gid. A directory the assignment does not mention keeps the manifest's
// stated path or the node's default layout.
func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
declared := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
declared[fmt.Sprint(r["id"])] = true
}
}
out := map[string]Placement{}
for _, layer := range layers {
raw, ok := layer.Values[PlacesSetting]
if !ok {
continue
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { directory: path | { path, owner } } map, and %q set it to something else",
m.Module, PlacesSetting, layer.From)
}
for id, body := range blocks {
if !declared[id] {
return nil, fmt.Errorf(
"%s places the directory %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, id, orNothing(namesOfDirs(directoriesOf(m))))
}
p := out[id]
switch v := body.(type) {
case string:
p.Path = strings.TrimSpace(v)
case map[string]any:
if path, said := v["path"]; said {
text, _ := path.(string)
p.Path = strings.TrimSpace(text)
}
if owner, said := v["owner"]; said {
text, _ := owner.(string)
if !ownerShape.MatchString(strings.TrimSpace(text)) {
return nil, fmt.Errorf("%s places %q with owner %v; an owner is uid:gid, numeric",
m.Module, id, owner)
}
p.Owner = strings.TrimSpace(text)
}
default:
return nil, fmt.Errorf("%s places %q with %v; a placement is a path, or { path, owner }",
m.Module, id, body)
}
if p.Path == "" {
return nil, fmt.Errorf("%s places %q without a path", m.Module, id)
}
if !strings.HasPrefix(p.Path, "/") {
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
}
p.Path = strings.TrimRight(p.Path, "/")
out[id] = p
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// AccessPlaces reads where this node keeps the operator's data the module accesses, by access id.
//
// It refuses an id the module declares no access under, and a path that is not absolute. An
// access declared by path alone cannot be placed — it has no name to place it by.
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
declared := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
declared[a.ID] = true
}
}
out := map[string]string{}
for _, layer := range layers {
raw, ok := layer.Values[AccessesSetting]
if !ok {
continue
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { access: path } map, and %q set it to something else",
m.Module, AccessesSetting, layer.From)
}
for id, body := range blocks {
if !declared[id] {
return nil, fmt.Errorf(
"%s places the access %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
}
path, _ := body.(string)
path = strings.TrimSpace(path)
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
m.Module, id, body)
}
out[id] = strings.TrimRight(path, "/")
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// placedAccess is one access with the path it resolves to on this node.
type placedAccess struct {
ID string
Path string
Mode string
}
// accessesFor is every access of a module with its path on this node: the assignment's where it
// placed one, the definition's where it carries a default, and refused where neither says — an
// access that resolves to nowhere would reach the machine as a mount of nothing.
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
placed, err := AccessPlaces(m, layers)
if err != nil {
return nil, nil, err
}
var out []placedAccess
byID := map[string]string{}
for _, a := range m.Accesses {
path := a.Path
if a.ID != "" {
if at, said := placed[a.ID]; said {
path = at
}
}
if path == "" {
return nil, nil, fmt.Errorf(
"%s accesses %q, and nothing says where that is on this node — the definition "+
"carries no path (it must not, novox/hq ADR 0112) and the assignment places "+
"none. Set %s: {%q: \"/where/it/is\"}",
m.Module, a.ID, AccessesSetting, a.ID)
}
out = append(out, placedAccess{ID: a.ID, Path: path, Mode: a.At()})
if a.ID != "" {
byID[a.ID] = path
}
}
return out, byID, nil
}
// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access.
func accessFill(s string, accesses map[string]string, module string) (string, error) {
var missing error
out := accessRef.ReplaceAllStringFunc(s, func(ref string) string {
id := accessRef.FindStringSubmatch(ref)[1]
path, has := accesses[id]
if !has {
missing = fmt.Errorf(
"%s says ${access:%s}, and %s declares no access %q. It declares %s",
module, id, module, id, orNothing(namesOfAccessIDs(accesses)))
return ref
}
return path
})
return out, missing
}
// accessInto fills every ${access:…} a resource carries — in its path, its content, its mounts,
// its environment and its env-files — with the path this node resolved for it. The same walk as
// dirInto, for the same reason: a literal `${access:x}` reaching the machine would be mounted as
// a directory called that.
func accessInto(resource map[string]any, accesses map[string]string, module string) error {
if !mentionsAccess(resource) {
return nil
}
fill := func(s string) (string, error) { return accessFill(s, accesses, module) }
var err error
if path, ok := resource["path"].(string); ok {
if resource["path"], err = fill(path); err != nil {
return err
}
}
if content, ok := resource["content"].(string); ok {
if resource["content"], err = fill(content); err != nil {
return err
}
}
for _, field := range []string{"volumes", "env-file"} {
list, ok := resource[field].([]any)
if !ok {
continue
}
filled := make([]any, len(list))
for i, v := range list {
filled[i] = v
if s, ok := v.(string); ok {
if filled[i], err = fill(s); err != nil {
return err
}
}
}
resource[field] = filled
}
if env, ok := resource["env"].(map[string]any); ok {
filled := make(map[string]any, len(env))
for key, v := range env {
filled[key] = v
if s, ok := v.(string); ok {
if filled[key], err = fill(s); err != nil {
return err
}
}
}
resource["env"] = filled
}
return nil
}
func mentionsAccess(resource map[string]any) bool {
for _, field := range []string{"path", "content"} {
if s, ok := resource[field].(string); ok && accessRef.MatchString(s) {
return true
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := resource[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok && accessRef.MatchString(s) {
return true
}
}
}
}
if env, ok := resource["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok && accessRef.MatchString(s) {
return true
}
}
}
return false
}
// ownerInto gives a placed directory the owner the assignment said, where it said one. The
// manifest's owner is what the image expects on any machine; the assignment's is what this
// machine's data already is.
func ownerInto(resource map[string]any, placed map[string]Placement) {
if fmt.Sprint(resource["type"]) != "directory" {
return
}
if p, ok := placed[fmt.Sprint(resource["id"])]; ok && p.Owner != "" {
resource["owner"] = p.Owner
}
}
// unknownAccessRefs is every ${access:…} in the definition that names no access the definition
// declares by id — refused where the author is, as unknownDirRefs does for directories.
func (m Manifest) unknownAccessRefs() []string {
declared := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
declared[a.ID] = true
}
}
seen := map[string]bool{}
var problems []string
refuse := func(s string, where any) {
for _, match := range accessRef.FindAllStringSubmatch(s, -1) {
id := match[1]
if declared[id] || seen[id] {
continue
}
seen[id] = true
problems = append(problems, fmt.Sprintf(
"%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+
"cannot place would reach the machine as a literal path",
m.Module, id, where, id))
}
}
for _, r := range m.Resources {
for _, field := range []string{"path", "content"} {
if s, ok := r[field].(string); ok {
refuse(s, r["id"])
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := r[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok {
refuse(s, r["id"])
}
}
}
}
if env, ok := r["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok {
refuse(s, r["id"])
}
}
}
}
sort.Strings(problems)
return problems
}
func directoriesOf(m Manifest) map[string]string {
dirs := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
dirs[fmt.Sprint(r["id"])] = ""
}
}
return dirs
}
func namesOfAccesses(m Manifest) []string {
var names []string
for _, a := range m.Accesses {
if a.ID != "" {
names = append(names, fmt.Sprintf("%q", a.ID))
}
}
sort.Strings(names)
return names
}
func namesOfAccessIDs(accesses map[string]string) []string {
var names []string
for id := range accesses {
names = append(names, fmt.Sprintf("%q", id))
}
sort.Strings(names)
return names
}
+172
View File
@@ -0,0 +1,172 @@
package catalogue
import (
"strings"
"testing"
)
// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put
// it — a library on its own pool that must never move, a configuration on a second disk owned by
// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment
// says it, by the ids the definition declared, and the machine receives concrete paths as always.
func placeable() Manifest {
m := mod("arr", nil, nil, nil)
m.Resources = []map[string]any{
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
{"id": "config", "type": "directory", "mode": "0755", "owner": "1000:1000"},
{"id": "server", "type": "container", "name": "arr", "image": "arr:1",
"volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:spool}:/downloads:ro"},
"env": map[string]any{"SPOOL": "${access:spool}"}},
}
m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}}
return m
}
func placedBy(values map[string]any) Rendering {
return Rendering{Settings: SettingsBy{"arr": {{From: "node anchor", Values: values}}}}
}
func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
out, err := got.Declaration(placedBy(map[string]any{
PlacesSetting: map[string]any{
"config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"},
},
AccessesSetting: map[string]any{
"series": "/storage/media/series",
"spool": "/storage/downloads",
},
}))
if err != nil {
t.Fatal(err)
}
seen := map[string]map[string]any{}
for _, r := range out {
seen[r["id"].(string)] = r
}
config := seen["arr.config"]
if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" {
t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"])
}
if seen["arr.state"]["path"] != "/var/lib/arr" {
t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"])
}
var accesses []string
for _, r := range out {
if r["type"] == "access" {
accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string))
}
}
if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" {
t.Fatalf("the accesses reached the machine as %v", accesses)
}
server := seen["arr.server"]
mounts := server["volumes"].([]any)
if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" ||
mounts[2] != "/storage/downloads:/downloads:ro" {
t.Fatalf("the mounts were not filled with the placed paths: %v", mounts)
}
if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" {
t.Fatalf("the environment was not filled: %v", server["env"])
}
}
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
// setting to write — not mounted as the literal, not skipped.
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
_, err = got.Declaration(placedBy(map[string]any{
AccessesSetting: map[string]any{"series": "/storage/media/series"},
}))
if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) {
t.Fatalf("an access nobody placed was not refused by name: %v", err)
}
}
// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal
// says what it does declare; a relative path and a non-numeric owner are refused too.
func TestPlacementsAreValidated(t *testing.T) {
m := placeable()
layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] }
_, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}}))
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) {
t.Fatalf("placing an undeclared directory was accepted: %v", err)
}
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}}))
if err == nil || !strings.Contains(err.Error(), "absolute") {
t.Fatalf("a relative placement was accepted: %v", err)
}
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{
"config": map[string]any{"path": "/services/arr", "owner": "media"}}}))
if err == nil || !strings.Contains(err.Error(), "uid:gid") {
t.Fatalf("a non-numeric owner was accepted: %v", err)
}
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}))
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) {
t.Fatalf("placing an undeclared access was accepted: %v", err)
}
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}}))
if err == nil || !strings.Contains(err.Error(), "absolute") {
t.Fatalf("a relative access was accepted: %v", err)
}
// And the two keys are never stray: they are validated here, not merged into a file.
if stray := UnusedSettings(m, layers(map[string]any{
PlacesSetting: map[string]any{"config": "/services/arr/config"},
AccessesSetting: map[string]any{"series": "/storage/media/series"},
})); len(stray) != 0 {
t.Fatalf("the placement keys were reported as unused: %v", stray)
}
}
// A definition that carries a path still works, as the default the assignment may replace — and
// the assignment's placement wins where both say.
func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) {
m := placeable()
m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}}
got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
out, err := got.Declaration(placedBy(map[string]any{
PlacesSetting: map[string]any{"config": "/services/arr/config"},
AccessesSetting: map[string]any{"series": "/storage/media/series"},
}))
if err != nil {
t.Fatal(err)
}
var paths []string
for _, r := range out {
if r["type"] == "access" {
paths = append(paths, r["path"].(string))
}
}
if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" {
t.Fatalf("placed one, defaulted the other: got %v", paths)
}
}
// A reference to an access the definition does not declare is refused where the author is.
func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) {
_, err := ParseManifest([]byte(`{
"module": "arr", "version": "1",
"accesses": [{"id": "series", "mode": "read-write"}],
"resources": [
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
{"id": "server", "type": "container", "name": "arr", "image": "arr:1",
"volumes": ["${access:movies}:/movies"]}
]}`))
if err == nil || !strings.Contains(err.Error(), "${access:movies}") {
t.Fatalf("a reference to an undeclared access was accepted: %v", err)
}
_, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`))
if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") {
t.Fatalf("an access with no id and no path was accepted: %v", err)
}
}
+28 -2
View File
@@ -179,6 +179,10 @@ type Needed struct {
// for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair // for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair
// credential, so two secrets from one provider to one module are two secrets. // credential, so two secrets from one provider to one module are two secrets.
Local string Local string
// SharedOwn is set when the provision's credential is one of the provider's own secrets, shared
// by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition.
// The plan mints the pair's copy from the provider's value rather than a value of its own.
SharedOwn string
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh // Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty // token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting // Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
@@ -322,7 +326,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
} }
needs = append(needs, Needed{ needs = append(needs, Needed{
Name: want, From: node.Name, At: at, Name: want, From: node.Name, At: at,
Serves: servedHere(catalogue, chosen, want), For: because[want]}) Serves: servedHere(catalogue, chosen, want), For: because[want],
SharedOwn: sharedHere(catalogue, chosen, want)})
} else if served := servedHere(catalogue, chosen, want); len(served) > 0 { } else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
// Answered here with no credential to mint, but the provider serves facts the // Answered here with no credential to mint, but the provider serves facts the
// consumer cannot guess — a port, a model name — and so still needs a binding. // consumer cannot guess — a port, a model name — and so still needs a binding.
@@ -369,8 +374,12 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
node.Name, want, p.Node, meshNetwork)) node.Name, want, p.Node, meshNetwork))
return return
} }
shared := ""
if pm, known := catalogue[p.Module]; known {
shared, _ = pm.SharedCredentialOf(want)
}
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
Serves: p.Serves, For: because[want]}) Serves: p.Serves, For: because[want], SharedOwn: shared})
} }
switch { switch {
case world.Unchecked: case world.Unchecked:
@@ -588,6 +597,20 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
// need that is never created is a binding the consumer never gets. It is right about that from the // need that is never created is a binding the consumer never gets. It is right about that from the
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not // manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
// enough for the values. // enough for the values.
// sharedHere is the own secret the provider of a provision on this same machine names as its
// credential (ADR 0158), or "" when the provider gives each consumer its own.
func sharedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) string {
for name, m := range catalogue {
if !chosen[name] {
continue
}
if own, shared := m.SharedCredentialOf(want); shared {
return own
}
}
return ""
}
func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any { func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any {
for name, m := range catalogue { for name, m := range catalogue {
if !chosen[name] { if !chosen[name] {
@@ -791,6 +814,9 @@ func checkResources(modules []Manifest) []string {
// which is what lets the stack in 04-ISSUES/036 co-resolve. // which is what lets the stack in 04-ISSUES/036 co-resolve.
for _, m := range modules { for _, m := range modules {
for _, a := range m.Accesses { for _, a := range m.Accesses {
if a.Path == "" {
continue // placed by the assignment; nothing to compare at registration
}
switch other := ownedPath[a.Path]; other { switch other := ownedPath[a.Path]; other {
case "": case "":
// Nobody owns it — the ordinary, correct case for shared data. // Nobody owns it — the ordinary, correct case for shared data.
+29 -7
View File
@@ -37,7 +37,9 @@ type Seat struct {
// today — which the bus refuses, because a namespace belongs to who it is named for. // today — which the bus refuses, because a namespace belongs to who it is named for.
Accepts []string Accepts []string
Emits []string Emits []string
Serves []string // Serves carries each verb in full — name, description, schema — because a role's tools are the
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
Serves []Verb
// Decision is the record that made it a seat. // Decision is the record that made it a seat.
Decision string Decision string
} }
@@ -51,8 +53,12 @@ var defaultSeats = []Seat{
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's // The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
// events belong to the role, so they keep their address while the holder is replaced. No accepts, // events belong to the role, so they keep their address while the holder is replaced. No accepts,
// so no work queue is raised for it — only what its holder may say. // so no work queue is raised for it — only what its holder may say.
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079", // And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
Emits: []string{"applied", "refused", "built-before"}}, // `assign` and the rest are a role's interface, not a container's, and stay addressable while
// the control plane is replaced.
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
Emits: []string{"applied", "refused", "built-before"},
Serves: ControllerVerbs},
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
@@ -60,7 +66,11 @@ var defaultSeats = []Seat{
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient // rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
// connection would mint a credential for each. // connection would mint a credential for each.
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"}, {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
// images and archives, by digest — which is why the provision is the artifact store and not an
// image registry.
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"}, {Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
@@ -70,8 +80,11 @@ var defaultSeats = []Seat{
// A build is work submitted to this role and its outcome is the role's own event (ADR 0129). // A build is work submitted to this role and its outcome is the role's own event (ADR 0129).
// One publish reaches whoever asked, the controller that records it, and the catalogue that // One publish reaches whoever asked, the controller that records it, and the catalogue that
// places it in the graph — what the old bus's shared exchange did for free. // places it in the graph — what the old bus's shared exchange did for free.
// 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.
{Name: "mesh-build-machine", Scope: ScopeMesh, {Name: "mesh-build-machine", Scope: ScopeMesh,
Accepts: []string{"build"}, Emits: []string{"built"}, Decision: "novox/hq ADR 0121"}, Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, {Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, {Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, {Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
@@ -91,8 +104,9 @@ var defaultSeats = []Seat{
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for // A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; // a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this // any other name is a module's own to define and claim. Some of the mesh's own seats predate this
// convention and are not yet renamed (git, npm-package-registry, the-artifact-store, // convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
// the-private-network) — those are in the set, so they resolve by name, not by prefix. // in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
func isSystemSeatName(name string) bool { func isSystemSeatName(name string) bool {
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
} }
@@ -269,6 +283,14 @@ func CanHold(m Manifest, seat Seat) error {
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope) m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
} }
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
// does not answer what the role promises is every caller's timeout, found at registration and
// at handover instead, naming the verbs rather than the fact that something is missing.
if missing := unservedVerbs(m.Tools, seat.Serves); len(missing) > 0 {
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
"(novox/hq ADR 0132) — a holder lists every verb its seat declares under tools",
m.Module, seat.Name, strings.Join(missing, ", "))
}
return nil return nil
} }
+6 -5
View File
@@ -38,8 +38,9 @@ type SeatDeclaration struct {
Accepts []string `json:"accepts,omitempty"` Accepts []string `json:"accepts,omitempty"`
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act. // Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
Emits []string `json:"emits,omitempty"` Emits []string `json:"emits,omitempty"`
// Serves are the verbs the holder answers: request and reply, awaited. // Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
Serves []string `json:"serves,omitempty"` // verb in full with its schema (novox/hq ADR 0132).
Serves []Verb `json:"serves,omitempty"`
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the // RetainSeconds is how long the inbound backlog survives with no holder, zero for the
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat // mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
@@ -62,7 +63,7 @@ func (s SeatDeclaration) At() string {
func (s SeatDeclaration) verbs() []string { func (s SeatDeclaration) verbs() []string {
out := append([]string{}, s.Accepts...) out := append([]string{}, s.Accepts...)
out = append(out, s.Emits...) out = append(out, s.Emits...)
return append(out, s.Serves...) return append(out, VerbNames(s.Serves)...)
} }
// declaredSeatProblems is what one manifest can be judged on alone. // declaredSeatProblems is what one manifest can be judged on alone.
@@ -228,8 +229,8 @@ func unserved(m Manifest, s SeatDeclaration) []string {
} }
var missing []string var missing []string
for _, t := range s.Serves { for _, t := range s.Serves {
if !has[t] { if !has[t.Name] {
missing = append(missing, t) missing = append(missing, t.Name)
} }
} }
return missing return missing
+1 -1
View File
@@ -13,7 +13,7 @@ func problemsFor(t *testing.T, shelf Shelf) string {
func telegram() Manifest { func telegram() Manifest {
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{ return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
Name: "telegram-sender", Scope: ScopeMesh, Name: "telegram-sender", Scope: ScopeMesh,
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []string{"status"}, Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}} }}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
} }
@@ -15,7 +15,7 @@ func aModuleWithAnEnvFileSecret(exception string) Manifest {
} }
return Manifest{ return Manifest{
Module: "app", Version: "1", Module: "app", Version: "1",
OwnSecrets: map[string]string{"token": "/var/lib/app/token.secret"}, OwnSecrets: OwnSecrets{"token": {Path: "/var/lib/app/token.secret"}},
Resources: []map[string]any{ Resources: []map[string]any{
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600", {"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
"content": "APP_TOKEN=${secret:token}\n"}, "content": "APP_TOKEN=${secret:token}\n"},
@@ -25,7 +25,7 @@ func fileNamed(out []map[string]any, id string) map[string]any {
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) { func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "[security]\nSECRET_KEY = ${secret:admin}\n", "content": "[security]\nSECRET_KEY = ${secret:admin}\n",
@@ -130,7 +130,7 @@ func TestTwoConsumersOfOneProvisionEachGetTheirOwnInAPlaceholderFile(t *testing.
func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) { func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "SECRET_KEY = ${secret:adnim}\n", "content": "SECRET_KEY = ${secret:adnim}\n",
@@ -151,7 +151,7 @@ func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
// One module may not read another's credential by guessing its name. // One module may not read another's credential by guessing its name.
func TestAFileCannotNameAnotherModulesSecret(t *testing.T) { func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{ r := Resolution{Node: "anchor", Modules: []Manifest{
{Module: "postgres", OwnSecrets: map[string]string{"superuser": "/var/lib/postgres/su.env"}}, {Module: "postgres", OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/postgres/su.env"}}},
{Module: "gitea", Resources: []map[string]any{{ {Module: "gitea", Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "PASSWORD=${secret:superuser}\n", "content": "PASSWORD=${secret:superuser}\n",
@@ -174,7 +174,7 @@ func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) { func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) {
r := Resolution{Node: "workstation", Modules: []Manifest{{ r := Resolution{Node: "workstation", Modules: []Manifest{{
Module: "chat", Module: "chat",
OwnSecrets: map[string]string{"api-token": "/home/operator/.config/chat/token"}, OwnSecrets: OwnSecrets{"api-token": {Path: "/home/operator/.config/chat/token"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "settings", "type": "file", "merge": "json", "id": "settings", "type": "file", "merge": "json",
"path": "/home/operator/.config/chat/settings.json", "content": "{}", "path": "/home/operator/.config/chat/settings.json", "content": "{}",
@@ -207,7 +207,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
Node: "anchor", Node: "anchor",
Modules: []Manifest{{ Modules: []Manifest{{
Module: "thing", Module: "thing",
OwnSecrets: map[string]string{"store": "/var/lib/thing/own.env"}, OwnSecrets: OwnSecrets{"store": {Path: "/var/lib/thing/own.env"}},
Secrets: map[string]string{"store": "/var/lib/thing/granted.env"}, Secrets: map[string]string{"store": "/var/lib/thing/granted.env"},
}}, }},
Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}}, Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}},
@@ -225,7 +225,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) { func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n",
}}, }},
+130
View File
@@ -0,0 +1,130 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
//
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
// one installation and of no other, and that a module's software must be told. They had nowhere to
// live but the definition, which is how a catalogue meant for any mesh came to name this one
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
// operator answering.
//
// `${setting:<key>}` in a file's content is filled from the module's settings layers — the mesh's,
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
// naming the key and the remedy: a definition that carried a default for a mail domain would be
// carrying the very literal this removes, and a blank written silently would be a service that
// comes up wrong somewhere that names neither the module nor the key.
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
// settingsUsed is every key a file's content asks for, once each, in order of first use.
func settingsUsed(content string) []string {
var keys []string
seen := map[string]bool{}
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
if !seen[m[1]] {
seen[m[1]] = true
keys = append(keys, m[1])
}
}
return keys
}
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
//
// The last layer setting a key wins, which is the node's over the mesh's — the same order settle
// applies to a mergeable file. A value that is not a string is written the way a program would read
// it (a number without a trailing .000000, a boolean as true/false).
func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
return fmt.Errorf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))
}
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
}
resource["content"] = content
return nil
}
func settingValue(layers []Layer, key string) (any, bool) {
var value any
set := false
for _, layer := range layers {
if v, has := layer.Values[key]; has {
value, set = v, true
}
}
return value, set
}
func orNoSettings(layers []Layer) string {
var keys []string
for _, l := range layers {
for k := range l.Values {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return "; no setting is set for this module"
}
sort.Strings(keys)
return "; set today: " + strings.Join(keys, ", ")
}
// settingKeysUsedBy is every key a module's files, contributions and served facts ask for, so a
// setting that lands in one is not called stray.
func settingKeysUsedBy(m Manifest) map[string]bool {
used := map[string]bool{}
note := func(s string) {
for _, k := range settingsUsed(s) {
used[k] = true
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if content, ok := r["content"].(string); ok {
note(content)
}
}
inValues := func(values map[string]any) {
for _, v := range values {
if s, ok := v.(string); ok {
note(s)
}
}
}
for _, values := range m.Contributes {
inValues(values)
}
for _, locals := range m.ContributesMany {
for _, values := range locals {
inValues(values)
}
}
for _, values := range m.Serves {
inValues(values)
}
return used
}
+55
View File
@@ -0,0 +1,55 @@
package catalogue
import (
"strings"
"testing"
)
// An operator's value reaches a file from the assignment's settings, the node's layer over the
// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name.
func TestASettingReachesAFileFromTheLayers(t *testing.T) {
file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env",
"content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"}
layers := []Layer{
{From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}},
{From: "this node", Values: map[string]any{"sitename": "This one"}},
}
if err := settingInto(file, layers, "mail"); err != nil {
t.Fatal(err)
}
if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" {
t.Fatalf("filled as %q", file["content"])
}
}
func TestASettingNothingSetIsRefusedByName(t *testing.T) {
file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"}
err := settingInto(file, []Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail")
if err == nil {
t.Fatal("a setting nothing set was written as something")
}
for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal lacks %q: %v", want, err)
}
}
// Left as it was: the literal placeholder must never reach a machine.
if file["content"] != "DOMAIN=${setting:domain}\n" {
t.Fatalf("content was changed on refusal: %q", file["content"])
}
}
// A key a file asks for is a destination, so setting it is not called stray.
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
m := Manifest{Module: "mail", Resources: []map[string]any{
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
}}
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
unused := strings.Join(UnusedSettings(m, layers), "; ")
if strings.Contains(unused, `"domain"`) {
t.Fatalf("a key a file asks for was called stray: %s", unused)
}
if !strings.Contains(unused, `"stray"`) {
t.Fatalf("a key nothing reads was not named: %s", unused)
}
}
+92 -13
View File
@@ -85,17 +85,67 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
return out, nil return out, nil
} }
// Settle lays settings over a module's own values. Exported for what a provider serves, which is // Settle lays settings over what a provider serves. Exported because a served fact is settled where
// settled where the mesh is walked rather than where a node is declared. // the mesh is walked rather than where a node is declared.
//
// **A setting overrides a served key; it never adds one** (novox/hq 04-ISSUES/173). What a consumer
// is told is the provider's contract, and a setting made for one of the provider's files — a site
// name, a public address — is not part of it. Before this, every setting of a module reached every
// consumer of every provision it served.
func Settle(base map[string]any, layers []Layer) (map[string]any, error) { func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
return settle(base, layers, nil, "what is served") return overridden(base, layers, "what is served")
}
// overridden lays settings over a map whose keys are its contract: a contribution, a served fact.
// Only the keys the map already declares are touched; the rest of a layer is somebody else's
// business (a file's, another destination's) and is left to reach it there.
//
// A declared value may itself be the operator's, `${setting:<key>}` (ADR 0155): a mail provider
// serves its domain, an identity provider its issuer, and neither is the definition's to state.
// Filled from the layers after the overrides, and refused by name when nothing sets it — a literal
// placeholder handed to a consumer is a service configured against a string nobody meant.
func overridden(base map[string]any, layers []Layer, what string) (map[string]any, error) {
kept := make([]Layer, 0, len(layers))
for _, layer := range layers {
values := map[string]any{}
for key, value := range layer.Values {
if _, declared := base[key]; declared {
values[key] = value
}
}
kept = append(kept, Layer{From: layer.From, Values: values})
}
merged, err := settle(base, kept, nil, what)
if err != nil {
return nil, err
}
for key, value := range merged {
s, ok := value.(string)
if !ok {
continue
}
for _, asked := range settingsUsed(s) {
v, set := settingValue(layers, asked)
if !set {
return nil, fmt.Errorf(
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
"assignment's, never the definition's (novox/hq ADR 0112)%s",
what, asked, key, asked, orNoSettings(layers))
}
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
}
merged[key] = s
}
return merged, nil
} }
// settle lays the layers over a module's own values, in order. // settle lays the layers over a module's own values, in order.
// //
// Shared by a file's content and a module's contributions, because they are the same act: the // Shared by a file's content and a module's contributions, because they are the same act: the
// module says what it means by default, and somebody says what it means here. A contribution that // module says what it means by default, and somebody says what it means here. A contribution that
// could not be settled would have to be edited to be reused anywhere else. // could not be settled would have to be edited to be reused anywhere else. The two differ in one
// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a
// configuration), a contribution or served fact does not (overridden).
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) ( func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
map[string]any, error) { map[string]any, error) {
merged := deepCopy(base) merged := deepCopy(base)
@@ -149,23 +199,22 @@ func deepCopy(in map[string]any) map[string]any {
return out return out
} }
// UnusedSettings names settings that reached no file. // UnusedSettings names settings that reach nothing.
// //
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed // Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
// nothing — and would find out by the machine not behaving differently, which is the slowest // nothing — and would find out by the machine not behaving differently, which is the slowest
// way there is. This is what makes that visible at the moment they set it. // way there is. This is what makes that visible at the moment they set it.
//
// Where a key can land: any mergeable file takes any key; a file asking for `${setting:<key>}`
// takes that key (ADR 0155); a contribution or a served fact takes a key it declares, and no other
// (novox/hq 04-ISSUES/173); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
func UnusedSettings(m Manifest, layers []Layer) []string { func UnusedSettings(m Manifest, layers []Layer) []string {
for _, r := range m.Resources { for _, r := range m.Resources {
if how, _ := r["merge"].(string); how != "" { if how, _ := r["merge"].(string); how != "" {
return nil return nil
} }
} }
// A contribution is a destination too. A route's hostname is exactly the kind of thing that
// differs between one mesh and the next, and calling it stray would refuse the one setting
// most modules that publish anything will have.
if len(m.Contributes) > 0 {
return nil
}
// A computed module has no resources here to look at — they are worked out per node, and // A computed module has no resources here to look at — they are worked out per node, and
// whether a setting lands is not knowable until then. Silence rather than a wrong answer: // whether a setting lands is not knowable until then. Silence rather than a wrong answer:
// claiming every setting on the private network is stray would be worse than saying nothing. // claiming every setting on the private network is stray would be worse than saying nothing.
@@ -173,9 +222,30 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
return nil return nil
} }
lands := settingKeysUsedBy(m)
for _, values := range m.Contributes {
for key := range values {
lands[key] = true
}
}
for _, locals := range m.ContributesMany {
for _, values := range locals {
for key := range values {
lands[key] = true
}
}
}
for _, served := range m.Serves {
for key := range served {
lands[key] = true
}
}
var unused []string var unused []string
for _, layer := range layers { for _, layer := range layers {
for key := range layer.Values { for key := range layer.Values {
if lands[key] {
continue
}
// `expose` is a real destination for a module that listens: it overrides a port's // `expose` is a real destination for a module that listens: it overrides a port's
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here. // source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
if key == ExposeSetting && len(m.Listens) > 0 { if key == ExposeSetting && len(m.Listens) > 0 {
@@ -197,9 +267,18 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
if key == EndpointsSetting && len(m.Listens) > 0 { if key == EndpointsSetting && len(m.Listens) > 0 {
continue continue
} }
// `places` puts a declared directory where this machine keeps it, `accesses` says where
// the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces.
if key == PlacesSetting && len(directoriesOf(m)) > 0 {
continue
}
if key == AccessesSetting && len(m.Accesses) > 0 {
continue
}
unused = append(unused, fmt.Sprintf( unused = append(unused, fmt.Sprintf(
"%s sets %q, and %s has no file or contribution to merge it into", "%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
layer.From, key, m.Module)) "declares no %q in what it contributes or serves",
layer.From, key, m.Module, key, key))
} }
} }
sort.Strings(unused) sort.Strings(unused)
+56 -1
View File
@@ -167,11 +167,45 @@ func TestSettingsThatReachNothingAreNamed(t *testing.T) {
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"}, {"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
}} }}
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}}) unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") { if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") {
t.Errorf("settings that reached nothing were not named: %v", unused) t.Errorf("settings that reached nothing were not named: %v", unused)
} }
} }
func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) {
// novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a
// setting for a key either declares, and a setting for a key neither declares is stray — it
// would not reach the route or the served fact, so it must be said rather than dropped.
m := Manifest{Module: "mail",
Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}},
Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}},
Resources: []map[string]any{
{"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"},
}}
layers := []Layer{{From: "the mesh", Values: map[string]any{
"host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}}
unused := UnusedSettings(m, layers)
if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) {
t.Errorf("only website reaches nothing; named: %v", unused)
}
}
func TestASettingOverridesAServedKeyAndAddsNone(t *testing.T) {
// What a consumer is told is the provider's contract. A setting made for one of the
// provider's files — its site name, its public address — is not part of it.
served, err := Settle(map[string]any{"host": "mail", "port": 25},
[]Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}})
if err != nil {
t.Fatal(err)
}
if served["host"] != "post" {
t.Errorf("the setting did not override the served host: %v", served)
}
if _, leaked := served["sitename"]; leaked {
t.Errorf("a setting for a file reached the consumers: %v", served)
}
}
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) { func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
// Rather than on the machine, at apply time, as a file the program cannot read. // Rather than on the machine, at apply time, as a file the program cannot read.
_, err := ApplySettings(file(`this is not json`), nil) _, err := ApplySettings(file(`this is not json`), nil)
@@ -179,3 +213,24 @@ func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
t.Fatal("a module claiming to merge as JSON shipped something else and was accepted") t.Fatal("a module claiming to merge as JSON shipped something else and was accepted")
} }
} }
func TestAServedValueMayBeTheOperators(t *testing.T) {
// A mail provider serves its domain and an identity provider its issuer; neither is the
// definition's to state (ADR 0155). Filled from the layers, refused by name when unset.
served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"},
[]Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}})
if err != nil {
t.Fatal(err)
}
if served["domain"] != "example.tld" {
t.Errorf("the operator's value did not fill the served key: %v", served)
}
_, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil)
if err == nil || !strings.Contains(err.Error(), `"domain"`) {
t.Errorf("a served value nothing sets must be refused by name; got %v", err)
}
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}}
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 {
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
}
}
@@ -0,0 +1,79 @@
package catalogue
import (
"strings"
"testing"
)
// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the
// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy
// from the provider's value.
func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"downloader","version":"1",
"own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}},
"provides":[{"name":"downloader-api","credential":{"own":"password"}}],
"serves":{"downloader-api":{"port":8080,"username":"admin"}}}`))
if err != nil {
t.Fatal(err)
}
if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" {
t.Fatalf("the offer's credential was not read: %v %v", own, shared)
}
if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" {
t.Fatalf("the provisions sharing the secret: %v", got)
}
for want, raw := range map[string]string{
"declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
"must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"},
"provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
"names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
func sharingShelf() map[string]Manifest {
return shelf(
Manifest{Module: "downloader", Version: "1",
Provides: []Offer{{Name: "downloader-api", Scope: ScopeMesh, Credential: &OfferCredential{Own: "password"}}},
OwnSecrets: OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: TakenAtStart}},
Serves: map[string]map[string]any{"downloader-api": {"port": 8080, "username": "admin"}}},
Manifest{Module: "manager", Version: "1", Requires: []string{"downloader-api"}},
)
}
func TestAConsumersNeedCarriesTheSharedSecretsName(t *testing.T) {
// On the same machine.
together, err := Resolve(sharingShelf(), []string{"downloader", "manager"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
found := false
for _, n := range together.Needs {
if n.Name == "downloader-api" && n.For == "manager" {
found = true
if n.SharedOwn != "password" {
t.Fatalf("the need on one machine does not name the shared secret: %+v", n)
}
}
}
if !found {
t.Fatalf("the manager's need was not resolved: %+v", together.Needs)
}
// Across machines, the provider known by its module.
apart, err := Resolve(sharingShelf(), []string{"manager"}, onBoth("example.tld"), World{
Offered: map[string][]Provider{"downloader-api": {{Node: "home-server", At: "home-server.internal",
Module: "downloader", Serves: map[string]any{"port": 8080}}}},
})
if err != nil {
t.Fatal(err)
}
for _, n := range apart.Needs {
if n.Name == "downloader-api" && n.SharedOwn != "password" {
t.Fatalf("the need across machines does not name the shared secret: %+v", n)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
// its answer (novox/hq ADR 0132, design 33 §2).
//
// **A name alone is not callable by something that has never seen the mesh before**, which is the
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
// a seat's idea of an argument and the caller's.
//
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
// nothing downstream cares which was written.
type Verb struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
}
func (v *Verb) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
var name string
if err := json.Unmarshal(trimmed, &name); err != nil {
return err
}
*v = Verb{Name: name}
return nil
}
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
// describe a tool nobody can call and refuse nothing.
type plain Verb
var p plain
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&p); err != nil {
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", err)
}
if p.Name == "" {
return fmt.Errorf("a served verb has no name: %s", trimmed)
}
*v = Verb(p)
return nil
}
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
func VerbNames(verbs []Verb) []string {
out := make([]string, 0, len(verbs))
for _, v := range verbs {
out = append(out, v.Name)
}
return out
}
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
const ControllerSeatName = "mesh-controller"
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
//
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
// is a command the controller's binary already answers, run by the holder of the seat with the
// arguments below and answered with what the command printed. A verb here is a contract every future
// holder must implement, which is why the list is short and made of what an operator asks weekly.
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
var ControllerVerbs = []Verb{
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
Input: schema(nil, nil)},
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
"machines are behind what the mesh would send them.",
Input: schema(nil, nil)},
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
Input: schema(nil, nil)},
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
"and which machines run it.",
Input: schema(nil, nil)},
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
Input: schema(nil, nil)},
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
Input: schema(map[string]string{
"module": "one module's name; every module when absent",
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
}, nil)},
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
{Name: "unassign", Description: "Take a module off a machine.",
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
Input: schema(map[string]string{
"provision": "a pair credential: the provision whose credential to replace",
"consumer": "with provision: only the holder on this machine (optional)",
"node": "an own secret: the machine",
"module": "an own secret: the module",
"secret": "an own secret: its name in the module's definition",
}, nil)},
{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{
"on": "instead of a repository: a module whose artifacts others stand on; every module built on it is rebuilt (the rebuild a changed base needs)",
"behind": "instead of a repository: \"yes\" rebuilds every module the mesh holds older than its source has",
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name)",
"path": "the module's directory inside it (optional)",
"ref": "the branch, tag or commit to build (optional)",
}, nil)},
}
// schema is a JSON schema for an object of string properties, which is every argument the verbs
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call.
func schema(properties map[string]string, required []string) map[string]any {
props := map[string]any{}
for name, description := range properties {
props[name] = map[string]any{"type": "string", "description": description}
}
out := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
out["required"] = required
}
return out
}
// unservedVerbs is what a seat promises and a claimant's `tools` does not answer.
func unservedVerbs(tools []string, promised []Verb) []string {
has := map[string]bool{}
for _, t := range tools {
has[t] = true
}
var missing []string
for _, v := range promised {
if !has[v.Name] {
missing = append(missing, v.Name)
}
}
return missing
}
+72
View File
@@ -0,0 +1,72 @@
package catalogue
import (
"strings"
"testing"
)
// A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132).
func TestAServedVerbIsANameOrADefinition(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` +
`"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` +
`"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` +
`"claims":[{"name":"shop-till","scope":"mesh"}]}`))
if err != nil {
t.Fatal(err)
}
got := m.DefinesSeats[0].Serves
if len(got) != 2 || got[0].Name != "price" || got[1].Name != "refund" || got[1].Description != "give it back" {
t.Fatalf("verbs not read: %+v", got)
}
if got[1].Input["type"] != "object" {
t.Fatalf("the schema did not travel with the verb: %+v", got[1].Input)
}
}
// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest.
func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"till","version":"1",` +
`"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`))
if err == nil || !strings.Contains(err.Error(), "descripton") {
t.Fatalf("a verb with a misspelt key was accepted: %v", err)
}
}
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
Serves: []Verb{{Name: "status"}, {Name: "push"}}}})
seat, _ := SeatNamed("mesh-controller")
partial := Manifest{Module: "a-controller", Tools: []string{"status"},
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
err := CanHold(partial, seat)
if err == nil || !strings.Contains(err.Error(), "does not serve push") {
t.Fatalf("a holder missing a verb was not refused by name: %v", err)
}
whole := Manifest{Module: "a-controller", Tools: []string{"status", "push"},
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
if err := CanHold(whole, seat); err != nil {
t.Fatalf("a holder serving every verb was refused: %v", err)
}
}
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
seen := map[string]bool{}
for _, v := range ControllerVerbs {
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
t.Errorf("%s: no description or no object schema", v.Name)
}
if seen[v.Name] {
t.Errorf("%s declared twice", v.Name)
}
seen[v.Name] = true
}
seat, _ := SeatNamed(ControllerSeatName)
if len(seat.Serves) != len(ControllerVerbs) {
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
}
}
+3 -2
View File
@@ -137,7 +137,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
} }
func asSeat(s catalogue.SeatDeclaration) broker.Seat { func asSeat(s catalogue.SeatDeclaration) broker.Seat {
return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves} return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
Serves: catalogue.VerbNames(s.Serves)}
} }
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work // MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
@@ -146,7 +147,7 @@ func MeshSeats() []broker.DeclaredSeat {
var out []broker.DeclaredSeat var out []broker.DeclaredSeat
for _, s := range catalogue.SeatsWithAProtocol() { for _, s := range catalogue.SeatsWithAProtocol() {
out = append(out, broker.DeclaredSeat{ out = append(out, broker.DeclaredSeat{
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves, Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves),
}) })
} }
return out return out
+2 -2
View File
@@ -32,7 +32,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
m := catalogue.Manifest{Module: "step-ca", Version: "1", m := catalogue.Manifest{Module: "step-ca", Version: "1",
Provides: catalogue.Offers("acme-ca"), OwnSecrets: map[string]string{"password": "/run/password"}} Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil { if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -228,6 +228,6 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T)
// withOwnSecret gives a fixture manifest an own secret, so a delivery to it is one the module // withOwnSecret gives a fixture manifest an own secret, so a delivery to it is one the module
// declares (novox/hq 04-ISSUES/078). // declares (novox/hq 04-ISSUES/078).
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest { func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
m.OwnSecrets = map[string]string{name: "/run/" + name} m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
return m return m
} }
@@ -0,0 +1,12 @@
-- A seat's protocol lives in the store, not in the binary (novox/hq ADR 0129, ADR 0132, design 33 §2).
--
-- ADR 0122 moved the seat set into this table with name, scope, delivers and decision, and the
-- protocol — what a role accepts, emits and serves — stayed compiled into the control plane and was
-- merged in as a row was read. Discovery that reads a binary disagrees with the mesh the moment the
-- two are on different versions, and a tool without a schema is not something an agent can call. So
-- the three halves become columns: accepts and emits as lists of verbs, serves as the verbs in full
-- ({name, description, input, output}). Seeded from the compiled defaults where a row has none,
-- additively thereafter (a verb a release adds joins the row; nothing is taken away).
alter table seat add column accepts jsonb not null default '[]'::jsonb;
alter table seat add column emits jsonb not null default '[]'::jsonb;
alter table seat add column serves jsonb not null default '[]'::jsonb;
@@ -0,0 +1,25 @@
-- The artifact store's seat is named for its scope, like the mesh's other seats (novox/hq ADR 0121,
-- ADR 0156, issue 123).
--
-- `the-artifact-store` was the last of the mesh's own seats named for the job it happened to do rather
-- than for the mesh; ADR 0121 decided the rename and deferred it because a delivering seat that stops
-- resolving mid-flight takes a provision away from every consumer. ADR 0122 removed that risk: a seat's
-- former name is an alias that resolves to it forever, a held record follows the rename by cascade, and
-- a claim written with the old name still holds.
--
-- **Both rows may exist when this runs.** A controller whose compiled defaults already carry the new
-- name seeds it as a new seat the moment it can, and on the mesh this was written for that happened
-- before the rename: the first form of this migration renamed into a duplicate key and the control
-- node's prepare failed on every attempt (2026-09-30). So: if the new row is already there, the old
-- row's holding moves to it and the old row goes; otherwise the old row is renamed. Either way the old
-- name becomes an alias.
update seat_holding set seat = 'mesh-artifact-store'
where seat = 'the-artifact-store'
and exists (select 1 from seat where name = 'mesh-artifact-store');
delete from seat
where name = 'the-artifact-store'
and exists (select 1 from seat where name = 'mesh-artifact-store');
update seat set name = 'mesh-artifact-store' where name = 'the-artifact-store';
insert into seat_alias (alias, seat) values ('the-artifact-store', 'mesh-artifact-store')
on conflict (alias) do update set seat = excluded.seat;
update seat_alias set seat = 'mesh-artifact-store' where seat = 'the-artifact-store';
@@ -0,0 +1,8 @@
-- A provider with one credential shares it with every consumer (novox/hq ADR 0158).
--
-- The provider's own secret and every consumer's pair row then carry one value, sealed once per
-- holder. The mesh keeps no plaintext, so it cannot tell by reading that they agree; it stamps the
-- act that made them instead. A pair row whose stamp is the own secret's was sealed from the same
-- value; one whose stamp differs, or is missing, is remade for every holder at once.
alter table module_secret add column generation text;
alter table secret add column generation text;
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) { func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1",
OwnSecrets: map[string]string{"superuser": "/run/superuser", "replication": "/run/replication"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"superuser": {Path: "/run/superuser"}, "replication": {Path: "/run/replication"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+115 -7
View File
@@ -2,6 +2,7 @@ package inventory
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
@@ -17,7 +18,7 @@ import (
// Seats is every seat the mesh defines, read from the store. // Seats is every seat the mesh defines, read from the store.
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) { func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,
`select name, scope, delivers, decided from seat order by name`) `select name, scope, delivers, decided, accepts, emits, serves from seat order by name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -26,9 +27,21 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
var seats []catalogue.Seat var seats []catalogue.Seat
for rows.Next() { for rows.Next() {
var s catalogue.Seat var s catalogue.Seat
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil { var accepts, emits, serves []byte
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision, &accepts, &emits, &serves); err != nil {
return nil, err return nil, err
} }
// The protocol, from the row (novox/hq ADR 0132). A row that predates the columns has empty
// lists, and UseSeats keeps the compiled protocol for it until the next seeding fills them.
if err := json.Unmarshal(accepts, &s.Accepts); err != nil {
return nil, fmt.Errorf("seat %s: accepts: %w", s.Name, err)
}
if err := json.Unmarshal(emits, &s.Emits); err != nil {
return nil, fmt.Errorf("seat %s: emits: %w", s.Name, err)
}
if err := json.Unmarshal(serves, &s.Serves); err != nil {
return nil, fmt.Errorf("seat %s: serves: %w", s.Name, err)
}
seats = append(seats, s) seats = append(seats, s)
} }
return seats, rows.Err() return seats, rows.Err()
@@ -41,21 +54,116 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting // exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
// the old name back. What a release removes from the defaults is not deleted here either; retiring a // the old name back. What a release removes from the defaults is not deleted here either; retiring a
// seat is its own decision, not a silent consequence of it dropping out of the binary. // seat is its own decision, not a silent consequence of it dropping out of the binary.
//
// **The protocol is seeded additively** (novox/hq ADR 0132, design 33 §7). A row that has none takes
// the compiled protocol whole — that is the compiled fallback becoming data, once. A row that has one
// gains any verb the defaults name and it lacks, and loses nothing: a seat's tools are an interface,
// additive within a version, and a verb an operator added to the row is theirs to keep.
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
var added int var added int
for _, s := range defaults { for _, s := range defaults {
tag, err := i.store.Pool().Exec(ctx, accepts, emits, serves, err := protocolJSON(s)
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
on conflict (name) do nothing`,
s.Name, s.Scope, s.Delivers, s.Decision)
if err != nil { if err != nil {
return added, err return added, err
} }
added += int(tag.RowsAffected()) tag, err := i.store.Pool().Exec(ctx,
`insert into seat (name, scope, delivers, decided, accepts, emits, serves)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (name) do nothing`,
s.Name, s.Scope, s.Delivers, s.Decision, accepts, emits, serves)
if err != nil {
return added, err
}
if n := int(tag.RowsAffected()); n > 0 {
added += n
continue
}
if err := i.widenProtocol(ctx, s); err != nil {
return added, err
}
} }
return added, nil return added, nil
} }
// widenProtocol adds to a seat's row whatever the defaults name and the row lacks, by verb name.
func (i *Inventory) widenProtocol(ctx context.Context, s catalogue.Seat) error {
var accepts, emits, serves []byte
if err := i.store.Pool().QueryRow(ctx,
`select accepts, emits, serves from seat where name = $1`, s.Name).Scan(&accepts, &emits, &serves); err != nil {
return err
}
var row catalogue.Seat
if err := json.Unmarshal(accepts, &row.Accepts); err != nil {
return err
}
if err := json.Unmarshal(emits, &row.Emits); err != nil {
return err
}
if err := json.Unmarshal(serves, &row.Serves); err != nil {
return err
}
changed := false
row.Accepts, changed = union(row.Accepts, s.Accepts, changed)
row.Emits, changed = union(row.Emits, s.Emits, changed)
have := map[string]bool{}
for _, v := range row.Serves {
have[v.Name] = true
}
for _, v := range s.Serves {
if !have[v.Name] {
row.Serves = append(row.Serves, v)
changed = true
}
}
if !changed {
return nil
}
a, e, sv, err := protocolJSON(row)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`update seat set accepts = $2, emits = $3, serves = $4 where name = $1`, s.Name, a, e, sv)
return err
}
func union(have, want []string, changed bool) ([]string, bool) {
seen := map[string]bool{}
for _, h := range have {
seen[h] = true
}
for _, w := range want {
if !seen[w] {
have = append(have, w)
seen[w] = true
changed = true
}
}
return have, changed
}
func protocolJSON(s catalogue.Seat) (accepts, emits, serves []byte, err error) {
if accepts, err = json.Marshal(orEmpty(s.Accepts)); err != nil {
return
}
if emits, err = json.Marshal(orEmpty(s.Emits)); err != nil {
return
}
verbs := s.Serves
if verbs == nil {
verbs = []catalogue.Verb{}
}
serves, err = json.Marshal(verbs)
return
}
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122). // Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) { func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`) rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
+298 -1
View File
@@ -505,7 +505,7 @@ func declaresOwn(m catalogue.Manifest) string {
if len(m.OwnSecrets) == 0 { if len(m.OwnSecrets) == 0 {
return "it declares no own secrets" return "it declares no own secrets"
} }
return "it declares: " + strings.Join(sortedNames(m.OwnSecrets), ", ") return "it declares: " + strings.Join(sortedNames(m.OwnSecrets.Paths()), ", ")
} }
func sortedNames(of map[string]string) []string { func sortedNames(of map[string]string) []string {
@@ -523,3 +523,300 @@ func orNone(names []string) string {
} }
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// ErrNotRotatable says why the mesh will not rotate a module's own secret; the words are the caller's
// to print, and the remedy is in them.
type ErrNotRotatable struct{ Why string }
func (e ErrNotRotatable) Error() string { return e.Why }
// RotateModuleSecret makes a module's own secret anew, the way the first mint did (novox/hq
// ADR 0114, issue 180). The caller sends the node, so the module is started again on the new value.
//
// **Only a secret the module reads when it starts.** A secret the module's code applies to a
// backend that takes it once would, rotated this way, leave the backend on the old value and the
// module reading the new one — the fault issue 179 was. That form is staged, which the mesh does
// not build yet, and is refused by name. A secret whose manifest says neither is refused with the
// word to write; a secret given to the mesh rather than made by it is refused as 0113 says: the
// mesh will not replace what it cannot read.
func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name string) error {
m, err := i.declared(ctx, module)
if err != nil {
return err
}
own, declared := m.OwnSecrets[name]
if !declared {
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
}
switch own.Taken {
case catalogue.TakenAtStart:
case catalogue.TakenApplied:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s applies %q to a backend that takes it once, so a rotation must be staged beside the "+
"current value until the module confirms it — the mesh does not do that yet (ADR 0114). "+
"Changing it is a person's work: change it in %s, then `secret accept %s %s %s`",
module, name, module, node, module, name)}
default:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s does not say how it takes %q, so the mesh will not rotate it: a secret rotated under "+
"software that never reads it again is worse than one left alone. Its definition says "+
"\"own-secrets\": {%q: {\"path\": …, \"taken\": \"at-start\"}} when the module reads it as it "+
"starts, or \"applied\" when its own code applies it",
module, name, name)}
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return err
}
if key == "" {
return fmt.Errorf("%s has no sealing key, so nothing can be sealed to it", node)
}
var origin string
err = i.store.Pool().QueryRow(ctx,
`select origin from module_secret where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&origin)
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("%s on %s holds no %q yet; the first push makes it", module, node, name)
}
if err != nil {
return err
}
if origin == OriginAccepted {
return ErrNotRotatable{Why: fmt.Sprintf(
"%s on %s holds %q as a value given to the mesh, not made by it, and the mesh will not "+
"replace what it cannot read (ADR 0113). Change it where it lives, then `secret accept "+
"%s %s %s` with the new value",
module, node, name, node, module, name)}
}
if len(m.ProvisionsSharing(name)) > 0 {
// Shared with every consumer of those provisions (ADR 0158): one new value, sealed to all.
return i.remakeShared(ctx, record.ID, key, module, name, "", nil, "", "", "")
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return err
}
made, blob, err := secrets.MakeWithOperator(key, key, operator)
if err != nil {
return err
}
forOperator, operatorKey := operatorColumns(operator, blob)
_, err = i.store.Pool().Exec(ctx,
`update module_secret set sealed = $4, node_key = $5, origin = 'made', made_at = now(),
operator_sealed = $6, operator_key = $7
where node = $1 and module = $2 and name = $3`,
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey)
return err
}
// SharedSecretFor is a consumer's copy of a provider's one credential (novox/hq ADR 0158): the
// provider's own secret, sealed to this consumer as a pair credential would be.
//
// **One value, many seals, made in one act.** The mesh keeps no plaintext, so a value cannot be
// sealed to a consumer that binds later; when a consumer's copy is missing or was made in a
// different act than the provider's own secret, a fresh value is made and sealed to the provider,
// to every consumer that holds the provision from this provider, to this consumer and to the
// operator — one generation, stamped on every row. Every holding machine must then be sent, which
// the plan's caller does by sending the node it was composing and `secret rotate` does for all.
//
// An accepted value is sealed to the consumers of the moment it was accepted and never remade: a
// consumer that binds later is refused with the way out, as ADR 0113 says.
func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, consumerModule,
provider, providerModule, local, own string) (Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return Secret{}, err
}
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return Secret{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return Secret{}, err
}
if consumerKey == "" || providerKey == "" {
return Secret{}, fmt.Errorf("%s and %s both need a sealing key before %s can be shared", consumer, provider, provision)
}
var ownGeneration, ownOrigin, ownKey *string
err = i.store.Pool().QueryRow(ctx,
`select generation, origin, node_key from module_secret where node = $1 and module = $2 and name = $3`,
providerNode.ID, providerModule, own).Scan(&ownGeneration, &ownOrigin, &ownKey)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Secret{}, err
}
var held Secret
var pairGeneration *string
err = i.store.Pool().QueryRow(ctx,
`select for_consumer, for_provider, consumer_key, provider_key, origin, generation from secret
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
provision, consumerNode.ID, consumerModule, providerNode.ID, local).
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin, &pairGeneration)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Secret{}, err
}
current := ownGeneration != nil && pairGeneration != nil && *ownGeneration == *pairGeneration &&
held.ConsumerKey == consumerKey && held.ProviderKey == providerKey && ownKey != nil && *ownKey == providerKey
if current {
held.Name, held.Consumer, held.Provider = provision, consumer, provider
held.ConsumerModule, held.Local = consumerModule, local
return held, nil
}
if ownOrigin != nil && *ownOrigin == OriginAccepted {
return Secret{}, fmt.Errorf(
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
"mesh, which cannot seal it to a consumer that binds later (ADR 0158): `secret accept %s %s %s` "+
"again, which seals it to every current consumer",
consumerModule, consumer, provision, provider, providerModule, own, provider, providerModule, own)
}
if err := i.remakeShared(ctx, providerNode.ID, providerKey, providerModule, own, provision, consumerNode.ID, consumerKey, consumerModule, local); err != nil {
return Secret{}, err
}
return i.SharedSecretFor(ctx, provision, consumer, consumerModule, provider, providerModule, local, own)
}
// remakeShared makes one fresh value and seals it to the provider's own secret, to every pair row
// of the provisions sharing it, to the one consumer being added (when there is one), and to the
// operator, all under one generation.
func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKey, providerModule, own,
provision string, addConsumerID any, addConsumerKey, addConsumerModule, addLocal string) error {
m, err := i.declared(ctx, providerModule)
if err != nil {
return err
}
provisions := m.ProvisionsSharing(own)
if len(provisions) == 0 {
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return err
}
value := secrets.Fresh()
generation := secrets.Stamp()
ownSealed, err := secrets.Seal(providerKey, []byte(value))
if err != nil {
return err
}
forOperator, operatorKey := "", ""
if operator != "" {
if forOperator, err = secrets.Seal(operator, []byte(value)); err != nil {
return err
}
operatorKey = operator
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key, generation)
values ($1, $2, $3, $4, $5, 'made', nullif($6,''), nullif($7,''), $8)
on conflict (node, module, name) do update set
sealed = excluded.sealed, node_key = excluded.node_key, origin = 'made', made_at = now(),
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key,
generation = excluded.generation`,
providerID, providerModule, own, ownSealed, providerKey, forOperator, operatorKey, generation); err != nil {
return err
}
// Every consumer that already holds one of the sharing provisions from this provider.
rows, err := tx.Query(ctx,
`select s.consumer, s.consumer_module, s.local, s.name, n.sealing_key
from secret s join node n on n.id = s.consumer
where s.provider = $1 and s.name = any($2)`, providerID, provisions)
if err != nil {
return err
}
type holder struct {
consumer any
consumerModule, local, name, key string
}
var holders []holder
for rows.Next() {
var h holder
var key *string
if err := rows.Scan(&h.consumer, &h.consumerModule, &h.local, &h.name, &key); err != nil {
rows.Close()
return err
}
if key != nil {
h.key = *key
}
holders = append(holders, h)
}
rows.Close()
if addConsumerID != nil {
holders = append(holders, holder{consumer: addConsumerID, consumerModule: addConsumerModule,
local: addLocal, name: provision, key: addConsumerKey})
}
for _, h := range holders {
if h.key == "" {
continue // a consumer whose key is gone cannot be sealed to; it is remade when it reports one
}
sealed, err := secrets.Accept(value, h.key, providerKey)
if err != nil {
return err
}
if _, err := tx.Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, origin, local, generation)
values ($1, $2, $3, $4, $5, $6, $7, $8, 'made', $9, $10)
on conflict (name, local, consumer, consumer_module, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
origin = 'made', generation = excluded.generation`,
h.name, h.consumer, h.consumerModule, providerID, sealed.ForConsumer, sealed.ForProvider,
h.key, providerKey, h.local, generation); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// SharedHolders is every machine holding a copy of a provider's shared credential: the provider's
// and every consumer's, for the send that follows a rotation.
func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule, own string) ([]string, error) {
m, err := i.declared(ctx, providerModule)
if err != nil {
return nil, err
}
provisions := m.ProvisionsSharing(own)
if len(provisions) == 0 {
return nil, nil
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select distinct n.name from secret s join node n on n.id = s.consumer
where s.provider = $1 and s.name = any($2)`, providerNode.ID, provisions)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]bool{provider: true}
out := []string{provider}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
sort.Strings(out)
return out, nil
}
+152 -3
View File
@@ -5,8 +5,10 @@ import (
"crypto/ecdh" "crypto/ecdh"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets" "github.com/novox/mesh-controller/internal/secrets"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -408,7 +410,7 @@ func TestACredentialGoesWhenEitherMachineDoes(t *testing.T) {
func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T) { func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1",
OwnSecrets: map[string]string{"broker": "/run/broker"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
const url = "amqps://builder:the-password-the-broker-was-told@broker/" const url = "amqps://builder:the-password-the-broker-was-told@broker/"
@@ -440,7 +442,7 @@ func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T
func TestASecretTheMeshWasGivenSurvivesAnOrdinaryPush(t *testing.T) { func TestASecretTheMeshWasGivenSurvivesAnOrdinaryPush(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1",
OwnSecrets: map[string]string{"broker": "/run/broker"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := inv.AcceptSecretForModule(ctx, "consumer", "builder", "broker", if err := inv.AcceptSecretForModule(ctx, "consumer", "builder", "broker",
@@ -725,7 +727,7 @@ func TestTheOperatorRecoversEachLocalNameApart(t *testing.T) {
func TestADeliveredSecretIsRefusedUnderANameTheModuleDoesNotDeclare(t *testing.T) { func TestADeliveredSecretIsRefusedUnderANameTheModuleDoesNotDeclare(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "step-ca", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "step-ca", Version: "1",
OwnSecrets: map[string]string{"password": "/run/password"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
err := inv.AcceptSecretForModule(ctx, "consumer", "step-ca", "root-key", "not-a-key") err := inv.AcceptSecretForModule(ctx, "consumer", "step-ca", "root-key", "not-a-key")
@@ -785,3 +787,150 @@ func TestADeliveredPairCredentialIsRefusedForARequirementTheModuleDoesNotHave(t
t.Errorf("a delivery under a kept local was refused: %v", err) t.Errorf("a delivery under a kept local was refused: %v", err)
} }
} }
// A module's own secret rotates when its definition says the module reads it at start: made anew,
// sealed to the machine and the operator, origin made. Refused with the reason when the definition
// says nothing, says the module applies it, or when the value was given to the mesh (novox/hq
// ADR 0114, issue 180).
func TestAnOwnSecretRotatesOnlyWhenTheModuleReadsItAtStart(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
m := catalogue.Manifest{Module: "idp", Version: "1", OwnSecrets: catalogue.OwnSecrets{
"session": {Path: "/var/lib/idp/session.secret", Taken: catalogue.TakenAtStart},
"admin": {Path: "/var/lib/idp/admin.secret", Taken: catalogue.TakenApplied},
"broker": {Path: "/var/lib/mesh/idp/broker"},
}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
before, err := inv.SecretForModule(ctx, "consumer", "idp", "session")
if err != nil {
t.Fatal(err)
}
if err := inv.RotateModuleSecret(ctx, "consumer", "idp", "session"); err != nil {
t.Fatal(err)
}
after, err := inv.SecretForModule(ctx, "consumer", "idp", "session")
if err != nil {
t.Fatal(err)
}
if after == before {
t.Fatal("rotating made no new value")
}
if _, err := inv.SecretForModule(ctx, "consumer", "idp", "admin"); err != nil {
t.Fatal(err)
}
var refused ErrNotRotatable
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "admin")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "staged") {
t.Fatalf("an applied secret must be refused as not yet stageable: %v", err)
}
if _, err := inv.SecretForModule(ctx, "consumer", "idp", "broker"); err != nil {
t.Fatal(err)
}
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "broker")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "does not say how it takes") {
t.Fatalf("a secret that says nothing of how it is taken must be refused: %v", err)
}
if err := inv.AcceptSecretForModule(ctx, "consumer", "idp", "session", "the-real-one"); err != nil {
t.Fatal(err)
}
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "session")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "given to the mesh") {
t.Fatalf("an accepted value must be refused: %v", err)
}
if err := inv.RotateModuleSecret(ctx, "consumer", "idp", "nothing"); err == nil || !strings.Contains(err.Error(), "does not declare") {
t.Fatalf("an undeclared secret: %v", err)
}
}
// A provider's one credential is one value sealed to every holder, remade for all at once when a
// consumer binds or a rotation is asked (novox/hq ADR 0158).
func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
provider := catalogue.Manifest{Module: "downloader", Version: "1",
Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Own: "password"}}},
OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: catalogue.TakenAtStart}}}
if err := inv.RegisterModule(ctx, provider, Source{}); err != nil {
t.Fatal(err)
}
for _, m := range []string{"manager", "indexer"} {
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil {
t.Fatal(err)
}
}
generationOf := func() string {
var g *string
node, _ := inv.NodeByName(ctx, "provider")
if err := inv.store.Pool().QueryRow(ctx, `select generation from module_secret where node = $1 and module = 'downloader' and name = 'password'`, node.ID).Scan(&g); err != nil {
t.Fatal(err)
}
if g == nil {
t.Fatal("the provider's own secret carries no generation")
}
return *g
}
pairGeneration := func(module string) string {
var g *string
cn, _ := inv.NodeByName(ctx, "consumer")
pn, _ := inv.NodeByName(ctx, "provider")
if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'downloader-api' and consumer = $1 and consumer_module = $2 and provider = $3`, cn.ID, module, pn.ID).Scan(&g); err != nil {
t.Fatal(err)
}
if g == nil {
return ""
}
return *g
}
first, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password")
if err != nil {
t.Fatal(err)
}
g1 := generationOf()
if pairGeneration("manager") != g1 {
t.Fatal("the consumer's copy was not sealed in the same act as the provider's own secret")
}
again, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password")
if err != nil || again.ForConsumer != first.ForConsumer {
t.Fatalf("asking twice remade the value: %v", err)
}
// A second consumer binding remakes the value for everyone, in one generation.
if _, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "indexer", "provider", "downloader", "", "password"); err != nil {
t.Fatal(err)
}
g2 := generationOf()
if g2 == g1 {
t.Fatal("a new consumer did not remake the shared value")
}
if pairGeneration("manager") != g2 || pairGeneration("indexer") != g2 {
t.Fatalf("not every holder was sealed in the new act: %s %s %s", g2, pairGeneration("manager"), pairGeneration("indexer"))
}
holders, err := inv.SharedHolders(ctx, "provider", "downloader", "password")
if err != nil || !reflect.DeepEqual(holders, []string{"consumer", "provider"}) {
t.Fatalf("the holders: %v %v", holders, err)
}
// Rotation remakes every copy.
if err := inv.RotateModuleSecret(ctx, "provider", "downloader", "password"); err != nil {
t.Fatal(err)
}
g3 := generationOf()
if g3 == g2 || pairGeneration("manager") != g3 || pairGeneration("indexer") != g3 {
t.Fatal("rotation did not remake every holder's copy")
}
// An accepted value: sealed to the consumers of the moment, and a later consumer is refused.
if err := inv.AcceptSecretForModule(ctx, "provider", "downloader", "password", "the-real-one"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "late", Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil {
t.Fatal(err)
}
_, err = inv.SharedSecretFor(ctx, "downloader-api", "consumer", "late", "provider", "downloader", "", "password")
if err == nil || !strings.Contains(err.Error(), "given to the mesh") {
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
}
}
+20
View File
@@ -43,6 +43,23 @@ type BuildRequest struct {
// written in a manifest the mesh has not read: it is inside the repository, and reading it is // written in a manifest the mesh has not read: it is inside the repository, and reading it is
// the build's first act. // the build's first act.
Held map[string]string `json:"held,omitempty"` Held map[string]string `json:"held,omitempty"`
// Seats is the clone base — `scheme://host:port` — of each seat a recipe's context may name
// (novox/hq ADR 0155): `git` for this mesh's own forge. Sent with the asking for the reason
// Held is: the context is written in a manifest the mesh has not read, and only the mesh knows
// which forge holds the seat here. A builder handed no base for a seat a context names refuses
// the build and says so.
Seats map[string]string `json:"seats,omitempty"`
// Source is the repository as the mesh records it when it lives on a seat's holder — the seat
// and the path on it, never the URL just composed (novox/hq ADR 0111). Carried with the
// asking and echoed in the outcome, so whoever hears the outcome can register the module with
// its true source, whether or not they were the one who asked (novox/hq issue 176).
Source *SourceOnSeat `json:"source,omitempty"`
}
// SourceOnSeat names a repository by the seat whose holder serves it and its path there.
type SourceOnSeat struct {
Seat string `json:"seat"`
Repository string `json:"repository"`
} }
// BuildResult is what a builder says back. // BuildResult is what a builder says back.
@@ -95,6 +112,9 @@ type BuildResult struct {
// Failed is why, when it did. // Failed is why, when it did.
Failed string `json:"failed,omitempty"` Failed string `json:"failed,omitempty"`
// Source is the request's, echoed: the seat form of the repository, for whoever registers.
Source *SourceOnSeat `json:"source,omitempty"`
} }
// ReadRepository is a repository a build read source from besides the module's own, at the branch, // ReadRepository is a repository a build read source from besides the module's own, at the branch,
+47
View File
@@ -27,6 +27,40 @@ const TheBuildMachine = "mesh-build-machine"
func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" } func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" }
func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" } func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" }
// BuildStarted is where a build machine says it has taken a build, and BuildLog is where it says
// what it is doing, one line per message, under the build's own id (novox/hq ADR 0157).
//
// **The whole build is on the bus as it happens.** The outcome alone told a person that a build
// failed and its first line why; everything between — which command, how long, where it hung —
// lived in one container's stderr on one machine. Every line is now an event of the role, retained
// with the rest of the mesh's events, so a reader follows a build live by subscribing its subject,
// or reads it back afterwards from the stream, and a viewer is a subscriber and nothing more.
func BuildStarted() string { return "mesh.seat." + TheBuildMachine + ".event.started" }
func BuildLog(id string) string { return "mesh.seat." + TheBuildMachine + ".event.log." + id }
// BuildStart is what a build machine says the moment it takes a build.
type BuildStart struct {
ID string `json:"id"`
Repository string `json:"repository"`
Path string `json:"path,omitempty"`
Ref string `json:"ref,omitempty"`
On string `json:"on"`
At string `json:"at"`
}
// BuildLine is one thing a build said while building.
type BuildLine struct {
ID string `json:"id"`
// Seq counts the lines of one build from 1, so a reader that joined late or read two copies
// can order them and see a gap.
Seq int `json:"seq"`
At string `json:"at"`
// Step is which part of the build spoke — clone, context, image, run, failed — and Message is
// what it said, as the builder's own log prints it.
Step string `json:"step"`
Message string `json:"message"`
}
// KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today. // KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today.
// //
// The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this // The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this
@@ -44,6 +78,12 @@ type Builders interface {
// need different remedies, which is why the message distinguishes them. // need different remedies, which is why the message distinguishes them.
Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error) Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error)
// Ask submits one build and does not wait: the outcome is the role's event, heard and taken in
// by the controller whether or not anybody waited (novox/hq issue 176). For a caller that
// cannot hold a connection for the minutes a build takes — a tool call — and follows the build
// by its id instead.
Ask(ctx context.Context, request BuildRequest) error
// Close lets go of whatever was dialled. // Close lets go of whatever was dialled.
Close() Close()
} }
@@ -57,6 +97,13 @@ type BuildMachine interface {
// Build is one request a machine has been handed. // Build is one request a machine has been handed.
type Build interface { type Build interface {
// Began says the build has been taken and is under way, before anything runs.
Began(ctx context.Context) error
// Say publishes one line of what the build is doing. Never fails the build: a line the bus
// did not take is a line lost, and the outcome still comes.
Say(step, message string)
// Request is what to build. // Request is what to build.
Request() BuildRequest Request() BuildRequest
+48
View File
@@ -43,6 +43,20 @@ func (b *natsBuilds) Close() {
} }
} }
// Ask publishes the work and returns; see Builders.
func (b *natsBuilds) Ask(ctx context.Context, request BuildRequest) error {
body, err := json.Marshal(request)
if err != nil {
return err
}
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := b.js.Context().Publish(BuildWork(), body, nats.Context(publish)); err != nil {
return fmt.Errorf("cannot submit a build: %w", err)
}
return nil
}
func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest, func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest,
wait time.Duration) (BuildResult, error) { wait time.Duration) (BuildResult, error) {
@@ -169,6 +183,7 @@ type natsBuild struct {
msg *nats.Msg msg *nats.Msg
on string on string
js *broker.JetStream js *broker.JetStream
seq int
} }
func (b *natsBuild) Request() BuildRequest { return b.request } func (b *natsBuild) Request() BuildRequest { return b.request }
@@ -203,4 +218,37 @@ func (b *natsBuild) Announce(ctx context.Context, result BuildResult) error {
func (b *natsBuild) Done() error { return b.msg.Ack() } func (b *natsBuild) Done() error { return b.msg.Ack() }
// Began publishes that this machine has taken the build, into the stream like the outcome, so a
// reader that asks afterwards sees when it started as well as how it ended.
func (b *natsBuild) Began(ctx context.Context) error {
body, err := json.Marshal(BuildStart{
ID: b.request.ID, Repository: b.request.Repository, Path: b.request.Path,
Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
return err
}
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := b.js.Context().Publish(BuildStarted(), body, nats.Context(publish)); err != nil {
return fmt.Errorf("cannot say a build started: %w", err)
}
return nil
}
// Say publishes one line under the build's id. Core publish, unawaited: the stream that holds the
// role's events captures it on its way through, and a build must not slow to the pace of an ack
// per line. A line the bus did not take is counted anyway, so the gap is visible to a reader.
func (b *natsBuild) Say(step, message string) {
b.seq++
body, err := json.Marshal(BuildLine{
ID: b.request.ID, Seq: b.seq, At: time.Now().UTC().Format(time.RFC3339Nano),
Step: step, Message: message,
})
if err != nil {
return
}
_ = b.js.Conn().Publish(BuildLog(b.request.ID), body)
}
func (b *natsBuild) Hold(after time.Duration) error { return b.msg.NakWithDelay(after) } func (b *natsBuild) Hold(after time.Duration) error { return b.msg.NakWithDelay(after) }
+32
View File
@@ -79,6 +79,14 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
defer func() { _ = watching.Unsubscribe() }() defer func() { _ = watching.Unsubscribe() }()
_ = js.Conn().Flush() _ = js.Conn().Flush()
// And a reader following this one build by its subject alone (novox/hq ADR 0157).
lines, err := js.Conn().SubscribeSync(BuildLog("b-1"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = lines.Unsubscribe() }()
_ = js.Conn().Flush()
// A build machine holding the role. // A build machine holding the role.
machine := MachineOverNATS(js, "anchor") machine := MachineOverNATS(js, "anchor")
defer machine.Close() defer machine.Close()
@@ -86,6 +94,9 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
go func() { go func() {
failed <- machine.Take(ctx, func(ctx context.Context, work Build) { failed <- machine.Take(ctx, func(ctx context.Context, work Build) {
r := work.Request() r := work.Request()
_ = work.Began(ctx)
work.Say("clone", "cloning /r")
work.Say("image", "building shop")
_ = work.Announce(ctx, BuildResult{ _ = work.Announce(ctx, BuildResult{
ID: r.ID, Repository: r.Repository, On: "anchor", Commit: "abc1234", ID: r.ID, Repository: r.Repository, On: "anchor", Commit: "abc1234",
Manifest: json.RawMessage(`{"module":"shop"}`), Manifest: json.RawMessage(`{"module":"shop"}`),
@@ -104,6 +115,27 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
} }
t.Fatalf("the asker never got an outcome: %v", err) t.Fatalf("the asker never got an outcome: %v", err)
} }
// The reader heard the build as it went, in order, under its id.
for want := 1; want <= 2; want++ {
msg, err := lines.NextMsg(5 * time.Second)
if err != nil {
t.Fatalf("line %d of the build never reached its subject: %v", want, err)
}
var line BuildLine
if err := json.Unmarshal(msg.Data, &line); err != nil || line.ID != "b-1" || line.Seq != want {
t.Fatalf("line %d came back as %s (%v)", want, msg.Data, err)
}
}
// And it is in the stream for a reader who comes later.
info, err := js.Context().StreamInfo(broker.EventsStream, &nats.StreamInfoRequest{SubjectsFilter: BuildLog("b-1")})
if err != nil {
t.Fatal(err)
}
if info.State.Subjects[BuildLog("b-1")] != 2 {
t.Fatalf("the stream holds %v under the build's subject, want 2", info.State.Subjects)
}
if err == nil {
}
if result.ID != "b-1" || result.Commit != "abc1234" { if result.ID != "b-1" || result.Commit != "abc1234" {
t.Fatalf("the asker got %+v", result) t.Fatalf("the asker got %+v", result)
} }
+127
View File
@@ -0,0 +1,127 @@
package link
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
)
// A role's tools, served by its holder (novox/hq ADR 0132, ADR 0154).
//
// The mesh's own verbs — `status`, `push`, `assign` — are the mesh-controller seat's tools, and the
// control plane is that seat's holder. So it answers them here, on the seat's subjects, the way a
// module's runtime answers a module's: one request, one reply on the asker's own inbox, `{result}` or
// `{error}`. Nothing about the transport is the command's business; a handler is a function of its
// arguments and gets the same answer the command line prints.
// ToolHandler answers one call of a role's tool. What it returns is marshalled as the result; an
// error is the tool answering with one, which is an answer and not a timeout.
type ToolHandler func(ctx context.Context, args json.RawMessage) (any, error)
// SeatToolSubject is where a mesh-scoped seat's tool is asked (design 33 §4).
func SeatToolSubject(seat, verb string) string { return "mesh.seat." + seat + ".tool." + verb }
// HandlerTimeout bounds one answer. A verb that runs a command — a push, a build with no wait —
// answers in seconds; anything that has not in this long is said to have not answered.
const HandlerTimeout = 5 * time.Minute
// RebindAfter is how long a refused subscription waits before it is tried again.
const RebindAfter = 30 * time.Second
// ServeSeatTools binds every handler on its seat's subject until stopped. A queue group per seat, so
// a second holder during a handover shares the calls rather than both answering one.
//
// **A holder binds when it may, not only when it starts.** The grant that lets the controller
// subscribe its seat's tools is a line in the bus's user list, and that list is composed by the
// controller and delivered to the broker's machine by a push — so the first controller to serve
// these started before the list named them, the server refused every subscription, and nothing
// tried again (2026-09-30). A refused subscription is therefore retried until it holds: the server
// says so asynchronously and invalidates the subscription, which is what is checked.
func (b OverNATS) ServeSeatTools(seat string, handlers map[string]ToolHandler, logger *log.Logger) (func(), error) {
var subs []*nats.Subscription
done := make(chan struct{})
stop := func() {
close(done)
for _, s := range subs {
_ = s.Unsubscribe()
}
}
for verb, handle := range handlers {
verb, handle := verb, handle
subject := SeatToolSubject(seat, verb)
bind := func() (*nats.Subscription, error) {
return b.Conn.QueueSubscribe(subject, "seat."+seat, func(msg *nats.Msg) {
// Its own goroutine per call: a slow `push` must not hold up a `status` asked beside it,
// and the library would otherwise run handlers one after another.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), HandlerTimeout)
defer cancel()
args := json.RawMessage(msg.Data)
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
var reply []byte
result, err := handle(ctx, args)
if err != nil {
reply, _ = json.Marshal(map[string]any{"error": err.Error()})
} else if reply, err = json.Marshal(map[string]any{"result": result}); err != nil {
reply, _ = json.Marshal(map[string]any{"error": "the answer could not be written as JSON: " + err.Error()})
}
if err := msg.Respond(reply); err != nil && logger != nil {
logger.Printf("%s: could not answer: %v", subject, err)
}
}()
})
}
sub, err := bind()
if err != nil {
stop()
return nil, fmt.Errorf("serving %s: %w", subject, err)
}
subs = append(subs, sub)
go keepBound(sub, bind, subject, done, logger)
}
if logger != nil {
logger.Printf("serving %d tool(s) of the %s seat", len(handlers), seat)
}
return stop, nil
}
// keepBound watches one subscription and re-binds it after the server refused it, until stopped.
// A subscription the server refused is invalid a moment after it was made; one it accepted stays
// valid. Checked rather than hooked, because the connection's error handler belongs to whoever
// dialled and a second one would replace it.
func keepBound(sub *nats.Subscription, bind func() (*nats.Subscription, error), subject string,
done <-chan struct{}, logger *log.Logger) {
current := sub
for {
select {
case <-done:
return
case <-time.After(3 * time.Second):
}
if current.IsValid() {
// Settled; from here a lost subscription is a lost connection, which the client
// restores itself with every subscription it holds.
return
}
if logger != nil {
logger.Printf("%s: the bus refused the subscription; trying again in %s — the grant "+
"arrives with the next push to the machine holding mesh-broker", subject, RebindAfter)
}
select {
case <-done:
return
case <-time.After(RebindAfter):
}
again, err := bind()
if err != nil {
continue
}
current = again
}
}
+20
View File
@@ -45,6 +45,26 @@ type Sealed struct {
ProviderKey string ProviderKey string
} }
// Fresh is a new secret value, the shape Make seals: for the one caller that must seal one value
// to many holders at once (novox/hq ADR 0158) and discards it the same way.
func Fresh() string {
value := make([]byte, 30)
if _, err := rand.Read(value); err != nil {
panic("the system's random source failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(value)
}
// Stamp is a random mark for one act of sealing a value to several holders: rows carrying the same
// stamp were sealed from the same value, which the mesh cannot otherwise tell, holding no plaintext.
func Stamp() string {
mark := make([]byte, 16)
if _, err := rand.Read(mark); err != nil {
panic("the system's random source failed: " + err.Error())
}
return hex.EncodeToString(mark)
}
// Make generates a secret and seals it to both ends, keeping no readable copy. // Make generates a secret and seals it to both ends, keeping no readable copy.
// //
// The plaintext exists for the length of this call. Rotation is therefore generating a new one // The plaintext exists for the length of this call. Rotation is therefore generating a new one
BIN
View File
Binary file not shown.
+30 -16
View File
@@ -18,22 +18,36 @@
} }
], ],
"own-secrets": { "own-secrets": {
"inventory": "/var/lib/mesh/mesh-controller/inventory", "inventory": "${dir:mesh-state}/inventory",
"identity": "/var/lib/mesh/mesh-controller/identity", "identity": "${dir:mesh-state}/identity",
"licences": "/var/lib/mesh/mesh-controller/licences", "licences": "${dir:mesh-state}/licences",
"broker": "/var/lib/mesh/mesh-controller/broker", "broker": "${dir:mesh-state}/broker",
"broker-management": "/var/lib/mesh/mesh-controller/broker-management", "broker-management": "${dir:mesh-state}/broker-management",
"broker-address": "/var/lib/mesh/mesh-controller/broker-address", "broker-address": "${dir:mesh-state}/broker-address",
"bus": "/var/lib/mesh/mesh-controller/bus" "bus": "${dir:mesh-state}/bus"
}, },
"secrets-owner": "65534:65534", "secrets-owner": "65534:65534",
"prepares": true, "prepares": true,
"tools": [
"tools",
"status",
"nodes",
"node",
"modules",
"seats",
"builds",
"plan",
"assign",
"unassign",
"push",
"build"
],
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mesh-controller", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "server", "id": "server",
@@ -59,13 +73,13 @@
}, },
"volumes": [ "volumes": [
"/var/lib/mesh-broker-tls:/broker-tls:ro", "/var/lib/mesh-broker-tls:/broker-tls:ro",
"/var/lib/mesh/mesh-controller/inventory:/run/secrets/inventory:ro", "${dir:mesh-state}/inventory:/run/secrets/inventory:ro",
"/var/lib/mesh/mesh-controller/identity:/run/secrets/identity:ro", "${dir:mesh-state}/identity:/run/secrets/identity:ro",
"/var/lib/mesh/mesh-controller/licences:/run/secrets/licences:ro", "${dir:mesh-state}/licences:/run/secrets/licences:ro",
"/var/lib/mesh/mesh-controller/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/mesh-controller/bus:/run/secrets/bus:ro", "${dir:mesh-state}/bus:/run/secrets/bus:ro",
"/var/lib/mesh/mesh-controller/broker-management:/run/secrets/broker-management:ro", "${dir:mesh-state}/broker-management:/run/secrets/broker-management:ro",
"/var/lib/mesh/mesh-controller/broker-address:/run/secrets/broker-address:ro" "${dir:mesh-state}/broker-address:/run/secrets/broker-address:ro"
], ],
"artifact": "server", "artifact": "server",
"restart-on": [ "restart-on": [