Author SHA1 Message Date
mesh-admin 36008e0642 Merge pull request 'Fill ${setting:} in a service's unit name, and refuse a value that makes no unit name' (#171) from feat/setting-in-a-unit-name into main 2026-10-09 00:32:51 +00:00
mesh-admin 155819f307 Merge pull request 'Issue 339 follow-ups: only step-ca's root may hold lines, every line end refused, the runtime's data and any .ssh refused' (#170) from fix/339-review-follow-ups into main 2026-10-08 23:55:34 +00:00
jochen eb5f9d2f53 Allow a setting in a unit's name only as a template's whole instance, never leading with a dash, at most 64 characters
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Third review of mesh-catalog #147. A setting anywhere else in a unit's name could make the unit another
unit or another kind; it is now refused where the manifest is read. A value beginning with '-' would be
read by systemctl as an option, and a long one is no pool's name.
2026-10-09 01:51:37 +02:00
jochen 0f58602c74 Count a file that says nothing as trusted, and drop the refusal date
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The fourth review (hq issue 339): the safe reading of a file that asks for a
setting and does not say is that root or a consumer trusts it, so its
settings are the terminal's; `"trusted": false` is the opt-out. With that,
nothing unsafe is left to refuse: `module check` lists and counts the
unmarked files and never refuses them.
2026-10-09 01:44:50 +02:00
jochen c3ae3f3e09 Refuse a file that asks for a setting without saying whether it is trusted from 2026-10-10
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
The operator's date (hq issue 339). A test holds the warning before it and the
refusal from it.
2026-10-09 01:37:43 +02:00
mesh-admin 2a9697cf70 Merge pull request 'Switch the memory store's failure under its lock, so a test cannot race the keeper' (#148) from fix/conditions-store-race into main 2026-10-08 23:35:59 +00:00
jochen e4d2868679 Fill ${setting:} in a service's unit name, and refuse a value that makes no unit name
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A module that holds the distribution's scrub timer for the pool the operator names
(zfs-scrub-weekly@<pool>.timer) cannot write the pool into its definition (hq ADR 0112). Settings were
substituted only into file content, so the unit reached the machine as
"zfs-scrub-weekly@${setting:scrub-pool}.timer", a unit no machine has, and the apply failed far from its
cause (second review of mesh-catalog #147).

A service's unit now takes ${setting:} from the same layers a file does, and is refused by key when
nothing sets it. A value is also refused unless it is only letters, digits, ':', '_', '.' and '-': the
name ends up in unit files and systemctl arguments, and a space, a slash or a newline must never reach
them. A key a unit asks for is not called stray.
2026-10-09 01:32:32 +02:00
jochen fe857ba081 Switch the memory store's failure under its lock, so a test cannot race the keeper
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The keeper keeps each transition from its own goroutine, which reads the memory
store's Fail field under the store's lock; tests assigned the exported field
bare, so TestAnUnreadableStoreClearsNothing failed under -race whenever the
goroutine appended in that window. The field is now set only through SetFail
(and Told's likewise), and a test makes the race certain rather than rare.
Test-only: the controller runs the bus store, never InMemory.
2026-10-09 01:25:04 +02:00
jochen b9fc09c375 Derive the terminal's settings from what a module serves and which files it trusts; a found directory is its own condition
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The third review of #170 (hq issue 339): a setting overrides any key a
provider serves, so any caller of the settings verb could move a database's
port, a registry's port or an issuer to a listener of its own and collect
what consumers present. TerminalKeys now derives from the manifest: places,
accesses, every served key and every setting a served value asks for, and
every setting a file marked `trusted` asks for. `trusted` is the catalogue's
word, taken out before the declaration; `module check` warns of a file that
asks for a setting without saying, and refuses it from 2026-10-30. The hand
list is gone. A directory used as found is now its own condition kind, the
operator's, never urgent, and the gate exempts it where it exempts a relogin.
2026-10-09 01:23:44 +02:00
jochen ec7b8bcd58 Keep the mesh's trust anchors at the terminal, refuse containerd's tree, and read a found directory as a wait
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The review of #170: step-ca's root, roots and path and the identity
provider's issuer are what every consumer trusts, and any caller of the
settings verb could replace them; they are now terminal keys like places and
accesses (hq issue 339). /var/lib/containerd joins the runtimes' data. And a
directory the node-engine uses as found failed its module's gate and rolled
its builds back; found before the send, it is now a wait for a person the
gate passes with, as a relogin is (ADR 0254), and only one the send itself
found holds the module.
2026-10-09 00:57:10 +02:00
mesh-admin cec797e996 Merge pull request 'Make the login shell's execute optional, so a machine may withhold it (hq ADR 0268)' (#169) from withhold-login-shell-execute into main 2026-10-08 22:52:28 +00:00
jochen 0e0ba93f6c Take back the test store's lock fix: open #148 carries the fuller one
Merged beside #148 the two would not compile (SetFail declared twice, m.Fail undefined). #148 lands on its own.
2026-10-09 00:51:18 +02:00
jochen 0f1e1b09fd Change a test store's failure under its lock, which the keeper's goroutine reads
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
TestAnUnreadableStoreClearsNothing wrote InMemory.Fail and its values unguarded
while the keeper's teller appended to the same store, and the race detector
failed mesh/repo-check on #170 (a test race on main, not the change).
2026-10-09 00:38:07 +02:00
jochen 1b502a37e0 Let only the authority's root hold lines, refuse every line end, and keep places off the runtime's data and any .ssh
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestAnUnreadableStoreClearsNothing (0.01s)
The review of hq issue 339 found the PEM exception too wide (any module, any
key, any label, anything base64), \v, \f, NEL and the Unicode separators
still let a value end a line in some readers, and the spool, /opt, the
container runtimes' data and an account's .ssh still placeable. Lines are now
taken only in step-ca's root setting, as certificates encoding/pem decodes and
x509 parses; every line end is refused; and those paths are the machine's own.
The test certificate is a real one, made for the tests with its key thrown away.
2026-10-09 00:28:08 +02:00
mesh-admin 522f2253e7 Merge pull request 'Issue 339: places and accesses only at the terminal, never at the machine's own trees; a setting is one line' (#168) from fix/339-places-and-accesses-are-the-terminals into main 2026-10-08 22:21:02 +00:00
jochen 2073bfe2e6 Make the login shell's execute optional, so a machine may withhold it
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group withhold-login-shell-execute delivered: every member is delivered
execute runs any command as the operator account, which can become root
without a person. The operator withholds it on the control-node until a
call needs a person's approval (hq ADR 0268); the holder withholds it per
machine through its own setting. With execute required, that holder could
not hold the seat there and would be judged silent. ADR 0246's optional
mark lets it hold the seat without serving the verb.
2026-10-09 00:07:33 +02:00
jochen f5824b31c6 Keep places and accesses at the terminal, and refuse a line break in any setting
mesh/delivery delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
Through the settings verb, or a settings line run by the generic command
verb, any caller of the mesh's console could place a module's directory at
/etc with an owner of its own and have the node-engine, as root, hand it
over at the next push, or mount any of the machine's paths into a container
(hq issue 339). A change to either key is now refused in every process a
verb runs, the generic verb refuses settings writes outright, and neither key
may name the machine's own trees from anywhere, the terminal included. A
line break, carriage return or NUL in any setting, which a file it is
written into reads as a line of the caller's own, is refused where a layer
is kept and where it is composed; PEM blocks alone may hold lines.
2026-10-08 23:58:08 +02:00
mesh-admin d059311c0f Merge pull request 'Describe node-nfs-server's exports and test as per-node addresses (hq ADR 0263, review follow-up)' (#166) from fix/shares-review-followups into main 2026-10-08 21:13:05 +00:00
mesh-admin 1a28cb3357 Merge pull request 'Hold the delivery planner to its recorded rules (table + property); a false cycle report found' (#167) from test/planner-rules into main 2026-10-08 20:41:15 +00:00
jochen 758537dd4e Plan a controller merge in the worker-of order in the three-kinds test
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The test's fixture had no worker-of edge and expected the builder first,
then the controller and the proxy together: the order before hq issue 206.
Since then the build seat's holder follows the controller that defines its
worker, and every controller merge plans controller, builder, proxy in
three tiers. The fixture now carries the edge and the test that order.
2026-10-08 22:22:24 +02:00
jochen add807f034 Say no cycle for a packages edge in a plan's last tier
A packages edge orders nothing, so a module and what packages its source
share a tier by rule; hasCycle counted the edge and the merge handler said
"the last tier depends on itself" of plans with no cycle. Skip the kind as
tiersOf does. The planner tests' cycle rows and property now pass.
2026-10-08 22:22:02 +02:00
jochen 8ca4b04321 Hold the delivery planner to its recorded rules with a table and a property
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: FAIL github.com/novox/mesh-controller/cmd/mesh-controller 167.881s
mesh/delivery superseded: a newer head of the same pull request
A table of merges (two repositories, every edge kind, a diamond, a cycle,
files no build reads) and a seeded property over 500 random catalogues pin
what a merge moves and in which tiers, per ADR 0162 and ADR 0238 §3. The
shared-repository rows document today's behaviour that issue 338 would
change, once with hand edges and once with edges derived from the store.

The cycle check fails on main: hasCycle reads a packages edge between two
modules of the last tier as a cycle, so a plan with none is said to have
one. Left failing, marked BUG, for the planner's fix.
2026-10-08 22:15:20 +02:00
mesh-admin 8170fc58a3 Merge pull request 'Keep a passed gate's verdict when the first machine's later reports go quiet (hq issue 335)' (#165) from fix/335-a-passed-gate-is-not-judged-again into main 2026-10-08 19:48:03 +00:00
jochen 5d7d8ee2d6 Describe nfs-server's exports and test as per-node addresses, as the server exports them since the review (hq ADR 0263 rule 5)
mesh/delivery delivered
mesh/delivery-group group fix/shares-review-followups delivered: every member is delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 21:14:09 +02:00
jochen 81e5458cbf Test that a carried module takes its lead's pass before its step is read (hq issue 335 review)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Without the reorder the passed build's walk stopped on the first machine's
later silence; the guard that caught it is now said to be one.
2026-10-08 21:08:12 +02:00
jochen fb74e24c9e Keep a passed gate's verdict when the first machine's later reports go quiet (hq issue 335)
A build that passed on its first machine was judged again from that
machine's next reports while its send to the rest waited; another walk's
unreported send there then failed the passed build at the wait's bound
and put it back.
2026-10-08 21:08:12 +02:00
34 changed files with 2226 additions and 64 deletions
+24
View File
@@ -65,6 +65,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
// catalogue-wide test, and at registration, which refuses in the same words.
if wrong := catalogue.TrustProblems(m); len(wrong) > 0 {
for _, p := range wrong {
fmt.Fprintf(out, "%s: %s\n", path, p)
}
failed += len(wrong)
faulted[m.Module] = true
}
if named := catalogue.InstallationProblems(m); len(named) > 0 {
for _, p := range named {
fmt.Fprintf(out, "%s: %s\n", path, p)
@@ -130,6 +137,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
}
// **A file that asks for a setting and does not say whether it is trusted counts as trusted** (novox/hq issue
// 339): listed and counted, never refused, so an author can opt out a file nothing trusts.
unsaid := 0
for _, name := range names {
unsaid += len(catalogue.UnsaidTrust(shelf[name]))
}
for _, name := range names {
m := shelf[name]
if faulted[name] {
@@ -171,6 +185,11 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
if len(checks) > 0 {
fmt.Fprintf(out, ", ready: %s", strings.Join(checks, "; "))
}
if missing := catalogue.UnsaidTrust(m); len(missing) > 0 {
fmt.Fprintf(out, "; WARNING: %s ask(s) for a setting and do(es) not say whether it is trusted, so it counts as "+
"trusted: set at the terminal alone; say %q false where nothing trusts it (novox/hq issue 339)",
strings.Join(missing, ", "), catalogue.TrustedField)
}
if missing := catalogue.Undeclared(m); len(missing) > 0 {
fmt.Fprintf(out, "; WARNING: %s stay(s) up and say(s) not how it is ready — judged by liveness alone, "+
"refused from %s (ADR 0240 rule 8)", strings.Join(missing, ", "), catalogue.HealthRequiredFrom.Format("2006-01-02"))
@@ -179,6 +198,7 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
// The count the catalogue keeps (ADR 0240 rule 8), in a line its merge check reads.
fmt.Fprintf(out, "%s %d\n", UndeclaredHealthLine, undeclared)
fmt.Fprintf(out, "%s %d\n", UnsaidTrustLine, unsaid)
if failed > 0 {
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
}
@@ -193,6 +213,10 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
// `health` in, over the manifests given: the catalogue's merge check compares it with the number it keeps.
const UndeclaredHealthLine = "long-running resources without health:"
// UnsaidTrustLine starts the line `module check` says the count of files that ask for a setting and do not say
// whether it is trusted, and so count as trusted (novox/hq issue 339).
const UnsaidTrustLine = "files asking for a setting without saying whether it is trusted:"
// checkNow is the clock `module check` judges the date by; a test sets it.
var checkNow = time.Now
+2 -2
View File
@@ -86,7 +86,7 @@ func TestASilenceThroughTheVerbHoldsAndTheConditionStaysOpen(t *testing.T) {
func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
open := aMesh(t)
_, store := withConditionsInMemory(t)
store.Fail = errors.New("the bus is away")
store.SetFail(errors.New("the bus is away"))
asked, err := theThreeQuestions(t.Context(), open)
if err != nil {
t.Fatal(err)
@@ -98,7 +98,7 @@ func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
if !strings.HasPrefix(said, "the open conditions could NOT be read") || strings.Contains(said, "no open conditions") {
t.Fatalf("%s", said)
}
store.Fail = nil
store.SetFail(nil)
asked, _ = theThreeQuestions(t.Context(), open)
said = printed(t, func() error { return printStatus(asked) })
if asked.well() && !strings.Contains(said, "no open conditions;") {
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A directory the node-engine uses as found (novox/hq issue 339) waits for a person to hand it over at the
// machine. Found before this send, it is no fault of the build: the gate passes with the wait carried, so an
// urgent fix of that module still goes through. Found by this send, the send brought it, and the gate holds.
func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + ".data", Kind: link.KindDirectory,
Target: "/srv/" + module, State: link.StateUnhealthy, Since: since,
Reason: link.ReasonUsedAsFound + " owned by 1000:1000, mode 700, as found; root, mode 755 was declared and " +
"not given it — `mesh-host hand-over` at the machine hands it to the mesh"}
}
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
now := time.Now()
sent := now.Add(-time.Minute)
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(-24*time.Hour))}}}}
h, why := moduleHealthWord("notes", "laptop", sent, f)
if h != healthPerson || !strings.Contains(why, "notes.data") || !strings.Contains(why, "hand-over") {
t.Fatalf("a directory found before the send reads %v %q; want a wait for a person", h, why)
}
// Found by this very send: the send brought it, and it is not passed.
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now,
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(time.Second))}}
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
t.Fatalf("a directory this send found reads %v %q; want not yet", h, why)
}
// A container down beside the old wait is a fault, as before.
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{
foundDirectory("notes", sent.Add(-time.Hour)),
{Module: "notes", Resource: "notes.web", Kind: "container", Target: "notes", State: link.StateUnhealthy, Reason: "down"}}}
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
t.Fatalf("a container down beside the wait reads %v %q; want not yet", h, why)
}
}
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
for _, c := range []struct {
name string
found time.Duration // when the directory was found, against now
passes bool
}{
{"found a day before the send", -24 * time.Hour, true},
{"found by this send", time.Hour, false},
} {
t.Run(c.name, func(t *testing.T) {
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
releaseHeard = func(context.Context, *stores) (map[string]bool, error) {
return map[string]bool{"anchor": true, "laptop": true}, nil
}
backlogFacts := gatherGateFacts
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f, err := backlogFacts(ctx, open, component)
// The used-as-found condition, raised after the send (its second statement): its own kind, never a
// fault the gate reads as the build's.
f.judged, f.openErr = true, nil
f.open = append(f.open, conditions.Condition{Key: usedAsFoundKey("app", "anchor"), Kind: kindUsedAsFound,
Subject: conditions.Subject{Scope: conditions.ScopeModule, ID: "app.anchor", Machine: "anchor"},
Raised: time.Now()})
f.health = map[string]inventory.NodeHealth{}
for _, n := range []string{"anchor", "laptop"} {
f.health[n] = inventory.NodeHealth{Node: n, HeardAt: time.Now(), Resources: []inventory.ResourceHealth{
{Module: "app", Resource: "app.web", Kind: "container", Target: "app", State: link.StateHealthy},
foundDirectory("app", time.Now().Add(c.found)),
{Module: "late", Resource: "late.web", Kind: "container", Target: "late", State: link.StateHealthy}}}
}
return f, err
}
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
gateSettle, gateEvery, gateBound = 0, 0, 300*time.Millisecond
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
advancePlans(ctx, b.open)
if p := b.release(t); p.State != inventory.PlanRolling {
break
}
time.Sleep(20 * time.Millisecond)
}
p := b.release(t)
v, found, err := inv.GateOf(ctx, "build-app-c2")
if c.passes {
if p.State != inventory.PlanDone || err != nil || !found || v.Verdict != inventory.GatePassed {
t.Fatalf("the walk is %s (%s); app's verdict %+v: want the fix through", p.State, p.Note, v)
}
if !strings.Contains(v.Why+p.Note, "hand it over") {
t.Errorf("the wait is not carried: verdict %q, walk %q", v.Why, p.Note)
}
return
}
if p.State == inventory.PlanDone {
t.Fatalf("a directory this send found let the walk through: %s", p.Note)
}
})
}
}
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
k, _ := withConditionsInMemory(t)
ctx := t.Context()
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
for i := 0; i < 2; i++ {
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": i + 1}, time.Now()); err != nil {
t.Fatal(err)
}
}
open, _ := k.Open(ctx)
var got *conditions.Condition
for i, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") {
got = &open[i]
}
if c.Kind == kindModuleUnhealthy {
t.Fatalf("raised as a fault: %+v", c)
}
}
if got == nil || got.Resolver != conditions.ResolverOperator || got.Severity == conditions.Urgent ||
!strings.Contains(got.Summary, "notes.data") {
t.Fatalf("the condition: %+v", got)
}
// Long open is still not urgent: only a person can hand it over, and nothing is broken by the wait.
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": 3}, time.Now().Add(48*time.Hour)); err != nil {
t.Fatal(err)
}
open, _ = k.Open(ctx)
for _, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") && c.Severity == conditions.Urgent {
t.Fatal("a directory used as found became urgent")
}
}
if err := judgeModuleHealth(ctx, nil, k, "laptop", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil {
t.Fatal(err)
}
open, _ = k.Open(ctx)
for _, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") {
t.Fatal("not cleared once handed over")
}
}
}
+6 -4
View File
@@ -216,9 +216,10 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
firstLine(f.openErr.Error())
}
for _, c := range f.open {
// A wait for a person's new login is the module's reading, not a fault raised since the send: the
// gate reads it from the statement below (ADR 0254).
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded {
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
// novox/hq issue 339).
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
continue
}
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
@@ -329,7 +330,8 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
for _, c := range f.open {
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
slices.Contains(c.Subject.Also, machine))
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) {
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound {
kept = append(kept, c)
continue
}
+25
View File
@@ -45,3 +45,28 @@ func TestModuleCheckCountsTheUndeclaredAndRefusesThemFromTheDate(t *testing.T) {
t.Errorf("the refusal does not name the resource:\n%s", out.String())
}
}
// A file that asks for a setting without saying whether it is trusted counts as trusted (novox/hq issue 339):
// `module check` lists and counts it, and never refuses it — there is nothing unsafe to refuse.
func TestModuleCheckListsUnmarkedFilesAndNeverRefusesThem(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "module.json")
os.WriteFile(path, []byte(`{"module":"power","resources":[
{"id":"logind","type":"file","path":"/etc/systemd/logind.conf.d/power.conf","mode":"0644","trusted":true,
"content":"HandleLidSwitch=${setting:lid}\n"},
{"id":"note","type":"file","path":"/var/lib/power/note","mode":"0644","content":"${setting:greeting}\n"}]}`), 0o600)
defer func() { checkNow = time.Now }()
for _, at := range []time.Time{time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), time.Date(2036, 1, 1, 0, 0, 0, 0, time.UTC)} {
checkNow = func() time.Time { return at }
var out bytes.Buffer
if err := moduleCheck([]string{path}, &out); err != nil {
t.Fatalf("refused at %v: %v\n%s", at, err, out.String())
}
for _, want := range []string{"note ask(s) for a setting and do(es) not say whether it is trusted, so it counts as trusted",
UnsaidTrustLine + " 1"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the check does not say %q:\n%s", want, out.String())
}
}
}
}
@@ -92,3 +92,26 @@ func TestHoldingNeedsAnAnswer(t *testing.T) {
}
}
}
// The control-node withholds the login shell's `execute` (novox/hq ADR 0268): its holder there serves
// nothing on the seat, and must not be judged silent for it, as the store's rows read it back.
func TestALoginShellWithholdingExecuteIsNotSilent(t *testing.T) {
defer catalogue.UseSeats(catalogue.DefaultSeats())
var rows []catalogue.Seat
for _, s := range catalogue.DefaultSeats() {
stored := s
stored.Serves = nil
for _, v := range s.Serves {
v.Optional = false // the store never keeps the mark
stored.Serves = append(stored.Serves, v)
}
rows = append(rows, stored)
}
catalogue.UseSeats(rows)
recorded := []catalogue.Held{{Claim: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Node: "anchor", Module: "zsh"}}
expected := holdersToHear(catalogue.SeatsWithAProtocol(), recorded, nil, map[string]bool{"anchor": true}, nil, time.Now())
if _, asked := expected[catalogue.LoginShellSeat]; asked {
t.Fatalf("the login shell's holder is expected to answer, so withholding execute would be said silent: %v",
expected[catalogue.LoginShellSeat])
}
}
+90 -3
View File
@@ -135,7 +135,8 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
}
standing := map[string]conditions.Condition{}
for _, c := range open {
if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded) && c.Subject.Machine == node {
if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound) &&
c.Subject.Machine == node {
standing[c.Key] = c
}
}
@@ -158,6 +159,21 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
heldOn := map[string]string{}
providers := map[catalogue.Chosen]bool{}
for _, m := range modules {
// **A directory used as found is said as that** (novox/hq issue 339): the operator's to hand over at the
// machine, never urgent — nothing is broken by the wait that a person was not told of — and its own kind,
// so the gate never reads it as a fault of the build that happened to be sent beside it.
if said, waits := foundWait(m, node, unhealthy[m]); waits {
o := usedAsFoundObservation(m, node, said, unhealthy[m])
seen[o.Key()] = true
became[m] = kindUsedAsFound
if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen {
continue
}
if _, err := k.Observe(ctx, o); err != nil {
problems = append(problems, err.Error())
}
continue
}
// **A wait for a person's new login is said as that** (novox/hq ADR 0254): one plain sentence to the
// operator, never urgent, cleared on the first statement that no longer says it.
if said, waits := personWait(m, node, unhealthy[m]); waits {
@@ -209,6 +225,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
if c.Kind == kindReloginNeeded {
why = fmt.Sprintf("%s says %s no longer waits for a new login", node, module)
}
if c.Kind == kindUsedAsFound {
why = fmt.Sprintf("%s says no directory of %s is used as found any more", node, module)
}
if on, held := heldOn[key]; held {
why = fmt.Sprintf("what %s finds on %s waits on %s, which is unhealthy: held under its condition", module, node, on)
}
@@ -499,6 +518,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
if waits && !f.groupsAdded[module] {
waits = false
}
var found []string
for _, r := range h.Resources {
if r.Module != module {
continue
@@ -506,6 +526,14 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
if waits && r.State == link.StateUnhealthy {
continue
}
// **A directory used as found before this send waits for a person** (novox/hq issue 339): the node-engine
// left its owner and mode, and only someone at the machine can hand it over. It is no fault of this
// build, so it does not hold the module's walk — an urgent fix still goes through — and the verdict
// carries the wait. Found by this very send, the send brought it, and it is judged as unhealthy.
if usedAsFound(r) && r.Since.Before(since) {
found = append(found, r.Resource)
continue
}
switch r.State {
case link.StateHealthy:
case link.StateStarting:
@@ -521,12 +549,25 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
reasonAfter(r.Reason))
}
}
if waits {
return healthPerson, wait
if waits || len(found) > 0 {
var said []string
if waits {
said = append(said, wait)
}
if len(found) > 0 {
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for a person to hand it over "+
"(`mesh-host hand-over <directory>` at the machine)", machine, module, strings.Join(found, ", ")))
}
return healthPerson, strings.Join(said, "; ")
}
return healthGood, ""
}
// usedAsFound is a directory the node-engine states it uses as found (novox/hq issue 339).
func usedAsFound(r inventory.ResourceHealth) bool {
return r.Kind == link.KindDirectory && r.State == link.StateUnhealthy && strings.HasPrefix(r.Reason, link.ReasonUsedAsFound)
}
func reasonAfter(s string) string {
if s == "" {
return ""
@@ -595,3 +636,49 @@ func addsAccountGroups(from catalogue.Manifest, hadFrom bool, to catalogue.Manif
}
return false
}
// kindUsedAsFound is a module's condition while the node-engine uses one of its directories as found (novox/hq
// issue 339): its own kind, the operator's, never urgent, and never read by the gate as a fault of a build.
const kindUsedAsFound = "directory-used-as-found"
// usedAsFoundKey is a module's used-as-found condition on a machine.
func usedAsFoundKey(module, node string) string {
return conditions.Key(conditions.ScopeModule, module+"."+node, kindUsedAsFound)
}
// foundWait is whether everything unhealthy of a module on a machine is a directory used as found, and that in
// one sentence. Anything else unhealthy beside it is judged as a fault, with the directory among its resources.
func foundWait(module, node string, rs []inventory.ResourceHealth) (string, bool) {
var ids, why []string
for _, r := range rs {
if r.Module != module || r.State != link.StateUnhealthy {
continue
}
if !usedAsFound(r) {
return "", false
}
ids = append(ids, r.Resource)
why = append(why, strings.TrimSpace(strings.TrimPrefix(r.Reason, link.ReasonUsedAsFound)))
}
if len(ids) == 0 {
return "", false
}
return fmt.Sprintf("%s on %s uses %s as found: %s", module, node, strings.Join(ids, ", "),
strings.Join(why, "; ")), true
}
// usedAsFoundObservation is a module whose directory the node-engine uses as found, in words: the operator's, a
// warning however long it stays, its summary naming the directories and their owners; the paths are evidence.
func usedAsFoundObservation(module, node, said string, rs []inventory.ResourceHealth) conditions.Observation {
o := moduleUnhealthyObservation(module, node, rs)
o.Token, o.Kind, o.Resolver, o.Severity, o.Summary = kindUsedAsFound, kindUsedAsFound, conditions.ResolverOperator,
conditions.Warning, said
o.Headline = fmt.Sprintf("%s waits for a directory on %s", module, node)
o.Explanation = fmt.Sprintf("A directory of %s was already on %s, with another owner or mode than %s declares. "+
"The mesh left it as it was rather than hand it to an account, so %s may not be able to use it.",
module, node, module, module)
o.Needs = fmt.Sprintf("on %s, run mesh-host hand-over with the directory's path as root.", node)
o.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node)
o.Actions = nil
return o
}
+57
View File
@@ -445,6 +445,9 @@ func settingsCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, values, positionals[0], where); err != nil {
return err
}
added, changed, removed := settingsChange(before, values)
if len(removed) > 0 && !*replace {
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
@@ -596,6 +599,13 @@ func settingsCommand(ctx context.Context, args []string) error {
if len(positionals) != 1 {
return errors.New("settings clear <module> [--node <node>]")
}
before, _, err := inv.Layer(ctx, *node, positionals[0])
if err != nil {
return err
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
return err
}
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
return err
}
@@ -988,6 +998,9 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
// in the same words. A name meant on purpose is declared with its reason and passes.
func namesNoInstallation(m catalogue.Manifest) error {
if problems := catalogue.TrustProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
named := catalogue.InstallationProblems(m)
if len(named) == 0 {
return nil
@@ -1051,3 +1064,47 @@ func declaresTools(m catalogue.Manifest) bool {
}
return false
}
// The keys no verb may change are catalogue.TerminalKeys (novox/hq issue 339). `places` says where the
// node-engine creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says
// which of the machine's paths are mounted into a module's container; a provider's trust anchors say what every
// consumer trusts. Set through a verb, any of them lets any caller of the mesh's verbs — an agent among them —
// have root hand it a directory, mount one of the machine's into a container it reaches, or have the mesh trust
// an authority of its own. They are the operator's, typed at the controller's terminal.
// throughAVerb says whether this process runs a seat verb's command line: the serving controller names the
// verb in the environment of every command it runs for one (runVerb), and a person at the terminal runs none.
// Every verb route reaches a command through runVerb — the named verbs and the generic `command` alike — so
// this is the one place that knows, whatever line the verb composed.
func throughAVerb() (string, bool) {
verb := os.Getenv(verbVar)
return verb, verb != ""
}
// refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove
// places or accesses; a change that leaves both as they were is not refused.
func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inventory, before, after map[string]any,
module, where string) error {
verb, through := throughAVerb()
if !through {
return nil
}
// Judged against the module's definition as the catalogue holds it: what it serves and which of its files
// are trusted. A catalogue that cannot be read refuses rather than judging against nothing.
shelf, err := inv.Catalogue(ctx)
if err != nil {
return fmt.Errorf("which settings of %s are the terminal's cannot be read, so nothing was changed: %w", module, err)
}
for _, key := range catalogue.TerminalKeys(shelf[module]) {
was, _ := json.Marshal(before[key])
now, _ := json.Marshal(after[key])
if string(was) == string(now) {
continue
}
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+
"line came through %q): it says where root creates and owns a module's directories, which of "+
"the machine's paths are mounted into its container, or what the mesh's consumers trust, and whoever "+
"may call a verb includes agents (novox/hq issue 339). Nothing was changed", key, module, where, verb)
}
return nil
}
+8
View File
@@ -204,6 +204,14 @@ var plainWordings = map[string]func(conditions.Observation) words{
}
return reloginWords(orModule(module), machineOr(o, "a machine"), false)
}),
kindUsedAsFound: worded(func(o conditions.Observation) words {
module := ""
if o.Scope == conditions.ScopeModule && o.Machine != "" {
module = strings.TrimSuffix(o.ID, "."+o.Machine)
}
w := usedAsFoundObservation(orModule(module), machineOr(o, "a machine"), o.Summary, nil)
return words{Headline: w.Headline, Explanation: w.Explanation, Needs: w.Needs, Resolved: w.Resolved}
}),
kindProviderFailing: worded(func(o conditions.Observation) words {
thing, consumer := conditions.ThingWords(o), idPart(o, 2)
if consumer == "" {
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **The planner over edges the store derives**, not edges written by hand: modules registered, builds
// recorded with what they stood on and which repositories they read, the relation answered by
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
// path a real merge takes, short of the bus.
//
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
// expectations marked 338 change with that decision.
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
asked := time.Now().Add(-time.Hour)
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
t.Fatal(err)
}
}
controllerRead := []inventory.ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
// The shape of issue 338.
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
register(catalogue.Manifest{Module: "gitea", Version: "1"}, "novox/mesh-catalog", "modules/gitea", nil, nil)
// A, B and C in one repository; D built against A's artifact, E declaring B, P packaging the repository.
for _, n := range []string{"a", "b", "c"} {
register(catalogue.Manifest{Module: n, Version: "1"}, "novox/one", "modules/"+n, nil, nil)
}
register(catalogue.Manifest{Module: "d", Version: "1"}, "novox/two", "d",
[]string{catalogue.ArtifactStoreScheme + "a/runtime@sha256:" + strings.Repeat("0", 64)}, nil)
register(catalogue.Manifest{Module: "e", Version: "1", Build: &catalogue.Build{
On: []catalogue.BuildsOn{{Arg: "BASE", Module: "b", Artifact: "runtime"}}}}, "novox/two", "e", nil, nil)
register(catalogue.Manifest{Module: "p", Version: "1"}, "novox/two", "p", nil,
[]inventory.ReadRepository{{Repository: "novox/one", Ref: "main"}})
entries, err := inv.Catalogued(ctx)
if err != nil {
t.Fatal(err)
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
edges, err := inv.Dependencies(ctx)
if err != nil {
t.Fatal(err)
}
// The edges the hand-written rows of TestASharedRepositoryMovesWhatPackagesItAsItDoesToday use are
// the ones derived here.
var shared []inventory.Edge
in338 := map[string]bool{"mesh-controller": true, "build-agent": true, "route-proxy": true, "gitea": true}
for _, e := range edges {
if in338[e.From] && in338[e.To] {
shared = append(shared, e)
}
}
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
}
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
for _, want := range []inventory.Edge{
dep("d", inventory.EdgeStandsOn, "a"),
dep("e", inventory.EdgeDeclared, "b"),
dep("p", inventory.EdgePackages, "a"),
dep("p", inventory.EdgePackages, "b"),
dep("p", inventory.EdgePackages, "c"),
dep("d", inventory.EdgeBuiltBy, "build-agent"),
} {
found := false
for _, e := range edges {
found = found || e == want
}
if !found {
t.Errorf("no %s %s %s derived: %v", want.From, want.Kind, want.To, edges)
}
}
for _, c := range []struct {
what, repo string
paths []string
want string
issue338 bool
}{
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
{"C alone: C, and P, which packages C's repository", "one",
[]string{"modules/c/x.go"}, "c,p", true},
{"a README of the repository P packages: P moves, nothing built from it does", "one",
[]string{"README.md"}, "p", true},
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent", false},
} {
_, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
tag := ""
if c.issue338 {
tag = " (current behaviour, issue 338)"
}
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
}
}
}
+447
View File
@@ -0,0 +1,447 @@
package main
import (
"fmt"
"math/rand/v2"
"sort"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
// expectation is not bent to the code.
//
// The kinds of edge, as the code reads them (release_plan.go):
//
// kind widens the plan orders the tiers
// stands-on yes yes, after its base is built
// declared yes yes, after its base is built
// packages yes no, the same tier (a code dependency)
// built-by no yes, after the build machine — except for what the build machine stands
// on, and for the controller whose worker it binds
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
const (
repoOne = "http://forge.internal:20000/novox/one.git"
repoTwo = "http://forge.internal:20000/novox/two.git"
)
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
func dep(from, kind, to string) inventory.Edge {
return inventory.Edge{From: from, To: to, Kind: kind}
}
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
func tiered(tiers [][]string) string {
var out []string
for _, t := range tiers {
out = append(out, strings.Join(t, ","))
}
return strings.Join(out, " | ")
}
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
// is in exactly one tier.
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
t.Helper()
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
r := reachOfMerge(m, entries, read, edges)
seen := map[string]int{}
for _, tier := range r.Plan.Tiers {
for _, name := range tier {
seen[name]++
}
}
for name := range r.Plan.Modules {
if seen[name] != 1 {
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
}
}
if len(seen) != len(r.Plan.Modules) {
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
}
return r, tiered(r.Plan.Tiers)
}
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
in := func(repo, dir string, names ...string) []inventory.Entry {
var out []inventory.Entry
for _, n := range names {
d := dir + "/" + n
if dir == "" {
d = n
}
out = append(out, fromRepo(n, repo, d))
}
return out
}
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
const (
standsOn = inventory.EdgeStandsOn
declared = inventory.EdgeDeclared
packages = inventory.EdgePackages
builtBy = inventory.EdgeBuiltBy
workerOf = inventory.EdgeWorkerOf
)
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
for _, c := range []struct {
what string
entries []inventory.Entry
edges []inventory.Edge
repo string
paths []string
want string // the tiers, " | " between them
unread string
cycle bool
}{
// The operator's case: two modules changed, a third beside them in the same repository untouched,
// each changed one with a dependent.
{what: "A and B changed, C untouched beside them, D on A and E on B",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
{what: "only A's directory: A and what stands on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
{what: "only C's directory: C alone, nothing is built on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
{what: "only the dependent changed: its base does not move",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
{what: "transitive: F on D on A, A changed",
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
{what: "transitive across kinds: F declared on D, D packages A",
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
// built by; the build seat's holder follows the controller that is built by it.
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
{what: "the build machine alone moves alone", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
// Two repositories.
{what: "a dependent in another repository follows its base",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
{what: "a directory of the same name in another repository is not this one's",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
{what: "the dependent's own repository moves the dependent alone",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"g/main.go"}, want: "g"},
// A diamond.
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
{what: "a diamond of mixed kinds orders on the ordering side only",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
// A cycle the catalogue should never produce: what remains is one last tier, and said.
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
dep("f", standsOn, "c")},
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
// Files no build reads.
{what: "a README at the root of a repository whose modules all live below it",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
unread: "modules/lib/x.go,modules/README.md"},
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
} {
e := entries
if c.entries != nil {
e = c.entries
}
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
}
if u := strings.Join(r.Unread, ","); u != c.unread {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
}
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
}
}
}
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
// built from a shared repository moves on every merge to it) and the decision pending on it would change
// every row here. Today:
//
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
// each a packages edge to every module built from it;
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
// tiers.
//
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{
fromRepo("mesh-controller", controllerRepo, ""),
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"),
}
read := map[string][]inventory.ReadRepository{
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
}
edges := sharedRepositoryEdges
for _, c := range []struct {
what, repo string
paths []string
want string
}{
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
{"the controller's own code: the same", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
// In the catalogue, where they live, the rule is path-precise.
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent"},
{"another module of the catalogue: neither", "mesh-catalog",
[]string{"modules/gitea/index.ts"}, "gitea"},
} {
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
}
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
}
}
}
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
// sorts them.
var sharedRepositoryEdges = []inventory.Edge{
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
}
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
// and any set of changed files in one repository:
//
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
// for a module built from the root), and everything reachable from them along stands-on, declared and
// packages — never along built-by or worker-of;
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
// depended on in an earlier tier;
// - no cycle is said.
//
// Seeded, so a failure is replayed by its seed and case.
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
// is a prefix of another.
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
falseCycles, firstFalseCycle := 0, ""
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
for n := 0; n < 100; n++ {
repos := 1 + rng.IntN(3)
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
count := 1 + rng.IntN(12)
var entries []inventory.Entry
repoOf, dirOf := map[string]int{}, map[string]string{}
for i := 0; i < count; i++ {
name := fmt.Sprintf("m%02d", i)
repo := rng.IntN(repos)
dir := dirs[rng.IntN(len(dirs))]
if rng.IntN(12) == 0 {
dir = "" // built from the repository's root
}
repoOf[name], dirOf[name] = repo, dir
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
}
// A graph with no cycle: a module depends only on modules made before it.
var edges []inventory.Edge
for i := 1; i < count; i++ {
for j := 0; j < i; j++ {
if rng.IntN(4) == 0 {
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
}
}
}
merged := rng.IntN(repos)
var paths []string
for k := 1 + rng.IntN(4); k > 0; k-- {
if rng.IntN(3) == 0 {
paths = append(paths, files[rng.IntN(len(files))])
} else {
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
}
}
// What the rules say.
want := map[string]bool{}
for _, e := range entries {
name := e.Manifest.Module
if repoOf[name] != merged {
continue
}
for _, p := range paths {
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
want[name] = true
}
}
}
for grew := true; grew; {
grew = false
for _, e := range edges {
if widens[e.Kind] && want[e.To] && !want[e.From] {
want[e.From], grew = true, true
}
}
}
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
got := map[string]bool{}
tierOf := map[string]int{}
for i, tier := range r.Plan.Tiers {
for _, name := range tier {
got[name], tierOf[name] = true, i
}
}
replay := func() string {
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
}
if !sameSet(got, want) {
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
}
for _, e := range edges {
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
}
}
if hasCycle(r.Plan.Tiers, edges) {
falseCycles++
if firstFalseCycle == "" {
firstFalseCycle = replay()
}
}
}
}
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
if falseCycles > 0 {
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
"the first:\n%s", falseCycles, firstFalseCycle)
}
}
func sameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func describe(entries []inventory.Entry) []string {
var out []string
for _, e := range entries {
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
e.Source.Path))
}
return out
}
+31 -2
View File
@@ -157,7 +157,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
return out
}
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
// no cycle, and saying one was is a false report in every such plan's log.
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
if len(tiers) == 0 {
return false
@@ -167,6 +169,9 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
last[m] = true
}
for _, e := range edges {
if e.Kind == inventory.EdgePackages {
continue
}
if last[e.From] && last[e.To] {
return true
}
@@ -701,7 +706,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
}
}
now := time.Now().UTC()
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
// **Sent with others, judged with them** (issue 281): the gate of the send that carried it is its
// verdict on its first machine. A failure there stopped the plan already.
if state.GatedBy != "" {
@@ -715,6 +719,9 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
}
passedWith(m, state, state.GatedBy, lead.Gate)
}
// Read after its pass is taken over from the send that carried it, so a passed gate is never judged
// again from the first machine's later reports (novox/hq issue 335).
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
switch {
case step.failed != "":
// The first machine refused or failed what it was sent, or never said: the gate failed, and
@@ -974,6 +981,19 @@ func failFirstSend(ctx context.Context, open *stores, p *inventory.Plan, module
fmt.Printf("%s: %s\n", p.ID, p.Note)
}()
g := state.Gate
if g != nil && g.Verdict == inventory.GatePassed {
// **A guard: a build that passed its gate is never put back for what came after** (novox/hq issue 335).
// Not reached while nextRollout answers a passed gate with the rest to send, and advanceOnce reads it
// after a carried module takes over its lead's pass; it is here so that a path added later cannot
// overturn a verdict. If it is reached, the plan stops and says why, and the build is not marked failed.
// The module is left a stopped rollout (its Why said, sent first and not to the rest), which
// `plans retry` takes: it sends the first machine again, and the passed gate then sends the rest.
state.Why = "passed its gate; its walk then stopped: " + why
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s passed its gate on %s in tier %d (%s) and is kept; its walk stopped after: %s",
module, strings.Join(g.Machines, ", "), p.Tier, g.Why, why)
return
}
if g != nil && slices.Contains(g.Returned, module) {
// Put back at once when it broke: its rollback was made then, and is not made again.
state.Why = "put back when it broke; its send failed: " + g.Why
@@ -1061,6 +1081,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
rest = append(rest, n)
}
}
// **A passed gate is the first machine's verdict, and its later reports are not** (novox/hq issue 335).
// Once the build passed there, what that machine reports next is about whatever it was sent after —
// another walk's send, a push — and says nothing of this build. On 2026-10-08 a build passed on the
// laptop, its send to the rest waited on another walk, that walk sent the laptop a new declaration it did
// not report for half an hour, and the plan read the silence as the first machine never applying the
// passed build: it marked it failed at its gate and put it back. The rest are sent, as the pass said.
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
return rolloutStep{send: rest}
}
var waiting, failed []string
for _, n := range s.First {
r, said := byNode[n]
+10 -5
View File
@@ -27,6 +27,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
// the build seat's holder follows the controller that defines its worker (hq issue 206)
{From: "builder", To: "mesh-controller", Kind: inventory.EdgeWorkerOf},
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
}
// The runtime image moved: everything on it, and what is built by what is on it.
@@ -62,12 +64,15 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
if len(small) != 3 {
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
}
// The builder packages the controller's source (same tier by that edge) and the controller is
// built by the builder (next tier by that one): the builder first, then the controller and the
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
smallTiers := tiersOf(small, edges)
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
}
// The builder alone moved: the builder, and nothing it builds.
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// TestReplay335 replays novox/hq issue 335 (2026-10-08): dunst's new build passed its gate on the laptop
// (healthy 3 times over 2m5s); its send to the rest was refused while another walk's build waited on the
// workstation; that walk then sent the laptop a declaration the laptop did not report on; and thirty minutes
// after the first send the plan read the laptop's silence as the passed build never applied, marked it failed
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
// its fix, so it is laid over the commit before.
func TestReplay335(t *testing.T) {
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
}
// novox/hq issue 335: a build that passed its gate on its first machine is not judged again from that
// machine's later reports. On 2026-10-08 the send to the rest waited on another walk, that walk sent the
// first machine a declaration it did not report for half an hour, and the plan failed the passed build at
// the wait's bound and put it back.
func testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports(t *testing.T) {
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
judged := sentAt.Add(2 * time.Minute)
later := sentAt.Add(5 * time.Minute)
state := inventory.PlanModule{First: []string{"laptop"}, FirstAt: &sentAt,
Gate: &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed,
Why: "healthy 3 times over 2m5s", JudgedAt: &judged, Kept: true}}
running := []string{"laptop", "workstation"}
now := sentAt.Add(30*time.Minute + 9*time.Second)
for what, reports := range map[string][]inventory.Reported{
"no report about what it was sent since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeApplied, Current: false}},
"another send failed there since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeFailed, Current: true}},
"no report at all": nil,
} {
step := nextRollout(state, running, false, reports, now, 30*time.Minute)
if step.failed != "" || step.waiting != "" || !reflect.DeepEqual(step.send, []string{"workstation"}) {
t.Errorf("%s: %+v, want the rest sent as the pass said", what, step)
}
}
}
// novox/hq issue 335: whatever stops a walk after its gate passed, the passed build is not marked failed at
// its gate, nor put back.
func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed, Why: "healthy 3 times over 2m5s"}
state := &inventory.PlanModule{Build: "build-1", First: []string{"laptop"}, FirstAt: &sentAt, Gate: g}
p := &inventory.Plan{ID: "plan-1", Modules: map[string]*inventory.PlanModule{"dunst": state}}
// No stores: a walk that keeps the pass touches none, and one that reaches for them is putting it back.
defer func() {
if r := recover(); r != nil {
t.Fatalf("the passed build was taken to be failed and put back: %v", r)
}
}()
failFirstSend(t.Context(), nil, p, "dunst", state, []string{"laptop"}, "laptop did not report it applied within 30m0s",
[]string{"workstation"})
if g.Verdict != inventory.GatePassed || g.Rollback != "" {
t.Fatalf("the passed gate became %q, rollback %q", g.Verdict, g.Rollback)
}
if strings.Contains(p.Note, "failed its gate") || strings.Contains(p.Note, "put back") ||
!strings.Contains(p.Note, "did not report it applied") {
t.Fatalf("the note reads %q", p.Note)
}
}
// novox/hq issue 335 review: **a module carried by its lead's send takes over the lead's pass before its own
// step is read.** The state is the one a step leaves when the lead's gate passed and the step ended before the
// carried module's turn (an error read before it, kept with the plan): the lead passed, the carried module has
// no gate of its own yet. Since then another send reached the first machine and it has not reported on it, and
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
// carried module's step said the first machine never applied it, and the passed build was put back.
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
tm := aTierMesh(t, "m01", "m02")
ctx := t.Context()
inv := tm.open.inventory
advancePlans(ctx, tm.open)
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: the first machine once, for the tier", tm.sent)
}
p := tm.plan(t)
lead, carried := p.Modules["m01"], p.Modules["m02"]
if lead.Gate == nil || carried.GatedBy != "m01" {
t.Fatalf("m02 is not carried by m01's send: lead %+v, carried %+v", lead.Gate, carried)
}
// The lead passed; the carried module's turn did not come. The first send is past the wait's bound.
sent := time.Now().UTC().Add(-planWaitBound - time.Minute)
judged := sent.Add(2 * time.Minute)
lead.FirstAt, carried.FirstAt, lead.Gate.Since = &sent, &sent, &sent
lead.Gate.Verdict, lead.Gate.Why, lead.Gate.JudgedAt, lead.Gate.Kept = inventory.GatePassed,
"healthy 3 times over 2m5s", &judged, true
carried.Gate = nil
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
// Another walk's send reached anchor, which has not reported on it.
if err := inv.RecordSent(ctx, nodeID(t, tm.open, "anchor"), "d-anchor-elsewhere",
map[string]string{"m01": "c2", "m02": "c2"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, tm.open)
p = tm.plan(t)
if p.State == inventory.PlanFailed {
t.Fatalf("the plan failed after its gate passed: %s", p.Note)
}
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}, {"laptop"}}) {
t.Fatalf("sent %v: the rest once, as the pass said", tm.sent)
}
current, err := inv.CurrentBuilds(ctx)
if err != nil {
t.Fatal(err)
}
for _, m := range []string{"m01", "m02"} {
if current[m].Commit != "c2" {
t.Errorf("%s was put back to %s after its gate passed", m, current[m].Commit)
}
if failed, _ := inv.GateFailed(ctx, "build-"+m+"-2"); failed {
t.Errorf("%s's build was marked failed at its gate after the gate passed", m)
}
}
if g := p.Modules["m02"].Gate; g == nil || g.Verdict != inventory.GatePassed {
t.Errorf("m02 did not take over m01's pass: %+v", g)
}
}
+8
View File
@@ -245,6 +245,14 @@ func (a *verbArguments) commandLine() ([]string, error) {
if len(argv) == 0 {
return nil, errors.New("command names no command")
}
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
// settings verb is where what a verb may not set is refused, and one route is one set of words.
// The command refuses places and accesses through any verb as well; this says so before it runs.
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
"Nothing was done")
}
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
// it says why, as it would through its own verb.
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
+1 -1
View File
@@ -245,7 +245,7 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) {
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
t.Fatalf("a plain line: %v %v", argv, err)
}
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
}
@@ -0,0 +1,213 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
// are said at the controller's terminal alone (novox/hq issue 339): through a verb, a caller who set `places`
// to /etc with an owner of its own would have the next push hand it /etc, and one who set `accesses` to /
// would have the machine's root mounted into a container.
// throughVerb runs a verb's call the way the serving controller does: the command line argvFor composes, in
// a process that carries the verb in its environment (runVerb), through this binary's own dispatch.
func throughVerb(t *testing.T, verb string, args map[string]any) error {
t.Helper()
argv, err := argvFor(verb, args)
if err != nil {
return err
}
t.Setenv(verbVar, verb)
defer os.Unsetenv(verbVar)
return runLine(t, argv)
}
// atTheTerminal runs a command line the way the operator does at the controller's terminal: no verb.
func atTheTerminal(t *testing.T, argv ...string) error {
t.Helper()
t.Setenv(verbVar, "")
os.Unsetenv(verbVar)
return runLine(t, argv)
}
func runLine(t *testing.T, argv []string) error {
t.Helper()
before := os.Args
defer func() { os.Args = before }()
os.Args = append([]string{"mesh-controller"}, argv...)
return run()
}
func TestPlacesAndAccessesAreRefusedThroughEveryVerb(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Accesses: []catalogue.Access{{ID: "media", Path: "/storage/media", Mode: "read"}},
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
// Nothing trusts this file: said, so a verb may change what it asks for (an unmarked one counts as
// trusted, and only the terminal could).
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
"content": "x = ${setting:x}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
layer := func() string {
t.Helper()
values, _, err := open.inventory.Layer(ctx, "laptop", "notes")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(values)
return string(raw)
}
refused := func(what string, err error) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
!strings.Contains(err.Error(), "issue 339") {
t.Fatalf("%s: %v", what, err)
}
}
// The attack the issue reports, through each verb route: nothing is kept.
attacks := []map[string]any{
{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}, "x": 1},
{"accesses": map[string]any{"media": "/srv/elsewhere"}, "x": 1},
}
for _, values := range attacks {
raw, _ := json.Marshal(values)
refused("settings verb, one machine", throughVerb(t, "settings",
map[string]any{"module": "notes", "node": "laptop", "values": string(raw)}))
refused("settings verb, the whole mesh", throughVerb(t, "settings",
map[string]any{"module": "notes", "values": string(raw)}))
for _, line := range []string{
"settings set notes '" + string(raw) + "' --node laptop",
"settings set --node laptop notes '" + string(raw) + "'",
"settings set notes '" + string(raw) + "'",
} {
if err := throughVerb(t, "command", map[string]any{"command": line}); err == nil {
t.Fatalf("the command verb ran %q", line)
}
}
if got := layer(); got != "null" && got != "{}" {
t.Fatalf("a refused call kept a layer: %s", got)
}
}
// At the terminal the same placement is taken.
if err := atTheTerminal(t, "settings", "set", "notes",
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":0}`, "--node", "laptop"); err != nil {
t.Fatalf("places at the terminal: %v", err)
}
// A verb may change another key and keep the placement as it is.
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":1}`}); err != nil {
t.Fatalf("another key through the verb: %v", err)
}
kept := layer()
// But not move it, drop it, or clear the layer that holds it.
refused("moved through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
"values": `{"places":{"data":{"path":"/srv/other","owner":"1001:1001"}},"x":1}`}))
refused("dropped through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
"values": `{"x":1}`, "replace": "true"}))
refused("cleared through the verb", throughVerb(t, "settings",
map[string]any{"module": "notes", "node": "laptop", "clear": "true"}))
if err := throughVerb(t, "command", map[string]any{"command": "settings clear notes --node laptop"}); err == nil {
t.Fatal("the command verb cleared a layer")
}
if got := layer(); got != kept {
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
}
// Reading through a verb still answers.
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop"}); err != nil {
t.Fatalf("reading through the verb: %v", err)
}
// The machine's own trees are refused from anywhere, the terminal too.
for _, path := range []string{"/etc", "/etc/sudoers.d", "/", "/home", "/var/lib", "/var/lib/mesh-host/x", "/srv/../etc"} {
err := atTheTerminal(t, "settings", "set", "notes",
`{"places":{"data":{"path":"`+path+`","owner":"1001:1001"}},"x":1}`, "--node", "laptop")
if err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Fatalf("a place at %s at the terminal: %v", path, err)
}
err = atTheTerminal(t, "settings", "set", "notes",
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"accesses":{"media":"`+path+`"},"x":1}`,
"--node", "laptop")
if err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Fatalf("an access at %s at the terminal: %v", path, err)
}
}
// A line break in any setting is refused where it is kept, through a verb or at the terminal.
for _, x := range []string{`"a\nPATH=/tmp"`, `"a\rb"`, `"a\u0000b"`, `["ok","x\ny"]`, `{"k":"x\ny"}`} {
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "replace": "true",
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":` + x + `}`})
if err == nil || !strings.Contains(err.Error(), "line break") {
t.Fatalf("a line break in %s: %v", x, err)
}
}
if got := layer(); got != kept {
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
}
}
// What a provider serves is set at the terminal alone (novox/hq issue 339): through the settings verb, a caller
// could move a database's port to a listener of its own and collect every consumer's credentials, or point every
// login at an issuer of its own.
func TestAServedKeyIsRefusedThroughAVerb(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "store", Version: "1",
Provides: catalogue.FromAnywhere("database"),
Serves: map[string]map[string]any{"database": {"port": 5432.0}},
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/store.conf", "mode": "0644",
"trusted": false, "content": "x = ${setting:x}\n"}}})
register(t, open, catalogue.Manifest{Module: "keycloak", Version: "1",
Provides: catalogue.FromAnywhere("oidc-client"),
Serves: map[string]map[string]any{"oidc-client": {"issuer": "${setting:issuer}"}},
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/kc.conf", "mode": "0644",
"trusted": false, "content": "x = ${setting:x}\n"}}})
register(t, open, catalogue.Manifest{Module: "power", Version: "1",
Resources: []map[string]any{{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf",
"mode": "0644", "trusted": true, "content": "HandleLidSwitch=${setting:lid}\nx=${setting:x}\n"}}})
if err := atTheTerminal(t, "settings", "set", "keycloak", `{"issuer":"https://id.example/realms/mesh","x":0}`,
"--node", "anchor"); err != nil {
t.Fatalf("the issuer at the terminal: %v", err)
}
if err := atTheTerminal(t, "settings", "set", "power", `{"lid":"suspend","x":0}`, "--node", "anchor"); err != nil {
t.Fatal(err)
}
for _, m := range []string{"store", "keycloak", "power"} {
if _, err := assign(ctx, open, "anchor", m); err != nil {
t.Fatal(err)
}
}
refused := func(what string, err error) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "controller's terminal") {
t.Fatalf("%s: %v", what, err)
}
}
refused("a served port through the verb", throughVerb(t, "settings", map[string]any{"module": "store",
"node": "anchor", "values": `{"port":6543,"x":0}`}))
refused("a served port, mesh-wide, through the verb", throughVerb(t, "settings",
map[string]any{"module": "store", "values": `{"port":6543}`}))
refused("the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak",
"node": "anchor", "values": `{"issuer":"https://evil.example/realms/mesh","x":0}`}))
refused("clearing the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak",
"node": "anchor", "clear": "true"}))
if err := throughVerb(t, "settings", map[string]any{"module": "keycloak", "node": "anchor",
"values": `{"issuer":"https://id.example/realms/mesh","x":1}`}); err != nil {
t.Fatalf("another key through the verb, the issuer kept: %v", err)
}
refused("a setting a trusted file asks for, through the verb", throughVerb(t, "settings", map[string]any{
"module": "power", "node": "anchor", "values": `{"lid":"ignore","x":0}`}))
// And `trusted` is the catalogue's word: it never reaches the machine, whose engine parses strictly.
plan := printed(t, func() error { return atTheTerminal(t, "plan", "anchor", "--json") })
if strings.Contains(plan, `"trusted"`) {
t.Fatal("the declaration carries the catalogue's `trusted`")
}
}
+9 -2
View File
@@ -17,9 +17,16 @@ import (
//
// 04-ISSUES/038 was the first of them (the port). These are the rest.
// A root certificate, in the shape a certificate authority serves one.
// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
const servedRoot = `-----BEGIN CERTIFICATE-----
MIIBeDCCAR2gAwIBAgIQfake0000000000000000000000
MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
-----END CERTIFICATE-----
`
+1
View File
@@ -913,6 +913,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// such field, and the reason is for a reader of the manifest.
delete(copied, SecretsInEnvironment)
delete(copied, NamesOnPurpose)
delete(copied, TrustedField)
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
// definition may not carry because it is true of one installation only. Filled from
// the same layers a mergeable file takes, and refused when no layer set it.
@@ -0,0 +1,41 @@
package catalogue
import "testing"
// The login shell's `execute` runs any command as the operator account, which can become root without a
// person, so the operator withheld it on the control-node until a call to it needs a person's approval
// (novox/hq ADR 0268). It is withheld per machine by the holder's own setting, so the seat must let a
// holder hold it without serving the verb there: `execute` is optional (ADR 0246's mark), and stays so in
// a seat row read back from the store, which never keeps the mark.
func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) {
check := func(t *testing.T) {
t.Helper()
seat, ok := SeatNamed(LoginShellSeat)
if !ok {
t.Fatal("node-login-shell is not defined")
}
if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" {
t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves)
}
if !seat.Serves[0].Optional {
t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there")
}
withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}}
if err := CanHold(withholding, seat); err != nil {
t.Fatalf("a holder serving no execute is refused: %v", err)
}
serving := withholding
serving.Claims = []Claim{{Name: LoginShellSeat, Scope: ScopeNode, Serves: []string{"execute"}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("a holder serving execute is refused: %v", err)
}
}
t.Run("compiled", check)
t.Run("read back from the store", func(t *testing.T) {
before := seats
t.Cleanup(func() { seats = before })
UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
Serves: []Verb{{Name: "execute"}}}})
check(t)
})
}
+1
View File
@@ -1759,6 +1759,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
}
problems = append(problems, invokeProblems(m)...)
problems = append(problems, replacesProblems(m)...)
problems = append(problems, UnitSettingProblems(m)...)
problems = append(problems, endpointNameProblems(m)...)
problems = append(problems, RouteProblems(m)...)
for _, port := range m.Guards {
+60 -2
View File
@@ -2,6 +2,7 @@ package catalogue
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -44,6 +45,53 @@ type Placement struct {
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339).
//
// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and
// whatever the module writes into it is written as root; an access is mounted into the module's container,
// which may run as root. A place at /etc owned by an account a caller names hands that account the machine,
// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here,
// before anything is kept or composed, and the node-engine refuses them again where it applies.
//
// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home,
// what lives only while the machine runs, the spool (cron's tables are there), the container runtimes' data
// (every container's filesystem), /opt, and the node-engine's and the mesh's own state. Any home's .ssh is
// refused as well, wherever the home is. systemRoots are
// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every
// module's or every person's directories, and owning it is owning all of them.
var (
systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys",
"/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/spool",
"/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/opt"}
systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/home",
"/mnt", "/media", "/srv", "/tmp", "/storage", "/data", "/services"}
)
// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "".
func systemPath(path string) string {
// Any home's keys, wherever the home is: a .ssh directory is its account's, and the keys and the list of who
// may log in as it are in there.
for _, part := range strings.Split(path, "/") {
if part == ".ssh" {
return path + " is an account's .ssh, which holds its keys and who may log in as it"
}
}
for _, tree := range systemTrees {
if path == tree || strings.HasPrefix(path, tree+"/") {
return path + " is in " + tree + ", the machine's own or the mesh's state"
}
if strings.HasPrefix(tree, path+"/") || path == "/" {
return path + " holds " + tree + ", the machine's own or the mesh's state"
}
}
for _, root := range systemRoots {
if path == root {
return path + " is the parent of every module's or every person's directories"
}
}
return ""
}
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
@@ -103,7 +151,12 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
if !strings.HasPrefix(p.Path, "/") {
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
}
p.Path = strings.TrimRight(p.Path, "/")
p.Path = filepath.Clean(p.Path)
if why := systemPath(p.Path); why != "" {
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+
"directory as root, with the owner the setting names — no placement is ever there "+
"(novox/hq issue 339)", m.Module, id, p.Path, why)
}
out[id] = p
}
}
@@ -147,7 +200,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
m.Module, id, body)
}
out[id] = strings.TrimRight(path, "/")
path = filepath.Clean(path)
if why := systemPath(path); why != "" {
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
"module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why)
}
out[id] = path
}
}
if len(out) == 0 {
+13 -6
View File
@@ -256,8 +256,9 @@ var defaultSeats = append([]Seat{
{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.
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
// may call where the machine serves it (optional: ADR 0268); 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
@@ -721,9 +722,6 @@ func uplinkVerbs() []Verb {
}
}
// 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.
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
// and restore beside the live data — never over it.
func backupVerbs() []Verb {
@@ -745,9 +743,18 @@ func backupVerbs() []Verb {
}
}
// loginShellVerbs is the contract of node-login-shell (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.
//
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
// become root without a person, so a machine may withhold it: the control-node does, through the
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
func loginShellVerbs() []Verb {
return []Verb{
{Name: "execute", Description: "Run one command on this machine as the operator account, in a " +
{Name: "execute", Optional: true, 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",
+159
View File
@@ -0,0 +1,159 @@
package catalogue
import (
"strings"
"testing"
)
// A setting may name the unit a service resource holds: a module that stops the distribution's own timer
// for the pool the operator names cannot write the pool into its definition (novox/hq ADR 0112), and a
// unit name with ${setting:…} left in it is a unit no machine has, so the apply fails far from its cause.
func scrubber() Manifest {
return Manifest{Module: "zfs",
Settings: map[string]SettingDeclaration{
"scrub-cadence": {Kind: KindPreference, Default: "weekly", Why: "what the distribution's timer did"},
},
Resources: []map[string]any{
{"id": "distribution-weekly", "type": "service", "unit": "zfs-scrub-weekly@${setting:scrub-pool}.timer",
"state": "stopped", "boot": "disabled"},
{"id": "distribution-monthly", "type": "service", "unit": "zfs-scrub-monthly@${setting:scrub-pool}.timer",
"state": "stopped", "boot": "disabled"},
{"id": "scrub-config", "type": "file", "path": "/etc/zfs-tools/scrub.conf",
"content": "pool=${setting:scrub-pool}\ncadence=${setting:scrub-cadence}\n"},
},
}
}
func TestASettingNamesAServicesUnit(t *testing.T) {
m := scrubber()
layers := WithDefaults(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}})
for i, want := range []string{"zfs-scrub-weekly@storage.timer", "zfs-scrub-monthly@storage.timer"} {
r := map[string]any{}
for k, v := range m.Resources[i] {
r[k] = v
}
if err := settingInto(r, layers, m.Module); err != nil {
t.Fatal(err)
}
if r["unit"] != want {
t.Errorf("composed as %q, not %q", r["unit"], want)
}
}
}
// Composed for a machine, the way a push writes it: the operator's pool and cadence reach the units' names
// and the file.
func TestAComposedMachineGetsTheUnitTheSettingNames(t *testing.T) {
r := anAdoptedAnchor()
r.Modules = append(r.Modules, scrubber())
with := anchorRendering(false)
with.Settings["zfs"] = []Layer{{From: "anchor", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}}
composed, err := r.Compose(with)
if err != nil {
t.Fatal(err)
}
if why, left := composed.LeftOut["zfs"]; left {
t.Fatalf("left out: %s", why)
}
got := byID(composed.Resources)
for id, want := range map[string]string{"zfs.distribution-weekly": "zfs-scrub-weekly@storage.timer",
"zfs.distribution-monthly": "zfs-scrub-monthly@storage.timer"} {
if got[id]["unit"] != want {
t.Errorf("%s composed as %v, not %s", id, got[id]["unit"], want)
}
t.Logf("%s: %v", id, got[id]["unit"])
}
if c := got["zfs.scrub-config"]["content"]; c != "pool=storage\ncadence=monthly\n" {
t.Errorf("the file: %q", c)
}
}
// A value that would not make a unit's name is refused by name, never written: a space, a slash, a
// newline that would begin a directive, or a second suffix.
func TestASettingThatMakesNoUnitNameIsRefused(t *testing.T) {
m := scrubber()
for _, bad := range []string{"", "stor age", "a/b", "storage\nExecStart=/bin/sh", "a@b", "$(x)", "*", `a\\x2d`} {
r := map[string]any{}
for k, v := range m.Resources[0] {
r[k] = v
}
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
if err == nil || !strings.Contains(err.Error(), "scrub-pool") || !strings.Contains(err.Error(), "unit") {
t.Errorf("%q: %v", bad, err)
}
if r["unit"] != m.Resources[0]["unit"] {
t.Errorf("%q: the unit was changed on refusal: %v", bad, r["unit"])
}
}
r := map[string]any{}
for k, v := range m.Resources[0] {
r[k] = v
}
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:scrub-pool}") {
t.Errorf("nothing set: %v", err)
}
}
// A key a unit's name asks for is a destination, so setting it is not called stray, and judging the
// settings sees it.
func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) {
m := scrubber()
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage"}}}), "; ")
if strings.Contains(stray, "scrub-pool") {
t.Errorf("called stray: %s", stray)
}
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "scrub-pool") {
t.Errorf("a unit's setting nothing sets is not refused when judged: %v", err)
}
}
// Third review: a setting may name only a template's instance — right after the `@`, before the final
// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind.
// Refused where the manifest is read, near its author.
func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) {
ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"}
bad := []string{
"${setting:unit}",
"${setting:name}.timer",
"zfs-scrub-${setting:cadence}@storage.timer",
"zfs-scrub@${setting:pool}-x.timer",
"zfs-scrub@x-${setting:pool}.timer",
"zfs-scrub@${setting:pool}.${setting:kind}",
"zfs-scrub@${setting:pool}",
"zfs-scrub@${setting:a}${setting:b}.timer",
}
for _, unit := range append(ok, bad...) {
m := Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}}
err := parsed(t, m)
refused := err != nil && strings.Contains(err.Error(), "instance")
want := false
for _, b := range bad {
want = want || b == unit
}
if refused != want {
t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err)
}
}
}
func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) {
m := scrubber()
for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} {
r := map[string]any{}
for k, v := range m.Resources[0] {
r[k] = v
}
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
if err == nil || !strings.Contains(err.Error(), "scrub-pool") {
t.Errorf("%q: %v", bad, err)
}
}
r := map[string]any{}
for k, v := range m.Resources[0] {
r[k] = v
}
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": strings.Repeat("a", 64)}}}, m.Module); err != nil {
t.Errorf("64 characters: %v", err)
}
}
+74 -6
View File
@@ -15,7 +15,7 @@ import (
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
// operator answering.
//
// `${setting:<key>}` in a file's content is filled from the module's settings layers — the mesh's,
// `${setting:<key>}` in a file's content, and in a service's unit name, is filled from the module's settings layers — the mesh's,
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
// naming the key and the remedy: a definition that carried a default for a mail domain would be
@@ -45,6 +45,9 @@ func settingsUsed(content string) []string {
// the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read
// it (a number without a trailing .000000, a boolean as true/false).
func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) == "service" {
return settingIntoUnit(resource, layers, module)
}
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
@@ -68,6 +71,67 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
return nil
}
// unitPart is what a setting may put into a unit's name: the characters systemd allows in a unit name,
// less the instance's `@` and the escape's `\`, and at least one of them. Anything else — a space, a slash,
// a newline that would begin a directive in the unit file the name ends up in — is refused, never written.
var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`)
// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template,
// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can
// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147).
var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`)
// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting
// anywhere but as a template's whole instance.
func UnitSettingProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "service" {
continue
}
unit, _ := r["unit"].(string)
if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") {
continue
}
if !unitInstance.MatchString(unit) {
problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+
"instance; a setting may stand only as the whole instance, after the @ and before the suffix "+
"(name@${setting:key}.timer)", m.Module, r["id"], unit))
}
}
return problems
}
// settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the
// distribution's timer for the pool the operator names cannot write the pool into its definition
// (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a
// unit's name; the resource is left as it was.
func settingIntoUnit(resource map[string]any, layers []Layer, module string) error {
unit, ok := resource["unit"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(unit) {
value, set := settingValue(layers, key)
if !set {
return fmt.Errorf(
"%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))
}
v := plainly(value)
if !unitPart.MatchString(v) {
return fmt.Errorf("%s: the setting %q is %q, which cannot be the instance of the unit %s: an instance "+
"takes letters, digits, ':', '_', '.' and '-', does not begin with '-' (a systemctl option), and is "+
"at most 64 characters", module, key, v, unit)
}
unit = strings.ReplaceAll(unit, "${setting:"+key+"}", v)
}
resource["unit"] = unit
return nil
}
func settingValue(layers []Layer, key string) (any, bool) {
var value any
set := false
@@ -106,11 +170,15 @@ func settingKeysUsedBy(m Manifest) map[string]bool {
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if content, ok := r["content"].(string); ok {
note(content)
switch fmt.Sprint(r["type"]) {
case "file":
if content, ok := r["content"].(string); ok {
note(content)
}
case "service":
if unit, ok := r["unit"].(string); ok {
note(unit)
}
}
}
inValues := func(values map[string]any) {
+107
View File
@@ -1,10 +1,14 @@
package catalogue
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -86,6 +90,7 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
out["content"] = string(rendered) + "\n"
delete(out, "merge")
delete(out, "protected")
delete(out, TrustedField)
return out, nil
}
@@ -209,6 +214,105 @@ func deepCopy(in map[string]any) map[string]any {
return out
}
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) or a NUL in any
// string of any setting, for every
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
// (certificates), because that is how step-ca serves it.
func settingsHoldOneLine(module string, layers []Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if module == rootModule && key == rootSetting {
if text, ok := layer.Values[key].(string); ok && certificates(text) {
continue
}
}
if at := lineBreakIn(layer.Values[key], key); at != "" {
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end or a NUL, which a "+
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
"issue 339)", module, at, layer.From)
}
}
}
return nil
}
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
// rootSetting is the one setting that may hold lines: the certificate authority's root, which step-ca serves to
// its consumers as the setting `root` and which they write into a bundle file (the co-located path, issue 038).
const (
rootModule = "step-ca"
rootSetting = "root"
)
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
// surrounds them, and every line ends with \n alone.
func certificates(v string) bool {
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
return false
}
rest := []byte(v)
found := 0
for len(rest) > 0 {
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
return false
}
block, after := pem.Decode(rest)
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
return false
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return false
}
found++
rest = after
}
return found > 0
}
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
func lineBreakIn(v any, at string) string {
if strings.ContainsAny(at, lineEnds) {
return strconv.Quote(at)
}
switch t := v.(type) {
case string:
if strings.ContainsAny(t, lineEnds) {
return at
}
case map[string]any:
for _, k := range sortedKeysAny(t) {
if found := lineBreakIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, e := range t {
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// UnusedSettings names settings that reach nothing.
//
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
@@ -405,6 +509,9 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
if err := settingsHoldOneLine(m.Module, layers); err != nil {
return err
}
// With no layers too: a definition may ask for a setting nobody made — an access placed by
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
if _, err := GivenPorts(m, layers); err != nil {
+9 -6
View File
@@ -29,9 +29,11 @@ const MountsSeat = "node-mounts"
func nfsServerVerbs() []Verb {
return []Verb{
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
"read-only, the owner every client is mapped to (uid and gid), the clients it is exported to (the " +
"private network's range), and whether the kernel holds it now. Also the exports found that are " +
"not the mesh's: a dataset's sharenfs property, a line in /etc/exports.",
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
"/etc/exports.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
@@ -39,11 +41,12 @@ func nfsServerVerbs() []Verb {
Input: schema(map[string]string{}, nil),
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
"NFS service is up; with an address, also whether that address is inside the private network's " +
"range the share is exported to. What a machine mounting the share asks before it mounts.",
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
"own private address, when the server grants it the share and the node asks for it. What a machine " +
"mounting the share asks before it mounts.",
Input: schema(map[string]string{
"share": "the share, by its name",
"address": "a client's address on the private network (optional)",
"address": "a node's private address, to ask whether the share is exported to it (optional)",
}, []string{"share"}),
Replaces: []string{"showmount -e"}},
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
+18
View File
@@ -159,3 +159,21 @@ func TestReloadSaysWhatItDoes(t *testing.T) {
}
}
}
// A share is exported to each node the server grants it to and that asks for it, at that node's own
// address (novox/hq ADR 0263 rule 5) — never to the private network's whole range. The verbs that
// describe the export say so, and do not promise the range.
func TestTheExportVerbsDescribePerNodeAddresses(t *testing.T) {
s, _ := SeatNamed(NFSServerSeat)
for _, v := range s.Serves {
if v.Name != "exports" && v.Name != "test" {
continue
}
if strings.Contains(v.Description, "private network's range") {
t.Errorf("%s still describes the export as the private network's range: %s", v.Name, v.Description)
}
if !strings.Contains(v.Description, "address") {
t.Errorf("%s does not say the export is to each node's address: %s", v.Name, v.Description)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package catalogue
import (
"fmt"
"sort"
)
// Which settings are the controller's terminal's alone (novox/hq issue 339).
//
// A setting is the operator's word on how a module is configured, and the `settings` verb writes it for any
// caller allowed to call verbs — agents among them. Most settings change only the module itself. Some change
// what root or another module trusts, and those are said at the terminal alone, never through a verb:
//
// 1. `places` and `accesses`: where the node-engine creates and, as root, owns a module's directories, and
// which of the machine's paths are mounted into its container.
// 2. **Every key a provider serves**, and every setting a served value asks for. A setting overrides a served
// key (Settle), and what is served is what every consumer of the provision connects to and believes: a
// database's port, an object store's scheme, a registry's port, an identity provider's issuer and token
// path. Through a verb, any caller could point every consumer at a listener of its own and collect the
// credentials they present.
// 3. **Every setting a file asks for, unless the file says `"trusted": false`.** A file root or a consumer trusts —
// a logind drop-in, an env file that says which uid a container runs as, a script run as root — must not
// change through a verb, and the safe reading of a file that says nothing is that it is one of them (fail
// closed). `"trusted": false` is the opt-out, for a file nothing trusts: a person's own notifier settings.
// `module check` lists the files that say nothing, so an author can opt one out where that is true.
//
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
// TrustedField is the key a file resource carries to say whether the settings it asks for are trusted: true, or
// absent, makes each a terminal key; false says, out loud, that none changes what root or a consumer trusts. Said in the
// catalogue, never on the machine: the composer takes it out before the node-engine, which parses strictly.
const TrustedField = "trusted"
// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and
// accesses, every key its provisions serve and every setting a served value asks for, and every setting a file
// asks for unless it says `"trusted": false`. Places and accesses first, then the rest sorted.
func TerminalKeys(m Manifest) []string {
keys := map[string]bool{}
for _, served := range m.Serves {
for key, value := range served {
keys[key] = true
if s, ok := value.(string); ok {
for _, asked := range settingsUsed(s) {
keys[asked] = true
}
}
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if trusted, said := r[TrustedField].(bool); said && !trusted {
continue
}
if content, ok := r["content"].(string); ok {
for _, asked := range settingsUsed(content) {
keys[asked] = true
}
}
}
delete(keys, PlacesSetting)
delete(keys, AccessesSetting)
rest := make([]string, 0, len(keys))
for k := range keys {
rest = append(rest, k)
}
sort.Strings(rest)
return append([]string{PlacesSetting, AccessesSetting}, rest...)
}
// UnsaidTrust is every file of a module that asks for a setting and does not say whether it is trusted, by id:
// each counts as trusted, and is listed so an author can opt out a file nothing trusts.
func UnsaidTrust(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
content, _ := r["content"].(string)
if len(settingsUsed(content)) == 0 {
continue
}
if _, said := r[TrustedField]; !said {
out = append(out, fmt.Sprint(r["id"]))
}
}
sort.Strings(out)
return out
}
// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but
// a file. Refused at registration and by `module check`.
func TrustProblems(m Manifest) []string {
var out []string
for _, r := range m.Resources {
v, said := r[TrustedField]
if !said {
continue
}
if fmt.Sprint(r["type"]) != "file" {
out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+
"asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField))
continue
}
if _, ok := v.(bool); !ok {
out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)",
m.Module, r["id"], TrustedField, v))
}
}
return out
}
@@ -0,0 +1,177 @@
package catalogue
import (
"strings"
"testing"
)
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
// issue 339); a module's own place elsewhere is taken.
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
Accesses: []Access{{ID: "media"}}}
for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib",
"/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity",
"/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("a place at %s: %v", path, err)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("an access at %s: %v", path, err)
}
}
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies",
"/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
t.Errorf("a place at %s: %v %v", path, got, err)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path {
t.Errorf("an access at %s: %v %v", path, got, err)
}
}
}
// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too —
// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339).
func TestASettingHoldsOneLine(t *testing.T) {
m := Manifest{Module: "mailu"}
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"},
map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} {
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
!strings.Contains(err.Error(), "line break") {
t.Errorf("%q: %v", v, err)
}
}
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil {
t.Error("a line break in a key was taken")
}
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0,
"l": []any{"a", "b"}}}}, false); err != nil {
t.Errorf("one line each: %v", err)
}
}
// Lines are allowed in one setting only: step-ca's `root`, as certificates that encoding/pem decodes and
// x509.ParseCertificate parses (novox/hq issue 339). Any other module or key, another label, or a block that is
// not a certificate is refused like any other line break.
func TestOnlyTheAuthoritysRootMayHoldLines(t *testing.T) {
ca := Manifest{Module: "step-ca"}
judge := func(m Manifest, key, v string) error {
return JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{key: v}}}, false)
}
for _, ok := range []string{servedRoot, servedRoot + servedRoot, strings.TrimSuffix(servedRoot, "\n")} {
if err := judge(ca, "root", ok); err != nil {
t.Errorf("the authority's root: %v", err)
}
}
body := strings.TrimSuffix(strings.TrimPrefix(servedRoot, "-----BEGIN CERTIFICATE-----\n"), "-----END CERTIFICATE-----\n")
for name, bad := range map[string]string{
"a line after it": servedRoot + "PATH=/tmp\n",
"a line before it": "PATH=\n" + servedRoot,
"another label": "-----BEGIN PRIVATE KEY-----\n" + body + "-----END PRIVATE KEY-----\n",
"headers": "-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n\n" + body + "-----END CERTIFICATE-----\n",
"base64 that is no cert": "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n",
"carriage returns": strings.ReplaceAll(servedRoot, "\n", "\r\n"),
} {
if err := judge(ca, "root", bad); err == nil {
t.Errorf("%s was taken", name)
}
}
if err := judge(ca, "other", servedRoot); err == nil {
t.Error("a certificate in another key of the authority was taken")
}
if err := judge(Manifest{Module: "mailu"}, "root", servedRoot); err == nil {
t.Error("a certificate in another module's setting was taken")
}
if err := JudgeSettings(ca, []Layer{{From: "anchor", Values: map[string]any{"root": []any{servedRoot}}}}, false); err == nil {
t.Error("a certificate below the top of the setting was taken")
}
}
// Every character a reader takes as the end of a line is refused, not only \n and \r: vertical tab, form feed,
// NEL and the Unicode line and paragraph separators (novox/hq issue 339).
func TestEveryLineEndIsRefused(t *testing.T) {
m := Manifest{Module: "mailu"}
for _, v := range []string{"a\vb", "a\fb", "a\u0085b", "a\u2028b", "a\u2029b"} {
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
!strings.Contains(err.Error(), "line break") {
t.Errorf("%q: %v", v, err)
}
}
}
// The review's additions: the spool, the container runtimes' data, /opt, and any home's .ssh.
func TestTheRuntimesDataAndAnyHomesSSHAreTheMachinesOwn(t *testing.T) {
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
Accesses: []Access{{ID: "media"}}}
for _, path := range []string{"/var/spool", "/var/spool/cron", "/var/lib/docker", "/var/lib/docker/volumes",
"/var/lib/containers/storage", "/var/lib/containerd", "/var/lib/containerd/io.containerd.snapshotter.v1", "/opt", "/opt/app", "/home/alice/.ssh", "/home/alice/.ssh/keys",
"/srv/backup/.ssh", "/root/.ssh"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("a place at %s: %v", path, err)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
t.Errorf("an access at %s: %v", path, err)
}
}
for _, path := range []string{"/var/lib/dockerish", "/home/alice/ssh", "/home/alice/.sshd-notes", "/storage/media"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if _, err := Places(m, layers); err != nil {
t.Errorf("a place at %s: %v", path, err)
}
}
}
// What a provider serves its consumers is the terminal's (novox/hq issue 339): any key under its `serves`, and any
// setting a served value asks for, is set at the terminal alone — a verb that could change a port could point every
// consumer at a listener of the caller's own. So is any setting a file marked `trusted` asks for.
func TestTerminalKeysAreDerived(t *testing.T) {
postgres := Manifest{Module: "postgres", Serves: map[string]map[string]any{"postgres-database": {"port": 5432.0}}}
keycloak := Manifest{Module: "keycloak", Serves: map[string]map[string]any{"oidc-client": {
"issuer": "${setting:issuer}", "token-path": "/protocol/openid-connect/token"}}}
power := Manifest{Module: "power", Resources: []map[string]any{
{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "trusted": true,
"content": "HandleLidSwitch=${setting:handle-lid-switch}\n"},
{"id": "note", "type": "file", "path": "/var/lib/power/note", "trusted": false, "content": "${setting:greeting}\n"},
// Unmarked counts as trusted (fail closed): only `"trusted": false` lets a verb change what a file asks for.
{"id": "unmarked", "type": "file", "path": "/etc/power/unmarked", "content": "${setting:unmarked}\n"}}}
for _, c := range []struct {
m Manifest
want string
}{
{postgres, "places,accesses,port"},
{keycloak, "places,accesses,issuer,token-path"},
{power, "places,accesses,handle-lid-switch,unmarked"},
{Manifest{Module: "plain"}, "places,accesses"},
} {
if got := strings.Join(TerminalKeys(c.m), ","); got != c.want {
t.Errorf("%s: %s; want %s", c.m.Module, got, c.want)
}
}
}
// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a
// boolean, on a file alone, and a file asking for a setting without it is named.
func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) {
m := Manifest{Module: "power", Resources: []map[string]any{
{"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"},
{"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"},
{"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}}
if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" {
t.Errorf("unsaid: %s; want unsaid", got)
}
for _, bad := range []map[string]any{
{"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"},
{"id": "y", "type": "directory", "trusted": true},
} {
if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 {
t.Errorf("%v was taken", bad)
}
}
}
+36 -19
View File
@@ -16,18 +16,27 @@ type InMemory struct {
values map[string]Entry
revision uint64
events []Event
// Fail, when set, is what every read and write answers: a store that is away.
Fail error
// fail, when set, is what every read and write answers: a store that is away. It is set only
// through SetFail, under the lock, because the keeper's telling goroutine reads it while a test
// takes the store away.
fail error
}
// NewInMemory is an empty store.
func NewInMemory() *InMemory { return &InMemory{values: map[string]Entry{}} }
// SetFail makes every read and write answer err from now on, or none when err is nil.
func (m *InMemory) SetFail(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.fail = err
}
func (m *InMemory) Get(_ context.Context, key string) (Entry, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return Entry{}, false, m.Fail
if m.fail != nil {
return Entry{}, false, m.fail
}
e, ok := m.values[key]
return e, ok, nil
@@ -36,8 +45,8 @@ func (m *InMemory) Get(_ context.Context, key string) (Entry, bool, error) {
func (m *InMemory) Create(_ context.Context, key string, value []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return m.Fail
if m.fail != nil {
return m.fail
}
if _, ok := m.values[key]; ok {
return ErrMoved
@@ -50,8 +59,8 @@ func (m *InMemory) Create(_ context.Context, key string, value []byte) error {
func (m *InMemory) Update(_ context.Context, key string, value []byte, revision uint64) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return m.Fail
if m.fail != nil {
return m.fail
}
if e, ok := m.values[key]; !ok || e.Revision != revision {
return ErrMoved
@@ -64,8 +73,8 @@ func (m *InMemory) Update(_ context.Context, key string, value []byte, revision
func (m *InMemory) Delete(_ context.Context, key string, revision uint64) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return m.Fail
if m.fail != nil {
return m.fail
}
if e, ok := m.values[key]; !ok || e.Revision != revision {
return ErrMoved
@@ -77,8 +86,8 @@ func (m *InMemory) Delete(_ context.Context, key string, revision uint64) error
func (m *InMemory) All(context.Context) (map[string]Entry, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return nil, m.Fail
if m.fail != nil {
return nil, m.fail
}
out := make(map[string]Entry, len(m.values))
for k, v := range m.values {
@@ -90,8 +99,8 @@ func (m *InMemory) All(context.Context) (map[string]Entry, error) {
func (m *InMemory) Append(_ context.Context, e Event) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return m.Fail
if m.fail != nil {
return m.fail
}
m.events = append(m.events, e)
return nil
@@ -100,8 +109,8 @@ func (m *InMemory) Append(_ context.Context, e Event) error {
func (m *InMemory) Since(_ context.Context, since time.Time) ([]Event, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return nil, m.Fail
if m.fail != nil {
return nil, m.fail
}
var out []Event
for _, e := range m.events {
@@ -118,14 +127,22 @@ type Told struct {
mu sync.Mutex
Events []Event
Names []string
Fail error
// fail, when set, is what every publish answers; set only through SetFail, under the lock.
fail error
}
// SetFail makes every publish answer err from now on, or none when err is nil.
func (t *Told) SetFail(err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.fail = err
}
func (t *Told) PublishSeatEvent(_ context.Context, seat, event string, body []byte) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.Fail != nil {
return t.Fail
if t.fail != nil {
return t.fail
}
if seat != Seat {
return errors.New("told under the wrong seat: " + seat)
+30
View File
@@ -0,0 +1,30 @@
package conditions
import (
"errors"
"fmt"
"testing"
)
// **A test may take the store away while the keeper is still telling.** The keeper keeps every
// transition from a goroutine of its own, so the memory store's failure is read there while a test
// switches it: it is switched under the store's lock, or the race detector fails the suite at random
// (it once failed TestAnUnreadableStoreClearsNothing). A hundred transitions still being kept while
// the failure is switched a hundred times makes the race certain, not rare, when the lock is skipped.
func TestTheStoreIsTakenAwayWhileTheKeeperIsTelling(t *testing.T) {
k, store, told, _ := keeper(t)
ctx := t.Context()
const n = 100
for i := range n {
if _, err := k.Observe(ctx, silent(fmt.Sprintf("m%d", i))); err != nil {
t.Fatal(err)
}
}
for range n {
store.SetFail(errors.New("the bus is away"))
store.SetFail(nil)
}
told.SetFail(errors.New("no responders"))
told.SetFail(nil)
settled(t, told, n)
}
+7 -6
View File
@@ -199,15 +199,17 @@ func TestAnUnreadableStoreClearsNothing(t *testing.T) {
if _, err := k.Observe(ctx, silent("ace")); err != nil {
t.Fatal(err)
}
store.Fail = errors.New("the bus is away")
store.SetFail(errors.New("the bus is away"))
if err := k.Reconcile(ctx, "S1", nil); err == nil {
t.Fatal("reconciled against a store it could not read")
}
if _, err := k.Open(ctx); err == nil {
t.Fatal("an unreadable store answered as read")
}
store.Fail = nil
store.SetFail(nil)
store.mu.Lock()
store.values["machine.g14.silent"] = Entry{Value: []byte("{not a condition"), Revision: 99}
store.mu.Unlock()
if _, err := k.Open(ctx); err == nil || !strings.Contains(err.Error(), "machine.g14.silent") {
t.Fatalf("an unreadable condition was left out rather than said: %v", err)
}
@@ -362,16 +364,15 @@ func TestTheEventShapeIsTheContract(t *testing.T) {
// **A transition the bus will not take is offered again**, and said lost only after TellFor.
func TestATransitionIsOfferedAgainWhileTheBusIsAway(t *testing.T) {
store, told, c := NewInMemory(), &Told{Fail: errors.New("no responders")}, newClock()
store, told, c := NewInMemory(), &Told{}, newClock()
told.SetFail(errors.New("no responders"))
k := NewKeeper(t.Context(), Options{Store: store, History: store, Teller: told, Now: c.now})
defer k.Close(context.Background())
if _, err := k.Observe(t.Context(), silent("ace")); err != nil {
t.Fatal(err)
}
time.Sleep(300 * time.Millisecond)
told.mu.Lock()
told.Fail = nil
told.mu.Unlock()
told.SetFail(nil)
said := settled(t, told, 1)
if said[0].Key != "machine.ace.silent" || k.Unsaid() != 0 {
t.Fatalf("said %+v, unsaid %d", said, k.Unsaid())
+8
View File
@@ -446,6 +446,14 @@ const KindUnit = "unit"
// starting ReasonRelogin when only a new login is missing.
const KindAccount = "account"
// KindDirectory is a directory of a module that the node-engine uses as found (novox/hq issue 339): there before
// the mesh, with another owner or mode than declared, and left so until a person hands it over at the machine
// (`mesh-host hand-over`). Stated unhealthy with a reason that starts ReasonUsedAsFound.
const KindDirectory = "directory"
// ReasonUsedAsFound starts the reason of a directory used as found.
const ReasonUsedAsFound = "used as found:"
// ReasonRelogin starts the reason of an account whose running session began before it was put in a group
// (ADR 0252): the build did what it should, and a person has one step left (novox/hq ADR 0254).
const ReasonRelogin = "relogin needed"