Compare commits

..
41 Commits
Author SHA1 Message Date
mesh-admin 5474ea2d41 Merge pull request 'A seat says what it receives, and its holder places every module's contribution (hq ADR 0212)' (#269) from feat/0212-contributions-to-a-seat into main 2026-10-04 14:48:13 +00:00
jochen b7912172af A seat says what it receives, and its holder places every module's contribution (hq ADR 0212)
Each contribution grain was a manifest field and a renderer of its own; a module now contributes
to any seat with a kind that seat receives, the holder places it with
${contribution:<seat>:<kind>}, and the contribution depends on the seat. node-hotkeys is the
first new seat to receive (triggers); the display session receives window-manager config.
2026-10-04 16:48:01 +02:00
mesh-admin b36babcd7d Merge pull request 'node-power: the power seat, and code for its moments (hq ADR 0211)' (#268) from feat/0211-node-power into main 2026-10-04 13:59:28 +00:00
jochen 49cf0aa562 node-power: the power seat, and code for its moments placed by its holder (hq ADR 0211)
A module that needs code after waking wrote into the service manager's sleep units; it now
contributes shell code for a named moment, which derives a dependency on node-power.
2026-10-04 15:59:17 +02:00
mesh-admin 5a4f73f761 Merge pull request 'A contribution depends on the seat that receives it; a collision is refused at assign (hq ADR 0210, issue 235)' (#267) from feat/0210-a-contribution-depends-on-its-seat into main 2026-10-04 13:45:46 +00:00
jochen 6af891e358 A contribution depends on the seat that receives it, and a collision is refused at assign (hq ADR 0210, issue 235)
The environment and shell contributions were written nowhere on a node without their holder;
they now derive a dependency on node-environment, node-login-shell or node-display-server, met
and refused as ADR 0207's are. Two modules declaring one package, path or unit made the node
unresolvable after the assignment was recorded; that is refused first now, because no later
assignment can complete it.
2026-10-04 15:45:35 +02:00
mesh-admin 568f55fd2e Merge pull request 'Refuse an unmet seat dependency the catalogue could meet (hq ADR 0207 §4)' (#266) from feat/0207-refuse-unmet-seat-dependencies into main 2026-10-04 11:07:52 +00:00
jochen 35314175f2 Refuse an unmet seat dependency the catalogue could meet (hq ADR 0207 §4)
status reported no unmet dependency on any node once systemd, pacman and docker
were assigned to all four (to-be 42), which is the condition ADR 0207 set for the
switch. A dependency no catalogue module could meet stays a report before and
after the switch, as assign already said it: there is no remedy to name.
2026-10-04 13:07:45 +02:00
mesh-admin ec2e6255a9 Merge pull request 'The unmet-dependency report names only the nodes an act touched (hq ADR 0207)' (#265) from fix/unheld-report-names-only-the-nodes-acted-on into main 2026-10-04 10:52:34 +00:00
jochen f3f34a170e Hold ssh-client to its new shape: an include region first, the mesh's hosts in config.d (mesh-catalog #266) 2026-10-04 12:52:03 +02:00
jochen e2622fd031 An act says the unmet seat dependencies of the node it acted on, not the mesh's (hq ADR 0207)
assign and unassign say only what they changed on their node; push <node>
lists that node's, push to many counts each and points at status. The
once-per-change log is the serving controller's alone: a one-shot command
starts with no memory, so it logged every node on every call.
2026-10-04 12:50:25 +02:00
mesh-admin 3ee32970ef Merge pull request 'Seat dependencies (hq ADR 0207), the graphical session's seats and display provisions (ADR 0208), groups from several modules' (#264) from feat/0207-a-module-depends-on-the-seats-that-apply-its-resources into main 2026-10-04 10:43:11 +00:00
jochen 11b654499b Several modules may add groups to one account; its shell and home stay one module's
The host only ever adds groups, so the container runtime's module can put the
operator in its group while the shell's module sets the same account's shell.
2026-10-04 12:42:00 +02:00
jochen d69e19103c The graphical session's seats, a display's machine reach, and the session's slots (hq ADR 0208)
Seed the eleven node seats with the verbs they start with. A provision may
have the machine's reach: a requirement for it resolves only to a provider
in the node's own set, is never pulled in, and is refused naming who could.
A shell contribution's for gains xinitrc and xresources, placed only by the
holder of node-display-server.
2026-10-04 12:38:53 +02:00
jochen 10f948e970 A module depends on the node seats that apply its resources (hq ADR 0207)
Seed node-package-manager and node-container-runtime. Derive each module's
dependencies from its declared service, package and container resources;
judge them over the node's whole set, exempting the foundation. Refuse at
assign (several modules may go on as one act) and at unassign of the last
holder; report at composition in status, behind one switch.
2026-10-04 12:34:11 +02:00
mesh-admin f421d4588c Merge pull request 'A grant secret belongs to whoever provisions (hq 225); the sweep skips what it will not address (hq 226); a container publishes only what it declares (hq 227)' (#263) from fix/a-grant-secret-is-read-by-the-account-that-provisions into main 2026-10-04 10:27:30 +00:00
jschoubben 74b0dab34c A container publishes only a port its module declares (hq issue 227)
The short form is a question the mesh answers: "80" means publish what the
software calls 80, and the mesh fills in the machine's half from the port it
assigned. It can only assign one for a port the module declared, so a number
appearing nowhere in listens gets no assignment and reaches the machine as
written — which is how the photo module asked for port 80 on the node whose
reverse proxy holds it.

Four modules publish 80 quite safely, because they declare 80. The difference
is the declaration, not the number. A catalogue-wide test now says so; it
names all three offenders against the catalogue as it was.
2026-10-04 12:25:27 +02:00
jschoubben 41b20b2782 A grant secret belongs to whoever provisions, and the sweep skips what it will not address
Issue 225. The mesh seals one credential per consumer beside the provider's
contributions file, and wrote it root-owned. That was right while a module's
own code ran in a container as root; ADR 0198 moved that code under the node's
runtime, as the node's account, and the secret stayed root's. On the control
machine two consumers went unprovisioned for three hours and the only sign
was a line reading 'secret not readable yet', 4330 times.

The same sentence is already written for a module's own secrets a few hundred
lines above — 'a root-owned 0600 file is one that process cannot read'. This
is that rule reaching the other kind of secret the mesh writes for a module.

Issue 226. The sweep met a reference recorded with the store's old address,
read 'I will not address this' as 'the store refuses everything', and
collected none of the 1681 it had found. Two changes: references from build
records are read through Recorded, where the provenance is known — not in
LetGo, which cannot tell one registry host from another and must stay strict
— and a reference the sweep will not address is now ErrNotOurs, skipped,
never a reason to stop. Only the store refusing ends a sweep.

make check: the two failures both fail on main as well — the resolver test
(hq 202/203) and the service-manager test, which reads this machine's own
shell environment.
2026-10-04 12:21:49 +02:00
mesh-admin 912e9f4e85 Merge pull request 'Assert every declared state's bucket on each push (hq ADR 0201)' (#262) from fix/buckets-on-push into main 2026-10-04 09:21:25 +00:00
jochen babd7b2f47 Assert every declared state's bucket on each push, before the memberships that name it (novox/hq ADR 0201)
The raise at start was the only place buckets were asserted, so a module
registered and assigned since had none until the control plane restarted —
found on the first module to declare state.
2026-10-04 11:13:34 +02:00
mesh-admin 892dfd1d08 Merge pull request 'Module state is hq ADR 0201 after all' (#261) from fix/module-state-is-0201 into main 2026-10-04 09:03:15 +00:00
jochen cfac579392 Module state is hq ADR 0201 after all: the derived-value record moved to 0202 on hq main 2026-10-04 11:02:42 +02:00
mesh-admin fe0d295490 Merge pull request 'Module state is hq ADR 0202 (0201 landed first for a provider's derivations)' (#258) from fix/adr-0202-module-state into main 2026-10-04 09:01:22 +00:00
mesh-admin d8a0238e02 Merge pull request 'The account's environment and the shell's contributions (hq ADR 0203, ADR 0204, to-be 41 WP2)' (#260) from feat/the-shell-and-its-environment into main 2026-10-04 08:49:35 +00:00
jochen dcf710a8d5 Merge remote-tracking branch 'origin/main' into feat/the-shell-and-its-environment 2026-10-04 10:31:03 +02:00
mesh-admin a2003ab616 Merge pull request 'while-stopped names the container as the machine knows it (hq ADR 0189)' (#259) from fix/while-stopped-names-the-composed-id into main 2026-10-04 02:18:44 +00:00
jschoubben 1363a2fe27 while-stopped names the container as the machine knows it (hq ADR 0189)
A module names its own resources locally; a declaration names them under the
module. restart-on and reload-on are rewritten for exactly that reason and
while-stopped was not, so the store's step said it held "store" still while
the machine's container is "distribution.store".

The host refuses a declaration naming a container it does not have — whole.
So novox took nothing at all, on every push, from 04:15 until this. The
machine was never damaged: refusing whole is what kept it serving.

Both sides' tests passed throughout. The controller's read manifests, the
host's read hand-written declarations with bare ids, and nothing composed one
and judged the result. That test now exists.
2026-10-04 04:18:22 +02:00
jochen f19a2254ac Compose the account's environment and the shell's code from every module (hq ADR 0203, 0204)
A module contributes environment variables, PATH entries and shell code in named slots;
the holder of the matching seat places them with ${environment:posix|systemd} and
${shell:<shell>:<slot>}. Rendered in module order with a naming line per contribution,
PATH entries added only when missing, machine facts resolved first. A variable two
modules set, or a placeholder outside its seat's holder, is refused at parse (the
catalogue check) and at composition. Filled after every other placeholder pass, so no
scanner ever reads a shell's own ${...}.
2026-10-04 04:03:50 +02:00
jochen 7d46e48b26 The account's environment and the login shell are the mesh's seats (hq ADR 0203, 0204)
node-environment says which module writes the account's environment; node-login-shell
replaces the module-declared login-shell, so a second shell claims it rather than
declaring a rival, and execute is the mesh's contract. login-shell is refused as a
module's seat name. Seeded into a live store by the existing additive seeding.
2026-10-04 04:03:50 +02:00
jochen 78915f9f7a Module state is hq ADR 0202: 0201 landed first for a provider's derivations 2026-10-04 03:44:50 +02:00
mesh-admin 17b8f14fe1 Merge pull request 'A module's state on the bus: buckets from the catalogue, grants, membership (hq ADR 0201)' (#257) from feat/module-state-on-the-bus into main 2026-10-04 01:43:36 +00:00
mesh-admin 42c394acc2 Merge pull request 'Group 8: a served value may name its consumer (hq ADR 0201), and the store keeps what the records name (hq ADR 0189)' (#227) from feat/the-store-keeps-what-the-records-name into main 2026-10-04 01:39:39 +00:00
jschoubben b9ad7a2948 Review before merge: refuse a silent disagreement, and bound the sweep
Three things found reading this back, each of which would have been quiet.

A consumer that keeps several holders of one provision (ADR 0094) gets a
login per holder, and a provider derives from the login — so it would make a
resource per holder while the consumer is told one value for the requirement.
That is issue 124's own failure one case to the side: authenticate, then be
refused on every object. Refused now, naming both ends.

The sweep runs inside somebody's build and was unbounded. At most two hundred
artifacts and sixty seconds, stopping at the first refusal because a store
that refuses one refuses all; the rest is offered again next build.

The citation and migration renumbers are in the commit before this one.
2026-10-04 03:27:32 +02:00
jochen cde22ff627 module check names a read of state its owner does not keep, and says what each module keeps and reads (novox/hq ADR 0201) 2026-10-04 02:50:02 +02:00
jschoubben 79993fb498 Rebased onto main: ADR 0188 renumbered to 0201, migration 0055 to 0056
The bundles refactor took ADR 0188 on main, so this work's record is 0201 and
every comment citing it moves with it. Main also took migration 0055 (an
older build never replaces a newer), so the store's collected-artifacts table
is 0056 — a number two migrations share is a schema nobody can trust.

make check passes except TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves,
which fails on main too and now for two stacked reasons (hq issues 203 and 202).
2026-10-04 02:45:06 +02:00
jochen aec55b7072 A module's state on the bus: buckets from the catalogue, grants, membership (novox/hq ADR 0201)
A manifest names the state it keeps (state) and reads (reads); the controller
asserts a key-value bucket per name on every raise, grants owners write and
readers read (measured against a running server), issues each assignment its
buckets in the membership, and reports buckets nothing declares without
removing them.
2026-10-04 02:40:49 +02:00
jschoubben c7884f5a72 The store keeps what the records name (hq ADR 0189)
The mesh names what may go from its own build records — a digest it did not
record making is never named, which is what keeps the sweep away from the
images genesis pushed. An artifact stays because a definition the mesh holds
names it, or because it belongs to one of the five most recent successful
builds of its module.

internal/artifacts asks the store to let go of one; internal/inventory
decides and remembers (migration 0055); the sweep runs after a build the mesh
recorded, which is when both the bytes and the keep set moved. Never fatal to
a build.

And the manifest side of while-stopped, refused from the definition alone:
no schedule, run-once, a container the module does not declare, itself.
2026-10-04 02:32:19 +02:00
jschoubben 580c4d66a7 A served value may name the consumer it is served to (hq ADR 0188)
${consumer:as} and ${consumer:as:dns} in a serves block are filled per
consumer at resolution, and the one filled value reaches both ends: the
consumer's binding and its ${bound:...} substitutions, and the provider's
contributions entry as `derived`. A fact or alphabet the mesh does not have
is refused at parse; a consumer whose own file already holds the derived
value is refused at resolution, naming the placeholder to write instead.
2026-10-04 02:32:19 +02:00
mesh-admin 63bfc5fda5 Merge pull request 'Refuse the tools-container shape for every module (to-be 38 WP4b's last step)' (#256) from feat/wp4b-the-gate-refuses-the-container-shape-for-all into main 2026-10-04 00:28:59 +00:00
jochen 02e3482eb5 Refuse the tools-container shape for every module (to-be 38 WP4b's last step)
While some thirty modules still stood in that shape, one already registered so was rebuilt without
complaint. Every module has moved since; the exception would only let one move back.
2026-10-04 02:28:54 +02:00
mesh-admin 294e83dab1 Merge pull request 'Run the controller as a Go bundle the host starts as a process (hq issue 213, 2 of 2)' (#253) from fix/issue-213-the-controller-is-a-process-manifest into main 2026-10-04 00:06:44 +00:00
54 changed files with 6068 additions and 117 deletions
+140 -15
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
"slices"
"sort" "sort"
"strings" "strings"
@@ -39,7 +40,10 @@ import (
// //
// It costs a resolution per machine. Assignment is a person typing a command, and being told which // It costs a resolution per machine. Assignment is a person typing a command, and being told which
// machines this just blocked is worth more than the milliseconds. // machines this just blocked is worth more than the milliseconds.
func assign(ctx context.Context, open *stores, node, module string) (string, error) { func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
if len(modules) == 0 {
return "", fmt.Errorf("assign %s names no module", node)
}
// Held while it is recorded, so it cannot land between a converge's preview and its flip and // Held while it is recorded, so it cannot land between a converge's preview and its flip and
// be taken without ever having been previewed (novox/hq ADR 0100). // be taken without ever having been previewed (novox/hq ADR 0100).
ctx, release, err := holdNodes(ctx, open, []string{node}) ctx, release, err := holdNodes(ctx, open, []string{node})
@@ -47,6 +51,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
return "", err return "", err
} }
defer release() defer release()
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
// resources are applied through a seat nothing on the node holds is refused, because that order
// — the service manager, the package manager and the runtime before anything that installs,
// runs or contains — is the mesh's to keep. Several modules in one act are judged together, so
// holders that depend on each other go on in one command.
shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules)
if err != nil {
return "", err
}
// **Before the new assignment can unsettle a seat somebody holds only by being alone** // **Before the new assignment can unsettle a seat somebody holds only by being alone**
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the // (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
// assignment resolves against a record rather than against a coincidence. // assignment resolves against a record rather than against a coincidence.
@@ -54,65 +68,176 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
if err != nil { if err != nil {
return "", err return "", err
} }
var lines []string
var added []string
for _, module := range modules {
fresh, err := open.inventory.Assign(ctx, node, module) fresh, err := open.inventory.Assign(ctx, node, module)
if err != nil { if err != nil {
return "", err return strings.Join(lines, "\n"), err
} }
if !fresh { if !fresh {
// Nothing changed, and saying "is assigned" would read as an action. One node runs one // Nothing changed, and saying "is assigned" would read as an action. One node runs one
// of each — the module's name is the assignment's identity (novox/hq ADR 0115). // of each — the module's name is the assignment's identity (novox/hq ADR 0115).
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed", lines = append(lines, fmt.Sprintf(
node, module), nil "%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module))
continue
} }
said := fmt.Sprintf("%s is assigned %s", node, module) added = append(added, module)
lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module))
}
if len(added) == 0 {
return strings.Join(lines, "\n"), nil
}
answer := strings.Join(lines, "\n")
for _, line := range settled { for _, line := range settled {
said += "\n " + line answer += "\n " + line
}
// What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq
// ADR 0207): a dependency of a module just assigned, or one this assignment met. The rest of the
// node's list, and every other node's, is `status`'s.
for _, line := range unheldChange(shelf, node, before, append(append([]string(nil), before...), added...)) {
answer += "\n " + line
} }
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its // Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
// credential exists delivers a process that cannot authenticate and crash-loops until somebody // credential exists delivers a process that cannot authenticate and crash-loops until somebody
// runs a second verb and a second push. Issued here when the module speaks on the bus and has // runs a second verb and a second push. Issued here when the module speaks on the bus and has
// no credential yet; kept when it has one, so re-assigning rotates nothing. // no credential yet; kept when it has one, so re-assigning rotates nothing.
for _, module := range added {
if line := issueOnAssign(ctx, open, node, module); line != "" { if line := issueOnAssign(ctx, open, node, module); line != "" {
said += "\n " + line answer += "\n " + line
}
} }
plan, _, err := planFor(ctx, open, node) plan, _, err := planFor(ctx, open, node)
if err != nil { if err != nil {
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still // Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
// worth reporting: this machine's refusal is rarely the only consequence. // worth reporting: this machine's refusal is rarely the only consequence.
return said + blockedElsewhere(ctx, open, node), err return answer + blockedElsewhere(ctx, open, node), err
} }
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose // Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
// capability the machine lacks is on the wrong machine, and the assignment records what a person // capability the machine lacks is on the wrong machine, and the assignment records what a person
// meant while this line says it will not run until it moves. The rest of the node still pushes. // meant while this line says it will not run until it moves. The rest of the node still pushes.
isAdded := map[string]bool{}
for _, m := range added {
isAdded[m] = true
}
for _, u := range plan.Unhostable { for _, u := range plan.Unhostable {
if u.Module != module { if !isAdded[u.Module] {
continue continue
} }
for _, c := range u.Missing { for _, c := range u.Missing {
said += "\n but " + catalogue.WrongMachine(u.Module, c, node) answer += "\n but " + catalogue.WrongMachine(u.Module, c, node)
} }
} }
return said + fmt.Sprintf("\n run `push %s` to send it", node) + return answer + fmt.Sprintf("\n run `push %s` to send it", node) +
blockedElsewhere(ctx, open, node), nil blockedElsewhere(ctx, open, node), nil
} }
// unassign takes a module off a node. What it leaves behind is the host's business: a directory // seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the
// catalogue and the node's assignments it was judged against. Modules already assigned are not new
// and are not judged again.
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) (
map[string]catalogue.Manifest, []string, error) {
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return nil, nil, err
}
assigned, err := open.inventory.Assigned(ctx, node)
if err != nil {
return nil, nil, err
}
already := map[string]bool{}
for _, a := range assigned {
already[a] = true
}
var adding []string
for _, m := range modules {
if !already[m] {
adding = append(adding, m)
}
}
// The lines AssignRefusal says beside an assignment it lets through are said by unheldChange
// with everything else this act changed, so they are not said twice.
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err
}
// Two modules declaring one package, path or unit is refused before anything is recorded
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err
}
return shelf, assigned, nil
}
// unassign takes modules off a node. What they leave behind is the host's business: a directory
// holding anything the mesh did not put there is kept (novox/hq ADR 0030). // holding anything the mesh did not put there is kept (novox/hq ADR 0030).
// //
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a // It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
// module off one machine is the ordinary way to stop providing something to another, and nothing // module off one machine is the ordinary way to stop providing something to another, and nothing
// about the command's own output would ever have said so. // about the command's own output would ever have said so.
func unassign(ctx context.Context, open *stores, node, module string) (string, error) { //
// **Refused when it takes away the last holder of a seat a module left on the node depends on**
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
// modules in one act are judged together, so a holder and its dependents come off in one command.
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
if len(modules) == 0 {
return "", fmt.Errorf("unassign %s names no module", node)
}
ctx, release, err := holdNodes(ctx, open, []string{node}) ctx, release, err := holdNodes(ctx, open, []string{node})
if err != nil { if err != nil {
return "", err return "", err
} }
defer release() defer release()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
assigned, err := open.inventory.Assigned(ctx, node)
if err != nil {
return "", err
}
// Every one checked before any is taken off, so a refusal leaves the node as it was.
runs := map[string]bool{}
for _, a := range assigned {
runs[a] = true
}
for _, module := range modules {
if !runs[module] {
return "", fmt.Errorf("%s is not assigned to %s", module, node)
}
}
if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil {
return "", err
}
for _, module := range modules {
if err := open.inventory.Unassign(ctx, node, module); err != nil { if err := open.inventory.Unassign(ctx, node, module); err != nil {
return "", err return "", err
} }
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so", }
node, module, node) + blockedElsewhere(ctx, open, node), nil answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
node, strings.Join(modules, ", "), node)
var left []string
for _, a := range assigned {
if !slices.Contains(modules, a) {
left = append(left, a)
}
}
for _, line := range unheldChange(shelf, node, assigned, left) {
answer += "\n " + line
}
return answer + blockedElsewhere(ctx, open, node), nil
}
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the
// command API and the controller seat's verbs as they do from the command line.
func splitModules(field string) []string {
var out []string
for _, m := range strings.Split(field, ",") {
if m = strings.TrimSpace(m); m != "" {
out = append(out, m)
}
}
return out
} }
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand. // blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
+2 -2
View File
@@ -95,10 +95,10 @@ func commands(who Authenticator) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
return assign(ctx, open, in.Node, in.Module) return assign(ctx, open, in.Node, splitModules(in.Module)...)
})) }))
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
return unassign(ctx, open, in.Node, in.Module) return unassign(ctx, open, in.Node, splitModules(in.Module)...)
})) }))
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`. // Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
+9
View File
@@ -558,6 +558,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
} }
return manifest, kept, err return manifest, kept, err
} }
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
// worked and the module is registered.
collect(ctx, inv)
return manifest, kept, nil return manifest, kept, nil
} }
@@ -646,6 +650,11 @@ type answers struct {
// public name on the machine went dark. The holds were correct; they were recorded only in the // public name on the machine went dark. The holds were correct; they were recorded only in the
// machine's own state file, and the one visible symptom was a count that did not add up. // machine's own state file, and the one visible symptom was a count that did not add up.
untaken map[string]map[string]int untaken map[string]map[string]int
// unheld is every module on a machine whose resources are applied through a seat nothing on
// that machine holds (novox/hq ADR 0207), with the modules that could hold it. Reported, not
// refused, until the switch — and while there is any, the mesh is not all well: the order the
// machines' modules are built in is the mesh's to keep, and this is where it says it is not kept.
unheld []catalogue.Unheld
} }
// heldBy is every artifact this mesh has built, for a build that may need one as its base. // heldBy is every artifact this mesh has built, for a build that may need one as its base.
+12
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
) )
@@ -90,6 +91,17 @@ func moduleCheck(paths []string, out io.Writer) error {
if len(m.Invokes) > 0 { if len(m.Invokes) > 0 {
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes)) fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
} }
// The state it keeps and reads (novox/hq ADR 0201), so a reviewer sees what lands on the bus.
if len(m.State) > 0 {
kept := make([]string, 0, len(m.State))
for _, s := range m.State {
kept = append(kept, s.Name)
}
fmt.Fprintf(out, ", keeps state %s", strings.Join(kept, ", "))
}
if len(m.Reads) > 0 {
fmt.Fprintf(out, ", reads %s", strings.Join(m.Reads, ", "))
}
fmt.Fprintln(out) fmt.Fprintln(out)
} }
if failed > 0 { if failed > 0 {
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/inventory"
)
// Letting the artifact store go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
//
// **Run where the records change.** A build is the moment new bytes landed in the store and the
// moment the keep set moved, so it is the moment to say what may go — and it needs no timer of
// its own. Reclaiming the bytes is the store's own nightly step; this only decides.
//
// Never fatal to a build. The build succeeded, the module is registered, and a store that could
// not be reached is a thing to say rather than a reason to undo any of that. The next build asks
// again, and the references it could not collect are still uncollected, so nothing is lost by
// having failed.
// collect asks the store to let go of everything the mesh made and no longer keeps, and records
// what it let go of. Says what it did and what it could not; returns nothing, because nothing
// upstream should branch on it.
func collect(ctx context.Context, inv *inventory.Inventory) {
references, err := inv.ToCollect(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "could not work out what the artifact store may let go of: %v\n", err)
return
}
if len(references) == 0 {
return
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "could not read the catalogue to find the artifact store: %v\n", err)
return
}
// As the mesh reaches it from the network. Empty means the store is not on the network — on a
// mesh being raised it is not yet, and there the store holds one build of anything and has
// nothing to collect.
address, err := artifactStoreAddress(ctx, inv, shelf, "")
if err != nil || address == "" {
if err != nil {
fmt.Fprintf(os.Stderr, "could not find the artifact store to collect from: %v\n", err)
}
return
}
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has
// never collected has the whole history to get through, and a person waiting on `build` should
// not pay for it. Two bounds, and what is left over is simply offered again next time —
// builds are frequent, and the point is that the store stops growing, not that it empties
// tonight.
within, stop := context.WithTimeout(ctx, sweepBudget)
defer stop()
store := artifacts.Store{Address: address}
var done []string
var left, skipped int
for i, reference := range references {
if i >= mostPerSweep || within.Err() != nil {
left = len(references) - i
break
}
err := store.LetGo(within, reference)
if err == nil || errors.Is(err, artifacts.Gone) {
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
// again for ever.
done = append(done, reference)
continue
}
if errors.Is(err, artifacts.ErrNotOurs) {
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
// marked collected — the mesh did not remove it and should not claim to — and not a
// reason to stop, because the store was never asked. One of these at the front of
// the oldest-first order ended every sweep until this.
skipped++
if skipped == 1 {
fmt.Fprintf(os.Stderr,
"the sweep will not address %s and went on: %v\n", reference, err)
}
continue
}
// **Stopped at the first refusal by the STORE, not pushed through.** A store that refuses
// one refuses all of them — deletion disabled, the store down, the network gone — so
// going on would be a hundred identical failures and a hundred identical log lines in
// front of whoever was building something.
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
reference, err)
left = len(references) - i
break
}
if len(done) > 0 {
// Recorded outside `within`: the deletions happened, and losing the record of them because
// the sweep ran out of budget would mean asking about them again for ever.
if err := inv.MarkCollected(ctx, done); err != nil {
fmt.Fprintf(os.Stderr, "the store let go of %d artifact(s) and the record of it did not keep: %v\n",
len(done), err)
return
}
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n",
len(done))
}
if left > 0 {
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
}
if skipped > 0 {
fmt.Fprintf(os.Stderr, "%d artifact(s) the sweep will not address were skipped\n", skipped)
}
}
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
// several times a day converges within days of this landing; small enough that no single build
// waits on the whole backlog.
const mostPerSweep = 200
// sweepBudget is the longest a sweep will keep a build waiting.
const sweepBudget = 60 * time.Second
+2 -2
View File
@@ -179,8 +179,8 @@ func usage() {
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131) 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 modules on a node, judged together (ADR 0207)
unassign <node> <module> take it off unassign <node> <module>... take them off
take <node> <module> preview a module's cutover on an adopted node: what runs beside take <node> <module> preview a module's cutover on an adopted node: what runs beside
what it declares; --yes <digest> cuts it over as previewed what it declares; --yes <digest> cuts it over as previewed
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
+5 -3
View File
@@ -334,8 +334,10 @@ func moduleCommand(ctx context.Context, args []string) error {
} }
func assignCommand(ctx context.Context, verb string, args []string) error { func assignCommand(ctx context.Context, verb string, args []string) error {
if len(args) != 2 { // Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
return fmt.Errorf("%s <node> <module>", verb) // service manager and the package manager — can only go on, or come off, together.
if len(args) < 2 {
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
} }
open, err := openStores(ctx) open, err := openStores(ctx)
if err != nil { if err != nil {
@@ -349,7 +351,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
if verb == "unassign" { if verb == "unassign" {
act = unassign act = unassign
} }
said, err := act(ctx, open, args[0], args[1]) said, err := act(ctx, open, args[0], args[1:]...)
if said != "" { if said != "" {
fmt.Println(said) fmt.Println(said)
} }
+90
View File
@@ -8,8 +8,10 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"slices"
"sort" "sort"
"strings" "strings"
"sync"
"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"
@@ -127,6 +129,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
// a mesh-wide gatherer may pass over — see notResolvable. // a mesh-wide gatherer may pass over — see notResolvable.
return catalogue.Resolution{}, nil, notResolvable{err} return catalogue.Resolution{}, nil, notResolvable{err}
} }
logUnheld(nodeName, resolved.Unheld)
// The credential for each thing this node takes from elsewhere. Made once and kept, so the // The credential for each thing this node takes from elsewhere. Made once and kept, so the
// password a provider is told to create is the one its consumer was given — and sealed to // password a provider is told to create is the one its consumer was given — and sealed to
@@ -1325,3 +1328,90 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C
} }
return "" return ""
} }
// unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says
// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document.
//
// **The serving controller's log only.** planFor runs for every node on every push, assignment and
// status — `blockedElsewhere` alone resolves the whole mesh — and a one-shot command starts with an
// empty memory, so every node's report was "a change" and a push printed the whole mesh's list,
// burying the line about the node it acted on. A command says what concerns its own act instead
// (unheldChange, reportUnheldPushed); the full list is `status`'s.
var (
unheldLogged = map[string]string{}
unheldLoggedMu sync.Mutex
logUnheldChanges bool
)
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
// it, including when they become none — in the serving controller, and nowhere else.
func logUnheld(node string, unheld []catalogue.Unheld) {
if !logUnheldChanges {
return
}
lines := make([]string, 0, len(unheld))
for _, u := range unheld {
lines = append(lines, u.String())
}
now := strings.Join(lines, "\n")
unheldLoggedMu.Lock()
before, seen := unheldLogged[node]
unheldLogged[node] = now
unheldLoggedMu.Unlock()
if (seen && before == now) || (!seen && now == "") {
return
}
if now == "" {
fmt.Fprintf(os.Stderr, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node)
return
}
fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
node, len(lines), strings.Join(lines, "\n "))
}
// unheldChange is what an act on one node changed about its unmet seat dependencies, judged over
// its assignments before and after (novox/hq ADR 0207): each dependency now unmet that was not —
// which includes every one of a module just assigned — and each now met that was not. Nothing about
// any other node, and nothing that was already true before the act.
func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string {
judge := func(names []string) map[string]catalogue.Unheld {
var set []catalogue.Manifest
for _, n := range names {
if m, known := shelf[n]; known {
set = append(set, m)
}
}
out := map[string]catalogue.Unheld{}
for _, u := range catalogue.UnheldDependencies(shelf, node, set, nil) {
out[u.Module+" "+u.Seat] = u
}
return out
}
was, now := judge(before), judge(after)
var lines []string
for _, k := range sortedNames(now) {
if _, already := was[k]; !already {
lines = append(lines, "but "+now[k].String())
}
}
for _, k := range sortedNames(was) {
if _, still := now[k]; still {
continue
}
u := was[k]
if !slices.Contains(after, u.Module) {
continue // went with its module, which says nothing about the seat
}
lines = append(lines, fmt.Sprintf("and %s on %s now has %s held", u.Module, node, u.Seat))
}
return lines
}
func sortedNames[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+57 -2
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"sort" "sort"
@@ -62,6 +63,9 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
} }
func serve(ctx context.Context) error { func serve(ctx context.Context) error {
// The one process whose log is read over time, so the one that says each change to a node's
// unmet seat dependencies once (novox/hq ADR 0207).
logUnheldChanges = true
open, err := openStores(ctx) open, err := openStores(ctx)
if err != nil { if err != nil {
return err return err
@@ -360,6 +364,9 @@ func pushCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
// Each machine's unmet seat dependencies (novox/hq ADR 0207), said after the sends: in full
// for a machine named, as a count for each of many — the full list is `status`'s.
unheld := map[string][]catalogue.Unheld{}
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) { sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
plan, settings, err := planFor(held, open, node) plan, settings, err := planFor(held, open, node)
if err != nil { if err != nil {
@@ -369,6 +376,7 @@ func pushCommand(ctx context.Context, args []string) error {
// healthy modules beside it are still resolved and sent. Reported so it is not silently // healthy modules beside it are still resolved and sent. Reported so it is not silently
// dropped — the remedy is to move it, and until then the rest of the node converges. // dropped — the remedy is to move it, and until then the rest of the node converges.
reportUnhostable(node, plan) reportUnhostable(node, plan)
unheld[node] = plan.Unheld
// The private network is in here with everything else. It used to be composed separately // The private network is in here with everything else. It used to be composed separately
// and prepended, which meant every machine with an address was on it and no machine could // and prepended, which meant every machine with an address was on it and no machine could
// be kept off. It is a module now, so it arrives the way a module does. // be kept off. It is a module now, so it arrives the way a module does.
@@ -402,6 +410,7 @@ func pushCommand(ctx context.Context, args []string) error {
} }
release() release()
fmt.Printf("\n%d node(s) told\n", len(sending)) fmt.Printf("\n%d node(s) told\n", len(sending))
reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld)
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most // And each machine's memberships, as every other send does (ADR 0160): a push is the one most
// operators run, and on 2026-10-01 it was the one path that issued none. // operators run, and on 2026-10-01 it was the one path that issued none.
if err := issueMemberships(ctx, open, server, sending); err != nil { if err := issueMemberships(ctx, open, server, sending); err != nil {
@@ -747,6 +756,16 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
if !ok { if !ok {
return nil return nil
} }
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
// raise at start asserts them too, but a module registered and assigned since would otherwise have
// its bucket only after the control plane next restarts — found the first time a module declared
// state: its bundle asked for a bucket that did not exist. Idempotent and cheap; a failure is said
// and the push stands, as a membership's is.
if buckets, err := open.inventory.DeclaredBuckets(ctx); err != nil {
fmt.Printf(" the modules' state could not be read, so no bucket was asserted: %v\n", err)
} else if _, err := broker.RaiseBuckets(broker.OnConn(bus.Conn), buckets); err != nil {
fmt.Printf(" the modules' state could not be asserted on the bus: %v — the next push tries again\n", err)
}
// The declarations are sent and recorded by now; a membership that cannot be issued is said // The declarations are sent and recorded by now; a membership that cannot be issued is said
// and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so // and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so
// the push stands, the first failure is named once, and the next push tries again. // the push stands, the first failure is named once, and the next push tries again.
@@ -898,6 +917,21 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil { if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
return err return err
} }
// Every module's state (novox/hq ADR 0201), from the catalogue: a bucket exists from
// registration, so a module reading one may watch it before its owner runs anywhere. One that
// nothing declares any more is said and kept — what it holds is data.
buckets, err := inv.DeclaredBuckets(ctx)
if err != nil {
return err
}
undeclared, err := broker.RaiseBuckets(js, buckets)
if err != nil {
return err
}
if len(undeclared) > 0 {
fmt.Printf("the bus holds state nothing declares any more, kept because it is data: %s — "+
"removing it is a person's act\n", strings.Join(undeclared, ", "))
}
// And how every module hears what it consumes. Derived from the same records the user list is // And how every module hears what it consumes. Derived from the same records the user list is
// composed from, so a module the mesh grants a consumer's subjects has that consumer waiting. // composed from, so a module the mesh grants a consumer's subjects has that consumer waiting.
// Done on every raise, not only when a credential is issued: every module moved onto this bus // Done on every raise, not only when a credential is issued: every module moved onto this bus
@@ -921,8 +955,8 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
} }
hearing++ hearing++
} }
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+ fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, %d module(s) "+
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing) "can hear what they consume, and %d bucket(s) of state\n", broker.BareAddress(address), len(names), hearing, len(buckets))
return nil return nil
} }
@@ -997,3 +1031,24 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body
} }
return digest, nil return digest, nil
} }
// reportUnheldPushed says what a push's machines lack of the seats their modules depend on
// (novox/hq ADR 0207): every line for a machine the push named, since that is the machine somebody
// is looking at, and one line per machine otherwise — a list per machine across the mesh is the
// hundred lines that buried the one that mattered. Nothing for a machine that lacks nothing.
func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[string][]catalogue.Unheld) {
for _, node := range asked {
lines := unheld[node]
if len(lines) == 0 {
continue
}
if named {
fmt.Fprintf(w, "\n%s has %d unmet seat dependenc(ies) (novox/hq ADR 0207):\n", node, len(lines))
for _, u := range lines {
fmt.Fprintf(w, " %s\n", u)
}
continue
}
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
}
}
+6
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"sort" "sort"
"time" "time"
@@ -73,6 +74,10 @@ type meshStatus struct {
// alone. A document without this called a machine well while a predecessor's chain refused // alone. A document without this called a machine well while a predecessor's chain refused
// what the mesh declared open. // what the mesh declared open.
Filtered []machineFiltered `json:"filtered,omitempty"` Filtered []machineFiltered `json:"filtered,omitempty"`
// Unheld is every module on a machine whose resources are applied through a seat nothing on
// that machine holds, with the modules that could hold it (novox/hq ADR 0207). Absent when every
// dependency is met. Reported, not refused, until the switch.
Unheld []catalogue.Unheld `json:"unheld,omitempty"`
} }
// machineFiltered is one rule set on a converged machine that the mesh did not write and that // machineFiltered is one rule set on a converged machine that the mesh did not write and that
@@ -204,6 +209,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses}) out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
} }
} }
out.Unheld = asked.unheld
for name := range asked.refused { for name := range asked.refused {
out.Unresolved = append(out.Unresolved, machineUnresolved{ out.Unresolved = append(out.Unresolved, machineUnresolved{
Node: name, Problem: asked.refused[name]}) Node: name, Problem: asked.refused[name]})
@@ -0,0 +1,149 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Defends novox/hq ADR 0207 at the controller's acts: `assign` refuses a module whose resources a
// seat nothing on the node holds applies, `unassign` refuses taking the last holder from under its
// dependents, and `status` reports what composition does not yet refuse.
func serviceManagerHolder() catalogue.Manifest {
return catalogue.Manifest{Module: "systemd", Version: "1",
Claims: []catalogue.Claim{{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode,
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}}},
Resources: []map[string]any{{"id": "systemd", "type": "package", "package": "systemd"}}}
}
func packageManagerHolder() catalogue.Manifest {
return catalogue.Manifest{Module: "pacman", Version: "1",
Claims: []catalogue.Claim{{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode}},
Resources: []map[string]any{{"id": "refresh", "type": "service", "unit": "pacman-refresh.timer"}}}
}
func aDaemon() catalogue.Manifest {
return catalogue.Manifest{Module: "sshd", Version: "1",
Resources: []map[string]any{{"id": "sshd", "type": "service", "unit": "sshd.service"}}}
}
func TestAnAssignmentWithoutItsHolderIsRefusedAndNotKept(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, serviceManagerHolder())
register(t, open, packageManagerHolder())
register(t, open, aDaemon())
_, err := assign(ctx, open, "laptop", "sshd")
if err == nil {
t.Fatal("sshd went onto a machine nothing holds the service manager of")
}
for _, want := range []string{catalogue.ServiceManagerSeat, "systemd", "ADR 0207"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
assigned, err := open.inventory.Assigned(ctx, "laptop")
if err != nil {
t.Fatal(err)
}
if contains(assigned, "sshd") {
t.Fatalf("a refused assignment was kept: %v", assigned)
}
// The holders depend on each other, so neither goes on alone — and both go on in one act.
if _, err := assign(ctx, open, "laptop", "systemd"); err == nil {
t.Fatal("systemd went on alone though its package needs a package manager")
}
if said, err := assign(ctx, open, "laptop", "systemd", "pacman"); err != nil {
t.Fatalf("the two holders assigned together were refused: %v\n%s", err, said)
}
if said, err := assign(ctx, open, "laptop", "sshd"); err != nil {
t.Fatalf("sshd beside its holder was refused: %v\n%s", err, said)
}
}
func TestTheControllerSeatsAssignTakesSeveralModulesAsOneAct(t *testing.T) {
argv, err := argvFor("assign", map[string]any{"node": "laptop", "module": "systemd, pacman"})
if err != nil {
t.Fatal(err)
}
if strings.Join(argv, " ") != "assign laptop systemd pacman" {
t.Errorf("the seat's assign became %v", argv)
}
}
func TestUnassigningTheLastHolderUnderItsDependentsIsRefused(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, serviceManagerHolder())
register(t, open, packageManagerHolder())
register(t, open, aDaemon())
if _, err := assign(ctx, open, "laptop", "systemd", "pacman", "sshd"); err != nil {
t.Fatal(err)
}
_, err := unassign(ctx, open, "laptop", "systemd")
if err == nil {
t.Fatal("the service manager came off a machine still running services")
}
for _, want := range []string{catalogue.ServiceManagerSeat, "sshd", "pacman"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q:\n%v", want, err)
}
}
assigned, _ := open.inventory.Assigned(ctx, "laptop")
if !contains(assigned, "systemd") {
t.Fatalf("a refused unassignment took the module off anyway: %v", assigned)
}
if _, err := unassign(ctx, open, "laptop", "sshd"); err != nil {
t.Fatalf("a dependent could not come off: %v", err)
}
}
func TestStatusReportsAnUnheldDependencyWithoutRefusingTheMachine(t *testing.T) {
// The mesh as it ran before the switch (novox/hq ADR 0207 §4).
defer catalogue.EnforcingSeatDependencies(false)()
open := aMesh(t)
ctx := t.Context()
register(t, open, serviceManagerHolder())
register(t, open, aDaemon())
// Assigned straight into the store: a machine whose modules predate the rule, which is every
// machine on the day it ships.
if _, err := open.inventory.Assign(ctx, "laptop", "sshd"); err != nil {
t.Fatal(err)
}
asked, err := theThreeQuestions(ctx, open)
if err != nil {
t.Fatal(err)
}
if _, refused := asked.refused["laptop"]; refused {
t.Fatalf("an unmet dependency refused the machine before the switch: %s", asked.refused["laptop"])
}
if asked.well() {
t.Error("a mesh with an unheld dependency reads as all well")
}
got := printed(t, func() error { return printStatus(asked) })
for _, want := range []string{"unheld", "laptop", "sshd", catalogue.ServiceManagerSeat, "systemd"} {
if !strings.Contains(got, want) {
t.Errorf("status does not say %q:\n%s", want, got)
}
}
body, err := statusAsJSON(asked)
if err != nil {
t.Fatal(err)
}
var doc struct {
Unheld []catalogue.Unheld `json:"unheld"`
}
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Unheld) != 1 || doc.Unheld[0].Module != "sshd" || doc.Unheld[0].Seat != catalogue.ServiceManagerSeat {
t.Errorf("the document's unheld is %+v", doc.Unheld)
}
}
+3 -1
View File
@@ -113,7 +113,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
if err := need("node", "module"); err != nil { if err := need("node", "module"); err != nil {
return nil, err return nil, err
} }
return []string{verb, str("node"), str("module")}, nil // Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
// the seats that apply resources depend on each other and go on together.
return append([]string{verb, str("node")}, splitModules(str("module"))...), nil
case "pin": case "pin":
if err := need("node", "provision", "from", "module"); err != nil { if err := need("node", "provision", "from", "module"); err != nil {
return nil, err return nil, err
+31 -1
View File
@@ -282,6 +282,22 @@ func printStatus(asked answers) error {
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n") fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
} }
if len(asked.unheld) > 0 {
// **Reported, and not refused yet** (novox/hq ADR 0207 §4). Each machine still resolves and
// is sent what it would be; this says which of its modules depend on a seat nothing there
// holds, until every machine has its holders and the switch makes it a refusal.
fmt.Printf("%d module dependenc(ies) on a seat nothing on the machine holds (unheld, ADR 0207):\n",
len(asked.unheld))
for _, u := range asked.unheld {
holders := "no module in the catalogue claims it yet"
if len(u.Holders) > 0 {
holders = "could be held by " + strings.Join(u.Holders, ", ")
}
fmt.Printf(" %-12s %-24s %-24s %s\n", u.Node, u.Module, u.Seat, holders)
}
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\n\n")
}
if adopted := adoptedNodes(nodes); len(adopted) > 0 { if adopted := adoptedNodes(nodes); len(adopted) > 0 {
// Said, because nothing forces the flip: a node left adopted is visible here rather than // Said, because nothing forces the flip: a node left adopted is visible here rather than
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well". // read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
@@ -386,6 +402,20 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
if err != nil { if err != nil {
return answers{}, err return answers{}, err
} }
// And which machines run a module whose resources a seat nothing there holds applies (novox/hq
// ADR 0207). Each machine resolved again rather than threaded through whoResolves, whose answer
// the private network is built from and should say nothing else; a machine that does not
// resolve is already in refused, and is passed over here.
for _, n := range out.nodes {
plan, _, err := planFor(ctx, open, n.Name)
if err != nil {
if unresolvable(err) {
continue
}
return answers{}, err
}
out.unheld = append(out.unheld, plan.Unheld...)
}
out.plans, err = inv.RecentPlans(ctx, 5) out.plans, err = inv.RecentPlans(ctx, 5)
if err != nil { if err != nil {
return answers{}, err return answers{}, err
@@ -496,7 +526,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
func (a answers) well() bool { func (a answers) well() bool {
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 && len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
len(a.filtered) == 0 len(a.filtered) == 0 && len(a.unheld) == 0
} }
// hostSplit is which machines report which host version, for every version more than one machine // hostSplit is which machines report which host version, for every version more than one machine
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// An act says what it changed about the node it acted on, and nothing about the rest of the mesh
// (novox/hq ADR 0207): after the seat dependencies shipped, every `push <node>` and `assign` printed
// every node's unmet dependencies, a hundred lines around the one about the module just assigned.
func aContainer(name string) catalogue.Manifest {
return catalogue.Manifest{Module: name, Version: "1",
Resources: []map[string]any{{"id": name, "type": "container", "image": name}}}
}
func TestAnAssignmentSaysOnlyWhatItChangedOnItsOwnNode(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, aContainer("web"))
register(t, open, aContainer("db"))
// anchor already lacks a runtime for db: true, and not this act's to say.
if _, err := open.inventory.Assign(ctx, "anchor", "db"); err != nil {
t.Fatal(err)
}
if _, err := open.inventory.Assign(ctx, "laptop", "db"); err != nil {
t.Fatal(err)
}
said, err := assign(ctx, open, "laptop", "web")
if err != nil {
t.Fatalf("%v\n%s", err, said)
}
if !strings.Contains(said, "web on laptop depends on "+catalogue.ContainerRuntimeSeat) {
t.Errorf("the assignment does not say what the module it assigned depends on:\n%s", said)
}
for _, not := range []string{"db on laptop", "db on anchor", "anchor:"} {
if strings.Contains(said, not) {
t.Errorf("the assignment says %q, which it did not change:\n%s", not, said)
}
}
}
func TestAnAssignmentThatMeetsADependencySaysSo(t *testing.T) {
shelf := map[string]catalogue.Manifest{
"web": aContainer("web"),
"docker": {Module: "docker", Claims: []catalogue.Claim{{Name: catalogue.ContainerRuntimeSeat}},
Resources: []map[string]any{{"id": "d", "type": "container", "image": "dind"}}},
}
lines := unheldChange(shelf, "laptop", []string{"web"}, []string{"web", "docker"})
if len(lines) != 1 || !strings.Contains(lines[0], "web on laptop now has "+catalogue.ContainerRuntimeSeat+" held") {
t.Errorf("meeting a dependency said %v", lines)
}
// Taking the dependent off says nothing: the dependency went with its module.
if lines := unheldChange(shelf, "laptop", []string{"web"}, nil); len(lines) != 0 {
t.Errorf("unassigning the dependent said %v", lines)
}
}
func TestAPushSaysANamedNodesDependenciesAndCountsTheRest(t *testing.T) {
unheld := map[string][]catalogue.Unheld{
"anchor": {{Node: "anchor", Module: "db", Seat: catalogue.ContainerRuntimeSeat}},
"laptop": {{Node: "laptop", Module: "web", Seat: catalogue.ContainerRuntimeSeat},
{Node: "laptop", Module: "sshd", Seat: catalogue.ServiceManagerSeat}},
}
var named bytes.Buffer
reportUnheldPushed(&named, true, []string{"laptop"}, unheld)
got := named.String()
if !strings.Contains(got, "laptop has 2 unmet") || !strings.Contains(got, "web on laptop") ||
!strings.Contains(got, "sshd on laptop") || strings.Contains(got, "anchor") {
t.Errorf("a named push said:\n%s", got)
}
var all bytes.Buffer
reportUnheldPushed(&all, false, []string{"anchor", "laptop", "quiet"}, unheld)
want := "anchor: 1 unmet seat dependenc(ies) — see `status`\nlaptop: 2 unmet seat dependenc(ies) — see `status`\n"
if all.String() != want {
t.Errorf("a push to every node said:\n%s\nwant\n%s", all.String(), want)
}
}
func TestOnlyTheServingControllerLogsEachChange(t *testing.T) {
read := func(f func()) string {
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
f()
_ = w.Close()
os.Stderr = old
var b bytes.Buffer
_, _ = b.ReadFrom(r)
return b.String()
}
u := []catalogue.Unheld{{Node: "n1", Module: "web", Seat: catalogue.ContainerRuntimeSeat}}
if got := read(func() { logUnheld("n1", u) }); got != "" {
t.Errorf("a command logged:\n%s", got)
}
logUnheldChanges = true
defer func() { logUnheldChanges = false }()
if got := read(func() { logUnheld("n1", u) }); !strings.Contains(got, "web on n1") {
t.Errorf("the serving controller did not log a change:\n%s", got)
}
if got := read(func() { logUnheld("n1", u) }); got != "" {
t.Errorf("an unchanged report was logged again:\n%s", got)
}
}
+113
View File
@@ -0,0 +1,113 @@
// Package artifacts speaks to the mesh's artifact store over its own door.
//
// Only what the mesh needs that nothing else does: letting go of something it put there
// (novox/hq ADR 0189, issue 108). Pushing is the builder's, through the container runtime; reading
// is every machine's, through its runtime. This is the one operation that belongs to the thing
// holding the records, because it is the only one that is a decision rather than a transfer.
package artifacts
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Store is the artifact store at an address, as this machine reaches it.
type Store struct {
// Address is `host:port` — the store as the caller reaches it now, composed and never
// recorded (novox/hq 04-ISSUES/102).
Address string
// HTTP is the client used; nil is a client with a modest timeout.
HTTP *http.Client
}
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
var Gone = errors.New("the store does not hold it")
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing
// the store holds by digest.
//
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite
// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an
// error" is how a cautious loop became one that did nothing while reporting the right number.
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
// LetGo asks the store to drop one artifact the mesh recorded making.
//
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
// and composes the address here at the moment of use.
//
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
// which of the two happened.
func (s Store) LetGo(ctx context.Context, reference string) error {
// **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its
// own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…`
// as the mesh's too — it cannot tell one registry host from another — so normalising belongs
// where the provenance is known, which is the sweep reading its own build records, not here
// where the only job is to refuse anything that is not plainly ours.
path, kept := catalogue.InArtifactStore(reference)
if !kept {
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
// delete for a reference of unknown shape is how a sweep reaches something that is not
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
if s.Address == "" {
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
}
repository, kind, digest, err := split(path)
if err != nil {
return err
}
url := "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return err
}
client := s.HTTP
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
return nil
case http.StatusNotFound:
return Gone
case http.StatusMethodNotAllowed:
// The registry was started without deletion enabled. Said plainly, because the remedy is
// a setting on the store's module and not anything about this artifact.
return fmt.Errorf(
"the artifact store refuses deletion: its server was started without it enabled "+
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
"module is applied again (novox/hq ADR 0189). Asking about %s", reference)
default:
return fmt.Errorf("the artifact store answered %s for %s", response.Status, reference)
}
}
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
//
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
// `<repository>/blobs/sha256:<hex>` is a blob.
func split(path string) (repository, kind, digest string, err error) {
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
return before, "manifests", "sha256:" + after, nil
}
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
return before, "blobs", "sha256:" + after, nil
}
return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path)
}
+117
View File
@@ -0,0 +1,117 @@
package artifacts
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
//
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
t.Helper()
var asked []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
}
asked = append(asked, r.URL.Path)
w.WriteHeader(answer)
}))
t.Cleanup(server.Close)
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
}
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
// different endpoints, and asking at the wrong one answers 404 — which this would then
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
store, asked := fakeStore(t, http.StatusAccepted)
ctx := context.Background()
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
if err := store.LetGo(ctx, image); err != nil {
t.Fatal(err)
}
if err := store.LetGo(ctx, archive); err != nil {
t.Fatal(err)
}
want := []string{"/v2/web/app/manifests/sha256:abc123", "/v2/web/config/blobs/sha256:def456"}
if len(*asked) != 2 || (*asked)[0] != want[0] || (*asked)[1] != want[1] {
t.Fatalf("the store was asked %v; want %v", *asked, want)
}
}
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
// The outcome wanted, already true. Told apart from success only so the sweep can say which
// happened; both are recorded, because retrying for ever is the thing to avoid.
store, _ := fakeStore(t, http.StatusNotFound)
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
if !errors.Is(err, Gone) {
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
}
}
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
// The registry answers 405 when it was started without deletion enabled. The remedy is a
// setting on the store's module, and saying "405" would send somebody to the wrong place.
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
if err == nil {
t.Fatal("a store that refuses deletion was read as success")
}
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
t.Fatalf("the refusal does not name the remedy: %v", err)
}
}
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
// A reference of another shape — a vendor's image, a package version — is refused rather
// than composed into a delete somewhere that is not the mesh's store.
store, asked := fakeStore(t, http.StatusAccepted)
for _, reference := range []string{
"docker.io/library/registry@sha256:abc123",
"registry@sha256:abc123",
"1.4.2",
} {
if err := store.LetGo(context.Background(), reference); err == nil {
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
}
}
if len(*asked) != 0 {
t.Fatalf("the store was asked about %v", *asked)
}
}
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
// record and not about the store (novox/hq issue 226).
//
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
// first live run met a reference recorded with the store's old address, read the refusal as "the
// store refuses everything", and collected none of the 1681 it had found.
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
store, asked := fakeStore(t, http.StatusAccepted)
for _, reference := range []string{
"docker.io/library/registry@sha256:abc123",
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
"1.4.2",
} {
err := store.LetGo(context.Background(), reference)
if !errors.Is(err, ErrNotOurs) {
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
}
}
if len(*asked) != 0 {
t.Fatalf("the store was asked about %v", *asked)
}
}
+56
View File
@@ -1,6 +1,7 @@
package broker package broker
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -12,6 +13,7 @@ import (
"time" "time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
) )
// The JetStream side of the controller: the one place the mesh's streams and consumers are // The JetStream side of the controller: the one place the mesh's streams and consumers are
@@ -84,6 +86,13 @@ func pinnedTo(path string) (*tls.Config, error) {
return PinnedToFingerprint(want), nil return PinnedToFingerprint(want), nil
} }
// OnConn is the JetStream handle over a connection the caller already holds — the control plane's
// link — for asserting what the bus holds without dialling a second time.
func OnConn(conn *nats.Conn) *JetStream {
js, _ := conn.JetStream()
return &JetStream{conn: conn, js: js}
}
// DialPinned is Dial with the server's certificate pinned by a fingerprint the caller already holds // DialPinned is Dial with the server's certificate pinned by a fingerprint the caller already holds
// — a module or a build machine that was handed one beside its credential, and has no file. // — a module or a build machine that was handed one beside its credential, and has no file.
func DialPinned(url, fingerprint string, opts ...nats.Option) (*JetStream, error) { func DialPinned(url, fingerprint string, opts ...nats.Option) (*JetStream, error) {
@@ -316,3 +325,50 @@ func retentionOf(r Retention) nats.RetentionPolicy {
return nats.LimitsPolicy return nats.LimitsPolicy
} }
} }
// EnsureBucket creates a module's bucket if it is absent and brings its options to match if it is
// present (novox/hq ADR 0201).
//
// **An update, never a delete and recreate**, for the reason a stream is updated: recreating
// discards what the bucket holds, and what a module's state holds is data. The mesh's caps are
// asserted with the owner's options, so a bucket made by hand converges to them.
func (j *JetStream) EnsureBucket(b Bucket) error {
history := b.History
if history == 0 {
history = 1
}
js, err := jetstream.New(j.conn)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: b.Bucket(),
Description: b.Why(),
History: uint8(history),
TTL: time.Duration(b.TTLSeconds) * time.Second,
MaxValueSize: StateMaxValueBytes,
MaxBytes: StateMaxBytes,
Storage: jetstream.FileStorage,
}); err != nil {
return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err)
}
return nil
}
// BucketNames is every key-value bucket on the server, the mesh's and anybody else's.
func (j *JetStream) BucketNames() ([]string, error) {
js, err := jetstream.New(j.conn)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
lister := js.KeyValueStoreNames(ctx)
var out []string
for name := range lister.Name() {
out = append(out, name)
}
return out, lister.Error()
}
+6
View File
@@ -45,6 +45,11 @@ type Membership struct {
// module that must tell the mesh from the world, the route proxy serving an internal name, reads // module that must tell the mesh from the world, the route proxy serving an internal name, reads
// it here rather than keeping a definition of its own. // it here rather than keeping a definition of its own.
Mesh []string `json:"mesh,omitempty"` Mesh []string `json:"mesh,omitempty"`
// State is every bucket this module's code may reach, by the name it uses for each, and whether
// it may write it (novox/hq ADR 0201): the runtime answers a bundle's state verbs from this list
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
// module on the machine.
State []StateIssued `json:"state,omitempty"`
} }
// Served is one address a tool is answered on. // Served is one address a tool is answered on.
@@ -103,6 +108,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
} }
} }
m.State = stateIssuedFor(d)
if len(d.Invokes) > 0 { if len(d.Invokes) > 0 {
m.Reaches = map[string][]string{} m.Reaches = map[string][]string{}
for _, t := range d.Invokes { for _, t := range d.Invokes {
+17
View File
@@ -103,6 +103,12 @@ type Principal struct {
// permission and nothing beside it. // permission and nothing beside it.
Invokes []string Invokes []string
// State is the local names of the state this principal's module keeps, and Reads the state of
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
// instances and read by whoever declares it.
State []string
Reads []string
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
// and never appears here: this file is written to a node's disk and read by a server, and a // and never appears here: this file is written to a node's disk and read by a server, and a
// secret that can be read from a configuration file is a secret with a wider blast radius // secret that can be read from a configuration file is a secret with a wider blast radius
@@ -421,6 +427,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
} }
} }
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
// watched, its own written too.
pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...)
case KindNodeTools: case KindNodeTools:
// **One process serves what every module on the machine would have served for itself** // **One process serves what every module on the machine would have served for itself**
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
@@ -487,6 +497,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable, "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
"$JS.ACK."+stream+"."+durable+".>") "$JS.ACK."+stream+"."+durable+".>")
} }
// **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union
// of what each may do with a bucket — an owner's write, a reader's read. That one module's code
// does not write another's bucket through it is the runtime's to keep, from the membership
// each assignment is issued, as it keeps each module's events under that module's own name.
for _, d := range p.Carries {
pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...)
}
sub = unique(sub) sub = unique(sub)
pub = unique(pub) pub = unique(pub)
} }
+169
View File
@@ -0,0 +1,169 @@
package broker
import (
"fmt"
"sort"
"strings"
)
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
//
// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each
// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers
// and watches, which is the state relationship the mesh already uses for declarations, opened to
// modules. The controller creates every bucket from the catalogue — from registration, like a
// seat's stream, so a reader may watch before the owner runs anywhere — and no module can.
//
// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the
// part that asks a server.
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
const (
StateMaxValueBytes = 256 * 1024
StateMaxBytes = 64 * 1024 * 1024
)
// A Bucket is one module's declared state as the bus holds it.
type Bucket struct {
Module string
Name string
// History is how many values a key keeps; zero is one.
History int
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
TTLSeconds int
}
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
// underscore, which neither may contain, so two modules can never derive one bucket.
func BucketName(module, name string) string { return module + "_" + name }
// Bucket is this bucket's name on the bus.
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
// Why is carried into the server's description of the bucket, so somebody reading the server's
// own state finds whose it is and why it is kept.
func (b Bucket) Why() string {
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
b.Module, b.Name, b.Module, b.Module)
}
// bucketOfRead is the bucket a read names, `<module>.<name>`, or false when it names none.
func bucketOfRead(read string) (string, bool) {
at := strings.LastIndex(read, ".")
if at <= 0 || at == len(read)-1 {
return "", false
}
module, name := read[:at], read[at+1:]
if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
return "", false
}
return BucketName(module, name), true
}
// stateGrants is what a principal publishes to reach the state its modules keep and read: for every
// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching,
// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
// owner keeps, writing under the bucket's own subjects too.
//
// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there
// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is
// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a
// watch cannot be stopped and lingers on the server. A write outside these is refused by the server
// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first.
func stateGrants(module string, keeps []string, reads []string) []string {
var out []string
read := func(bucket string) {
stream := "KV_" + bucket
out = append(out,
"$JS.API.STREAM.INFO."+stream,
"$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.CONSUMER.CREATE."+stream+".>",
"$JS.API.CONSUMER.DELETE."+stream+".>",
"$JS.FC."+stream+".>")
}
for _, name := range keeps {
if !safeSubject.MatchString(name) {
continue
}
bucket := BucketName(module, name)
read(bucket)
out = append(out, "$KV."+bucket+".>")
}
for _, r := range reads {
if bucket, ok := bucketOfRead(r); ok {
read(bucket)
}
}
return out
}
// StateIssued is one bucket an assignment may reach, by the name its module uses for it: its own
// state by the local name, another's as `<module>.<name>` (novox/hq ADR 0201).
type StateIssued struct {
Name string `json:"name"`
Bucket string `json:"bucket"`
Writes bool `json:"writes,omitempty"`
}
// stateIssuedFor is every bucket a module's code may reach, as its membership lists them.
func stateIssuedFor(d Declared) []StateIssued {
var out []StateIssued
for _, b := range d.State {
out = append(out, StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true})
}
for _, r := range d.Reads {
if bucket, ok := bucketOfRead(r); ok {
out = append(out, StateIssued{Name: r, Bucket: bucket})
}
}
return out
}
// stateNames is the local names of a module's own buckets.
func stateNames(buckets []Bucket) []string {
out := make([]string, 0, len(buckets))
for _, b := range buckets {
out = append(out, b.Name)
}
return out
}
// A BucketAsserter is the part of a JetStream connection bucket assertion needs.
type BucketAsserter interface {
// EnsureBucket creates the bucket if absent and brings its options to match if present, never
// discarding what it holds.
EnsureBucket(b Bucket) error
// BucketNames is every key-value bucket on the server.
BucketNames() ([]string, error)
}
// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing
// declares any more.
//
// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is
// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's
// work that must not take data with it. Removing one is a person's act.
func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) {
sorted := append([]Bucket(nil), buckets...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() })
declared := map[string]bool{}
for _, b := range sorted {
if err := a.EnsureBucket(b); err != nil {
return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err)
}
declared[b.Bucket()] = true
}
names, err := a.BucketNames()
if err != nil {
return nil, fmt.Errorf("listing the bus's state: %w", err)
}
for _, n := range names {
if !declared[n] {
undeclared = append(undeclared, n)
}
}
sort.Strings(undeclared)
return undeclared, nil
}
+180
View File
@@ -0,0 +1,180 @@
package broker
import (
"slices"
"strings"
"testing"
"github.com/nats-io/nats.go"
)
// The grants measured against a running server (novox/hq research 024): an owner reads and writes
// its bucket, a reader only reads, and neither reaches any other bucket.
func TestAnOwnerWritesItsStateAndAReaderOnlyReads(t *testing.T) {
owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "claude-code",
State: []string{"servers"}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
for _, s := range []string{
"$KV.claude-code_servers.>",
"$JS.API.STREAM.INFO.KV_claude-code_servers",
"$JS.API.DIRECT.GET.KV_claude-code_servers.>",
"$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>",
"$JS.API.CONSUMER.DELETE.KV_claude-code_servers.>",
"$JS.FC.KV_claude-code_servers.>",
} {
has(t, owner.Publish, s)
}
hasNot(t, owner.Publish, "$KV.>")
hasNot(t, owner.Publish, "$JS.API.>")
reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "console",
Reads: []string{"claude-code.servers"}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, reader.Publish, "$JS.API.DIRECT.GET.KV_claude-code_servers.>")
has(t, reader.Publish, "$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>")
hasNot(t, reader.Publish, "$KV.claude-code_servers.>")
for _, s := range reader.Subscribe {
if s == "$KV.claude-code_servers.>" {
t.Fatalf("a reader subscribes the bucket's subjects directly: %v", reader.Subscribe)
}
}
}
// One runtime carries every module on its machine, so its grant is the union: the owner's write
// where an owner is carried, a read where only a reader is.
func TestTheRuntimeKeepsAndReadsStateForItsModules(t *testing.T) {
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule,
Carries: []Declared{
{Module: "claude-code", State: []Bucket{{Module: "claude-code", Name: "servers"}},
Reads: []string{"licence-manager.bindings"}},
{Module: "audit"},
}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, perms.Publish, "$KV.claude-code_servers.>")
has(t, perms.Publish, "$JS.API.DIRECT.GET.KV_licence-manager_bindings.>")
hasNot(t, perms.Publish, "$KV.licence-manager_bindings.>")
}
// A module with no state is granted nothing of any bucket — the composition of every module that
// existed before this is unchanged.
func TestAModuleWithNoStateReachesNoBucket(t *testing.T) {
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
Emits: []string{"order.placed"}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
for _, s := range perms.Publish {
if strings.HasPrefix(s, "$KV.") || strings.HasPrefix(s, "$JS.FC.") || strings.Contains(s, ".KV_") {
t.Fatalf("granted %q without declaring state", s)
}
}
}
// A read that names no bucket grants nothing rather than something that happens to parse.
func TestAReadThatNamesNoBucketGrantsNothing(t *testing.T) {
if got := stateGrants("a", nil, []string{"nodot", "x.", ".y", "a.b>"}); len(got) != 0 {
t.Fatalf("granted %v for reads that name no bucket", got)
}
}
// The membership lists every bucket the module's code may reach, by the name the module uses for
// it, and whether it may write it — the list the runtime refuses from.
func TestAMembershipListsTheStateItsModuleMayReach(t *testing.T) {
m := MembershipFor("one", Declared{Module: "claude-code",
State: []Bucket{{Module: "claude-code", Name: "servers"}},
Reads: []string{"licence-manager.bindings"}}, Placements{})
want := []StateIssued{
{Name: "servers", Bucket: "claude-code_servers", Writes: true},
{Name: "licence-manager.bindings", Bucket: "licence-manager_bindings"},
}
if !slices.Equal(m.State, want) {
t.Fatalf("issued %+v, want %+v", m.State, want)
}
if none := MembershipFor("one", Declared{Module: "audit"}, Placements{}); none.State != nil {
t.Fatalf("a module with no state was issued %+v", none.State)
}
}
type buckets struct {
ensured []string
on []string
}
func (b *buckets) EnsureBucket(x Bucket) error {
b.ensured = append(b.ensured, x.Bucket())
return nil
}
func (b *buckets) BucketNames() ([]string, error) { return b.on, nil }
// Every declared bucket is asserted; one on the server that nothing declares is said, not removed.
func TestRaisingStateReportsWhatNothingDeclares(t *testing.T) {
b := &buckets{on: []string{"claude-code_servers", "gone_old", "ours_by_hand"}}
undeclared, err := RaiseBuckets(b, []Bucket{{Module: "claude-code", Name: "servers"}, {Module: "a", Name: "b"}})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(b.ensured, []string{"a_b", "claude-code_servers"}) {
t.Fatalf("asserted %v", b.ensured)
}
if !slices.Equal(undeclared, []string{"gone_old", "ours_by_hand"}) {
t.Fatalf("reported %v", undeclared)
}
}
// Against a real server: a bucket is created with the owner's options and the mesh's caps,
// asserting it again changes nothing and keeps what it holds, and a changed option is brought to
// match in place.
func TestABucketIsAssertedInPlace(t *testing.T) {
js := aLiveBus(t)
b := Bucket{Module: "statetest", Name: "servers"}
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
t.Fatalf("a real server refused a module's bucket: %v", err)
}
kv, err := js.Context().KeyValue(b.Bucket())
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
t.Fatal(err)
}
b.History = 3
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
}
got, err := kv.Get("all.one")
if err != nil || string(got.Value()) != `{"kept":true}` {
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
}
status, err := kv.Status()
if err != nil {
t.Fatal(err)
}
if status.History() != 3 {
t.Fatalf("history is %d, the owner declared 3", status.History())
}
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
}
}
}
// Against a real server: the handle over a connection the control plane already holds asserts a
// bucket as Dial's does — what a push uses, so a module registered since the last start has its
// bucket before its membership names it.
func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
js := aLiveBus(t)
held := OnConn(js.Conn())
if _, err := RaiseBuckets(held, []Bucket{{Module: "statetest", Name: "held"}}); err != nil {
t.Fatalf("asserting over a held connection failed: %v", err)
}
if _, err := js.Context().KeyValue("statetest_held"); err != nil {
t.Fatalf("the bucket is not there: %v", err)
}
}
+5
View File
@@ -33,6 +33,10 @@ type Declared struct {
Watches []Seat Watches []Seat
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152). // Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
Invokes []string Invokes []string
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
State []Bucket
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
Reads []string
} }
// Records is what composing a user list needs to know about the mesh, and nothing more. // Records is what composing a user list needs to know about the mesh, and nothing more.
@@ -81,6 +85,7 @@ func Users(r Records) ([]Principal, error) {
Kind: KindModule, Node: node, Module: d.Module, Kind: KindModule, Node: node, Module: d.Module,
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads,
}) })
} }
if runtimeHere { if runtimeHere {
+12 -4
View File
@@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string {
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A // Three facts the mesh states about any provision, plus whatever the provider said it serves. A
// module may not reach a binding it does not have — the same boundary as a secret, for the same // module may not reach a binding it does not have — the same boundary as a secret, for the same
// reason. // reason.
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string { func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
out := map[string]map[string]string{} out := map[string]map[string]string{}
for _, want := range m.Wants() { for _, want := range m.Wants() {
for i := range needs { for i := range needs {
@@ -54,12 +54,20 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
if n.Name != want || n.For != m.Module { if n.Name != want || n.For != m.Module {
continue continue
} }
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
values := map[string]string{ values := map[string]string{
"at": n.At, "at": n.At,
"from": n.From, "from": n.From,
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)), "as": as,
} }
for key, value := range n.Serves { // What the provider derives for this consumer rather than for all of them
// (novox/hq ADR 0201). Filled here, the one place a provision and the module
// requiring it are both in hand.
served, err := ServedTo(n.Serves, as)
if err != nil {
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
}
for key, value := range served {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
// which is what a float would write and what a connection string would refuse. // which is what a float would write and what a connection string would refuse.
values[key] = plainly(value) values[key] = plainly(value)
@@ -67,7 +75,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
out[want] = values out[want] = values
} }
} }
return out return out, nil
} }
// withOwnNames adds a module's own composed names to what it may name from one binding: // withOwnNames adds a module's own composed names to what it may name from one binding:
+311
View File
@@ -0,0 +1,311 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// What a provider derives for one consumer, said once in the provider's definition and delivered
// to both ends (novox/hq ADR 0201, issue 124).
//
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
//
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
// served value is a string.
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
// consumerFacts are what a served value may name about the consumer it is being derived for.
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
// so it is the one thing the mesh can hand to a provider without either end guessing.
var consumerFacts = []string{"as"}
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
// identifier with its separator written `-` instead of `_` — the whole of the difference between
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
var consumerAlphabets = []string{"dns"}
// ServedTo fills a provider's served values for one consumer.
//
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
// nothing.
//
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
// stated outright, and is left alone.
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
if len(serves) == 0 {
return serves, nil
}
var out map[string]any
for _, key := range sortedAnyKeys(serves) {
text, ok := serves[key].(string)
if !ok || !strings.Contains(text, "${consumer:") {
continue
}
filled, err := consumerInto(text, as)
if err != nil {
return nil, fmt.Errorf("the value served as %q: %w", key, err)
}
if out == nil {
// Copied only once something actually changes: the caller's map is the manifest's,
// and a provider that derives nothing must not have it rewritten underneath it.
out = make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
}
out[key] = filled
}
if out == nil {
return serves, nil
}
return out, nil
}
// consumerInto replaces every `${consumer:…}` in one value.
//
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
// is refused by (boundInto).
func consumerInto(value, as string) (string, error) {
var failed error
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
parts := consumerFact.FindStringSubmatch(match)
fact, alphabet := parts[1], parts[2]
if fact != "as" {
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
}
return match
}
switch alphabet {
case "":
return as
case "dns":
return asDNSLabel(as)
default:
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
}
return match
}
})
if failed != nil {
return "", failed
}
return out, nil
}
// asDNSLabel writes a minted identity as a DNS label.
//
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So
// this is the separator and nothing else — no lower-casing of what is already lower case, no
// truncation to a limit the identity is already inside, no padding of a name that is already long
// enough. Each of those would be the mesh guessing at a rule it has not been given.
func asDNSLabel(as string) string {
return strings.ReplaceAll(as, "_", "-")
}
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
// have, when the definition is parsed rather than when a consumer is resolved.
//
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
// first time somebody required it is a definition that is wrong now.
func CheckServes(m Manifest) []string {
var problems []string
for _, provision := range sortedServes(m.Serves) {
for _, key := range sortedAnyKeys(m.Serves[provision]) {
text, ok := m.Serves[provision][key].(string)
if !ok {
continue
}
// A probe identity, because what is checked is the shape of the statement and not
// what any consumer is called.
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
problems = append(problems, fmt.Sprintf(
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
}
}
}
return problems
}
func sortedServes(serves map[string]map[string]any) []string {
out := make([]string, 0, len(serves))
for k := range serves {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedAnyKeys(values map[string]any) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
// derivedFor is what the provider on this machine derives for one consumer of one provision
// (novox/hq ADR 0201).
//
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
// already in the provider's own definition, so repeating it here would be a second copy to go
// stale.
//
// The first module in the resolved order that says it serves the provision answers, which is the
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
// then there is nothing derived to tell.
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
for _, m := range r.Modules {
serves, said := m.Serves[provision]
if !said {
continue
}
var names map[string]any
for key, value := range serves {
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
if names == nil {
names = map[string]any{}
}
names[key] = value
}
}
if names == nil {
return nil, nil
}
// **A consumer that keeps several holders of this provision is refused** — this is issue
// 124's own failure one case to the side, and it would be just as quiet.
//
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
// login, so it would make one resource per holder. The consumer's side has no such
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
// derived from the un-suffixed identity. So the provider would create the holder's
// resource and the consumer would be configured against a name nothing made — it would
// authenticate successfully and be refused on every object, which reads like a credential
// fault and is not one.
//
// Lifting this means giving the consumer's side a local dimension. That is a decision,
// not an omission, and until it is taken the mesh says so rather than guessing.
if local != "" {
return nil, fmt.Errorf(
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
"consumer from the login the mesh minted. Each holder has its own login, and a "+
"consumer is told one value per requirement — so the two ends would name "+
"different things and nothing would compare them (novox/hq ADR 0201)",
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
}
settled, err := Settle(names, settings[m.Module])
if err != nil {
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
}
derived, err := ServedTo(settled, as)
if err != nil {
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
}
return derived, nil
}
return nil, nil
}
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
// instead of asking for it (novox/hq ADR 0201, issue 124).
//
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
// nothing compared it to what the provider would actually create: the module would have
// authenticated successfully and been refused on every object, which reads like a credential fault
// and is not one. It looked authoritative for months.
//
// The test is exact and costs one string search: a definition whose file already contains the
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
// because after substitution every consumer's file contains it legitimately.
//
// Only values that actually name the consumer are judged. A provider that serves a constant under
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
// rather than wrong.
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok || content == "" {
return nil
}
for _, provision := range sortedKnown(known) {
values := known[provision]
identity := values["as"]
if identity == "" {
continue
}
for _, key := range sortedStringKeys(values) {
if key == "as" {
// The login is not derived from itself, and a consumer that must present it in a
// connection string legitimately has it from `${bound:…}` — which is what it will
// be after substitution, so this would judge the substitution, not the module.
continue
}
value := values[key]
if value == "" || !namesTheConsumer(value, identity) {
continue
}
if !strings.Contains(content, value) {
continue
}
return fmt.Errorf(
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
"keeping its own copy of somebody else's naming rule is one that can disagree "+
"with it, silently. Say ${bound:%s:%s} and be told",
module, value, resource["id"], provision, provision, key)
}
}
return nil
}
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
// from it, whatever else it may be.
func namesTheConsumer(value, identity string) bool {
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
}
func sortedKnown(known map[string]map[string]string) []string {
out := make([]string, 0, len(known))
for k := range known {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedStringKeys(values map[string]string) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,121 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0210 §3: a contribution is a dependency on the seat that receives it.
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
env := mod("theme", nil, nil, nil)
env.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
path := mod("toolchain", nil, nil, nil)
path.Environment = &Environment{Path: []PathEntry{{Entry: "/opt/x/bin"}}}
shell := mod("prompt", nil, nil, nil)
shell.Shell = []ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
session := mod("wallpaper", nil, nil, nil)
session.Shell = []ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
resources := mod("bar", nil, nil, nil)
resources.Shell = []ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
empty := mod("nothing", nil, nil, nil)
empty.Environment = &Environment{}
cases := map[string]struct {
m Manifest
want []string
}{
"a variable": {env, []string{EnvironmentSeat}},
"a path entry": {path, []string{EnvironmentSeat}},
"shell code": {shell, []string{LoginShellSeat}},
"the session's start": {session, []string{DisplayServerSeat}},
"the session's X resources": {resources, []string{DisplayServerSeat}},
"an empty environment": {empty, nil},
}
for name, c := range cases {
got := DependsOn(c.m)
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s depends on %v, want %v", name, got, c.want)
}
}
}
func TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) {
holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})
contributor := mod("theme", nil, nil, nil)
contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
catalogue := map[string]Manifest{"node-env": holder, "theme": contributor}
if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil {
t.Fatalf("a contributor beside the holder is refused: %v", err)
}
_, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"})
if err == nil {
t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere")
}
if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") {
t.Errorf("the refusal names neither the seat nor its holder: %v", err)
}
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil {
t.Errorf("the contributor and the holder assigned together are refused: %v", err)
}
}
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
// The display server's module contributes nothing to its own seat today, but the zsh module's
// environment contributions do go to another seat: a claim meets only the seat it names.
zsh := mod("zsh", nil, nil, nil, Claim{Name: LoginShellSeat})
zsh.Shell = []ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
zsh.Environment = &Environment{Variables: map[string]string{"EDITOR": "vim"}}
catalogue := map[string]Manifest{"zsh": zsh,
"node-env": mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})}
unheld := UnheldDependencies(catalogue, "laptop", []Manifest{zsh}, nil)
if len(unheld) != 1 || unheld[0].Seat != EnvironmentSeat {
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat)
}
}
func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *testing.T) {
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
res("package", "pacman-contrib"))
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar, "other": other}
err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"})
if err == nil || !strings.Contains(err.Error(), "pacman-contrib") || !strings.Contains(err.Error(), "bar") {
t.Fatalf("the second owner of a package was not refused by name: %v", err)
}
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"other"}); err != nil {
t.Errorf("a module declaring nothing shared is refused: %v", err)
}
// A collision already on the node is status's, not a reason to refuse an unrelated assignment.
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman", "bar"}, []string{"other"}); err != nil {
t.Errorf("an unrelated assignment is refused for a collision already there: %v", err)
}
}
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
for _, moment := range powerMoments {
m := mod("laptop", nil, nil, nil)
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
}
if p := m.shellProblems(); len(p) != 0 {
t.Errorf("code for %s is refused: %v", moment, p)
}
}
m := mod("laptop", nil, nil, nil)
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
if p := m.shellProblems(); len(p) == 0 {
t.Error("code for a moment that does not exist was accepted")
}
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
}
}
+97 -7
View File
@@ -415,6 +415,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation, filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
with.OutwardLinks, with.TunnelInterface) with.OutwardLinks, with.TunnelInterface)
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
// 0203 §5): the account has one environment, and a machine whose holder arrives later should not
// be the moment two modules are found to disagree about it.
if err := variablesSetOnce(r.Modules); err != nil {
return nil, err
}
var out []map[string]any var out []map[string]any
for _, m := range r.Modules { for _, m := range r.Modules {
if with.Adopted && m.Filtering != nil { if with.Adopted && m.Filtering != nil {
@@ -603,14 +610,14 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// and nothing would say so. // and nothing would say so.
continue continue
} }
first = append(first, map[string]any{ first = append(first, ownedBy(r.provisionsAs(m), map[string]any{
// One file per holder — the consumer's module with its local name after it // One file per holder — the consumer's module with its local name after it
// where it keeps several (ADR 0094); the lab found two files with one id. // where it keeps several (ADR 0094); the lab found two files with one id.
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)), "id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
"type": "file", "type": "file",
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)), "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
"sealed": g.Sealed, "sealed": g.Sealed,
}) }))
} }
} }
for _, to := range sortedKeys(m.Binds) { for _, to := range sortedKeys(m.Binds) {
@@ -645,7 +652,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if err != nil { if err != nil {
return nil, err return nil, err
} }
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own) as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
// What the provider derives for THIS consumer, filled here where the consumer is
// known (novox/hq ADR 0201). The same fill knownFor does below, so the binding file
// and the module's `${bound:…}` substitutions cannot say different things.
told := *found
told.Serves, err = ServedTo(told.Serves, as)
if err != nil {
return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
}
file, err := boundFile(told, m.Binds[to], as, own)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -719,7 +735,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
return nil, err return nil, err
} }
// And what its bindings say, for the half of a connection that is not secret. // And what its bindings say, for the half of a connection that is not secret.
known := knownFor(m, r.Needs, r.Node) known, err := knownFor(m, r.Needs, r.Node)
if err != nil {
return nil, err
}
// A requirement answered on this same machine is not in r.Needs — its binding file is // A requirement answered on this same machine is not in r.Needs — its binding file is
// written from `here` (above) — and so `${bound:…}` could not name it, though the file // written from `here` (above) — and so `${bound:…}` could not name it, though the file
// beside it said the same facts. Filled from the same answer, so the two cannot disagree. // beside it said the same facts. Filled from the same answer, so the two cannot disagree.
@@ -736,7 +755,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
} }
local := *answered local := *answered
local.For = m.Module local.For = m.Module
for provision, values := range knownFor(m, []Needed{local}, r.Node) { here, err := knownFor(m, []Needed{local}, r.Node)
if err != nil {
return nil, err
}
for provision, values := range here {
known[provision] = values known[provision] = values
} }
} }
@@ -761,6 +784,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// 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)
// **A definition that already holds the answer transcribed it** (novox/hq ADR 0201).
// Judged over what the module itself declares, and before anything is substituted: the
// mesh's own generated files — the binding, the contributions — legitimately carry the
// derived value, and after substitution so does every consumer's file, so this is the one
// moment the two can be told apart.
for _, own := range m.Resources {
if err := notTranscribed(own, known, m.Module); err != nil {
return nil, err
}
}
// Which of this module's files carry a secret, for the rule that a container may not read // Which of this module's files carry a secret, for the rule that a container may not read
// one of them as its environment without saying so (ADR 0086, issue 041). // one of them as its environment without saying so (ADR 0086, issue 041).
secretFiles := secretFilesOf(resources) secretFiles := secretFilesOf(resources)
@@ -862,6 +896,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
return nil, err return nil, err
} }
publishedOn(copied, m.Module, with) publishedOn(copied, m.Module, with)
// The account's environment and every module's shell code, where this module holds the
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
// shell's own syntax, full of `${…}` no pass above should ever be shown.
if err := contributionsInto(copied, m, r.Modules, thisMachine); err != nil {
return nil, err
}
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
// A service saying what it reflects names resources within its own module, so those // A service saying what it reflects names resources within its own module, so those
// are prefixed too or they would point at nothing. // are prefixed too or they would point at nothing.
@@ -880,6 +921,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
copied["reload-on"] = renamed copied["reload-on"] = renamed
} }
// And which of its module's containers a scheduled step holds still (novox/hq ADR 0189).
// **The loudest of the three when it is missed.** An unprefixed `restart-on` matches
// nothing and a service quietly never restarts; an unprefixed `while-stopped` names a
// container the declaration does not contain, and the host refuses the whole
// declaration — so the machine takes nothing at all, for every push, until this is
// right. That is what it did on the control node (2026-10-04).
if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil {
copied[WhileStopped] = renamed
}
// And what a process replaces (novox/hq issue 213): a resource of this module's that it // And what a process replaces (novox/hq issue 213): a resource of this module's that it
// no longer declares, named as the host recorded it, or the host hands nothing over and // no longer declares, named as the host recorded it, or the host hands nothing over and
// removes it first. // removes it first.
@@ -1175,6 +1225,41 @@ type Contribution struct {
// requirement's name — everything providing `reverse-proxy` understands the same shape, which // requirement's name — everything providing `reverse-proxy` understands the same shape, which
// is what makes swapping one for another cost nothing. // is what makes swapping one for another cost nothing.
Values map[string]any `json:"values"` Values map[string]any `json:"values"`
// Derived is what this provider's own definition said it derives for this consumer, already
// derived (novox/hq ADR 0201).
//
// **The provider is told, rather than recomputing it.** A served value may name the consumer's
// identity — a bucket named for who is asking, a database prefixed with it — and before this
// the rule lived twice: once in the provisioner's code, once transcribed into every consumer's
// definition. The mesh fills the provider's own statement here and delivers the same filled
// value to the consumer, so the two cannot disagree: there is no second computation to
// disagree with.
//
// Only the keys that are per-consumer. The rest of what the provider serves is the same for
// everyone and is in its own definition, where it already is.
Derived map[string]any `json:"derived,omitempty"`
}
// provisionsAs is the account that reads what the mesh writes for this provider: the one secret
// per consumer it must open to set that consumer's password (novox/hq issue 225).
//
// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already
// written above for a module's own secrets — and the grant secret is the other kind of secret
// the mesh writes for a module, so it is the same rule.
//
// Which account depends on where the module's code runs. A module whose code is a bundle is run
// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container
// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that
// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the
// harness composes a grant secret's path from the contributions file, not from a word.
//
// Empty is root, which is what it was and what a module with no bundle and no declared owner
// still wants.
func (r Resolution) provisionsAs(m Manifest) string {
if len(m.Bundles) > 0 && r.Account != "" {
return r.Account
}
return m.SecretsOwner
} }
// grantPath is where one consumer's sealed credential lands on the providing machine. // grantPath is where one consumer's sealed credential lands on the providing machine.
@@ -1266,12 +1351,17 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
// told about it and withdraws the login on its next pass. // told about it and withdraws the login on its next pass.
continue continue
} }
as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local)
derived, err := r.derivedFor(g.Provision, as, g.From, g.Local, settings)
if err != nil {
return nil, err
}
out[g.Provision] = append(out[g.Provision], Contribution{ out[g.Provision] = append(out[g.Provision], Contribution{
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
// One holder per local name: the identity the consumer is known by, and the local name // One holder per local name: the identity the consumer is known by, and the local name
// after it where the module keeps several (ADR 0094). Not a login any backend checks — // after it where the module keeps several (ADR 0094). Not a login any backend checks —
// a secret is not a login — so the identity limit does not apply to the suffix. // a secret is not a login — so the identity limit does not apply to the suffix.
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local), As: as,
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
}) })
if granted[g.Provision] == nil { if granted[g.Provision] == nil {
@@ -0,0 +1,343 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// What a provider derives for each consumer, said once and delivered to both ends
// (novox/hq ADR 0201, issue 124).
//
// The failure these are written against: the object store's provisioner derived each consumer's
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
// consumer which bucket that was — so all three consumers wrote the answer into their own
// definitions by hand. Two were right. One named a predecessor's bucket and would have
// authenticated successfully and been refused on every object. Each of them also named the
// machine the module happens to run on, which a definition may not do.
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
// a bucket named for whoever is asking.
func store() Manifest {
return Manifest{
Module: "store", Version: "1",
Provides: FromAnywhere("s3-bucket"),
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
Serves: map[string]map[string]any{"s3-bucket": {
"region": "eu-west",
"bucket": "${consumer:as:dns}",
}},
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
}},
}
}
// files is a consumer that writes the bucket into its own configuration — which is the thing it
// could not do before, and had to transcribe.
func files() Manifest {
return Manifest{
Module: "files", Version: "1", Slug: "files",
Requires: []string{"s3-bucket"},
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
Resources: []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
}},
}
}
// pics is a second consumer of the same provider on the same machine: two derivations, neither
// the other's.
func pics() Manifest {
return Manifest{
Module: "pics", Version: "1", Slug: "pics",
Requires: []string{"s3-bucket"},
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
Resources: []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
}},
}
}
// The three places the derived value lands must agree, because agreeing is the whole point: the
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
out, err := r.Declaration(Rendering{Grants: []Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
want := strings.ReplaceAll(as, "_", "-")
if want == as || !strings.Contains(as, "_") {
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
}
env := fileNamed(out, "files.env")
if env == nil {
t.Fatalf("the consumer was given no file: %v", out)
}
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
}
binding := fileNamed(out, "files.bound-s3-bucket")
if binding == nil {
t.Fatalf("the consumer was given no binding: %v", out)
}
var said struct {
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
t.Fatal(err)
}
if said.Serves["bucket"] != want {
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
}
// And what is the same for everybody is still the same for everybody.
if said.Serves["region"] != "eu-west" {
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
}
given := storeGrants(t, out)
if len(given) != 1 {
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
}
if given[0].Derived["bucket"] != want {
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
}
// Only the per-consumer half. The region is the same for everyone and is already in the
// provider's own definition; repeating it here would be a copy to go stale.
if _, carried := given[0].Derived["region"]; carried {
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
}
}
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
r, err := Resolve(shelf(store(), files(), pics()),
[]string{"store", "files", "pics"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
out, err := r.Declaration(Rendering{Grants: []Grant{
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk"},
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
Values: map[string]any{}, Sealed: "c2VhbGVk"},
}})
if err != nil {
t.Fatal(err)
}
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
if forFiles == forPics {
t.Fatal("the two consumers were given the same identity; this test proves nothing")
}
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
t.Errorf("files was not given its own bucket:\n%s", got)
}
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
t.Errorf("pics was not given its own bucket:\n%s", got)
}
var buckets []any
for _, g := range storeGrants(t, out) {
buckets = append(buckets, g.Derived["bucket"])
}
if len(buckets) != 2 || buckets[0] == buckets[1] {
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
}
}
// An operator may still set what the provider serves, and the mesh still derives the rest: the
// setting is laid on first, then the consumer's half is filled.
func TestASettingComposesWithADerivedValue(t *testing.T) {
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
out, err := r.Declaration(Rendering{
Settings: SettingsBy{"store": {{From: "the operator",
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
})
if err != nil {
t.Fatal(err)
}
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
}
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
}
}
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
// consumer happens to be resolved — and the refusal says what may be said instead.
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
for _, c := range []struct{ value, says string }{
{"${consumer:node}", "as"},
{"${consumer:as:punycode}", "dns"},
} {
m := store()
m.Serves["s3-bucket"]["bucket"] = c.value
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
_, err = ParseManifest(raw)
if err == nil {
t.Fatalf("%s was accepted", c.value)
}
if !strings.Contains(err.Error(), c.value) {
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
}
}
}
// `dns` is checked against an identity the mesh actually mints, not an invented string.
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
t.Fatalf("the mesh would not mint this identity at all: %v", err)
}
label := asDNSLabel(as)
if strings.Contains(label, "_") {
t.Errorf("%q is not a DNS label", label)
}
if strings.ReplaceAll(label, "-", "_") != as {
t.Errorf("%q is not %q with its separator rewritten", label, as)
}
}
// The check that would have caught the one wrong instance: a consumer that writes the derived
// value into its own definition instead of asking for it is refused, whether it transcribed the
// right answer or a predecessor's.
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
transcribed := strings.ReplaceAll(as, "_", "-")
m := files()
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
// Exactly what the provider will create — correct today, and a copy of a rule that is
// not this module's.
"content": "BUCKET=" + transcribed + "\n",
}}
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
_, err = r.Declaration(Rendering{Grants: []Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err == nil {
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
}
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
t.Errorf("the refusal does not say what to write instead: %v", err)
}
// And a constant the provider serves to everyone is not a transcription: repeating it is
// redundant, not wrong, and refusing it would be the mesh policing style.
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "REGION=eu-west\n",
}}
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
if _, err := r.Declaration(Rendering{Grants: []Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}}); err != nil {
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
}
}
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/var/lib/store/grants/mesh.json" {
continue
}
var parsed struct {
Given []Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("the provider was given no contributions file: %v", out)
return nil
}
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
// while its provider is told another.
//
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
// would make one resource per holder. The consumer's side has no such dimension: there is one
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
// which is exactly the fault this whole record exists to end.
//
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
// consumer's side a local dimension, which is a decision and not an omission.
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
m := files()
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
m.Secrets = nil
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
"hot": "/var/lib/files/hot.secret",
"cold": "/var/lib/files/cold.secret",
}}
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
}}
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
_, err = r.Declaration(Rendering{Grants: []Grant{
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
}})
if err == nil {
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
"its two ends would have disagreed in silence")
}
for _, want := range []string{"files", "s3-bucket", "bucket"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q: %v", want, err)
}
}
}
+585
View File
@@ -0,0 +1,585 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// The account's environment and the login shell's code, composed from the modules a node runs
// (novox/hq ADR 0203, ADR 0204).
//
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
// result with a placeholder in its own file, and the controller fills it from every module on the
// node. A node not running a module has none of its contribution, and unassigning one takes its
// lines away at the next composition.
//
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
// controller writes in two standard formats — POSIX assignment and the service manager's
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
// 0204).
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
// contributed: the account's environment, and the login shell's code.
const (
EnvironmentSeat = "node-environment"
LoginShellSeat = "node-login-shell"
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
PowerSeat = "node-power"
)
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
const (
PathAtStart = "start"
PathAtEnd = "end"
)
// Environment is what one module adds to the account's environment (novox/hq ADR 0203).
type Environment struct {
// Variables are names and literal values. A value may name the machine's own facts with
// ${machine:…}, resolved before anything is written, and nothing else that expands.
Variables map[string]string `json:"variables,omitempty"`
// Path is entries on the account's PATH, each at its start or its end, in the order declared.
Path []PathEntry `json:"path,omitempty"`
}
// PathEntry is one directory a module puts on the account's PATH.
type PathEntry struct {
Entry string `json:"entry"`
At string `json:"at"`
}
// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204).
type ShellCode struct {
// For is the shell the code is written in.
For string `json:"for"`
// Slot is where it runs among the other modules' code: first, normal or last. Named rather
// than numbered, because every contributor would guess a number and a collision says nothing.
Slot string `json:"slot"`
// Code is never interpreted — it is the shell's syntax, and only the shell reads it.
Code string `json:"code"`
}
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
// at the check rather than code that silently lands in no placeholder.
var (
knownShells = []string{"zsh", "bash", "fish"}
knownSlots = []string{"first", "normal", "last"}
// sessionFiles are the two files of the graphical session's start that read no directory, so a
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
// display server's holder, as a shell's slots are placed by the login shell's.
sessionFiles = []string{"xinitrc", "xresources"}
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
)
// contributionTargets is every name a contribution's `for` may take.
func contributionTargets() []string {
out := append(append([]string(nil), knownShells...), sessionFiles...)
return append(out, powerMoments...)
}
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
// ADR 0208 §4).
func placerOf(target string) string {
if oneOf(sessionFiles, target) {
return DisplayServerSeat
}
if oneOf(powerMoments, target) {
return PowerSeat
}
return LoginShellSeat
}
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
const (
EnvironmentPOSIX = "posix"
EnvironmentSystemd = "systemd"
)
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
var (
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
ofContributed = regexp.MustCompile(`\$\{(shell|contribution):([^}]*)\}`)
)
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// environmentProblems is what is wrong with this module's environment contribution, from the
// manifest alone.
func (m Manifest) environmentProblems() []string {
if m.Environment == nil {
return nil
}
var problems []string
for _, n := range sortedKeys(m.Environment.Variables) {
switch {
case !variableName.MatchString(n):
problems = append(problems, fmt.Sprintf(
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
"underscore, then letters, digits and underscores", m.Module, n))
continue
case n == "PATH":
// PATH is the one variable every module shares, so no module may set it whole: a second
// setter would replace the first's entries, and the account's own PATH with them.
problems = append(problems, fmt.Sprintf(
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
continue
}
if why := literalProblem(m.Environment.Variables[n]); why != "" {
problems = append(problems, fmt.Sprintf(
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
}
}
seen := map[string]bool{}
for i, p := range m.Environment.Path {
switch {
case p.Entry == "":
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
// A colon is PATH's own separator, so an entry holding one is two entries, and the
// check that it is already present would look for the wrong thing.
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
case seen[p.Entry]:
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
default:
if why := literalProblem(p.Entry); why != "" {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
}
}
seen[p.Entry] = true
if p.At != PathAtStart && p.At != PathAtEnd {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
}
}
return problems
}
// literalRule is why a value must be literal, said with every refusal of one.
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
// quoting in one and a character in the other; a line break ends the line in both.
func literalProblem(v string) string {
switch {
case strings.ContainsAny(v, `'"`):
return "holds a quote"
case strings.Contains(v, `\`):
return "holds a backslash"
case strings.ContainsAny(v, "\n\r"):
return "holds a line break"
case strings.ContainsRune(v, 0):
return "holds a NUL"
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
return "holds a $ that is not one of the machine's ${machine:…} facts"
}
return ""
}
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
// itself is not judged: it is the shell's syntax, which the controller does not read.
func (m Manifest) shellProblems() []string {
var problems []string
for i, c := range m.Shell {
if !oneOf(contributionTargets(), c.For) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
}
if !oneOf(knownSlots, c.Slot) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
strings.Join(knownSlots, ", ")))
}
if strings.TrimSpace(c.Code) == "" {
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
}
}
return problems
}
// contributionPlaceholderProblems is every place this module's resources name the environment or
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
// a mesh does, and again at composition in the same words.
func (m Manifest) contributionPlaceholderProblems() []string {
var problems []string
for _, r := range m.Resources {
problems = append(problems, placeholderProblems(m, r)...)
problems = append(problems, seatPlaceholderProblems(m, r)...)
}
return problems
}
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
//
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
// authorises the bus's user list: a module that does not hold the account's environment writing it
// would be a second writer of a file there is one of, and a module that does not hold the login
// shell writing every module's shell code would be a second shell.
func placeholderProblems(m Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
s, ok := r[field].(string)
if !ok {
continue
}
env := ofEnvironment.FindAllStringSubmatch(s, -1)
code := ofShell.FindAllStringSubmatch(s, -1)
if len(env)+len(code) == 0 {
continue
}
if field != "content" {
// Placed only where a file's bytes are, which is where every one of them is meant to go:
// a path or an owner holding several lines of shell is nothing the host could act on.
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
continue
}
for _, e := range env {
if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd))
}
}
if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
"written by that seat's holder alone (novox/hq ADR 0203)",
m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat))
}
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
// one placing the other's would be a second writer of a file there is one of.
refusedFor := map[string]bool{}
for _, c := range code {
target, slot, two := strings.Cut(c[1], ":")
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
continue
}
seat := placerOf(target)
if m.ClaimsSeat(seat) || refusedFor[seat] {
continue
}
refusedFor[seat] = true
if seat == PowerSeat {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
m.Module, r["id"], c[0], m.Module, seat))
continue
}
if seat == DisplayServerSeat {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
"the display server's holder alone (novox/hq ADR 0208)",
m.Module, r["id"], c[0], m.Module, seat, target))
continue
}
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
"placed by the login shell's holder alone (novox/hq ADR 0204)",
m.Module, r["id"], c[0], m.Module, seat))
}
}
return problems
}
func placeholderOf(env, code [][]string) string {
if len(env) > 0 {
return env[0][0]
}
return code[0][0]
}
// contributedEnvironment is one node's environment, gathered and in the order it is written.
type contributedEnvironment struct {
// variables is by module in name order, each module's sorted by name.
variables []setBy
// start and end are PATH's entries in their final order, each once.
start, end []placedOn
}
type setBy struct {
module string
names []string
values map[string]string
}
type placedOn struct {
module, entry string
}
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
func inModuleOrder(modules []Manifest) []Manifest {
out := append([]Manifest(nil), modules...)
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
return out
}
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
// naming both. Neither is chosen: whichever was written last would win in one reader and not
// necessarily in the other, and the module that lost would not be told.
func variablesSetOnce(modules []Manifest) error {
setter := map[string]string{}
for _, m := range inModuleOrder(modules) {
if m.Environment == nil {
continue
}
for _, n := range sortedKeys(m.Environment.Variables) {
if first, taken := setter[n]; taken {
return fmt.Errorf(
"%s and %s both set %s on this machine; the account has one environment, so one "+
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
}
setter[n] = m.Module
}
}
return nil
}
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
//
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) {
var env contributedEnvironment
if err := variablesSetOnce(modules); err != nil {
return env, err
}
placed := map[string]bool{}
for _, m := range inModuleOrder(modules) {
if m.Environment == nil {
continue
}
if len(m.Environment.Variables) > 0 {
set := setBy{module: m.Module, values: map[string]string{}}
for _, n := range sortedKeys(m.Environment.Variables) {
v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n)
if err != nil {
return env, err
}
set.names = append(set.names, n)
set.values[n] = v
}
env.variables = append(env.variables, set)
}
for _, p := range m.Environment.Path {
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
if err != nil {
return env, err
}
if strings.Contains(entry, ":") {
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
m.Module, entry)
}
if placed[entry] {
continue
}
placed[entry] = true
if p.At == PathAtEnd {
env.end = append(env.end, placedOn{m.Module, entry})
} else {
env.start = append(env.start, placedOn{m.Module, entry})
}
}
}
return env, nil
}
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
// either format is written, so both say the same thing (novox/hq ADR 0203).
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
for _, key := range machineUsed(v) {
value, has := facts[key]
if !has {
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
module, what, key, orNothing(namesOfFacts(facts)))
}
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
}
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
// take alike.
if why := literalProblem(v); why != "" {
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
}
return v, nil
}
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
//
// The start entries are written last-first: each is put in front of PATH, so the last written ends
// up first, and the result reads in module order, then the order each module declared.
func (e contributedEnvironment) posix() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
}
}
named := ""
for i := len(e.start) - 1; i >= 0; i-- {
p := e.start[i]
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
p.entry, p.entry)
}
named = ""
for _, p := range e.end {
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
p.entry, p.entry)
}
if len(e.start)+len(e.end) > 0 {
b.WriteString("export PATH\n")
}
return b.String()
}
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
// once per manager start, so it needs no guard against running twice; the account's existing PATH
// sits between the start and the end entries.
func (e contributedEnvironment) systemd() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
}
}
if len(e.start) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
}
if len(e.end) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
}
return b.String()
}
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
func modulesOf(entries []placedOn) string {
var names []string
seen := map[string]bool{}
for _, p := range entries {
if !seen[p.module] {
seen[p.module] = true
names = append(names, p.module)
}
}
return strings.Join(names, ", ")
}
func entriesOf(entries []placedOn) string {
out := make([]string, len(entries))
for i, p := range entries {
out[i] = p.entry
}
return strings.Join(out, ":")
}
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
// order, each module's pieces in the order it declared them, each preceded by a line naming the
// module, and empty when nothing is contributed.
func shellCode(modules []Manifest, shell, slot string) string {
var b strings.Builder
for _, m := range inModuleOrder(modules) {
named := false
for _, c := range m.Shell {
if c.For != shell || c.Slot != slot {
continue
}
if !named {
fmt.Fprintf(&b, "# %s\n", m.Module)
named = true
}
b.WriteString(c.Code)
if !strings.HasSuffix(c.Code, "\n") {
b.WriteString("\n")
}
}
}
return b.String()
}
// contributionsInto fills a holder's file with the node's environment and its shell code.
//
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
// wrote, so a contributed piece is never scanned again.
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string) error {
if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 {
return fmt.Errorf("%s", problems[0])
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
if ofEnvironment.MatchString(content) {
env, err := environmentOn(modules, facts)
if err != nil {
return err
}
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd {
return env.systemd()
}
return env.posix()
})
}
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
// the controller does not read, so neither may be scanned after the other is in place — a
// contributed line that happened to spell the other's placeholder would be filled.
if ofContributed.MatchString(content) {
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
found := ofContributed.FindStringSubmatch(placeholder)
first, second, _ := strings.Cut(found[2], ":")
if found[1] == "contribution" {
return seatContributions(modules, first, second)
}
return shellCode(modules, first, second)
})
}
resource["content"] = content
return nil
}
+471
View File
@@ -0,0 +1,471 @@
package catalogue
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module
// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder).
// contributors is a fixed set of contributions, in no particular order: what the renderings are
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
// ordinary case of two modules sharing a directory, and is written once.
func contributors() []Manifest {
return []Manifest{
{Module: "zsh", Environment: &Environment{
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
Path: []PathEntry{
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
{Entry: "${machine:account-home}/bin", At: PathAtStart},
{Entry: "/opt/scripts", At: PathAtEnd},
},
}},
{Module: "go-toolchain", Environment: &Environment{
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
Path: []PathEntry{
{Entry: "${machine:account-home}/go/bin", At: PathAtStart},
{Entry: "/usr/local/go/bin", At: PathAtStart},
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
},
}},
{Module: "agent", Environment: &Environment{
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}},
}},
// A module contributing nothing is in the set and writes nothing.
{Module: "postgres"},
}
}
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
// The final PATH this set composes, around whatever the account had: the start entries in module
// order and then declared order, the account's own, then the end entries.
const composedPOSIX = `# agent
export DISABLE_AUTOUPDATER='1'
# go-toolchain
export GOPATH='/home/op/go'
# zsh
export EDITOR='vim'
export XDG_CONFIG_HOME='/home/op/.config'
# zsh
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
# go-toolchain
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
# agent
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
# zsh
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
export PATH
`
const composedSystemd = `# agent
DISABLE_AUTOUPDATER=1
# go-toolchain
GOPATH=/home/op/go
# zsh
EDITOR=vim
XDG_CONFIG_HOME=/home/op/.config
# go-toolchain, zsh
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
# agent, zsh
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
`
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
env, err := environmentOn(contributors(), operatorFacts)
if err != nil {
t.Fatal(err)
}
if got := env.posix(); got != composedPOSIX {
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
}
}
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
env, err := environmentOn(contributors(), operatorFacts)
if err != nil {
t.Fatal(err)
}
if got := env.systemd(); got != composedSystemd {
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
}
}
// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file
// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim
// is about what a shell does with the file, not about what the file looks like.
func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("no sh on this machine")
}
script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX +
`[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" +
`echo "$PATH"; echo "$GOPATH"`
out, err := exec.Command(sh, "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("sourcing twice: %v\n%s", err, out)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
if lines[0] != want {
t.Fatalf("PATH is %s, not %s", lines[0], want)
}
if lines[1] != "/home/op/go" {
t.Fatalf("GOPATH was not exported: %q", lines[1])
}
// And an entry the account already has stays where it is, and once.
out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput()
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got !=
"/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" {
t.Fatalf("an entry already on PATH was added again or moved: %s", got)
}
}
// The environment.d rendering, read by the service manager's own generator where this machine has
// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted.
func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) {
generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator"
if _, err := os.Stat(generator); err != nil {
t.Skip("no environment.d generator on this machine")
}
config := t.TempDir()
if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(composedSystemd), 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.Command(generator)
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + config, "XDG_CONFIG_HOME=" + config}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
want := "PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
if !strings.Contains(string(out), want+"\n") || !strings.Contains(string(out), "GOPATH=/home/op/go\n") {
t.Fatalf("the service manager read\n%s", out)
}
}
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
func TestNoContributionsRenderNothing(t *testing.T) {
env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts)
if err != nil {
t.Fatal(err)
}
if env.posix() != "" || env.systemd() != "" {
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
}
}
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
!strings.Contains(err.Error(), "${machine:account-home}") {
t.Fatalf("a missing account home was not refused by name: %v", err)
}
}
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{
Variables: map[string]string{"EDITOR": "nvim"}}})
_, err := environmentOn(modules, operatorFacts)
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
t.Fatalf("a variable set twice was not refused naming both: %v", err)
}
// And at composition, whether or not the node holds the environment.
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
t.Fatalf("composition accepted a variable set twice: %v", err)
}
}
// shells contributes code for several shells and slots, in no order.
func shells() []Manifest {
return []Manifest{
{Module: "zsh-syntax-highlighting", Shell: []ShellCode{
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
}},
{Module: "powerlevel10k", Shell: []ShellCode{
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
}},
{Module: "zsh-autosuggestions", Shell: []ShellCode{
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
}},
{Module: "direnv", Shell: []ShellCode{
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
}},
}
}
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
// order it declared them under a line naming it; empty when nothing is contributed.
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+
"# zsh-autosuggestions\n"+
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want {
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
}
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
}
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
t.Fatalf("bash's last slot is %q, not %q", got, want)
}
if got := shellCode(shells(), "fish", "first"); got != "" {
t.Fatalf("a slot nobody contributed to holds %q", got)
}
}
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
func zshHolder() Manifest {
return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}},
Resources: []map[string]any{
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
"${shell:zsh:first}" +
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
"${shell:zsh:normal}" +
"alias ll='ls -l'\n" +
"${shell:zsh:last}"},
}}
}
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
}})
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
var zshrc map[string]any
for _, res := range out {
if res["id"] == "zsh.zshrc" {
zshrc = res
}
}
if zshrc == nil {
t.Fatalf("the holder's file was not composed: %v", out)
}
if zshrc["path"] != "/home/op/.zshrc" {
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
}
want := "# powerlevel10k\n" +
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
"# powerlevel10k\n" +
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" +
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" +
"# zsh-autosuggestions\n" +
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" +
"alias ll='ls -l'\n" +
"# sly\n" +
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
"# zsh-syntax-highlighting\n" +
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
if got := zshrc["content"]; got != want {
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
}
}
// The holder of node-environment places both renderings, and they are the same as rendered alone.
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}},
Resources: []map[string]any{
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
"content": "# The mesh's environment.\n${environment:posix}"},
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
"content": "${environment:systemd}"},
}}
r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
by := map[string]any{}
for _, res := range out {
by[res["id"].(string)] = res["content"]
}
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
}
if by["node-env.systemd"] != composedSystemd {
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
}
}
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
// which is what the catalogue check and registration run, and again at composition, in the same words.
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
for _, c := range []struct{ content, want string }{
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
} {
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
}
m := Manifest{Module: "toolchain", Resources: []map[string]any{
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
}
}
}
// A key nobody renders is refused, not left in the file as a literal.
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
for _, c := range []struct{ content, want string }{
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
} {
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s was accepted: %v", c.content, err)
}
}
// And outside a file's content, where nothing could be placed.
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil ||
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
t.Errorf("a placeholder in a path was accepted: %v", err)
}
}
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
for _, c := range []struct{ environment, want string }{
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
} {
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
}
}
// What is allowed: a literal, and the machine's own facts.
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
t.Fatalf("a well-formed environment was refused: %v", err)
}
}
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
for _, c := range []struct{ shell, want string }{
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
} {
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
}
}
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
}
}
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
// shell's contract is `execute`, described and with a schema an agent can call.
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
env, ok := SeatNamed("node-environment")
if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" ||
len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" {
t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok)
}
shell, ok := SeatNamed("node-login-shell")
if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" {
t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok)
}
if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" {
t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves)
}
props, _ := shell.Serves[0].Input["properties"].(map[string]any)
required, _ := shell.Serves[0].Input["required"].([]string)
if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" {
t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input)
}
if _, has := props["timeout_seconds"]; !has {
t.Fatalf("execute takes no timeout: %v", props)
}
}
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
// name nor under the name a module gave it before.
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
_, err := ParseManifest([]byte(raw))
if err == nil {
t.Errorf("a module declaring %q was accepted", n)
}
}
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
t.Fatalf("declaring login-shell was not refused by name: %q", got)
}
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
if err != nil {
t.Fatal(err)
}
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
}
}
@@ -0,0 +1,103 @@
package catalogue
import (
"strings"
"testing"
)
// A grant secret is read by whatever provisions, and that stopped being root (novox/hq issue 225).
//
// The mesh seals one credential per consumer beside the provider's contributions file. The
// provider's harness reads both: the file to learn who asked, the secret to set their password.
// While a module's own code ran in a container as root, a root-owned 0600 file was readable by
// the thing that needed it. ADR 0198 moved that code under the node's runtime, which runs as the
// operator's account — and the secret stayed root's.
//
// **The cost was silence.** The harness says `secret not readable yet`, which is true and
// ordinary on the first pass, so four thousand refusals in three hours read as patience. No user
// was ever created, and two consumers crash-looped against a database that had never heard of
// them.
//
// The same reasoning is already written for a module's *own* secrets, three hundred lines above:
// "a root-owned 0600 file is one that process cannot read". This is that rule reaching the other
// kind of secret the mesh writes for a module.
// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape
// every TypeScript provisioner has since ADR 0198.
func aProviderWithABundle() Manifest {
return Manifest{
Module: "mongodb", Version: "1",
Provides: FromAnywhere("mongodb-database"),
Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"},
Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"},
Bundles: []Bundle{{Name: "code", Language: "typescript"}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "mongodb-server",
"image": "mongo@sha256:" + strings.Repeat("a", 64),
}},
}
}
func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
r.Account = "operator"
out, err := r.Declaration(Rendering{Grants: []Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
var secret map[string]any
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
secret = res
}
}
if secret == nil {
t.Fatalf("no grant secret was composed at all: %v", out)
}
if got := secret["owner"]; got != "operator" {
t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
}
}
// And a provider whose code still runs in a container keeps the owner it declares, so this
// changes nothing for the modules the runtime has not taken.
func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) {
m := aProviderWithABundle()
m.Bundles = nil
m.SecretsOwner = "65534:65534"
r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
r.Account = "operator"
out, err := r.Declaration(Rendering{Grants: []Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
if got := res["owner"]; got != "65534:65534" {
t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got)
}
return
}
}
t.Fatal("no grant secret was composed")
}
func fmtPath(r map[string]any) string {
p, _ := r["path"].(string)
return p
}
+109
View File
@@ -0,0 +1,109 @@
package catalogue
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
// a role rather than each inventing one — and a machine running two of one role is refused at
// assignment instead of found by two bars on one screen.
const (
LoginManagerSeat = "node-login-manager"
DisplayServerSeat = "node-display-server"
DisplaySessionSeat = "node-display-session"
TerminalEmulatorSeat = "node-terminal-emulator"
LauncherSeat = "node-launcher"
NotifierSeat = "node-notifier"
LockScreenSeat = "node-lock-screen"
ClipboardSeat = "node-clipboard"
BarSeat = "node-bar"
CompositorSeat = "node-compositor"
SecretServiceSeat = "node-secret-service"
)
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
// research 026/05 starts it with. Three have none yet: the bar, the compositor and the secret
// service are roles a second holder competes for, and nothing has needed to ask them anything.
func graphicalSessionSeats() []Seat {
const decided = "novox/hq ADR 0208"
return []Seat{
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
"one the operator account starts by default.",
Input: schema(map[string]string{}, nil)},
}},
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
"where it is placed; and the layout profile in force, if one matches.",
Input: schema(map[string]string{}, nil)},
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
"identities: list them, save the current arrangement under a name, or apply one.",
Input: withEnum(schema(map[string]string{
"action": "list, save or apply",
"name": "the profile to save or apply (save and apply only)",
}, []string{"action"}), "action", "list", "save", "apply")},
}},
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
// bindings, start-up commands, rules — placed by the session's holder, never written into
// its directory by the contributor.
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
Input: schema(map[string]string{}, nil)},
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
"it is visible or focused.",
Input: schema(map[string]string{}, nil)},
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
"whether it has focus; narrowed to one workspace when named.",
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
}},
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
"or the login shell, in a directory or the account's home.",
Input: schema(map[string]string{
"command": "what to run in it (optional; the login shell when absent)",
"directory": "where it starts (optional; the account's home when absent)",
}, nil)},
}},
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
Input: map[string]any{"type": "object", "required": []string{"choices"},
"properties": map[string]any{
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the lines to choose between, in order"},
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
}}},
}},
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "send", Description: "Show the operator a notification.",
Input: withEnum(schema(map[string]string{
"title": "the notification's summary",
"body": "its text (optional)",
"urgency": "low, normal (the default) or critical",
}, []string{"title"}), "urgency", "low", "normal", "critical")},
{Name: "history", Description: "The notifications shown lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
}},
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "lock", Description: "Lock the operator's session now.",
Input: schema(map[string]string{}, nil)},
}},
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "history", Description: "What the clipboard held lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
{Name: "copy", Description: "Put text on the operator's clipboard.",
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
}},
{Name: BarSeat, Scope: ScopeNode, Decision: decided},
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
}
}
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
// the choices by the schema rather than by a refusal.
func withEnum(s map[string]any, property string, values ...string) map[string]any {
props := s["properties"].(map[string]any)
p := props[property].(map[string]any)
p["enum"] = values
return s
}
@@ -0,0 +1,213 @@
package catalogue
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats.
// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with.
func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
want := map[string][]string{
LoginManagerSeat: {"sessions"},
DisplayServerSeat: {"displays", "layout"},
DisplaySessionSeat: {"reload", "workspaces", "windows"},
TerminalEmulatorSeat: {"open"},
LauncherSeat: {"menu"},
NotifierSeat: {"send", "history"},
LockScreenSeat: {"lock"},
ClipboardSeat: {"history", "copy"},
BarSeat: nil,
CompositorSeat: nil,
SecretServiceSeat: nil,
}
for name, verbs := range want {
s, ok := SeatNamed(name)
if !ok {
t.Errorf("%s is not in the mesh's set", name)
continue
}
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" {
t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision)
}
var got []string
for _, v := range s.Serves {
got = append(got, v.Name)
if v.Description == "" || v.Input["type"] != "object" {
t.Errorf("%s.%s has no description or no object schema", name, v.Name)
}
}
if !reflect.DeepEqual(got, verbs) {
t.Errorf("%s serves %v, want %v", name, got, verbs)
}
}
// The launcher's menu takes a list, and the layout verb says its actions.
menu, _ := SeatNamed(LauncherSeat)
choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any)
if choices["type"] != "array" {
t.Errorf("menu's choices are %v, not a list", choices["type"])
}
display, _ := SeatNamed(DisplayServerSeat)
action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any)
if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) {
t.Errorf("layout's actions are %v", action["enum"])
}
// And they survive the store's JSON, which is where the live set comes from.
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
t.Fatal(err)
}
}
// §2: a module may claim one of them, and may not declare it as its own.
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
t.Fatalf("a module declared node-display-server as its own: %v", err)
}
}
func displayServer(name, display string, claims ...string) Manifest {
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
for _, c := range claims {
m.Claims = append(m.Claims, Claim{Name: c})
}
return m
}
func windowManager() Manifest {
return Manifest{Module: "i3", Requires: []string{"x11-display"}}
}
// §3: a display is resolved on the requiring module's own node.
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat))
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
if err != nil {
t.Fatalf("i3 beside xorg did not resolve: %v", err)
}
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
t.Errorf("resolved %v", names(got))
}
}
// §3: never answered by installing a provider, and never by another machine's.
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
cat := shelf(windowManager(),
displayServer("xorg", "x11-display", DisplayServerSeat),
displayServer("xwayland", "x11-display"))
// Another machine runs xorg and says so to the world; it does not count.
world := World{Offered: map[string][]Provider{
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
if err == nil {
t.Fatal("i3 resolved on a machine with no display of its own")
}
for _, want := range []string{
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
// With a single provider in the catalogue too: one candidate is still not a choice to make
// for somebody, unlike a node-scoped provision without the machine's reach.
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)),
[]string{"i3"}, workstation(), World{})
if err == nil {
t.Fatal("xorg was pulled in for i3")
}
// Not in the first pass, whose refusals take the machine off the network.
if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil {
t.Errorf("the first pass refused: %v", err)
}
}
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
for _, c := range []struct{ provides, want string }{
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
} {
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
}
}
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
if err != nil {
t.Fatal(err)
}
if !m.Provides[0].MachineReach() {
t.Fatal("the reach was not read")
}
back, _ := json.Marshal(m.Provides[0])
if string(back) != `{"name":"x11-display","reach":"machine"}` {
t.Errorf("written back as %s", back)
}
}
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
t.Fatalf("a provision with and without the machine's reach gave %v", err)
}
}
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
Resources: []map[string]any{
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
"content": "${shell:xresources:normal}"},
}}
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
theme := Manifest{Module: "theme", Shell: []ShellCode{
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
{For: "zsh", Slot: "normal", Code: "not for the session"},
}}
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
by := map[string]any{}
for _, res := range out {
by[res["id"].(string)] = res["content"]
}
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
t.Errorf("the .xinitrc is %q", got)
}
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
t.Errorf("the .Xresources is %q", got)
}
// The contributions parse; the placeholders parse only in the holder.
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
t.Fatalf("a session contribution was refused: %v", err)
}
for _, c := range []struct{ claims, content, want string }{
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
} {
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
}
}
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
t.Errorf("the display server's holder could not place the session's slots: %v", err)
}
}
+141 -4
View File
@@ -147,8 +147,17 @@ type Offer struct {
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key // 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. // cannot give each consumer a login of its own. The named secret must say how it is taken.
Credential *OfferCredential `json:"credential,omitempty"` Credential *OfferCredential `json:"credential,omitempty"`
// Reach is ReachMachine for a provision usable only on the provider's own machine — a display
// (novox/hq ADR 0208 §3). Node scope already keeps a provision off other machines; what this adds
// is that a requirement for it is never answered by installing a provider: the display server is
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
Reach string `json:"reach,omitempty"`
} }
// MachineReach is whether a provision is usable only on its provider's own machine.
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
// OfferCredential names which of the provider's own secrets a provision's consumers receive. // OfferCredential names which of the provider's own secrets a provision's consumers receive.
type OfferCredential struct { type OfferCredential struct {
Own string `json:"own"` Own string `json:"own"`
@@ -196,27 +205,29 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
Credential *OfferCredential `json:"credential,omitempty"` Credential *OfferCredential `json:"credential,omitempty"`
Reach string `json:"reach,omitempty"`
} }
dec := json.NewDecoder(bytes.NewReader(raw)) dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil { if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err) return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err)
} }
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
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 == "" && o.Credential == nil { if o.Scope == "" && o.Credential == nil && o.Reach == "" {
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,omitempty"` Scope string `json:"scope,omitempty"`
Credential *OfferCredential `json:"credential,omitempty"` Credential *OfferCredential `json:"credential,omitempty"`
}{o.Name, o.Scope, o.Credential}) Reach string `json:"reach,omitempty"`
}{o.Name, o.Scope, o.Credential, o.Reach})
} }
// Manifest is everything a module says about itself. // Manifest is everything a module says about itself.
@@ -325,6 +336,15 @@ type Manifest struct {
// person's account (design 25 §7) already had the same shape. // person's account (design 25 §7) already had the same shape.
Invokes []string `json:"invokes,omitempty"` Invokes []string `json:"invokes,omitempty"`
// State is the current state this module keeps on the bus, by local name: each a key-value
// bucket the controller creates, which every instance of the module writes and reads
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
State []StateDeclaration `json:"state,omitempty"`
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
Reads []string `json:"reads,omitempty"`
// Capabilities the machine must have. A different field from Requires because the remedy // Capabilities the machine must have. A different field from Requires because the remedy
// differs: a missing module can be assigned, and a missing capability means the wrong // differs: a missing module can be assigned, and a missing capability means the wrong
// machine. // machine.
@@ -508,6 +528,22 @@ type Manifest struct {
// holder. Like Filtering: one module per node gathers what every module declared and writes it. // holder. Like Filtering: one module per node gathers what every module declared and writes it.
Jailing *Jailing `json:"jailing,omitempty"` Jailing *Jailing `json:"jailing,omitempty"`
// Environment is what this module adds to the operator account's environment: variables, and
// entries on PATH (novox/hq ADR 0203). Facts, not lines of one shell's syntax — the holder of
// node-environment places them, and the controller writes them in each reader's format. Like
// Jails: any module contributes, gathered from every module on the node, written by the holder.
Environment *Environment `json:"environment,omitempty"`
// Shell is code this module adds to the login shell's startup, for a named shell in a named
// slot (novox/hq ADR 0204). The controller never reads it: it is placed, in module order, where
// the holder of node-login-shell put the slot's placeholder.
Shell []ShellCode `json:"shell,omitempty"`
// Contributions are configuration this module gives the holder of a seat it does not hold, in
// that tool's own grammar (novox/hq ADR 0212): a seat, a kind the seat receives, and the text. The
// holder places them; the module depends on the seat (ADR 0210 §3).
Contributions []SeatContribution `json:"contributions,omitempty"`
// Guards are ports of this module's the mesh refuses on an adopted node except from the // Guards are ports of this module's the mesh refuses on an adopted node except from the
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
// broker's management port. The ports the software uses; the mesh guards where the machine // broker's management port. The ports the software uses; the mesh guards where the machine
@@ -1297,6 +1333,19 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s provides %q at scope %q; a provision is %q or %q", "%s provides %q at scope %q; a provision is %q or %q",
m.Module, p, s, ScopeNode, ScopeMesh)) m.Module, p, s, ScopeNode, ScopeMesh))
} }
switch {
case offer.Reach == "":
case offer.Reach != ReachMachine:
// The one reach a provision has (novox/hq ADR 0208): a provision reached over the private
// network is mesh scope, and the world reaches nothing but a name.
problems = append(problems, fmt.Sprintf(
"%s provides %q with reach %q; a provision's reach is %q or nothing",
m.Module, p, offer.Reach, ReachMachine))
case offer.At() != ScopeNode:
problems = append(problems, fmt.Sprintf(
"%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+
"machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At()))
}
if p == m.Module { if p == m.Module {
// Harmless and worth saying: a module always provides its own name, so writing it // Harmless and worth saying: a module always provides its own name, so writing it
// suggests the author expected it not to. // suggests the author expected it not to.
@@ -1316,6 +1365,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
// module whose event names are wrong installs, starts, connects and reacts to nothing, with // module whose event names are wrong installs, starts, connects and reacts to nothing, with
// every log line saying it is fine (novox/hq 04-ISSUES/127). // every log line saying it is fine (novox/hq 04-ISSUES/127).
problems = append(problems, EventProblems(m)...) problems = append(problems, EventProblems(m)...)
// And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201).
problems = append(problems, StateProblems(m)...)
wellFormed := true wellFormed := true
for _, c := range m.Claims { for _, c := range m.Claims {
if !name.MatchString(c.Name) { if !name.MatchString(c.Name) {
@@ -1428,6 +1479,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s serves %q to whoever requires it, and does not provide it", m.Module, to)) "%s serves %q to whoever requires it, and does not provide it", m.Module, to))
} }
} }
// A served value may be derived for the consumer it is served to (novox/hq ADR 0201). Read
// here, where the definition is, rather than when somebody first requires it: a rule that
// would be refused at the first consumer is wrong from the moment it is written.
problems = append(problems, CheckServes(m)...)
for to, where := range m.Binds { for to, where := range m.Binds {
if !placedOrAbsolute(where) { if !placedOrAbsolute(where) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
@@ -1622,6 +1677,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
} }
// **A scheduled step may hold this module's own containers still while it runs**
// (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each
// id is a container placed on that machine; what belongs here is what only the definition
// shows: that the ids are this module's, that they are containers, and that the step is
// scheduled. A module naming a neighbour's container would be a module that can stop the
// mesh, and the manifest is where that is visible.
problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...)
} }
for name, own := range m.OwnSecrets { for name, own := range m.OwnSecrets {
if !placedOrAbsolute(own.Path) { if !placedOrAbsolute(own.Path) {
@@ -1783,6 +1845,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.unknownDirRefs()...) problems = append(problems, m.unknownDirRefs()...)
problems = append(problems, m.unknownAccessRefs()...) problems = append(problems, m.unknownAccessRefs()...)
problems = append(problems, m.jailProblems()...) problems = append(problems, m.jailProblems()...)
// What a module adds to the account's environment and to the login shell, and the holder's
// placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses
// them in the words registration does.
problems = append(problems, m.environmentProblems()...)
problems = append(problems, m.shellProblems()...)
problems = append(problems, m.contributionPlaceholderProblems()...)
problems = append(problems, m.seatContributionProblems()...)
for i, r := range m.Resources { for i, r := range m.Resources {
id, _ := r["id"].(string) id, _ := r["id"].(string)
@@ -2153,3 +2222,71 @@ func (o OwnSecrets) Paths() map[string]string {
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same // InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
// on every machine, so any instance may answer for the module. // on every machine, so any instance may answer for the module.
const InstancesInterchangeable = "interchangeable" const InstancesInterchangeable = "interchangeable"
// WhileStopped is the resource key naming the containers a scheduled step holds still while it
// runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`.
const WhileStopped = "while-stopped"
// hasSchedule is whether a resource declares a cadence, as a string.
func hasSchedule(r map[string]any) bool {
s, _ := r["schedule"].(string)
return s != ""
}
// whileStoppedProblems judges one container's maintenance window against its own definition
// (novox/hq ADR 0189).
//
// Three things the manifest is the only place to see: that the step is scheduled (a one-time
// offline job says *before* rather than *instead of* — at apply the host already has a window,
// because the declaration is applied in order and a run-once step gates what follows); that every
// id it names is **this module's own** container; and that it does not name itself.
//
// The host checks the fourth — that the container is actually placed on that machine — because
// that is a fact about the declaration and not about the definition.
func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string {
raw, present := r[WhileStopped]
if !present {
return nil
}
ids, ok := raw.([]any)
if !ok {
return []string{fmt.Sprintf(
"%s declares %s on %v as a %T; it is a list of this module's container ids",
m.Module, WhileStopped, r["id"], raw)}
}
var problems []string
if len(ids) > 0 && !scheduled {
problems = append(problems, fmt.Sprintf(
"%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+
"step: at apply the mesh already has one, because a run-once step gates what is "+
"declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"]))
}
containers := map[string]bool{}
for _, own := range m.Resources {
if fmt.Sprint(own["type"]) == "container" {
containers[fmt.Sprint(own["id"])] = true
}
}
for _, each := range ids {
id, ok := each.(string)
if !ok {
problems = append(problems, fmt.Sprintf(
"%s declares %s on %v naming a %T; each entry is a container's id",
m.Module, WhileStopped, r["id"], each))
continue
}
if id == fmt.Sprint(r["id"]) {
problems = append(problems, fmt.Sprintf(
"%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"]))
continue
}
if !containers[id] {
problems = append(problems, fmt.Sprintf(
"%s declares %s on %v naming %q, which is not a container this module declares. "+
"A step may hold still its own module's containers and nobody else's — one "+
"that could quiesce a neighbour could stop the mesh",
m.Module, WhileStopped, r["id"], id))
}
}
return problems
}
@@ -0,0 +1,75 @@
package catalogue
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A container publishes only a port its module declares (novox/hq issue 227).
//
// **The short form is a question the mesh answers.** `"80"` means *publish what the software
// calls 80*, and the mesh fills in the machine's half from the port it assigned
// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a
// container publishing a number that appears nowhere in `listens` gets no assignment, and
// `publishedOn` falls back to the number as written. It escapes to the machine.
//
// That is how the photo module asked for port 80 on the control node, where the reverse proxy
// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never
// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh
// gives them a machine port for it. The difference is the declaration, not the number.
//
// A mapping written the long way is a module pinning both halves on purpose and is left alone.
func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) {
root := catalogueRoot(t)
entries, err := os.ReadDir(filepath.Join(root, "modules"))
if err != nil {
t.Fatal(err)
}
var escaped []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json"))
if err != nil {
continue
}
m, err := ParseManifest(raw)
if err != nil {
// Whether every manifest parses is TestEveryCatalogueManifestParses's question.
continue
}
declared := map[int]bool{}
for _, l := range m.Listens {
declared[l.Port] = true
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
listed, _ := r["ports"].([]any)
for _, p := range listed {
written := strings.Split(fmt.Sprint(p), "/")[0]
if strings.Contains(written, ":") {
continue // pinned by hand, both halves, on purpose
}
port, err := strconv.Atoi(strings.TrimSpace(written))
if err != nil || declared[port] {
continue
}
escaped = append(escaped, fmt.Sprintf(
"%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+
"to assign, so %d reaches the machine as written",
m.Module, r["id"], port, m.Module, port))
}
}
}
if len(escaped) > 0 {
t.Fatalf("a container may publish only a port its module declares:\n - %s",
strings.Join(escaped, "\n - "))
}
}
+115
View File
@@ -147,6 +147,10 @@ type Resolution struct {
// dropped nor fatal to the rest. A module that is *required* by something running here is a // dropped nor fatal to the rest. A module that is *required* by something running here is a
// different case — that set is incoherent and is refused (see checkCapabilities). // different case — that set is incoherent and is refused (see checkCapabilities).
Unhostable []Unhostable Unhostable []Unhostable
// Unheld is every dependency of this node's modules on a seat nothing here holds (novox/hq ADR
// 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a
// holder still converges and `status` says what it is short of.
Unheld []Unheld
} }
// Unhostable is one directly-assigned module the machine cannot run. // Unhostable is one directly-assigned module the machine cannot run.
@@ -229,6 +233,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
local[o.Name] = true local[o.Name] = true
} }
} }
// Which names are usable only on their provider's own machine (novox/hq ADR 0208 §3). Also a
// property of the name: a display one provider says is the machine's and another says is not
// would be pulled in for one consumer and refused for the next.
machineReach := map[string]bool{}
plainLocal := map[string]bool{}
for _, m := range catalogue {
for _, o := range m.Provides {
if o.MachineReach() {
machineReach[o.Name] = true
} else if o.At() == ScopeNode {
plainLocal[o.Name] = true
}
}
}
for want := range machineReach {
if plainLocal[want] {
problems = append(problems, fmt.Sprintf(
"the catalogue disagrees about %q: some modules provide it with the machine's reach and "+
"others without, so a requirement for it would be met differently by each", want))
}
}
for want := range brokered { for want := range brokered {
if local[want] { if local[want] {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
@@ -495,6 +520,23 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
continue continue
} }
// Usable only on its provider's own machine, and not here: refused, never answered by
// installing a provider (novox/hq ADR 0208 §3). The display server is the machine's own role,
// gated by its graphical session; one pulled in for a window manager is the misassignment
// research 026 found. Another node's provider never counts — node scope is never brokered.
if machineReach[want] && !isModule(catalogue, want) {
reported[want] = true
if world.Unchecked {
// The first pass's refusals take a machine off the network; the second says it.
continue
}
problems = append(problems, fmt.Sprintf(
"%q is wanted by %s and is usable only on the machine that provides it, and nothing "+
"assigned to %s does — %s", want, because[want], node.Name,
machineReachRemedy(catalogue, want, node.Name)))
continue
}
candidates := offers[want] candidates := offers[want]
switch len(candidates) { switch len(candidates) {
case 0: case 0:
@@ -628,6 +670,22 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
problems = append(problems, checkResources(resolution.Modules)...) problems = append(problems, checkResources(resolution.Modules)...)
resolution.Claims = claims resolution.Claims = claims
// Judged over the closure — what this node will actually run — so a holder pulled in by a
// requirement counts, and the holders' mutual dependence resolves (novox/hq ADR 0207 §3).
// Reported until the switch; refused after it, though never in the first pass, whose refusals
// make a machine vanish from the network rather than report anything.
resolution.Unheld = UnheldDependencies(catalogue, node.Name, resolution.Modules, nil)
// Only a dependency some module in the catalogue could meet is refused: one with no possible
// holder has no remedy to name, and stays a report in `status` (novox/hq ADR 0207 §4, read with
// the assign rule above it).
if enforceSeatDependencies && !world.Unchecked {
for _, u := range resolution.Unheld {
if len(u.Holders) > 0 {
problems = append(problems, u.String())
}
}
}
if len(problems) > 0 { if len(problems) > 0 {
sort.Strings(problems) sort.Strings(problems)
return Resolution{}, &Refusal{Problems: problems} return Resolution{}, &Refusal{Problems: problems}
@@ -804,6 +862,28 @@ func checkResources(modules []Manifest) []string {
// does not exist is the manifest's own problem, refused where it was made. // does not exist is the manifest's own problem, refused where it was made.
dirs := dirsFor(m, Rendering{}) dirs := dirsFor(m, Rendering{})
for _, r := range m.Resources { for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "user" {
// **An account is shared; what it is set to is not.** Several modules may need one
// login: the shell's module sets its shell, the container runtime's puts it in the
// `docker` group. The host only ever adds groups — it never takes the account out of
// one, not even when the resource that named it is undeclared — so groups from
// several modules cannot contradict each other and are not owned. A shell or a home
// is one value, and two modules setting it would each be undone by the other's
// apply: each stays one module's per node, and two are refused naming both.
name, _ := r["name"].(string)
for _, field := range []string{"shell", "home"} {
if v, ok := r[field].(string); !ok || v == "" || name == "" {
continue
}
key := "user " + field + " " + name
if other, taken := owner[key]; taken && other != m.Module {
problems = append(problems, fmt.Sprintf(
"%s and %s both set the %s of the user %q", other, m.Module, field, name))
}
owner[key] = m.Module
}
continue
}
for _, field := range []string{"path", "unit", "name", "package"} { for _, field := range []string{"path", "unit", "name", "package"} {
value, ok := r[field].(string) value, ok := r[field].(string)
if !ok || value == "" { if !ok || value == "" {
@@ -1019,3 +1099,38 @@ func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed
} }
return needs return needs
} }
// machineReachRemedy names what would meet a requirement with the machine's reach: every module in
// the catalogue that provides it, each with the node seats it holds — for a display, the holders of
// node-display-server (novox/hq ADR 0208 §3), named by the seat because that is the role being
// asked for, without this code knowing which seat any provision belongs to.
func machineReachRemedy(catalogue map[string]Manifest, want, node string) string {
var named []string
for _, name := range sortedKeys(catalogue) {
m := catalogue[name]
provides := false
for _, o := range m.Provides {
if o.Name == want {
provides = true
}
}
if !provides {
continue
}
var held []string
for _, c := range m.Claims {
if c.At() == ScopeNode {
held = append(held, c.Name)
}
}
if len(held) > 0 {
named = append(named, fmt.Sprintf("%s (holds %s)", name, strings.Join(held, ", ")))
} else {
named = append(named, name)
}
}
if len(named) == 0 {
return "and nothing in the catalogue provides it"
}
return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; "))
}
+156
View File
@@ -0,0 +1,156 @@
package catalogue
import (
"fmt"
"regexp"
"strings"
)
// A module contributes to a seat it does not hold (novox/hq ADR 0212).
//
// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a
// contribution to the seat. The environment, the shell's slots and the power moments each became a
// field of their own; this is the general form, so a new seat that takes contributions is a row in
// the seat table rather than a change to the manifest: a contribution names a seat, a kind that
// seat receives, and text in the tool's own grammar, which the controller never reads.
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
const HotkeysSeat = "node-hotkeys"
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
type SeatContribution struct {
// Seat is the seat whose holder places it.
Seat string `json:"seat"`
// Kind is which of the seat's receivable kinds it is.
Kind string `json:"kind"`
// Content is the text, in the tool's own grammar. Never interpreted.
Content string `json:"content"`
}
// ofContribution is where a holder places a kind: ${contribution:<seat>:<kind>}. Loose inside the
// braces, so a misspelt seat or kind is found and refused rather than written out as text.
var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`)
// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat
// is unknown or does not receive it.
func receivable(seat, kind string) (Seat, Receivable, bool) {
s, known := SeatNamed(seat)
if !known {
return Seat{}, Receivable{}, false
}
for _, r := range s.Receives {
if r.Kind == kind {
return s, r, true
}
}
return s, Receivable{}, false
}
// kindsOf names a seat's receivable kinds for a refusal.
func kindsOf(s Seat) string {
if len(s.Receives) == 0 {
return "it receives no contributions"
}
var kinds []string
for _, r := range s.Receives {
kinds = append(kinds, r.Kind)
}
return "it receives " + strings.Join(kinds, ", ")
}
// seatContributionProblems is what is wrong with this module's contributions, from the manifest
// alone (novox/hq ADR 0212 §2).
func (m Manifest) seatContributionProblems() []string {
var problems []string
for i, c := range m.Contributions {
s, _, ok := receivable(c.Seat, c.Kind)
switch {
case s.Name == "":
problems = append(problems, fmt.Sprintf(
"%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat))
case !ok:
problems = append(problems, fmt.Sprintf(
"%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)",
m.Module, i+1, s.Name, c.Kind, kindsOf(s)))
}
if strings.TrimSpace(c.Content) == "" {
problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1))
}
}
return problems
}
// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a
// file's content, naming a seat and a kind it receives, and only by a module that claims that seat
// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3).
func seatPlaceholderProblems(m Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
found := ofContribution.FindAllStringSubmatch(v, -1)
if len(found) == 0 {
continue
}
if field != "content" {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s in its %s; contributions are placed only in a file's content",
m.Module, r["id"], found[0][0], field))
continue
}
for _, f := range found {
seat, kind, two := strings.Cut(f[1], ":")
s, _, ok := receivable(seat, kind)
if !two || !ok {
detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat)
if s.Name != "" {
detail = s.Name + ": " + kindsOf(s)
}
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s; a contribution is ${contribution:<seat>:<kind>} (%s)",
m.Module, r["id"], f[0], detail))
continue
}
if !m.ClaimsSeat(s.Name) {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+
"seat are placed by its holder alone (novox/hq ADR 0212)",
m.Module, r["id"], f[0], m.Module, s.Name))
}
}
}
return problems
}
// seatContributions is every module's contribution of one kind to one seat (novox/hq ADR 0212 §3):
// in module order, each module's pieces in the order it declared them, each module's preceded by a
// comment line naming it in the tool's grammar, and empty when nothing is contributed.
func seatContributions(modules []Manifest, seat, kind string) string {
s, r, ok := receivable(seat, kind)
if !ok {
return ""
}
var b strings.Builder
for _, m := range inModuleOrder(modules) {
named := false
for _, c := range m.Contributions {
if c.Kind != kind {
continue
}
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
continue
}
if !named {
fmt.Fprintf(&b, "%s %s\n", r.Comment, m.Module)
named = true
}
b.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
b.WriteString("\n")
}
}
}
return b.String()
}
@@ -0,0 +1,117 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0212: a seat says what it receives, its holder places it, and a contribution
// depends on the seat.
func hotkeysHolder() Manifest {
return Manifest{Module: "triggerhappy", Claims: []Claim{{Name: HotkeysSeat}}, Resources: []map[string]any{
{"id": "triggers", "type": "file", "path": "/etc/triggerhappy/triggers.d/mesh.conf",
"content": "# the mesh's triggers\n${contribution:node-hotkeys:trigger}"},
}}
}
func TestAContributionReachesTheHoldersFileInModuleOrderNamedByModule(t *testing.T) {
laptop := Manifest{Module: "laptop", Contributions: []SeatContribution{
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_PROG1 1 play ${machine:account-home}"},
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"},
}}
another := Manifest{Module: "another", Contributions: []SeatContribution{
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute ${shell:zsh:first}"},
}}
unrelated := Manifest{Module: "bar", Contributions: []SeatContribution{
{Seat: DisplaySessionSeat, Kind: "config", Content: "bar { }"},
}}
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder(), laptop, another, unrelated}}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
var file map[string]any
for _, res := range out {
if res["id"] == "triggerhappy.triggers" {
file = res
}
}
if file == nil {
t.Fatalf("the holder's file was not composed: %v", out)
}
// Module order; each module named once; text never read, so neither ${machine:…} nor ${shell:…}
// inside a contribution is filled.
want := "# the mesh's triggers\n" +
"# another\nKEY_MUTE 1 mute ${shell:zsh:first}\n" +
"# laptop\nKEY_PROG1 1 play ${machine:account-home}\nKEY_F21 1 touchpad\n"
if got := file["content"]; got != want {
t.Fatalf("the holder's file is\n%s\nnot\n%s", got, want)
}
}
func TestNoContributionPlacesNothing(t *testing.T) {
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder()}}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
for _, res := range out {
if res["id"] == "triggerhappy.triggers" && res["content"] != "# the mesh's triggers\n" {
t.Fatalf("an empty kind left %q", res["content"])
}
}
}
func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) {
cases := map[string]string{
`{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger",
`{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define",
`{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content",
`{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config",
}
for c, want := range cases {
raw := `{"module":"laptop","contributions":[` + c + `]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
}
}
}
func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") {
t.Errorf("a placeholder outside the holder was accepted: %v", err)
}
raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") {
t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err)
}
}
func TestAContributionDependsOnItsSeat(t *testing.T) {
m := Manifest{Module: "laptop", Contributions: []SeatContribution{
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
}}
if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
t.Errorf("depends on %v, want %v", got, want)
}
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"laptop"}); err == nil ||
!strings.Contains(err.Error(), HotkeysSeat) {
t.Errorf("a contributor without the holder was accepted: %v", err)
}
}
func TestTheHotkeysSeatIsTheMeshsAndReceivesTriggers(t *testing.T) {
s, ok := SeatNamed(HotkeysSeat)
if !ok || s.Scope != ScopeNode || len(s.Receives) != 1 || s.Receives[0].Kind != "trigger" {
t.Fatalf("%+v %v", s, ok)
}
d, _ := SeatNamed(DisplaySessionSeat)
if len(d.Receives) != 1 || d.Receives[0].Kind != "config" {
t.Fatalf("the display session receives %+v", d.Receives)
}
}
+349
View File
@@ -0,0 +1,349 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), and on the
// seats it contributes to (novox/hq ADR 0210).
//
// Some of what a module declares is applied through software on the machine that is itself a
// module: a service through the service manager, a package through the package manager, a container
// through the container runtime. A *capability* only says that software is installed; it does not
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
// the resources — never stated in a manifest, because a module that adds a service and forgets a
// field would pass — and is met when some module assigned to the same node holds the seat.
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
// the seed and the messages all turn on these strings.
const (
ServiceManagerSeat = "node-service-manager"
PackageManagerSeat = "node-package-manager"
ContainerRuntimeSeat = "node-container-runtime"
)
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
// names them and no more. A process is supervised by the host itself, a file, a directory, an
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
// without a role in between — adding one here is a decision, not a refinement.
var appliedThrough = map[string]string{
"service": ServiceManagerSeat,
"package": PackageManagerSeat,
"container": ContainerRuntimeSeat,
}
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
// which is when the three holders are assigned to every node: switching it before then would stop
// every machine lacking one from being sent anything at all.
//
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
//
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
var enforceSeatDependencies = true
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
// packages that hold the behaviour from before the switch; nothing else calls it.
func EnforcingSeatDependencies(on bool) (restore func()) {
was := enforceSeatDependencies
enforceSeatDependencies = on
return func() { enforceSeatDependencies = was }
}
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
// the host and the private network. Registered as modules so they can be assigned, but what they
// declare is the installation's, not a module's, so it is never judged. The third piece, the
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
// bundle the host applies itself, and never pass through a resolution here.
//
// The private network's module is overlay.Name, written out because the overlay package composes
// on top of this one; its resources are computed, which exempts it by the rule below as well.
var foundationModules = map[string]bool{
"mesh-host": true,
"mesh-wireguard": true,
}
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
// module whose resources are computed is the mesh's by construction — the private network's peer
// list and its tools are the controller's, written per node — so it counts whatever its name.
func isFoundation(m Manifest) bool {
return foundationModules[m.Module] || m.Computed != ""
}
// DependsOn is every seat a module needs held on its node, derived from the resource types it
// declares itself (novox/hq ADR 0207 §2), sorted.
//
// **The module's own `resources` only.** What the controller composes around a module — its
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
// module is assigned, and depending on it would make the module answer for the mesh's choices.
func DependsOn(m Manifest) []string {
if isFoundation(m) {
return nil
}
seen := map[string]bool{}
for _, r := range m.Resources {
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
seen[seat] = true
}
}
for _, seat := range contributedTo(m) {
seen[seat] = true
}
out := make([]string, 0, len(seen))
for s := range seen {
out = append(out, s)
}
sort.Strings(out)
return out
}
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
// resource is on the seat that applies it. The environment goes to node-environment's holder
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
func contributedTo(m Manifest) []string {
var out []string
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
out = append(out, EnvironmentSeat)
}
for _, c := range m.Shell {
out = append(out, placerOf(c.For))
}
// Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the
// mesh does not define is refused at registration and depends on nothing here.
for _, c := range m.Contributions {
if s, known := SeatNamed(c.Seat); known {
out = append(out, s.Name)
}
}
return out
}
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
// (ADR 0122), so a rename leaves the dependency met.
func claimsSeat(m Manifest, seat string) bool {
for _, c := range m.Claims {
if c.At() != ScopeNode {
continue
}
name := c.Name
if s, known := SeatNamed(name); known {
name = s.Name
}
if name == seat {
return true
}
}
return false
}
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
// refusal names as the remedy.
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
var out []string
for name, m := range catalogue {
if claimsSeat(m, seat) {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// Unheld is one dependency of one module on a node that nothing on that node holds.
type Unheld struct {
Node string `json:"node"`
Module string `json:"module"`
Seat string `json:"seat"`
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
Holders []string `json:"holders"`
}
// String is the line a refusal and a report both say, so the two never drift.
func (u Unheld) String() string {
remedy := "and no module in the catalogue claims it yet"
if len(u.Holders) > 0 {
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
}
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
u.Module, u.Node, u.Seat, u.Node, remedy)
}
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
// leaves unmet (novox/hq ADR 0207 §3).
//
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
// the service manager's own package needs the package manager, and the package manager's timer
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
// two assigned together meet each other. A module holding a seat it depends on meets its own
// dependency. `judged` nil judges every module of the set.
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
held := map[string]bool{}
for _, m := range set {
for _, seat := range nodeSeatsClaimed(m) {
held[seat] = true
}
}
var out []Unheld
for _, m := range set {
if judged != nil && !judged[m.Module] {
continue
}
for _, seat := range DependsOn(m) {
if held[seat] {
continue
}
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
Holders: PossibleHolders(catalogue, seat)})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].Seat < out[j].Seat
})
return out
}
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
func nodeSeatsClaimed(m Manifest) []string {
var out []string
for _, c := range m.Claims {
if c.At() != ScopeNode {
continue
}
name := c.Name
if s, known := SeatNamed(name); known {
name = s.Name
}
out = append(out, name)
}
return out
}
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
// know contributes nothing, as it does to a resolution.
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
var out []Manifest
seen := map[string]bool{}
for _, n := range names {
if m, known := catalogue[n]; known && !seen[n] {
seen[n] = true
out = append(out, m)
}
}
return out
}
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
// included, leave unmet.
//
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
//
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
// module that would meet it; with none registered there is no remedy to name, and refusing would
// stop every assignment of that kind until a module that does not exist yet is written. The answer
// still says it, and `status` reports it — before the switch and after it alike: there is never a
// remedy to name for it. The first return is those lines.
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
judged := map[string]bool{}
for _, a := range adding {
judged[a] = true
}
var refused, said []string
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
if len(u.Holders) == 0 {
said = append(said, u.String())
continue
}
refused = append(refused, u.String())
}
if len(refused) > 0 {
return said, &Refusal{Problems: append(refused,
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
}
return said, nil
}
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
// a module left there depends on it. Names the dependents, because they are what must go first —
// or the holder's replacement come.
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
gone := map[string]bool{}
for _, r := range removing {
gone[r] = true
}
var left []string
for _, a := range assigned {
if !gone[a] {
left = append(left, a)
}
}
before := map[string]bool{}
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
before[u.Module+"\x00"+u.Seat] = true
}
dependents := map[string][]string{}
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
if before[u.Module+"\x00"+u.Seat] {
continue // unmet already; not this removal's doing
}
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
}
if len(dependents) == 0 {
return nil
}
seats := make([]string, 0, len(dependents))
for s := range dependents {
seats = append(seats, s)
}
sort.Strings(seats)
var problems []string
for _, s := range seats {
problems = append(problems, fmt.Sprintf(
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
"or assign another holder first", strings.Join(removing, ", "), s, node,
strings.Join(dependents[s], ", ")))
}
return &Refusal{Problems: problems}
}
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
//
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
// A collision is not an order anything can complete — no further assignment makes two owners of one
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
// it from the mesh. Only collisions involving a module being added are refused; one already on the
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
before := map[string]bool{}
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
before[p] = true
}
var problems []string
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
if before[p] {
continue // on the node already; not this assignment's doing
}
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
"depends on the owner's seat instead)")
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
}
@@ -0,0 +1,247 @@
package catalogue
import (
"errors"
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources.
func res(kind, id string) map[string]any {
r := map[string]any{"id": id, "type": kind}
switch kind {
case "service":
r["unit"] = id + ".service"
case "package":
r["package"] = id
case "container":
r["image"] = id
case "file":
r["path"] = "/etc/" + id
}
return r
}
func withResources(m Manifest, rs ...map[string]any) Manifest {
m.Resources = rs
return m
}
// The three holders as to-be 42 names them, each declaring what it really does: systemd's own
// package needs the package manager, pacman's timer needs the service manager, docker's package and
// service need both.
func coreThree() []Manifest {
return []Manifest{
withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")),
withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")),
withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}),
res("package", "docker"), res("service", "docker")),
}
}
func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) {
cases := map[string][]string{
"service": {ServiceManagerSeat},
"package": {PackageManagerSeat},
"container": {ContainerRuntimeSeat},
// The host's own acts, or the mesh's: nothing in between holds a role for them.
"file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil,
"action": nil, "network": nil, "access": nil,
}
for kind, want := range cases {
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, want) {
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
}
}
all := DependsOn(withResources(mod("m", nil, nil, nil),
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
}
}
func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} {
s, ok := SeatNamed(name)
if !ok {
t.Fatalf("%s is not in the mesh's set", name)
}
if s.Scope != ScopeNode {
t.Errorf("%s is held per %s, want per node", name, s.Scope)
}
}
// No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and
// the runtime's verbs wait for ADR 0166's acceptance.
for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} {
if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 {
t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name)
}
}
}
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
cat := shelf(append(coreThree(), web)...)
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if len(got.Unheld) != 0 {
t.Errorf("a node holding all three seats reports %v", got.Unheld)
}
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
t.Errorf("assigning beside the three holders was refused: %v", err)
}
}
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
cat := shelf(append(coreThree(), web)...)
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
var refusal *Refusal
if !errors.As(err, &refusal) {
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
}
msg := err.Error()
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
if !strings.Contains(msg, want) {
t.Errorf("the refusal does not say %q:\n%s", want, msg)
}
}
// What the node does hold is not named as missing.
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
}
}
func TestADependencyNoCatalogueModuleCanMeetIsSaidNeverRefused(t *testing.T) {
// No runtime module in the catalogue: refusing would stop every container's assignment until one
// is written, with no remedy to name — so it is said, with the switch on as with it off.
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
cat := shelf(web)
for _, on := range []bool{false, true} {
enforceSeatDependencies = on
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
if err != nil {
t.Fatalf("switch %v: a dependency nothing could meet was refused: %v", on, err)
}
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
t.Errorf("switch %v: the assignment does not say what it depends on: %v", on, said)
}
if _, err := Resolve(cat, []string{"web"}, workstation(), World{}); err != nil {
t.Errorf("switch %v: a dependency nothing could meet refused the node: %v", on, err)
}
}
enforceSeatDependencies = true
}
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
cat := shelf(coreThree()...)
// Alone, each needs the other.
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
!strings.Contains(err.Error(), "pacman") {
t.Errorf("systemd alone was not refused naming pacman: %v", err)
}
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
!strings.Contains(err.Error(), "systemd") {
t.Errorf("pacman alone was not refused naming systemd: %v", err)
}
// Together, in one act, they meet each other — and docker meets its own seat.
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
t.Errorf("the three holders assigned together were refused: %v", err)
}
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if len(got.Unheld) != 0 {
t.Errorf("systemd and pacman together report %v", got.Unheld)
}
}
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
// Before the switch (the state the mesh ran in until every node held the three seats).
enforceSeatDependencies = false
defer func() { enforceSeatDependencies = true }()
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
cat := shelf(append(coreThree(), web)...)
got, err := Resolve(cat, []string{"web"}, workstation(), World{})
if err != nil {
t.Fatalf("an unmet dependency refused the node before the switch: %v", err)
}
want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}}
if !reflect.DeepEqual(got.Unheld, want) {
t.Errorf("reported %+v, want %+v", got.Unheld, want)
}
}
func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
enforceSeatDependencies = true
defer func() { enforceSeatDependencies = true }()
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
cat := shelf(append(coreThree(), web)...)
_, err := Resolve(cat, []string{"web"}, workstation(), World{})
if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") {
t.Fatalf("with the switch on, an unmet dependency gave %v", err)
}
// Never in the first pass, whose refusals take a machine off the network instead.
if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil {
t.Errorf("the first pass refused an unmet dependency: %v", err)
}
}
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
// The private network, as the controller computes it: its tools' package and its service are
// the mesh's, written per node, and so are never a module's dependency.
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
res("package", "wireguard-tools"), res("service", "overlay-up"))
network.Computed = "mesh-wireguard"
// The host, whatever it declares.
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
cat := shelf(append(coreThree(), network, host)...)
for _, m := range []Manifest{network, host} {
if d := DependsOn(m); len(d) != 0 {
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
}
}
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if len(got.Unheld) != 0 {
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
}
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
t.Errorf("assigning the foundation was refused: %v", err)
}
}
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
cat := shelf(append(coreThree(), sshd)...)
on := []string{"systemd", "pacman", "docker", "sshd"}
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
if err == nil {
t.Fatal("the last service manager came off a node still running services")
}
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q:\n%v", want, err)
}
}
// A dependent comes off freely, and the holders with everything depending on them in one act.
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
t.Errorf("a dependent's unassignment was refused: %v", err)
}
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
t.Errorf("unassigning everything together was refused: %v", err)
}
}
+66 -3
View File
@@ -40,16 +40,28 @@ type Seat struct {
// Serves carries each verb in full — name, description, schema — because a role's tools are the // 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). // mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
Serves []Verb Serves []Verb
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
// 0212): each kind is text in the tool's own grammar, placed by the holder with
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
// definition of the role, and the store's rows carry no column for it.
Receives []Receivable
// Decision is the record that made it a seat. // Decision is the record that made it a seat.
Decision string Decision string
} }
// Receivable is one kind of contribution a seat receives (novox/hq ADR 0212 §2): its name, and the
// comment prefix of the tool's grammar, with which the controller names each contributing module.
type Receivable struct {
Kind string
Comment string
}
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is // fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
// written; the store's table is seeded from it and thereafter is the live, editable copy. // written; the store's table is seeded from it and thereafter is the live, editable copy.
// //
// In the order a person reads it: the mesh's own, then a node's. // In the order a person reads it: the mesh's own, then a node's.
var defaultSeats = []Seat{ var defaultSeats = append([]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.
@@ -148,8 +160,39 @@ var defaultSeats = []Seat{
// system or user scope; the holder answers questions and operator acts about them, each verb // system or user scope; the holder answers questions and operator acts about them, each verb
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are // taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
// served by the node tools runtime (ADR 0175). // served by the node tools runtime (ADR 0175).
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177", {Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
Serves: serviceManagerVerbs()}, Serves: serviceManagerVerbs()},
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
// mesh's word for "something on this machine answers for installing", where a capability only
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
// of it is decided when someone needs to ask.
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
// dependency has a seat to name and the runtime's module has one to claim.
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
// every module contributes to it. No verbs — the seat says who places the environment's files,
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
// which module wrote it.
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
// the seat exists whether or not zsh's definition is registered. `execute` is the contract any
// node may call; the holder places every module's shell code in its slots.
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
Serves: loginShellVerbs()},
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
// code modules contribute for the power moments, and publishes the machine's power states as
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
// does not — a laptop's vendor keys — run by one module per machine, which owns its
// configuration. Every other module with keys contributes trigger lines to it.
{Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
Receives: []Receivable{{Kind: "trigger", Comment: "#"}}},
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
// model change, not a rename, so it stays until that is built. // model change, not a rename, so it stays until that is built.
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
@@ -161,7 +204,9 @@ var defaultSeats = []Seat{
// rather than a condition in the resolver's module, so a machine running two managers is // rather than a condition in the resolver's module, so a machine running two managers is
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
} },
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
graphicalSessionSeats()...)
// 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;
@@ -210,6 +255,10 @@ func UseSeats(s []Seat) {
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
} }
} }
// What a seat receives is never stored (novox/hq ADR 0212), so it is always the compiled one.
if d, known := byName[row.Name]; known {
row.Receives = d.Receives
}
merged = append(merged, row) merged = append(merged, row)
} }
seats = merged seats = merged
@@ -456,3 +505,17 @@ func serviceManagerVerbs() []Verb {
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})}, Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
} }
} }
// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR
// 0204): one command, run the way the operator's own terminal would run it, bounded below the
// runtime's thirty-second call limit so a hung command answers rather than times the caller out.
func loginShellVerbs() []Verb {
return []Verb{
{Name: "execute", Description: "Run one command on this machine as the operator account, in a " +
"non-interactive login shell in its home; answers with what it printed and how it exited.",
Input: schema(map[string]string{
"command": "the command line, as you would type it",
"timeout_seconds": "give up after this long, at most 25 (default 20)",
}, []string{"command"})},
}
}
+20
View File
@@ -25,6 +25,10 @@ import (
// and nothing to keep in step when a mesh seat is added. // and nothing to keep in step when a mesh seat is added.
const meshSeatPrefix = "mesh-" const meshSeatPrefix = "mesh-"
// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's,
// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204).
const retiredLoginShell = "login-shell"
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says, // A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
// and what it answers. A caller declares that it uses the *seat*, never the module, so the // and what it answers. A caller declares that it uses the *seat*, never the module, so the
// implementation can be replaced under it. // implementation can be replaced under it.
@@ -88,6 +92,15 @@ func declaredSeatProblems(m Manifest) []string {
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*")) "seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
continue continue
} }
if s.Name == retiredLoginShell {
// The name ADR 0176 gave the login shell when the zsh module declared it. The seat is
// the mesh's now, so a module declaring the old name would be a second login shell
// beside it, with a protocol of its own (novox/hq ADR 0204).
problems = append(problems, fmt.Sprintf(
"%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+
"module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat))
continue
}
if seen[s.Name] { if seen[s.Name] {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s declares the seat %q twice", m.Module, s.Name)) "%s declares the seat %q twice", m.Module, s.Name))
@@ -215,6 +228,13 @@ func CatalogueProblems(shelf Shelf) []string {
} }
} }
} }
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
// owner is on the shelf, as a consumer may be installed before its emitter.
var manifests []Manifest
for _, module := range shelfOrder(shelf) {
manifests = append(manifests, shelf[module])
}
problems = append(problems, StateReadsNothingDeclares(manifests)...)
sort.Strings(problems) sort.Strings(problems)
return problems return problems
} }
+10 -5
View File
@@ -18,7 +18,9 @@ import (
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq // vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
// and a row in to-be 26, not a new number here. // and a row in to-be 26, not a new number here.
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
record := regexp.MustCompile(`^novox/hq ADR \d{4}$`) // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined
// it and the one that seeded it.
record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`)
seen := map[string]bool{} seen := map[string]bool{}
delivered := map[string]string{} delivered := map[string]string{}
for _, s := range Seats() { for _, s := range Seats() {
@@ -44,10 +46,13 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
delivered[s.Delivers] = s.Name delivered[s.Delivers] = s.Name
} }
} }
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired // Thirty-four with node-hotkeys (novox/hq ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the graphical session's
// mesh-build-machine row goes, when no registered manifest claims it any more. // eleven (ADR 0208); twenty-one with
if len(Seats()) != 17 { // node-package-manager and node-container-runtime (ADR 0207); nineteen with node-environment and
t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+ // node-login-shell (ADR 0203, ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer
// once the retired mesh-build-machine row goes, when no registered manifest claims it any more.
if len(Seats()) != 34 {
t.Errorf("the mesh defines %d seats rather than 34; the set is closed, so a change here is "+
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
} }
} }
+42
View File
@@ -0,0 +1,42 @@
package catalogue
import (
"strings"
"testing"
)
// One account, several modules: the shell's module sets its shell, the container runtime's adds it
// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is
// one value, owned by one module per node.
func userResource(fields map[string]any) map[string]any {
r := map[string]any{"id": "operator", "type": "user", "name": "op"}
for k, v := range fields {
r[k] = v
}
return r
}
func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) {
zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}}
docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}}
other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}}
if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 {
t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems)
}
if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil {
t.Fatalf("the three did not resolve together: %v", err)
}
}
func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) {
for _, field := range []string{"shell", "home"} {
a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}}
b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}}
problems := checkResources([]Manifest{a, b})
want := `zsh and fish both set the ` + field + ` of the user "op"`
if len(problems) != 1 || !strings.Contains(problems[0], want) {
t.Errorf("two modules setting %s gave %v, want %q", field, problems, want)
}
}
}
+40 -16
View File
@@ -5,19 +5,23 @@ import (
"testing" "testing"
) )
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29): every other node's // The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a
// Host block written into a marked region of the operator's ~/.ssh/config, owned by the account, // region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first,
// with ~/.ssh created 0700 — the operator's own config kept. // because ssh takes the first value it finds for each option — and the mesh's Host blocks as the
// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d
// are created 0700 and owned by the account; the operator's own config below the region is kept.
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) { func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
shelf := shelf(catalogueManifest(t, "ssh-client")) shelf := shelf(catalogueManifest(t, "ssh-client"))
got, err := Resolve(shelf, []string{"ssh-client"}, got, err := Resolve(shelf, []string{"ssh-client"},
Node{Name: "homer", At: "homer.internal", Account: "jo"}, World{}) Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, World{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2"} names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2",
"bart.internal": "10.10.0.3"}
out, err := got.Declaration(Rendering{ out, err := got.Declaration(Rendering{
Names: names, Machines: names, Accounts: map[string]string{"homer": "jo", "marge": "jo"}, Names: names, Machines: names,
Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"},
Suffix: "internal", Suffix: "internal",
}) })
if err != nil { if err != nil {
@@ -28,19 +32,39 @@ func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
by[r["id"].(string)] = r by[r["id"].(string)] = r
} }
dir := by["ssh-client.ssh-dir"] for id, path := range map[string]string{
if dir == nil || dir["path"] != "/home/jo/.ssh" || dir["owner"] != "jo" || dir["mode"] != "0700" { "ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} {
t.Fatalf("~/.ssh is not created 0700 owned by the account: %v", dir) dir := by[id]
if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" {
t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir)
} }
cfg := by["ssh-client.fact-ssh-config"]
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || cfg["into"] != "block" {
t.Fatalf("the ssh config is not written into the operator's ~/.ssh/config as a region: %v", cfg)
} }
body := cfg["content"].(string)
if !strings.Contains(body, "Host marge marge.internal") || !strings.Contains(body, "User jo") { cfg := by["ssh-client.config"]
t.Fatalf("the config does not name the peer node and its account:\n%s", body) if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" ||
cfg["into"] != "block" || cfg["at"] != "start" {
t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg)
}
if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") {
t.Fatalf("the region does not just include config.d:\n%s", body)
}
var hosts map[string]any
for _, r := range out {
if r["path"] == "/home/jo/.ssh/config.d/00-mesh" {
hosts = r
}
}
if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil {
t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts)
}
body := hosts["content"].(string)
for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} {
if !strings.Contains(body, want) {
t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body)
}
} }
if strings.Contains(body, "Host homer ") { if strings.Contains(body, "Host homer ") {
t.Fatalf("the config names the machine itself, not only its peers:\n%s", body) t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body)
} }
} }
+150
View File
@@ -0,0 +1,150 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strings"
)
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
//
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
// mesh derives the bucket from the two names, so the module and the local name must each be one
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
// never derive one bucket.
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// The mesh's caps on what a module may ask of a bucket's history.
const (
// StateMostHistory is the most past values a key may keep. The server's own limit.
StateMostHistory = 64
)
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
type StateDeclaration struct {
Name string `json:"name"`
// History is how many values a key keeps, the current one included; zero is one.
History int `json:"history,omitempty"`
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
TTLSeconds int `json:"ttl-seconds,omitempty"`
}
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
return json.Unmarshal(trimmed, &s.Name)
}
type plain StateDeclaration
var full plain
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err)
}
*s = StateDeclaration(full)
return nil
}
// MarshalJSON writes back the short form when there is nothing else to say.
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
if s.History == 0 && s.TTLSeconds == 0 {
return json.Marshal(s.Name)
}
type plain StateDeclaration
return json.Marshal(plain(s))
}
// ReadState splits a read into the owning module and the local name, or says why it is not one.
func ReadState(read string) (module, local string, err error) {
at := strings.LastIndex(read, ".")
if at <= 0 || at == len(read)-1 {
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
}
module, local = read[:at], read[at+1:]
if !stateName.MatchString(module) {
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
}
if !stateName.MatchString(local) {
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
}
return module, local, nil
}
// StateProblems is what is wrong with a manifest's state and reads.
//
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
// starts, and is refused on its first write with a reason about a bucket nobody named.
func StateProblems(m Manifest) []string {
var problems []string
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
"name — no dot (novox/hq ADR 0201)", m.Module))
}
seen := map[string]bool{}
for _, s := range m.State {
switch {
case !stateName.MatchString(s.Name):
problems = append(problems, fmt.Sprintf(
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
case seen[s.Name]:
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
}
seen[s.Name] = true
if s.History < 0 || s.History > StateMostHistory {
problems = append(problems, fmt.Sprintf(
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
}
if s.TTLSeconds < 0 {
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
}
}
for _, r := range m.Reads {
module, _, err := ReadState(r)
if err != nil {
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
continue
}
if module == m.Module {
problems = append(problems, fmt.Sprintf(
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
}
}
return problems
}
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
// such state. An absent owner says nothing — a module may be installed long before the one whose
// state it reads, as a consumer may before its emitter (design 32 §1).
func StateReadsNothingDeclares(manifests []Manifest) []string {
declared := map[string]map[string]bool{}
for _, m := range manifests {
own := map[string]bool{}
for _, s := range m.State {
own[s.Name] = true
}
declared[m.Module] = own
}
var problems []string
for _, m := range manifests {
for _, r := range m.Reads {
module, local, err := ReadState(r)
if err != nil {
continue
}
if own, present := declared[module]; present && !own[local] {
problems = append(problems, fmt.Sprintf(
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
}
}
}
return problems
}
+94
View File
@@ -0,0 +1,94 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
// bare name or with the owner's options.
func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` +
`"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` +
`"reads":["licence-manager.bindings"]}`))
if err != nil {
t.Fatal(err)
}
if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 {
t.Fatalf("state not read: %+v", m.State)
}
if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" {
t.Fatalf("reads not read: %v", m.Reads)
}
// Written back as it came in: the short form where nothing else is said.
out, _ := json.Marshal(m.State)
if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` {
t.Fatalf("written back as %s", out)
}
}
// A name the bus could not hold, or that would let two modules derive one bucket, is refused at
// registration in the manifest's words.
func TestAStateNameIsLocalAndOneToken(t *testing.T) {
for _, c := range []struct{ manifest, says string }{
{`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`},
{`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`},
{`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`},
{`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`},
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`},
} {
_, err := ParseManifest([]byte(c.manifest))
if err == nil {
t.Errorf("%s was accepted", c.manifest)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
}
}
}
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
// because a module may be installed before the one whose state it reads.
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
t.Fatalf("a read of declared state was refused: %v", p)
}
p := StateReadsNothingDeclares([]Manifest{owner, bad})
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
t.Fatalf("a read of state nobody keeps was not named: %v", p)
}
}
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
// names state that owner keeps.
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
manifests := theCatalogue(t)
var problems []string
for _, m := range manifests {
problems = append(problems, StateProblems(m)...)
}
problems = append(problems, StateReadsNothingDeclares(manifests)...)
if len(problems) > 0 {
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
}
}
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
shelf := Shelf{
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
}
problems := CatalogueProblems(shelf)
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
t.Fatalf("the catalogue pass said %v", problems)
}
}
+7 -4
View File
@@ -102,10 +102,13 @@ var ControllerVerbs = []Verb{
}, nil)}, }, nil)},
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.", {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"})}, 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.", {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"})}, "or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
{Name: "unassign", Description: "Take a module off a machine.", Input: schema(map[string]string{"node": "the machine's name",
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})}, "module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
Input: schema(map[string]string{"node": "the machine's name",
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " + {Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
"it runs on, both. Asked for when more than one could answer; the refusal lists them.", "it runs on, both. Asked for when more than one could answer; the refusal lists them.",
Input: schema(map[string]string{ Input: schema(map[string]string{
+145
View File
@@ -0,0 +1,145 @@
package catalogue
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
//
// The host judges what it receives — whether each id is a container on that machine. What the
// definition is the only place to see is judged here, near whoever wrote it.
func aStoreManifest(step map[string]any) []byte {
m := map[string]any{
"module": "distribution", "version": "1",
"resources": []any{
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
"image": "registry@sha256:" + strings.Repeat("a", 64)},
step,
},
}
raw, _ := json.Marshal(m)
return raw
}
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
raw := aStoreManifest(map[string]any{
"id": "collect", "type": "container", "name": "mesh-registry-collect",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
})
if _, err := ParseManifest(raw); err != nil {
t.Fatalf("a step holding its own module's container still was refused: %v", err)
}
}
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
for _, c := range []struct {
name string
step map[string]any
says string
}{
{
"on a step with no schedule",
map[string]any{"id": "collect", "type": "container", "name": "c",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"while-stopped": []any{"store"}},
"gates what is declared after it",
},
{
"on a run-once step, which already has order",
map[string]any{"id": "collect", "type": "container", "name": "c",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"run-once": true, "while-stopped": []any{"store"}},
"A maintenance window is for a recurring step",
},
{
"naming a container this module does not declare",
map[string]any{"id": "collect", "type": "container", "name": "c",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
"could quiesce a neighbour could stop the mesh",
},
{
"naming itself",
map[string]any{"id": "collect", "type": "container", "name": "c",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
"naming itself",
},
{
"written as something that is not a list",
map[string]any{"id": "collect", "type": "container", "name": "c",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"schedule": "30 3 * * *", "while-stopped": "store"},
"a list of this module's container ids",
},
} {
_, err := ParseManifest(aStoreManifest(c.step))
if err == nil {
t.Errorf("%s was accepted", c.name)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
}
}
}
// A composed declaration names the step's held containers the way the machine knows them.
//
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
// ["store"]`, because a module names its own resources locally; the declaration a machine
// receives calls that container `distribution.store`, because every resource is composed under
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
// `while-stopped` was not — so the host found no container by that id and refused the whole
// declaration, every push, until it was fixed.
//
// It passed every test on both sides: the controller's tests read manifests, the host's read
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
store := Manifest{
Module: "distribution", Version: "1",
Provides: FromAnywhere("artifact-store"),
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
Resources: []map[string]any{
{"id": "store", "type": "container", "name": "mesh-registry",
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
"image": "registry@sha256:" + strings.Repeat("a", 64),
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
},
}
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
collect := fileNamed(out, "distribution.collect")
if collect == nil {
for _, res := range out {
if res["id"] == "distribution.collect" {
collect = res
}
}
}
if collect == nil {
t.Fatalf("the step was not composed at all: %v", out)
}
held, _ := collect[WhileStopped].([]any)
if len(held) != 1 {
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
}
if got := fmt.Sprint(held[0]); got != "distribution.store" {
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
"called %q — the host refuses a declaration naming a container it does not have, "+
"whole, so the machine would take nothing at all", got, "distribution.store")
}
}
+27
View File
@@ -132,6 +132,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
Serves: m.Tools, Serves: m.Tools,
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's. // And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
Invokes: m.Invokes, Invokes: m.Invokes,
// And the state it keeps and reads (novox/hq ADR 0201).
State: bucketsOf(m),
Reads: m.Reads,
} }
for _, c := range m.Claims { for _, c := range m.Claims {
// Every seat with a protocol, the mesh's own included. One that says only who does a job is // Every seat with a protocol, the mesh's own included. One that says only who does a job is
@@ -148,6 +151,30 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
return d return d
} }
// bucketsOf is the state a module keeps, as the bus holds it.
func bucketsOf(m catalogue.Manifest) []broker.Bucket {
var out []broker.Bucket
for _, s := range m.State {
out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds})
}
return out
}
// DeclaredBuckets is every bucket the catalogue declares, registered modules assigned or not: a
// bucket exists from registration, like a seat's stream, so a module reading it may watch before its
// owner runs anywhere (novox/hq ADR 0201).
func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error) {
declared, err := i.Catalogue(ctx)
if err != nil {
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
}
var out []broker.Bucket
for _, m := range declared {
out = append(out, bucketsOf(m)...)
}
return out, nil
}
func asSeat(s catalogue.SeatDeclaration) broker.Seat { func asSeat(s catalogue.SeatDeclaration) broker.Seat {
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
Serves: catalogue.VerbNames(s.Serves)} Serves: catalogue.VerbNames(s.Serves)}
+6 -31
View File
@@ -78,11 +78,12 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the // (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue // runtime's image. Refused at registration, by name, for a module that is new to the catalogue
// or that was registered in another shape — the mechanism that keeps the old pattern from // or that was registered in another shape — the mechanism that keeps the old pattern from
// returning by habit. **Not refused for a module already registered in that shape**: the // returning by habit. Before the runtime exists the pattern is accepted as it always was.
// catalogue holds some thirty of them the day the runtime arrives, each moves to a bundle in //
// its own change (to-be 38 WP4 onward), and a gate that refused every rebuild of every unmoved // *Since 2026-10-04 (to-be 38 WP4b's last step):* refused for **every** module. While some
// module in the meantime would stop the whole pipeline to make a point the record already makes. // thirty modules still stood in that shape, one already registered so was rebuilt without
// Before the runtime exists the pattern is accepted as it always was. // complaint, so the pipeline kept running while each moved; every module has moved since, and
// the exception would only let one move back.
if m.Module != catalogue.RuntimeModule { if m.Module != catalogue.RuntimeModule {
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" { if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule) runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
@@ -90,16 +91,10 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
return err return err
} }
if runtime { if runtime {
already, err := i.registeredInThatShape(ctx, m.Module)
if err != nil {
return err
}
if !already {
return fmt.Errorf("%s is not registered: %s", m.Module, why) return fmt.Errorf("%s is not registered: %s", m.Module, why)
} }
} }
} }
}
raw, err := json.Marshal(m) raw, err := json.Marshal(m)
if err != nil { if err != nil {
return err return err
@@ -152,26 +147,6 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
return nil return nil
} }
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
// the runtime's image — judged from the manifest it holds and what that module's newest build stood
// on, the same two things the gate judges a new registration by. False for a module the catalogue
// does not hold.
func (i *Inventory) registeredInThatShape(ctx context.Context, name string) (bool, error) {
held, err := i.Catalogue(ctx)
if err != nil {
return false, err
}
stored, has := held[name]
if !has {
return false, nil
}
against, err := i.BuiltAgainst(ctx)
if err != nil {
return false, err
}
return catalogue.ToolContainerOnTheRuntime(stored, against[name]) != "", nil
}
// hasModule is whether the catalogue holds a module of that name. // hasModule is whether the catalogue holds a module of that name.
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
var one int var one int
+8 -8
View File
@@ -688,9 +688,9 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container // Once the node's tool runtime is in the catalogue, a module serving its tools from a container
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175, // built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
// to-be 38 WP2.4) — for a module new to the catalogue or one that had moved away from it; a module // to-be 38 WP2.4) — for every module, since every module has moved (WP4b's last step; WP3's
// already standing in that shape is rebuilt as before, so the catalogue's pipeline keeps running // amendment let one already standing in that shape be rebuilt while each moved). Before the
// while each moves (WP3's amendment). Before the runtime, it is accepted as it always was — so a // runtime, it is accepted as it always was — so a
// mesh converts in the order the design says and nothing is refused before there is anything to // mesh converts in the order the design says and nothing is refused before there is anything to
// move to. // move to.
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) { func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
@@ -715,11 +715,11 @@ func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// **A module already registered in that shape is rebuilt without complaint** (to-be 38 WP2.4 as // **A module already registered in that shape is refused too** (WP4b's last step): every module
// amended by WP3): some thirty of them stand the day the runtime arrives, and each moves in its // has moved, and a rebuild in the old shape is one moving back.
// own change. The gate is against the pattern spreading, not against the pipeline running. if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err == nil ||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil { !strings.Contains(err.Error(), "ADR 0175") {
t.Fatalf("a rebuild of a module that already had the pattern was refused: %v", err) t.Fatalf("a rebuild of a module in the old pattern was registered beside the runtime: %v", err)
} }
// A module new to the catalogue in that shape is refused, naming the record. // A module new to the catalogue in that shape is refused, naming the record.
newcomer := filter newcomer := filter
+264
View File
@@ -0,0 +1,264 @@
package inventory
import (
"context"
"encoding/json"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
)
// What the artifact store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
//
// The store has never collected anything: every build pushes another layer set and nothing has
// ever removed one. The registry's own answer — collect what no tag names — is wrong here, because
// the mesh pushes each artifact under one moving tag and pins machines by digest, so every build
// but the newest is untagged and some machine may still be running it.
//
// **So the mesh decides, from its own records, and it never has to look in the store to do it.**
// It has never put anything there it did not record, which means every digest it could remove is
// already in a build row. A digest the mesh did not record making is therefore never named here —
// not as a safety margin but as the rule restated, and it is what keeps the sweep away from the
// images genesis pushed before any record existed (04-ISSUES/102, F4).
// KeptBuilds is how many successful builds of each module keep their artifacts, counting the
// newest. The newest is what the mesh hands a machine now; the four behind it are how far back a
// release that turns out wrong can be taken.
const KeptBuilds = 5
// ToCollect is every artifact the mesh made, no longer keeps, and has not already collected.
//
// Three reasons an artifact stays, and nothing else is a reason:
//
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
// what the mesh would hand a machine now. No age limit: this is the floor;
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
// recent successful builds of its module;
// - it was already collected, in which case there is nothing left to do.
//
// Returned in a stated order so two runs over the same records ask for the same things in the
// same sequence, which is what makes a failed sweep safe to simply run again.
func (i *Inventory) ToCollect(ctx context.Context) ([]string, error) {
keep, err := i.keptReferences(ctx)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
// Every artifact of every successful build, oldest first, minus what has already been
// collected. A failed build published nothing, so it names nothing to remove.
`select b.made
from build b
where b.failed = '' and b.module is not null and b.module <> ''
order by b.at asc, b.id asc`)
if err != nil {
return nil, err
}
defer rows.Close()
collected, err := i.alreadyCollected(ctx)
if err != nil {
return nil, err
}
seen := map[string]bool{}
var out []string
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var made []Artifact
if err := json.Unmarshal(raw, &made); err != nil {
// One unreadable record must not stop the rest being collected — and an artifact this
// row named is simply not offered, which errs toward keeping.
continue
}
for _, a := range made {
reference := asRecorded(a.Reference)
if reference == "" || keep[reference] || collected[reference] || seen[reference] {
continue
}
seen[reference] = true
out = append(out, reference)
}
}
return out, rows.Err()
}
// keptReferences is every artifact reference the mesh still keeps, for either of the two reasons.
func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error) {
keep := map[string]bool{}
// **Whatever a definition the mesh holds names.** Read as text rather than by walking the
// resource shapes: a reference may be a container's image, a bundle's source, or a field some
// later kind of resource grows, and what matters is only whether the mesh could hand this
// string to a machine. A manifest that mentions it is a manifest that might.
manifests, err := i.store.Pool().Query(ctx, `select manifest::text from module where manifest is not null`)
if err != nil {
return nil, err
}
defer manifests.Close()
var named []string
for manifests.Next() {
var text string
if err := manifests.Scan(&text); err != nil {
return nil, err
}
named = append(named, text)
}
if err := manifests.Err(); err != nil {
return nil, err
}
// The KeptBuilds most recent successful builds of each module, whole.
recent, err := i.store.Pool().Query(ctx,
`select made from (
select made, row_number() over (partition by module order by at desc, id desc) as back
from build
where failed = '' and module is not null and module <> ''
) ranked where back <= $1`, KeptBuilds)
if err != nil {
return nil, err
}
defer recent.Close()
for recent.Next() {
var raw []byte
if err := recent.Scan(&raw); err != nil {
return nil, err
}
var made []Artifact
if err := json.Unmarshal(raw, &made); err != nil {
continue
}
for _, a := range made {
if reference := asRecorded(a.Reference); reference != "" {
keep[reference] = true
}
}
}
if err := recent.Err(); err != nil {
return nil, err
}
// And anything a manifest mentions. Done after the recent set so the scan runs over the
// candidates rather than over every reference ever recorded: a manifest holds a reference
// composed with the store's address or kept bare, so the search is for the digest within it.
if len(named) > 0 {
all, err := i.everyReferenceMade(ctx)
if err != nil {
return nil, err
}
for _, reference := range all {
if keep[reference] {
continue
}
digest := digestIn(reference)
if digest == "" {
// Not something the store holds by digest; nothing here can speak for it, so it
// is kept rather than guessed about.
keep[reference] = true
continue
}
for _, text := range named {
if strings.Contains(text, digest) {
keep[reference] = true
break
}
}
}
}
return keep, nil
}
// everyReferenceMade is every artifact reference any successful build recorded.
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
rows, err := i.store.Pool().Query(ctx,
`select made from build where failed = '' and module is not null and module <> ''`)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]bool{}
var out []string
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var made []Artifact
if err := json.Unmarshal(raw, &made); err != nil {
continue
}
for _, a := range made {
reference := asRecorded(a.Reference)
if reference == "" || seen[reference] {
continue
}
seen[reference] = true
out = append(out, reference)
}
}
return out, rows.Err()
}
// asRecorded is an artifact reference in the one vocabulary the sweep speaks (novox/hq issue 226).
//
// **Every reference here came from a build record, so every one of them is the mesh's own.** That
// is what makes it safe to normalise: references kept before the store's address stopped being
// written are `<host>:<port>/<path>@sha256:…` (04-ISSUES/102), and `Recorded` reads those as the
// `artifact-store://` references the rest of the mesh uses. Done here rather than when the store
// is asked, because `Recorded` cannot tell one registry host from another — only the provenance
// can, and the provenance is here.
//
// The oldest artifacts are exactly the ones recorded the old way, and exactly the ones a
// sweep reaches first. Untranslated, the first of them ended every sweep.
func asRecorded(reference string) string {
if reference == "" {
return ""
}
return catalogue.Recorded(reference)
}
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
func digestIn(reference string) string {
for _, marker := range []string{"@sha256:", "/sha256:"} {
if _, after, ok := strings.Cut(reference, marker); ok {
return "sha256:" + after
}
}
return ""
}
// alreadyCollected is what the store has already been asked to let go.
func (i *Inventory) alreadyCollected(ctx context.Context) (map[string]bool, error) {
rows, err := i.store.Pool().Query(ctx, `select reference from artifact_collected`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var reference string
if err := rows.Scan(&reference); err != nil {
return nil, err
}
out[reference] = true
}
return out, rows.Err()
}
// MarkCollected records that the store no longer holds these.
//
// **A store that answered "not found" is recorded too.** The outcome wanted is that the artifact
// is gone, and it is; retrying it every sweep for ever is the failure this table exists to
// prevent. Only a store that could not be reached, or refused, leaves a reference unmarked — and
// then the next sweep asks again, which is what should happen.
func (i *Inventory) MarkCollected(ctx context.Context, references []string) error {
for _, reference := range references {
if _, err := i.store.Pool().Exec(ctx,
`insert into artifact_collected (reference) values ($1) on conflict (reference) do nothing`,
reference); err != nil {
return err
}
}
return nil
}
+192
View File
@@ -0,0 +1,192 @@
package inventory
import (
"context"
"fmt"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// What the store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
//
// The store has collected nothing since it was raised, and the registry's own answer — collect
// what no tag names — would delete images machines are running, because the mesh pushes under one
// moving tag and pins by digest. So the rule is the mesh's, read from its own records, and these
// are the three reasons an artifact stays and the one reason it goes.
// ref is an artifact reference as the mesh records one.
func ref(module, artifact string, n int) string {
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
}
// built records one successful build of a module publishing one image.
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
t.Helper()
reference := ref(module, "app", n)
b := aBuild(id, module, "")
b.Made = []Artifact{{Name: "app", Kind: "image", Reference: reference}}
if err := inv.RecordBuild(context.Background(), b); err != nil {
t.Fatal(err)
}
return reference
}
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
// release that turns out wrong can be taken back to.
var made []string
for i := 1; i <= 8; i++ {
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
want := made[:3] // the three oldest
if len(go_) != len(want) {
t.Fatalf("offered %v to collect; want the %d oldest of %d", go_, len(want), len(made))
}
for i := range want {
if go_[i] != want[i] {
t.Fatalf("offered %v; want %v — and in that order, so a failed sweep is safe to run again",
go_, want)
}
}
}
func TestADefinitionNamingAnArtifactKeepsItHoweverOldItIs(t *testing.T) {
// The floor: no age limit. A module recorded at an older commit still names what the mesh
// would hand a machine now, and that is what must not be collected out from under it.
inv := fresh(t)
ctx := context.Background()
var made []string
for i := 1; i <= 8; i++ {
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
}
oldest := made[0]
// A definition the mesh holds, whose container runs that oldest image.
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
"id": "app", "type": "container", "name": "web", "image": oldest,
}}}
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
t.Fatal(err)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
for _, reference := range go_ {
if reference == oldest {
t.Fatalf("the mesh offered to collect %s, which a definition it holds names", oldest)
}
}
if len(go_) != 2 {
t.Fatalf("offered %v; want the two oldest that nothing names", go_)
}
}
func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
// Without this the sweep reissues a delete for every artifact it has ever collected, every
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
inv := fresh(t)
ctx := context.Background()
for i := 1; i <= 7; i++ {
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
}
first, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(first) != 2 {
t.Fatalf("offered %v, want two", first)
}
if err := inv.MarkCollected(ctx, first); err != nil {
t.Fatal(err)
}
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("offered %v again after collecting it", again)
}
}
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
// A failed build published nothing, so it is neither kept nor collected — and it must not
// count against the module's five.
for i := 1; i <= 6; i++ {
built(t, inv, fmt.Sprintf("w%02d", i), "web", i)
}
if err := inv.RecordBuild(ctx, aBuild("w99", "web", "the recipe would not build")); err != nil {
t.Fatal(err)
}
// And a second module with three builds keeps all three: five each, not five between them.
for i := 1; i <= 3; i++ {
built(t, inv, fmt.Sprintf("d%02d", i), "db", 100+i)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(go_) != 1 || go_[0] != ref("web", "app", 1) {
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
}
}
// An artifact recorded with the store's old address is offered for collection, in the vocabulary
// the rest of the mesh speaks (novox/hq issue 226).
//
// Before references were kept without an address the mesh recorded
// `<host>:<port>/<path>@sha256:…` (04-ISSUES/102). Those are the oldest artifacts, which makes
// them exactly the ones an oldest-first sweep reaches first — and the first live run met one,
// read "I will not address this" as "the store refuses everything", and collected none of 1681.
func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
// The oldest build published the old way; five newer ones fill the module's five.
old := aBuild("a00", "tools", "")
old.Made = []Artifact{{Name: "build", Kind: "image",
Reference: "127.0.0.1:5100/tools/build@sha256:" + fmt.Sprintf("%064x", 1)}}
if err := inv.RecordBuild(ctx, old); err != nil {
t.Fatal(err)
}
for i := 2; i <= 6; i++ {
built(t, inv, fmt.Sprintf("a%02d", i), "tools", i)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
want := ref("tools", "build", 1)
if len(go_) != 1 || go_[0] != want {
t.Fatalf("offered %v; want %q — the address is a route to the artifact, not part of its "+
"name, and the sweep speaks the name", go_, want)
}
// And marking it collected uses that same name, so the next sweep does not offer it again
// under a spelling it has not seen.
if err := inv.MarkCollected(ctx, go_); err != nil {
t.Fatal(err)
}
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("offered %v again after collecting it", again)
}
}
@@ -0,0 +1,23 @@
-- What the artifact store no longer keeps (novox/hq ADR 0189, issue 108).
--
-- The mesh removes from its store only what it put there and can account for: every digest it
-- could remove is already in a build record, so the sweep reads its own records rather than
-- enumerating the store. What it does not get from those records is whether it has already
-- removed something -- `build.made` says what that build published, for ever, which is history
-- and not an index of what is on disk.
--
-- Without this the sweep would reissue a delete for every artifact it has ever collected, every
-- time it runs, and each one would answer 404 -- a number of requests that grows with the mesh's
-- whole history and never shrinks.
--
-- Keyed by the reference as the mesh records it (`artifact-store://<module>/<artifact>@sha256:…`),
-- because that is the identity the record uses everywhere else. Not a foreign key to build: two
-- builds can publish the same digest (the same source built twice produces the same bytes), and
-- what is collected is the artifact, not the attempt that made it.
create table artifact_collected (
reference text primary key,
-- When the store answered. Kept so a reader of an old build record can tell "this artifact is
-- gone" from "this artifact was never there", which are different kinds of surprise.
at timestamptz not null default now()
);