Author SHA1 Message Date
jschoubben d12bba0109 Give the kept-folder tests' folders an unprivileged owner when they run as root (hq ADR 0325)
mesh/merge-gate head d12bba01090d merged into trunk dd9ab7927d96 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head d12bba01090d merged into trunk dd9ab7927d96 — pass: its merge-check.sh passed
mesh/delivery delivered
The build seat runs merge-check as root, so every folder a test made was root's
data, which is never kept (§12), and 28 tests failed for that alone. A test's
folders now stand for an operator's: run as root, they are given to nobody before
they are judged. The root rule is unchanged.
2026-10-12 19:34:59 +02:00
jschoubben c85c2804a0 Apply an older controller's accesses exactly as before (hq ADR 0325)
mesh/merge-gate head c85c2804a010 merged into trunk dd9ab7927d96 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head c85c2804a010 merged into trunk dd9ab7927d96 — fail: its merge-check.sh failed: --- FAIL: TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWait…
mesh/delivery superseded: a newer head of the same pull request
The engine delivers before the controller half, and today's controller sends no
identities, users or taken. Keeping now starts only for an access whose folder has
users, or a declaration with identities; every other access is mounted when present
and refused when absent as on main, with nothing made, kept, held or judged. A
leftover whose walk could not run says why, not always that its disk is away.
2026-10-12 19:24:24 +02:00
jschoubben 2ff40109ac Read a filesystem that could not be named as unknown, never as a move (hq ADR 0325 sixth review)
A name read that timed out gave an empty name, which differed from the recorded one
and so reset the record: a folder kept while empty asked again, a warranted one was
walked again. An unnamed filesystem now leaves the folder exactly as it is, the
access is said not known for that look, and it resumes once it is named.
2026-10-12 19:20:53 +02:00
jschoubben 46c16e18fe Name a filesystem by its UUID, its kernel id or its source, and keep nothing that has no name (hq ADR 0325 fifth review)
NFS and FUSE report a zero f_fsid, so another export or another FUSE mount at the
same point was named alike and kept without a warrant; xfs's f_fsid follows the
device number. A filesystem is now named by its UUID (its device matched in
/dev/disk/by-uuid), else a nonzero f_fsid, else mountinfo's source; one that cannot
be named is never kept or taken. Entries left on a filesystem not back within 30 days
are forgotten, said once, and the naming read is shared per mount.
2026-10-12 19:14:42 +02:00
jschoubben f00f9c2a2f Name a filesystem by its kernel id, never its device name (hq ADR 0325 fourth review)
An ext4 or xfs library mounted by UUID can come back from a reboot as another device
(/dev/sdb1 as /dev/sdc1); naming the filesystem by mountinfo's source read that as a
move, stripped the warranted library's entries and asked for a new Approve. A
filesystem is now named by statfs's f_fsid (from the UUID, or the ZFS dataset), its
type and its mount point. Entries left on the filesystem a folder is on now are
merged back or held, never walked away, and a TakenOn naming a mount point is not
taken.
2026-10-12 19:10:18 +02:00
jschoubben 21cd036232 Format the third review's tests (hq ADR 0325) 2026-10-12 19:06:57 +02:00
jschoubben f0adb177f0 Leave no leftover for a folder kept only while empty, and name filesystems by more than their mount point (hq ADR 0325 third review)
A folder kept because it was empty, then covered by its disk, was said as unhealthy
leftover entries for ever: its entries sit on a hidden empty folder and grant nothing,
so they are now forgotten, and only entries walked onto data under a warrant are kept
to be taken out. A warrant and a kept folder now name their filesystem by mount point,
source and type, so another pool at the same point is a move; records naming a mount
point are read as its name. A record with no access ids is said under a path-free name.
2026-10-12 19:06:34 +02:00
jschoubben d2d9fbbd7d Hold what mounts a missing disk, and say entries the mesh could not take out (hq ADR 0325 re-review)
A container was still created over an unmounted mount point, so its writes landed on
the filesystem below; a folder moved to another disk was kept on the old warrant; and
entries left by a drop or a move that could not run lived only in a log line. Every
container mounting an access whose disk is away now waits, the warrant holds only for
the filesystem it was carried out on (the wait names the new one, path-free), entries
left on a filesystem are recorded per mount and taken out when it is back, and every
leftover is said as unhealthy until it is. A hung stat is shared per path, and a
widened mask takes from other entries only the bits it adds.
2026-10-12 19:03:02 +02:00
jschoubben 2026ef36f3 Judge a kept folder before every walk, and keep nothing a disk mounted over (hq ADR 0325 review)
A walk run while a dataset was unmounted wrote onto root's empty mount point, or
"dropped" entries from it and forgot the ones on the data; a folder kept while empty
below an unnamed mount was then swept whole once the disk mounted, with no warrant.
Every walk is now judged as an apply is, a changed mount judges the folder afresh, and
systemd .mount units and mounts seen are read for the first-sight rule. Widening a
mask caps the entries it limited, so the owning group gains nothing. The watch lets
go of removed folders, re-arms on lost notice, reads users under its lock and ends
with its context. A drop that cannot read an ACL is counted, a broken folder's words
carry no path, a grant's group that could not be left is tried again, and a folder's
stat is bounded.
2026-10-12 18:55:47 +02:00
jschoubben 51e97a8c14 Give every module an identity and keep shared folders through ACL entries (hq ADR 0325, issue 567)
A module's containers ran as whatever their image chose and shared folders were
made writable by putting accounts in the folder's group (ADR 0312), which ADR 0325
supersedes. The engine now makes each declared module identity as a system account
with the controller's numbers, runs a container declaring `identity` as it, and keeps
each placed shared folder usable through one POSIX ACL entry per user, set through
the kernel's xattrs on descriptors it opened itself. A folder holding data is kept
only after the operator's warrant (the take act); a missing folder is made where its
data lives and waits where its disk is not; root's data is never kept. A watch and a
bounded sweep put back what an app narrows. The group grant is removed, and what it
put an account in is taken back.

Reuses the descriptor walks, the create act, the missing-path wait and the warrant-
backed act message from the paused issue 567 branch.
2026-10-12 18:43:04 +02:00
mesh-admin dd9ab7927d Merge pull request 'Genesis trusts its registry before it pushes to it (issue 92)' (#91) from fix/92-genesis-trusts-its-registry into main 2026-10-12 14:36:16 +00:00
jochen 0818e4c29a Hold the trust-before-push order by a test, and refuse early what would stop it (issue 92)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The fix is an order, and nothing failed if the trust call moved after the
push or went: steps 7 and 8 are now one function a test drives against a
runtime that refuses an untrusted push. Preflight now refuses, before
anything is raised, a runtime file that is no JSON object, a docker.service
that is not loaded and active, and a dockerd started with
--insecure-registry, which would refuse the reload. The comment names the
order the docker module's takeover rests on.
2026-10-12 16:29:14 +02:00
jochen 41e640d668 Have genesis trust its registry before pushing to it (issue 92)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Genesis pushed the control plane's image to the mesh's plain-HTTP registry
before anything told the container runtime to trust it; the docker module
that writes that trust is applied long after. Genesis now writes the trust
itself at the registry step, with the same into-json writer and a reload,
for the address given and the node's mesh name on the node's registry
port, and refuses before any push unless the runtime says it trusts each.
The docker module later finds the mesh name there and takes it over
without a change.
2026-10-12 16:13:11 +02:00
mesh-admin a1f9856b39 Merge pull request 'A package manager that does not answer fails only the packages; the rest of the declaration applies (issue 233)' (#90) from fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 into main 2026-10-12 14:11:11 +00:00
jochen 74ed7cbe0a Say a re-apply whose result changed, refuse whole when the package manager is not there, and install the packages in the apply that made it answer (issue 233 review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 delivered: every member is delivered
2026-10-12 15:39:07 +02:00
mesh-admin 36704b5471 Merge pull request 'Give a mesh directory its mode and owner only through a descriptor (issue 358)' (#87) from fix/358-descriptor-only-directory-mode into main 2026-10-12 13:13:13 +00:00
jochen 6e44a27a63 Fail only the packages when the package manager does not answer, and apply the rest, so a later declaration can restore what an earlier one removed (issue 233)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 15:12:42 +02:00
mesh-admin dce0eef1ca Merge pull request 'Judge a node's directories read-only before the walk refuses any (issue 358)' (#88) from fix/358-judge-directories-read-only into main 2026-10-12 12:48:55 +00:00
jschoubben e3c3bb37d0 Give a mesh directory its mode and owner only through a descriptor, and gate what the walk refuses (issue 358)
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/delivery delivered
A mesh directory outside every home was chmodded and chowned by path, so a
parent turned into a link between two applies carried root's chmod and chown
to the link's target. It is now made and changed through the descriptor of
the walk from / that follows a link only root alone could have made.

A directory that walk refuses is a refusal like the placement guard's
(review 47): judged before anything is applied, and again when the
directory is applied, used as found or not; refused at either point it gates
its module and refuses what is below it, so no file is written into the
link's target and the report does not say everything was attempted.
2026-10-12 14:00:53 +02:00
jschoubben b92fb68116 Judge a node's directories read-only before the walk refuses any (issue 358)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/358-judge-directories-read-only delivered: every member is delivered
Once a mesh directory's mode and owner go only through a walk that follows no
link another account could make, a directory behind such a link is refused
and its module gated: on the control-node that could stop the controller.
The operator is to be told which directories that walk would refuse on every
node before it lands. So the engine judges the declaration it keeps with the
same walk, making and changing nothing: on a signed ask from the controller
(mesh.node.<node>.ask.judge-directories, held to the hand-over's checks) and
as `nox-mesh-host judge-directories` at the machine.
2026-10-12 13:57:52 +02:00
mesh-admin 3f7f797168 Merge pull request 'A read-write access grants the path's own group, and the path is never changed (hq ADR 0312, issue 537)' (#86) from feat/537-a-read-write-access-grants-the-paths-group into main 2026-10-12 11:22:56 +00:00
jschoubben c433e76de2 Judge a granted group by number, grant nothing a group cannot write, and keep the look bounded (hq ADR 0312, issue 537 review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The engine is built without cgo, so a check by name missed a root group a name
service holds or one number carrying another name: the root groups are resolved
to numbers (now with kmem and shadow) and a number named nothing is refused for
containers too. A group without write is granted nothing (§2). The look reads
each path off the lock within five seconds, so a dead network mount cannot stop
every health statement. A grant's account may not start with a dash. A group a
record leaves for another asker is handed to it, so it is still taken back when
the last asker goes (§5). The scheduler skips paths issue 339's guard refuses.
2026-10-12 12:53:42 +02:00
jschoubben f4efc3cad0 Grant a read-write access's path group to what runs the module's code, so a module can write shared data without the mesh changing it (hq ADR 0312, issue 537)
The engine reads the group owning the accessed path at apply and gives it to the
containers and the tools' account an access's new `grant` names: --group-add for a
container (recreated with it), an account group for the account (recorded, taken
back as ADR 0252 takes back). The path is never changed; an access whose group
cannot write is stated unhealthy for the operator to decide; group 0 and the
root-granting groups are refused by name. The tool runner restarts through the
restart-on the controller gives it, because the access reports updated exactly
when the account's groups moved. Health says GrantContract (4), which tells the
controller it may send `grant`.
2026-10-12 12:23:38 +02:00
mesh-admin a76baa4b47 Merge pull request 'Carry the controller's grant to ask the delivery's owner for its groups in the installer's first user list (hq ADR 0306, issue 525)' (#85) from feat/525-grant-delivery-groups into main 2026-10-12 10:14:32 +00:00
jschoubben 90ce9e9d9f Carry the controller's grant to ask the delivery's owner for its groups in the installer's first user list (hq ADR 0306, issue 525)
mesh/delivery delivered
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: the change touches no module of the mesh's graph
The controller is to ask mesh-delivery whether a merge's delivery
group still has a member to merge, so that a merge in no waiting group
is cut at once (ADR 0304, ADR 0306); its bus grant names the verb, and
the installer's first user list must too, as for times (212a3cc).
2026-10-12 12:09:18 +02:00
mesh-admin df5de3bd9f Merge pull request 'Say an unhealthy resource's restart count as what it counts (issue 458)' (#84) from fix/458-restarts-say-what-they-count into main 2026-10-12 09:03:47 +00:00
jschoubben a18d09db75 Say an unhealthy resource's restart count as the engine's own since it began watching
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/458-restarts-say-what-they-count delivered: every member is delivered
Beside the state it read as restarts since then (novox/hq issue 458).
2026-10-12 10:47:07 +02:00
mesh-admin 3333a05a74 Merge pull request 'A refused directory gates its module, and a file meets the placement guard (issue 495)' (#83) from fix/495-a-refused-directory-gates-its-module into main 2026-10-11 17:00:22 +00:00
jschoubben 4695dfc0a2 Say both a gated module and a machine stop, and count skipped apart (review of #83)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A module-less action that stopped the apply after a module was gated was reported as
everything else attempted. The failure now names the gated modules and the stop, counts
skipped resources apart from applied ones, and the tests cover an archive below a refused
path, a gated module tried again by the next apply, and a refused file among the failures.
2026-10-11 18:51:04 +02:00
jschoubben 7233ddbe35 Gate a module behind its refused directory and judge a file by the placement guard (issue 495)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
On ace the engine refused Docker's data root as not placed, said nothing was touched, then
wrote a file into it and ran the module's step. A refused directory now gates the rest of
its module as a failed step does, a file is refused in a forbidden tree or below a refused
path, and the failure names the modules whose rest was not attempted.
2026-10-11 18:19:25 +02:00
jschoubben 1acfed4add Test that a refused directory gates its module and a file meets the same guard (issue 495)
Both fail on main: the shape seen on ace, a refused directory, a file below it and a
step after it, writes the file, runs the step and reports that everything was attempted.
2026-10-11 18:17:59 +02:00
mesh-admin 6defec8f68 Merge pull request 'Refuse a declaration composed from an older assignment generation than the machine applied (issue 234)' (#82) from fix/234-refuse-a-declaration-from-an-older-generation into main 2026-10-11 16:00:01 +00:00
mesh-admin 2833caf3d6 Merge pull request 'A failed run-once process runs again on every apply until it passes (hq issue 467)' (#81) from fix/467-a-failed-step-runs-again into main 2026-10-11 09:30:49 +00:00
jschoubben 20d5af9b2d Refuse a declaration composed from an older assignment generation than the machine applied (hq issue 234)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A newer sequence carrying an older view of the assignments undeclared four
assigned modules on 2026-10-04; the sequence cannot tell it, the generation
can. The engine keeps the highest generation it applied, refuses an older one
before reading the machine unless the declaration is a gate's put-back, and
says the refusal in its report so the controller can name the sender. A
declaration without a generation is applied as before, and every report says
it reads one, so the controller sends it only to engines that do.
2026-10-11 11:22:30 +02:00
jschoubben 0ec96a048e Clear a step's success only when it was started and failed, and say updated when it passes again (novox/hq issue 467)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A bundle that could not be fetched refused nothing; clearing for it would rerun
every step on a node once the artifact store came back from an outage.
2026-10-11 11:12:15 +02:00
jschoubben c957f1ec5e Run a failed run-once process again on every apply until it passes (novox/hq issue 467)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A failure recorded nothing, so the record of the step's last success stayed and
the next apply skipped it, opening ADR 0136's gate on what it had just refused.
The record is kept, for removal, with what it was run as cleared.
2026-10-11 11:07:17 +02:00
mesh-admin 85c51ebb59 Merge pull request 'A refused line's request is read before it is answered, so a busy line reads busy (issue 434)' (#80) from fix/434-a-busy-line-is-answered-busy-whatever-the-load into main 2026-10-11 00:55:47 +00:00
jschoubben f9805b2113 Bound what a refusal costs the engine, and let the busy tests fail instead of hang
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A refusal waiting for its request parsed it into a 64 KiB reader for up to 5 s,
on a socket every account may open. It now drains at most 4 KiB through a small
buffer for at most 1 s and never past the accept-time deadline, only 32 wait at
once, and an unknown account is refused before it takes a slot. The busy tests
wait for the first line with a timeout and say what came of it (novox/hq issue 434).
2026-10-11 02:47:11 +02:00
jschoubben 70cb613614 Read a refused line's request before answering it, so a busy line is read as busy
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The engine answered busy and closed before the client's request had landed;
the client's write then broke and it never read the refusal waiting for it.
Under the whole suite's load this failed the busy test (novox/hq issue 434).
2026-10-11 02:35:20 +02:00
mesh-admin bd5cc69804 Merge pull request 'The setuid walk paces itself by the machine's I/O pressure (issue 433)' (#79) from fix/433-the-setuid-walk-yields-to-a-busy-disk into main 2026-10-11 00:03:46 +00:00
jschoubben f311fa36dc The setuid walk is paced before each file too, so a large directory is not read blind (review of issue 433)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A file's own details (lstat) may be read from the disk as a directory is,
so a directory of tens of thousands of files was read without a look and
the header's promise that no burst is longer than PaceLook did not hold.
The walk now asks the pacer before each file as well; the 250 ms gate
makes that a clock read per file. A test with one directory of twenty
files fails without it. The clock is read once after a run of pauses,
and the constants test says that it guards the constants only.
2026-10-11 01:55:39 +02:00
jschoubben 30242197fa The paced walk keeps a tenth of the time, and reads the pressure since its last look (review of issue 433)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The review of #79 found that pacing alone brought back issue 361's
fault. The control-node's own I/O pressure sits near the 10 % bound with
no walk running, so a walk that paused 30 s for every 250 ms it read
would read 0.8 % of the time: a 16-minute walk would take some 32 hours,
outlive the age a search judges for, and be begun again at an engine
restart, leaving the machine unjudged.

A run of pauses is now at most nine times as long as the walk read since
the one before, so the walk keeps at least a tenth of the time whatever
the pressure, and takes at most ten times as long as unpaced: about
2.7 hours for the 16-minute walk, inside the 12 hours the controller
waits quietly. A test ties the bound to rootsearch.Quiet.

The pressure is the share of the time since the last look in which some
task waited on I/O, from PSI's cumulative counter, not its ten-second
average, which the kernel moves only every two seconds. A pause is
500 ms, then the pressure is looked at again.

The engine's walker is built in one place, engineWalker, and a test
fails if it is not paced by /proc/pressure/io. What the walk cannot read
is said through the search's own channel (the cache's Say), as the rest
of the search is, with what it means for the disk.
2026-10-11 01:53:25 +02:00
jschoubben 8f7db549ae The setuid walk paces itself by the machine's I/O pressure (issue 433)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The walk for setuid programs is put in the idle I/O class so it yields
to everything else, but only a scheduler that schedules by class (BFQ)
honours that. The control-node's disks run mq-deadline, which does not,
so the walk read at full priority. On 2026-10-11 the walk started at
01:27, right after the engine applied a new controller, and ran sixteen
minutes; it kept two spinning disks saturated, the bus whose store
shares them stalled for half a minute twice, and the controller lost
its lease and restarted each time.

The walk now also paces itself by PSI, which reads the same whatever
the scheduler: before it reads a directory, while some task has waited
on I/O for more than 10 % of the last ten seconds, it pauses two
seconds at a time. It looks at most every 250 ms, pauses at most 30 s
in a row and then reads one more directory, so a machine that is never
quiet is walked slowly but still judged. Its own reads count in the
pressure, so on an otherwise idle machine it holds the disk below the
bound. Pressure it cannot read pauses nothing, and is said once.

A paced walk finds what an unpaced one finds; a walk ended while it
pauses is an incomplete walk, never one that found nothing.
2026-10-11 01:47:43 +02:00
mesh-admin 5226d7bcd0 Merge pull request 'Node-engine: state a tool check that waits for the operator as waiting (issue 386, ADR 0283)' (#78) from feat/386-a-wait-for-the-operator into main 2026-10-10 20:03:27 +00:00
jochen 9545121ec9 Clear an old pass on a wait, and say a change of waits (review of #78)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A resource that passed, then waited, then failed once after its grace was
still stated healthy from the old pass; and a change of what it waits for
alone raised no change event, so the controller could miss it.
2026-10-10 21:14:14 +02:00
jochen 3abb07f1e2 State a tool check that waits for the operator as waiting (issue 386, ADR 0283)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
A module whose only fault was a secret the operator had not given yet was
stated unhealthy, failed its first-node gate and was put back. The engine
now reads the tool's answer through mesh-sdk go/health and states such a
resource waiting, with what it waits for, on one look; a malformed wait
stays unhealthy.
2026-10-10 21:11:02 +02:00
mesh-admin 9d8aa9d6f0 Merge pull request 'Carry the controller's grant to ask mesh-delivery for its delivery times in the installer's first user list (hq ADR 0282, issue 382)' (#77) from feat/382-carry-times-grant into main 2026-10-10 18:39:44 +00:00
jochen 212a3cc113 Carry the controller's grant to ask the delivery's owner for its delivery times in the installer's first user list (hq ADR 0282)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 20:32:16 +02:00
mesh-admin cc0b8554b1 Merge pull request 'Show how far the setuid search got, and start a fresh one when the terminal asks (hq issue 361)' (#76) from fix/361-the-setuid-search-shows-its-progress into main 2026-10-10 00:21:51 +00:00
jochen 0b4c86e3be Count a setuid find only where the account can execute it, read at every judgement (hq issue 361)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The two finds on the control-node sit in the operator's home of mode 0700, which the agent account cannot
search. The search keeps every find; each judgement reads the modes on the way again, since an account can
open its own home without root. A find in a home is said by count, its path in the journal alone. Also
from review: a checkpoint racing SearchNow forgets the walk it just kept, and a walk kept by an older engine
says its counts as lower bounds.
2026-10-10 02:00:10 +02:00
jochen 8e79403c21 Show how far the setuid search got, and start a fresh one when the terminal asks (hq issue 361)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered (0.00s)
mesh/delivery superseded: a newer head of the same pull request
The walk kept its progress every minute and nobody could read it: root-free said only that a search ran.
The account verdict now carries the progress (counts, times, mount points; never a path) as fields and in
its reason, outside what makes a verdict changed. A signed ask from the controller's terminal throws the last
search away and starts a full one, after the operator removed by hand what it found.
2026-10-10 01:55:58 +02:00
mesh-admin b8c8546118 Merge pull request 'Let the setuid search run to completion, resumably, and judge from the last complete one (hq issue 361)' (#75) from fix/361-the-setuid-search-runs-to-completion into main 2026-10-09 23:18:44 +00:00
jochen a7270f00cb Keep the walk's paths as bytes, forget a walk root superseded, and leave other mounts unread (hq issue 361 review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion delivered: every member is delivered
The review found a cursor of a name that is not UTF-8 came back changed
from JSON, so a resumed walk skipped what it stood before; an ask to
search again was lost to a restart, which carried the older walk on; a
fresh search judged a filesystem it never walked; a mount point root
cannot read failed or hung the walk; a found file gone before the package
manager was asked failed every resume; and skipped or kept outcomes
started a search on every reconcile, back to back with a long walk.
2026-10-10 01:10:56 +02:00
jochen 2dbbda51c6 Walk for setuid programs in the engine and carry an interrupted walk on (hq issue 361)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between
updates, so a search that starts over at every restart never completes.
The walk is the engine's own, in lexical order, so its progress is one
cursor per filesystem, kept every minute beside the engine's state and
carried on after a restart while younger than rootsearch.FreshFor. It runs
on a thread of its own at nice 19 and the idle I/O class.
2026-10-10 01:00:47 +02:00
jochen 2410796869 Let the setuid search run to completion and judge from the last complete one (hq issue 361)
A machine with millions of files never finished the walk within fifteen
minutes, so its agent account was never judged and the router refused to
link. Only root can make a setuid-root file, so a complete search stays
sound until root acts: it now runs at idle priority with no bound, is kept
beside the engine's state, judges for rootsearch.FreshFor from its start,
runs again daily and after an apply that moves accounts, packages, actions
or sudo rules, and an incomplete or stale search is never free.
2026-10-10 00:53:05 +02:00
mesh-admin 090b9082eb Merge pull request 'Renew the engine placed by hand to the proved build, and take a hand-over from the controller's terminal (hq issue 356)' (#74) from fix/356-hand-over-by-nox-and-the-engine-on-path-renewed into main 2026-10-09 18:07:21 +00:00
jochen 8fc1d72cd5 Keep the setuid search's time on the caller's clock
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery failed: its walk failed: a gate on a first machine (what it carried put back), a build, a machine
SetuidCache compared the caller's now with a time read from the wall clock when the search finished. When the caller's clock ran behind the wall clock, a search was never due again: TestTheSetuidSearchRunsInTheBackgroundAndIsKeptForItsInterval, whose clock is a fixed date, has failed on main and every branch since 2026-10-09 18:00 UTC. A search's time is now the now it was started at; a result reads at most one search older than it is, which errs towards searching again.
2026-10-09 20:04:11 +02:00
jochen 9a807de659 Give a handed-over directory through no link on every apply, not only the first (hq issue 356, review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheSetuidSearchRunsInTheBackgroundAndIsKeptForItsInterval (0.00s)
mesh/delivery superseded: a newer head of the same pull request
After the first apply the record is no longer used as found, so the directory read as the mesh's own and later applies set its mode and owner by path: a parent swapped for a link after the hand-over took carried root's chmod and chown to its target. The hand-over is now checked before that shortcut, so a handed-over directory always goes through applyHandedOver. The test swaps the parent and the grandparent after an apply already gave the directory its mode; both cases fail with the old order.
2026-10-09 19:58:27 +02:00
jochen fc6c5a92e6 Believe a hand-over only signed by the mesh, and give it through no link (hq issue 356, review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Two holes the review proved. The ask's subject proved nothing — a responder can reach it through a reply — so the engine now verifies the mesh's signature over a fixed context with the key it verifies declarations with, before reading anything, and refuses an ask for another node, expired, good too long, without a nonce, or replayed. And the apply followed a link the hand-over had refused: a component swapped for a link after the record carried root's chmod and chown to its target. A handed-over directory must now still resolve to itself, and is changed only through one descriptor opened from / with no link followed (fchmod, fchown). Tests for the reply guard, the run loop's hook, the fallback renewal on proof and a non-regular fallback; 27 checks each removed in turn, each failing a test.
2026-10-09 19:45:00 +02:00
jochen e74ee82a76 Renew the engine placed by hand to the proved build, and take a hand-over from the controller's terminal (hq issue 356)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The file on PATH was the engine a person copied at installation, and every delivered engine ran from the versions directory: the operator typed hand-over and a build from before it answered "unexpected argument". A proved engine now copies itself over that file when the bytes differ (never a link). And the hand-over of a directory used as found (issue 339) gets its channel: the controller's terminal asks this node's engine on mesh.node.<node>.ask.hand-over; the engine judges every value — the ask's size and shape, who asked, the path as it states it, a directory and no link, through no link — and records it or refuses and records nothing. Every text the engine addresses to the operator names the nox line (ADR 0272); the engine's own subcommands are install-time and rescue, and its usage says so.
2026-10-09 18:34:10 +02:00
mesh-admin b0d03be7a4 Merge pull request 'mesh-cli: forward the line's standard input to the controller, with the line alone (hq ADR 0259 §10, ADR 0272)' (#73) from feat/a-terminal-line-takes-standard-input into main 2026-10-09 15:58:48 +00:00
jschoubben 4ea6a2f04e mesh-cli: forward the line's standard input to the controller, with the line alone
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/a-terminal-line-takes-standard-input stopped: a member was stopped
A secret given at the controller's terminal through mesh-cli (secret accept … --from -) never reached the
line: the engine forwarded the words only. It now forwards what mesh-cli's standard input held (at most
64 KiB, the SDK's bound) on the same core request to the controller, says it nowhere, and sends none
with a follow of the same call. mesh-sdk moves to f047d0a.
2026-10-09 16:34:45 +02:00
mesh-admin d8ff1540df Merge pull request 'Installer's first user list: the controller's grant to ask the desk for a secret (hq ADR 0259 §10)' (#68) from feat/a-secret-given-at-the-desk into main 2026-10-09 14:17:41 +00:00
mesh-admin cdc2af7b43 Merge pull request 'Installer's first user list: the controller's grants to ask the operator (hq ADR 0259)' (#59) from feat/the-controller-asks-the-operator into main 2026-10-09 14:17:38 +00:00
mesh-admin 7a2ffc0a4c Merge pull request 'Read the session record strictly, and make its tests hold (hq ADR 0272)' (#72) from fix/272-the-session-record-read-strictly into main 2026-10-09 12:12:11 +00:00
jochen b1e7b6d26e Read the session record strictly, and make its tests hold (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
- UID, CLASS and SCOPE are read at their first occurrence: logind writes
  each once, and a later line saying otherwise is not the record's word.
- A comment says /run/systemd/sessions is systemd's private format, and
  that a format change fails closed: no session, so nobody is the terminal.
- Tests that hold, each proved by the mutation it guards failing:
  - a record naming UID=1001 for session-8.scope under user-1000.slice,
    asked as uid 1000 (the record's UID check alone);
  - session-a.b.scope with a passing record (the id's characters alone);
  - session-9.scope/x.scope (the scope being the slice's direct child);
  - a key said twice, either way round (first occurrence);
  - the client sends its line only after the session was read, so reading
    the session in the forward fails the test.
2026-10-09 14:05:53 +02:00
mesh-admin 512b0edb02 Merge pull request 'A login session is the account's own, of class user, read at accept (hq ADR 0272)' (#71) from feat/272-the-session-is-a-users-read-at-accept into main 2026-10-09 12:04:18 +00:00
jschoubben 4ba6d0864e Carry the controller's grant to ask the desk for a secret in the installer's first user list (hq ADR 0259 §10)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/a-secret-given-at-the-desk delivered: every member is delivered
mesh-controller #156 (give) grants the controller mesh.seat.node-launcher.tool.secret.*; the
controller's test holds the installer's list equal to what it composes, and failed without it.
2026-10-09 14:04:01 +02:00
jochen 73e0916968 Carry the controller's grants to ask the operator and act on a warrant in the installer's first user list, so a mesh raised from it is not refused them (hq ADR 0259)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-host@4ba6d0864e3d (merged as d8ff1540 into main, walk plan-17915554870866…
mesh/delivery-group group feat/the-controller-asks-the-operator delivered: every member is delivered
2026-10-09 14:03:58 +02:00
jochen bf3f45e769 A login session is the account's own, of class user, read at accept (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The cgroup names a scope; only logind's record of it says whose session it
is. The engine now takes a session only when /run/systemd/sessions/<id>
says it is this account's (UID), of class user (not a greeter's or the
user manager's), and of this very scope. And it reads the session the
moment the connection is accepted, with the namespaces, before the line,
so the peer is judged as it was when it connected.

Tests: session-9.scope with a user record is taken; x.scope, a different
uid, another account's session, a greeter's, a record naming another
scope, no record, and a path with dots are not; the session is read
before the line is forwarded.
2026-10-09 13:55:45 +02:00
mesh-admin fd13834c0d Merge pull request 'Say the login session a mesh-cli line comes from, and pin the SDK at go/v0.1.9 (hq ADR 0272)' (#70) from feat/272-a-login-session-and-the-tagged-sdk into main 2026-10-09 11:54:31 +00:00
jochen 4a5dcb0209 Say the login session a mesh-cli line comes from, and pin the SDK at go/v0.1.9 (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A service running as the operator — the tool runner, a unit of the
account's user manager — shares the operator's user id, and was as much the
terminal on the control-node as a person typing. The engine now reads the
asking process's cgroup and sends its login session's scope
(session-<id>.scope under the account's own user-<uid>.slice), or nothing;
the controller makes nothing else the terminal. The SDK is pinned at its
tag now that go/v0.1.9 exists.
2026-10-09 13:40:17 +02:00
mesh-admin 1ac7741c63 Merge pull request 'Vouch for the account that runs mesh-cli, and ask the controller its line (hq ADR 0272)' (#69) from feat/272-the-engine-vouches-for-mesh-cli into main 2026-10-09 11:38:39 +00:00
jochen f684967c3b Merge main, and close what the confirmation review found (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
- Any failure of a request the link may have delivered now says the line
  may have run; "nothing ran" is said only of a request the link knows it
  never asked (no link open, nobody listening: link.ErrNotAsked).
- A test holds that a connection saying nothing is answered within its
  deadline, set at accept, and gives its slot back.
- The README says the socket refuses a process outside the machine's own
  namespaces, so flatpak, toolbox and distrobox shells are refused.
2026-10-09 13:09:48 +02:00
jochen 655f515aa7 Merge remote-tracking branch 'origin/main' into feat/272-the-engine-vouches-for-mesh-cli 2026-10-09 13:09:16 +02:00
mesh-admin 8f763ee911 Merge pull request 'Judge whether an account declared never to become root can (hq ADR 0266)' (#61) from feat/an-agent-account-never-becomes-root into main 2026-10-09 10:56:00 +00:00
jochen 564948c2f2 mesh-cli: follow a line to its answer, bound each account, refuse other namespaces (review of hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
- A request that timed out was said as "the controller was not reached,
  nothing ran", though the line may have run. The engine now follows a line
  the controller answers as running, by its call, until it ends; when no
  answer comes in time it says the line may have run and names the call.
  "Nothing ran" is said only of a request no controller took.
- One account could hold the socket: accepting waited for a free slot, and
  a connection could stall before its deadline was set. The deadline is set
  at accept, before any lookup; each user id has two lines at once and the
  machine eight, and one over is answered busy at once.
- A container running with the operator's user id shares the number, not
  the machine: a peer whose mount or user namespace is not the engine's is
  refused, and nothing is asked.

SDK pinned at the go/cli branch's newest commit until go/v0.1.9 is tagged.
2026-10-09 12:48:20 +02:00
jochen 1c61b72f35 Read the homes in one order, so their test does not depend on a map's
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The homes come from a map of the user database, and the test compared them in
the order it happened to iterate: it failed one run in four, the repo check
of 2026-10-09 among them.
2026-10-09 12:36:56 +02:00
jochen 2673e7a2c9 Let the setuid search run to its 15-minute bound, ask pacman once, and call a walk with errors incomplete
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots (0.00s)
mesh/delivery superseded: a newer head of the same pull request
The search kept a two-minute limit of its own inside its 15-minute bound, so
a long walk always ended incomplete; pacman was asked once per path; and find
exiting with an error but some output was read as a complete walk. The bound
and the not-judged reasons move to the public rootsearch package so the
controller reads the same values (the review of 2026-10-09).
2026-10-09 12:11:57 +02:00
jochen 021d860ca4 Vouch for the account that runs mesh-cli, and ask the controller its line (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The records send the operator to "the controller's terminal", and nothing
reaches it (hq issue 343). mesh-cli holds no credential: it asks the engine
on its own machine over a socket. The engine reads the asking account from
the kernel (SO_PEERCRED), never from the program, and asks the controller on
mesh.control.<node>.cli, its own node's subject, so the bus server says which
node asked. The controller decides what the line may do; nothing received
on the socket is run or applied here, and the socket has no network address
(ADR 0004 stands).

The local protocol is the SDK's go/cli package, at a pseudo-version of its
branch until that merges and is tagged go/v0.1.8.
2026-10-09 12:05:53 +02:00
jochen b28d7bbcbf Search for setuid programs in the background, and judge each polkit rule alone
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A search over a large disk ran inside the look, holding the judge's lock and
stalling the health statement; it now runs apart, serving its last result,
backing off after a failure, and saying not judged until it has one. And a
rule naming a user, or a comment, no longer hides another rule's
unconditional yes (hq ADR 0266).
2026-10-09 10:10:05 +02:00
jochen e2e8be72fa Keep main's placement guard, and refuse only an archive or a written-into file below an agent's home
Issue 339's guard (#63/#64/#65) now refuses the machine's own trees, another account's
directory below a home, and uses a found directory as found; this branch's own copy of
those rules goes. What stays is the one rule only an account that never becomes root
needs: below its home it owns every parent and could swap a link in between the
engine's check and an unpack or a write-into (hq ADR 0266 §7). One reader of the user
database serves both.
2026-10-09 10:10:05 +02:00
jochen d4af8bba1b Judge an opened file's kind and links below a home, and more ways to root
A hard link swapped in for ~/.claude would have had root chown another
account's file; fstat on the descriptor now refuses a second link, a fifo or
an unexpected kind before anything is changed (hq ADR 0266, the re-review).
The judge also finds polkit rules for every account, a runtime's API on TCP,
setgid-to-root programs whoever owns them, setuid programs on every suid
filesystem, and unprotected links; the rest is listed as not judged.
2026-10-09 10:07:52 +02:00
jochen 02a1c5f753 Refuse to take over a system account as one that never becomes root
The controller cannot read a machine's user database, so a name it is given
for the agents' account (hq ADR 0266) may be a service's own; taking it over
would hand agents that service's files. Refuse it, touching nothing.
2026-10-09 10:07:52 +02:00
jochen b89b143fe7 Follow no link below a home as root, and judge more ways to root
An account could replace ~/.claude with a link to /etc and have the
node-engine chown, chmod or write through it on the next apply. Below a
person's or an agent's home every component is now opened without
following a link, and a link refuses the resource in words.

The root judge also reads doas and polkit rules, the container runtimes'
sockets with their ACLs, ACLs on the secrets, and setuid-root programs no
package owns, in the C locale; Judged and NotJudged write down exactly
what it covers (hq ADR 0266 review).
2026-10-09 10:07:52 +02:00
jochen 3a727011ad Judge whether an account declared never to become root can, so a machine's agents are known confined
An account an agent runs as (novox/hq ADR 0266) is read on every look for a
uid of 0, a group that grants root, any sudo rule and a mesh secret it can
read; any way found is unhealthy and said, a read that fails is unknown.
2026-10-09 10:07:35 +02:00
jochen d93cc86f95 Let a user say it never becomes root, so the agents' account can be judged
A declaration may now state root: never on a user (novox/hq ADR 0266), and a
health statement carries it under contract 3; the judging follows.
2026-10-09 10:07:35 +02:00
mesh-admin a390bce5f2 Merge pull request 'Give a service's held state its own field and key, so main compiles again' (#67) from fix/store-declared-redeclared into main 2026-10-09 01:09:40 +00:00
jochen c2bae14994 Give a service's held state its own field and key, so main compiles again
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
#64/#65 and #66 each added Applied.Declared: a directory's declared owner and
mode (string) and a service's held state (*FoundUnit), both under the JSON key
"declared". The directory's is already on nodes, written by d28ca95, so it
keeps the name and key; the service's becomes DeclaredUnit, "declared_unit".
#66 never deployed, so no node holds a record under the new key.
2026-10-09 03:03:58 +02:00
mesh-admin 690966d847 Merge pull request 'Start again on removal what the mesh's own declaration stopped (hq ADR 0269)' (#66) from feat/give-back-a-unit-the-mesh-stopped into main 2026-10-09 00:54:13 +00:00
mesh-admin d28ca959f0 Merge pull request 'Issue 339 follow-up to #64: ask the running runtimes where they keep their data, and read every form a unit says it in' (#65) from fix/339-runtime-roots-asked into main 2026-10-09 00:15:24 +00:00
mesh-admin b232db4e17 Merge pull request 'Issue 339 follow-ups: a directory used as found is said and can be handed over; the runtime's data and any .ssh refused' (#64) from fix/339-found-directories-are-said into main 2026-10-09 00:15:17 +00:00
jochen dfb14151bd Say in the plan that a unit is restored only if it is still as the mesh held it
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery failed: its walk failed: a gate on a first machine (what it carried put back), a build, a machine
Confirmation pass of #66: the plan is a preview, and the removal reads the unit when it runs; a masked
unit or one changed since is left as it is.
2026-10-09 02:03:51 +02:00
jochen a018ebd1ca Pass -- before every unit, and give a unit back only when it is exactly as the mesh held it
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Third review of mesh-catalog #147.

A unit's name can now come from a setting (a template's instance), so every systemctl call that names a
unit passes `--` before it, and a name can never be read as an option.

On removal, a unit is started or enabled again only when it is exactly the stopped and disabled the mesh
held it to. A masked unit, or one somebody started or enabled since, is left as it is, and the report
says why. A restore that fails (the start refused, or the unit not running after it) puts back what this
attempt changed and is reported as waiting, so no apply is made fatal by it and the next one tries again.

The tests' fake service managers read systemctl's arguments in the shape they were written for
(oldShape); internal/system's own test holds the `--`.
2026-10-09 01:54:26 +02:00
jochen 7c046a8a1d Accept and ignore a file's trusted, and give each runtime its own time to answer
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The fourth review (hq issue 339): `trusted` is the catalogue's word and the
controller takes it out, but a controller that passes it on, an older one or
one rolled back to, must not cost a node its whole declaration. A file now
accepts it and nothing reads it; any other resource still refuses it. And a
podman that hangs no longer eats docker's ten seconds.
2026-10-09 01:42:09 +02:00
jochen 9d48b8baf9 Start again on removal what the mesh's own declaration stopped (hq ADR 0269)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A unit found running and enabled that a declaration held stopped and disabled stayed stopped and
disabled when the declaration went: ADR 0118 never starts a unit on the way out. Unassigning the zfs
module would so have left the home server's pool with no scrub schedule at all, said nowhere.

The host now records what it declared for a service (Declared, beside Found). On removal, a unit found
running and declared stopped is started again, and one found enabled and declared disabled is enabled
again, each read back; the report says restored, and the plan says it first. A record from before the
field starts nothing.
2026-10-09 01:35:27 +02:00
jochen ff2553f4db Ask the running runtimes where they keep their data, and read every form a unit says it in
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The third review of #64 (hq issue 339): an ExecStart continued over lines, a
quoted path, a variable from Environment= or EnvironmentFile= across the unit
and its drop-ins, dockerd's --config-file and podman's TOML literal strings
each hid a data root from the guard. The running runtimes are now asked first
(docker info, podman info) on a runner of the guard's own, and the files are
read beside them in all those forms.
2026-10-09 01:22:11 +02:00
jochen ae1293e73e Read every runtime's data root, keep when a directory was found, and key a hand-over to its resource
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-host@7c046a8a1d84 (merged as d28ca959 into main, walk plan-17915049347305…
The review of #64 asked for containerd's tree, dockerd's --data-root from its
unit and drop-ins, and podman's graphroot to be refused like docker's own
(hq issue 339); and the controller now tells a directory a send found from
one found before it, which needs the time it was first found kept across
applies instead of the time of the last apply. A hand-over now names the
resource it was for, so it lapses if another declaration points at the path.
2026-10-09 00:55:21 +02:00
mesh-admin a2c2c35e85 Merge pull request 'Issue 339: the node-engine places and mounts nothing at the machine's own trees, and uses a found directory as found' (#63) from fix/339-the-engine-places-nothing-in-the-machines-own-trees into main 2026-10-08 22:36:45 +00:00
jochen 07164e40f3 Say every directory used as found, let a person hand it over, and refuse the runtime's data and any .ssh
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A directory the engine used as found kept its owner and mode in silence, so
a module that cannot write it would just fail (hq issue 339 review). Each
one is now a WARNING in every apply's log and its module's resource of kind
directory, stated unhealthy on every look with its owner and what was
declared, which the controller raises as the module's condition; `mesh-host
hand-over <dir>` at the machine hands it to the mesh, and the next apply
gives it the declared owner and mode. The guard also refuses the spool, /opt,
the container runtimes' data (its configured data-root too), and an account's
.ssh as an access, a mount, or a directory anywhere but its own home.
2026-10-09 00:28:01 +02:00
jochen f39dbdbaf0 Give the home test's account a uid of its own, so it holds when the suite runs as root
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
2026-10-09 00:10:28 +02:00
jochen 3a002f6e7b Refuse a placement or mount at the machine's own trees, and use a found directory as found
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestBelowAHomeOnlyThatAccountsDirectoryIsPlaced (0.00s)
A module's places setting could point a directory anywhere, with an owner it
named, and the engine chowned and chmodded whatever it was pointed at as root,
through a link too; its accesses setting could mount any path into a container
(hq issue 339). The engine now refuses, as a failed resource with its reason,
a directory, access or mount source that is or holds one of the machine's
roots, anything placed in the kernel's, the boot loader's, root's or the
engine's own trees, a non-root directory below /etc, /usr or /run, an access
there, another account's directory below a home, and a container mounting
anything it refused, judged with every link resolved. A directory whose last
component is a link is refused, and one found here that the mesh did not make
keeps its owner and mode and is never removed.
2026-10-08 23:58:14 +02:00
mesh-admin ee20388239 Merge pull request 'Hold the removal order a module may rely on, and say what a killed stop leaves (hq ADR 0263)' (#62) from fix/shares-review-followups into main 2026-10-08 20:57:40 +00:00
jochen fedd9dcf35 Hold the removal order a module may rely on, and say what a killed stop leaves (hq ADR 0263)
mesh/delivery-group group fix/shares-review-followups delivered: every member is delivered
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 21:16:04 +02:00
mesh-admin 29a7de7699 Merge pull request 'Let the first controller keep and deliver again what a consumer gives up on (hq issue 330, ADR 0264)' (#60) from fix/330-a-message-given-up-on-is-kept into main 2026-10-08 18:21:04 +00:00
jochen a31659ed03 Carry no seat queue grant for the first controller
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/330-a-message-given-up-on-is-kept delivered: every member is delivered
mesh/delivery delivered
The controller no longer delivers a seat's ask again (review of hq issue 330).
2026-10-08 18:37:58 +02:00
jochen ccbfa9b58a Let the first controller keep and deliver again what a consumer gives up on (hq issue 330)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The installer's user list must be what the controller composes, or it is
refused on its first copy into DEAD_LETTERS (ADR 0264).
2026-10-08 17:52:59 +02:00
mesh-admin e813391dcf Merge pull request 'Add the replay of issue 331 (R331)' (#58) from replays/331-a-tool-check-on-the-live-link into main 2026-10-08 15:17:20 +00:00
jochen 5684a4579d Add the replay of issue 331, so a tool check refused as no link fails before a merge
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group replays/331-a-tool-check-on-the-live-link delivered: every member is delivered
Written with only what the node-engine had before its fix, so mesh-lab's prover can
lay it over the commit before (R331).
2026-10-08 17:10:29 +02:00
mesh-admin 824911dc50 Merge pull request 'Ask a declared tool check on the NATS link the node-engine holds (hq issue 331)' (#57) from fix/tool-check-asks-on-the-link into main 2026-10-08 15:06:51 +00:00
jochen 5e189c2fca Say a tool check's errors once and as they stand, and test the link on a real bus
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
The evidence read "asking it: asking <subject>": the words are now the link's own.
A deadline is said as the time the check gave it. A refusal is said only when the bus
refused this question, not an earlier one under a grant since widened. merge-check.sh
runs the tests that need a bus against a throwaway nats-server when the toolchain has
one, and says so when it does not (hq issue 331).
2026-10-08 17:01:15 +02:00
jochen 809ab4cf29 Ask a declared tool check on the NATS link the node-engine holds
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The queue asks a tool through a ToolBus assertion that only OverNATS satisfied; the
link the engine actually holds (natsLink) had no Ask, so every tool check failed as
"no link to the bus is open" while the link was up (hq issue 331). Give natsLink Ask,
assert at build time every optional interface the queue expects of it, and tell a link
that cannot ask apart from no link.
2026-10-08 16:44:01 +02:00
mesh-admin 6400e92d19 Merge pull request 'Report whether a battery powers the machine and whether anything measures its draw (hq ADR 0255)' (#56) from feat/the-bar-takes-blocks into main 2026-10-08 12:55:38 +00:00
jochen 41b3392789 Count the same meters the power module's sampler reads (hq ADR 0255)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
An APU's integrated graphics and Intel's second (MMIO) view of a package made a machine
report a power meter the sampler would not read, so its bar could only say not
measured.
2026-10-08 14:44:55 +02:00
jochen e41ab3a505 Report whether a battery powers the machine and whether anything measures its draw (hq ADR 0255)
The power module is on every machine and its bar blocks apply only where the
hardware is. Two capabilities read from the kernel let the controller compose them
there and nowhere else: battery (a Battery supply not scoped to a device) and
power-meter (a system battery, a powercap package counter, or a graphics device
reporting its power).
2026-10-08 14:44:55 +02:00
mesh-admin 800aabed83 Merge pull request 'Name the account whose own manager runs a unit (hq ADR 0254, issue 318)' (#55) from fix/318-a-wait-for-a-person-is-not-a-failure into main 2026-10-08 12:41:05 +00:00
jochen 9d8398206e Name the account whose own manager runs a unit, so a wait for a new login is not read as a fault (hq ADR 0254, issue 318)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/318-a-wait-for-a-person-is-not-a-failure delivered: every member is delivered
2026-10-08 13:41:46 +02:00
mesh-admin 9815796891 Merge pull request 'Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)' (#54) from feat/module-groups into main 2026-10-08 10:09:22 +00:00
jochen ab4ca44f98 Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/module-groups delivered: every member is delivered
A module puts the operator's account in a group by declaring the account with that group alone.
The node-engine now records each group it added, takes back only those when nothing declared still
asks for them, refuses a group the machine lacks before usermod runs, and states each such account
as its module's resource of kind account: relogin needed while the running session lacks the group.
2026-10-08 12:04:08 +02:00
mesh-admin ef8378a990 Merge pull request 'Say every failed unit, the module's and the machine's (hq issue 315)' (#53) from fix/315-a-failed-unit-is-a-condition into main 2026-10-08 09:40:27 +00:00
jochen 14e5d91c9a Say every failed unit, the module's and the machine's (hq issue 315)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/315-a-failed-unit-is-a-condition delivered: every member is delivered
Liveness judged only what a module runs long-lived, so a module whose
daemon is a package's unit started by D-Bus activation failed at every
start while its machine read healthy, and a degraded service manager was
said by nothing but the profile.

The engine now reads the failed units of the machine's manager and of
every account manager the declaration names, on the two-look rule, and
says whose each is: a declared service or process, a unit file the mesh
writes, or a package the mesh installs makes it that module's, said as
an unhealthy resource of kind unit; anything else is the machine's own,
said in the statement's new units field. It reads; it never acts.
2026-10-08 11:28:51 +02:00
mesh-admin f863adb741 Merge pull request 'A machine makes its tunnel key first and joins through the tunnel (hq ADR 0169)' (#52) from feat/a-machine-joins-through-the-tunnel into main 2026-10-08 08:24:16 +00:00
jochen 86cbebd10f Say controller in the installer's and the token's words, as the glossary does
mesh/delivery delivered
mesh/delivery-group group feat/a-machine-joins-through-the-tunnel delivered: every member is delivered
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 01:46:21 +02:00
jochen 0e57186bf1 Place the bus's users before the enrolment's wait begins
The installer placed them between making the enrolment's deadline and
using it, so a failure returned without cancelling it, and go vet refused
the package.
2026-10-08 01:40:47 +02:00
jschoubben e30d838395 A joining machine dials the bus once the hub has answered its tunnel
Issuing the token sends the hub its new peer, and the hub applies it on
its own time; a bus dialled before then timed out naming the bus. The
first tunnel now waits for a handshake with the hub, and says so in the
tunnel's words when there is none (novox/hq ADR 0169).
2026-10-08 01:33:30 +02:00
jschoubben 0863695012 The installer lets the first node onto the bus it raised
At genesis the bus's users reach it in no declaration, because the
machine running it has not enrolled. The installer, which raised the
bus from its bundle, places the control plane's composed list beside it
and makes it re-read it: before the machine enrols, for the token's
account, and after, for the node's own (novox/hq issue 146).
2026-10-08 01:33:30 +02:00
jschoubben c38f21bb39 A machine makes its tunnel key first and joins through the tunnel
nox-mesh-host key makes the tunnel key, or reads the one made, and
prints its public half for the token to be issued for. enrol with a
token that carries a tunnel takes that key, refuses another, writes
mesh0 with the hub as its one peer and starts it, then reaches the bus
over it (novox/hq ADR 0169). Tokens without a tunnel enrol as before.
2026-10-08 01:33:30 +02:00
mesh-admin fcca8d9ce8 Merge pull request 'Judge the machine's own networking beside what its modules run (hq ADR 0241)' (#51) from feat/machine-network-health into main 2026-10-07 18:16:08 +00:00
jochen f8ef2de91d Hold the network statement's field names on the engine's side (hq ADR 0241)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/machine-network-health delivered: every member is delivered
2026-10-07 18:53:21 +02:00
jochen babd32cfc7 Take a tick a little early as the network look's tick
The liveness loop's ticker drifts; a look skipped for a few milliseconds
would wait a whole further tick.
2026-10-07 18:52:16 +02:00
jochen 039abeff69 Drill the network judge in a throwaway machine (hq ADR 0241)
The judge's tests run against files and fakes; the drill runs it against a
real resolver file and real resolvers, rewritten as the VPN client does,
and records the statements the controller's replay raises and clears from.
2026-10-07 18:51:45 +02:00
jschoubben 429ea42357 Judge the machine's own networking beside what its modules run (hq ADR 0241)
A VPN client rewrote the laptop's resolver file and every mesh name failed
while each module read healthy: nothing asked the machine. The engine now
looks every 30 s at the resolver file the uplink holder declared (naming
the program that rewrote it), the names through each listed resolver
(NXDOMAIN for a mesh name's IPv6 address is a finding, issue 262), the
tunnel's handshake with the hub, the bus and the default route; a part is
unhealthy on its second failing look, and the statement carries it.
2026-10-07 18:43:39 +02:00
mesh-admin 56e2ebec4b Merge pull request 'Judge how a module says it is ready, beside whether it stays up (hq ADR 0240, to-be 48 Phase B)' (#50) from feat/health-the-field into main 2026-10-07 16:28:34 +00:00
jochen 96bf9415aa Ask the merge check for this pull request: opening it announced nothing to the build seat
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/health-the-field delivered: every member is delivered
2026-10-07 16:17:40 +02:00
jochen bdd44154cc Judge how a module says it is ready, beside whether it stays up (hq ADR 0240, to-be 48 Phase B)
Liveness alone could not see a web application whose port was open and whose
program ran while every request hung for eleven hours (issue 145). A resource
now carries the `health` its module declared: the engine makes http and tcp
looks itself from the machine to the endpoint's published port, reads a unit's
readiness from the show it already makes, hands an exec command or the image's
own check to the runtime as the container's check with the declared timing and
reads its state from the inspect it already makes, and asks a module's tool on
its own node tools. Starting until the check passed, unhealthy once its looks
after the grace fail the declared number of times; never more looks than the
measured budget; nothing restarted. The statement says contract 2, which tells
the controller this engine may be sent the field.
2026-10-07 14:08:32 +02:00
mesh-admin 41f908b803 Merge pull request 'Let a planned maintenance window fail no apply (hq issue 291)' (#49) from fix/a-planned-window-fails-no-apply into main 2026-10-07 11:20:20 +00:00
jochen 3c1ac6aef2 Let a planned maintenance window fail no apply (hq issue 291)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
At 03:30 the store's collector held the registry still while the
node-engine's reconcile on that machine was fetching bundle blobs from
it: every archive failed 'connection refused' and the machine was held
until the next pass. Other machines can meet the same window.

A scheduled step now opens its window only once no apply is in flight
here (the apply lock is taken just to write the record, so a push still
never queues behind the window). An apply whose fetch the store does
not answer waits for a window open on its own machine to close, and
elsewhere retries with backoff within one bounded budget per apply,
well past the window's length; an answer such as 404 still fails at
once.
2026-10-07 13:11:03 +02:00
mesh-admin 038eff5ca0 Merge pull request 'Keep a replaced build reachable, and delete it only once no process runs from it (hq issue 289)' (#48) from fix/a-verb-survives-the-handover into main 2026-10-07 00:55:23 +00:00
jochen 3a117c2d2b Keep a replaced build reachable, and delete it only once no process runs from it (hq issue 289)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The witness moved a running controller's build into a 0700 directory and deleted it
on proof, while the old process could still be serving. Its directories are now
0711, and a build without a reader is retired and swept once /proc shows nothing
runs from it.
2026-10-07 02:45:08 +02:00
mesh-admin 03031a46dd Merge pull request 'Judge whether what a module runs stays up, and say it (hq ADR 0240, to-be 48 Phase A)' (#47) from feat/a-module-says-how-it-is-healthy into main 2026-10-07 00:39:39 +00:00
jochen 1fc1e74cc3 Judge whether what a module runs stays up, and say it (hq ADR 0240, to-be 48 Phase A)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/a-module-says-how-it-is-healthy delivered: every member is delivered
A container that crash-looped after its compose applied passed every check the
gate had: nothing looked at what a module runs. The node-engine now judges every
long-running resource on every look — one read of the runtime, one per service
manager — keeps the restarts it counts across recreates and its own restarts,
and says the state in every report and as an event on change, again every minute
while not healthy. It reads only; nothing is restarted for being unhealthy.
2026-10-07 02:28:15 +02:00
mesh-admin a338c2e581 Merge pull request 'Lay out the publishing test as every gofmt agrees (hq issue 286)' (#46) from fix/gofmt-as-the-toolchain into main 2026-10-07 00:27:48 +00:00
jochen bd30534ab1 Check again, judged by the controller with the gate's fix (novox/hq issue 285)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 02:06:51 +02:00
jochen 2a4b521e1b Cite the hq issues by the numbers they were given: 285, 286, 287
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (0 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-07 02:00:13 +02:00
jochen ed43d65bf3 Lay out the publishing test's return as every gofmt agrees, so the build seat's check passes
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (0 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The toolchain the build seat runs merge-check.sh in (Go 1.26) and a newer local Go format a return of
several multi-line composite literals differently; the seat's is the one that judges, and it failed every
pull request on this file (novox/hq issue 283).
2026-10-07 01:29:06 +02:00
mesh-admin 971881d58d Merge pull request 'Let the controller ask the delivery's owner stalled and close (hq ADR 0239)' (#45) from feat/mesh-delivery-waits-said into main 2026-10-06 22:30:57 +00:00
jochen b196cabd8f Let the controller ask the delivery's owner stalled and close (hq ADR 0239)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (0 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: internal/bootstrap/publish_test.go
mesh/delivery delivered
mesh/delivery-group group feat/mesh-delivery-waits-said delivered: every member is delivered
The controller's self-check reads mesh-delivery's stalled and healer H2 calls
its close; a controller raised from genesis must be granted both.
2026-10-07 00:14:19 +02:00
mesh-admin 880e7461f9 Merge pull request 'Carry plan-moved in the installer's first user list (hq ADR 0239)' (#44) from feat/mesh-delivery into main 2026-10-06 22:07:19 +00:00
jochen 94e49c6b2d Carry plan-moved in the installer's first user list (hq ADR 0239)
mesh/merge-gate pass: every machine composes with the change as it did without (0 of 4 compose)
mesh/delivery delivered
mesh/delivery-group group feat/mesh-delivery delivered: every member is delivered
The controller says every walk it keeps as plan-moved; a controller raised
from genesis must be allowed to say it from its first start.
2026-10-06 23:59:19 +02:00
mesh-admin ad812a5888 Merge pull request 'Leave the gate to the build seat; merge-check.sh checks the node-engine's own code (hq ADR 0238)' (#43) from feat/the-graph-decides-what-is-checked into main
mesh/delivery delivered
2026-10-06 21:00:35 +00:00
jochen f823ade868 Leave the gate to the build seat; check the node-engine's own code here (hq ADR 0237)
The build seat now runs the gate itself, judging a node-engine change with the change's
validator in the running controller because the module graph builds mesh-host from here.
This script is the repository's own layer (mesh/repo-check): format, vet, and the suite
under the race detector when the toolchain can.
2026-10-06 21:51:49 +02:00
mesh-admin f39e282fed Merge pull request 'Phase 5: check the node-engine's changes against every machine before they merge (hq ADR 0237)' (#42) from feat/merge-gate into main
mesh/delivery delivered
2026-10-06 19:19:20 +00:00
jochen 2fb7c91eaf Check the node-engine's changes against every machine before they merge (hq ADR 0237)
merge-check.sh runs the suite, then builds the controller the mesh runs with this change's
validator in place of the one it vendors and runs the merge gate: a change to the node-engine
that would refuse what the mesh sends today fails its pull request, naming the machine. The
installer's first user list lets the controller say a check's verdict and hear a pull request's
head, as the controller now composes it.
2026-10-06 21:16:33 +02:00
mesh-admin 7a6f9218a0 Merge pull request 'Core upgrades that roll back: the host side (hq to-be 45 Phase 4, ADR 0227 rule 8)' (#40) from feat/core-upgrades-roll-back into main
mesh/delivery delivered
2026-10-06 18:00:07 +00:00
mesh-admin ad39988bc6 Merge pull request 'Genesis grants for the gate's event and the bus step's snapshot (hq ADR 0236)' (#41) from feat/core-upgrades-that-roll-back into main
mesh/delivery delivered
2026-10-06 17:15:07 +00:00
jochen 7b450ab371 Carry the controller's grants for the gate's event and the bus step's snapshot (hq ADR 0236)
The controller says a rollback as rolled-back and asks the bus machine's backup holder
for a snapshot before the planned bus step; the installer's first user list must grant
both, or a fresh mesh's controller is refused the first time it uses them.
2026-10-06 18:34:41 +02:00
jochen 0c405b70cc Roll a core build back by a witness that is not the new build (hq to-be 45 Phase 4)
The launcher trusted a counter only a by-hand reconcile ever cleared and a
known-good nothing in the daemon wrote, so no machine could roll its host back;
the controller and the node tools were replaced in place with nothing kept.

- The launcher runs a delivered host that is not known-good on trial: one that
  crashes, stops for nothing, or does not report within ten minutes goes back
  to known-good, once per version, recorded in rolled-back. The host proves
  itself when the mesh takes a report under its own build, says every standing
  verdict on its reports, never stands aside for a rolled-back version, and
  restarts its service once when its launcher was replaced on disk.
- The engine keeps the controller's and the node tools' previous build beside
  the new one and judges the new one: the lease taken by the controller it
  started (read-only direct get of mesh-controller_lease/holder), or this
  machine's runtime answering $SRV.PING.node-tools.<node>, within sixty seconds
  of time it could ask. Not healthy: the previous restored, once, said. Proved:
  the previous deleted. A build declared not-reversible is never rolled back.
- Retire never removes a version newer than the running one.
2026-10-06 18:23:56 +02:00
mesh-admin 8d853791b2 Merge pull request 'Genesis grant for D13; a kept directory is never 'removed by hand' (hq ADR 0233)' (#39) from feat/a-module-declares-the-data-it-holds into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 15:00:52 +00:00
jochen 7e9ae9f07e Carry the controller's new self-check grant; stop telling people to delete kept data (hq ADR 0233)
The installer's first user list must match the controller's composed grant, which now reads every
machine's backup holder; and a kept directory's report pointed at the one act that loses data.
2026-10-06 16:47:49 +02:00
mesh-admin 2e884438f9 Merge pull request 'Genesis assigns mesh-wireguard, not the retired networking bundle (hq ADR 0226)' (#31) from feat/genesis-assigns-the-private-network into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 13:04:28 +00:00
jochen 98885e953e Genesis assigns mesh-wireguard, not the retired networking bundle (hq ADR 0226)
A controller that no longer ships networking refuses it, which would stop genesis where the hub
joins the private network.
2026-10-06 14:59:26 +02:00
mesh-admin f274b6effe Merge pull request 'Genesis lock: let the controller hear provisioner.retirement (hq ADR 0230)' (#38) from feat/retired-consumers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 12:46:32 +00:00
jochen 240b903a04 Let the genesis controller hear what providers retire (hq ADR 0230)
The controller now derives a subscription to provisioner.retirement; the first
user list the installer carries must match it, or the genesis controller is
refused the subject its composition expects.
2026-10-06 14:45:56 +02:00
mesh-admin 280d3b2e1e Merge pull request 'Say again what was last applied when the mesh asks (hq to-be 45 Phase 3, the report verb)' (#37) from feat/a-core-that-cannot-fail-silently-phase-3 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 12:29:14 +00:00
jochen 6e1bcdbde4 Say again what was last applied when the mesh asks (hq to-be 45 Phase 3, the report verb)
The controller's healer H1 answers a send that went unreported by asking the
machine first: a report lost on its way (issue 264) needs no second send. The
node-engine now hears mesh.node.<self>.ask.report on core NATS and enqueues a
reconcile whose account is said whether or not it is news; a delivery waiting
meanwhile is applied and reported instead. The answer is an ordinary report on
its own subject, so the node publishes nothing new and answers nobody's inbox.

The genesis lock grants the controller the healer-acted seat event, which it
now composes; the genesis test in mesh-controller holds the two equal.
2026-10-06 14:05:02 +02:00
mesh-admin b2808549ee Merge pull request 'Grant the genesis controller its lease and secret-replaced, not mesh.control.> (hq to-be 45 Phase 2)' (#36) from feat/a-core-that-cannot-fail-silently-phase-2-grants into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:30:28 +00:00
jochen bbe74f3fc2 Grant the genesis controller its lease and the secret-replaced event, and not what the machines say (hq to-be 45 Phase 2)
The controller now composes a publish grant for its lease bucket
($KV.mesh-controller_lease.>), which it must write before it acts, and for
the seat event secret-replaced (hq ADR 0228, mesh-controller #82); and it no
longer publishes mesh.control.>: a machine's report has one writer, its
node-engine, and the writers table refuses a second at composition. The
installer's first user list must say what the controller derives, or a new
mesh's first controller is refused its lease and serves without one.
2026-10-06 12:25:12 +02:00
mesh-admin 3e80b7ae32 Merge pull request 'Apply through one queue, and order what is applied and reported (hq to-be 45 Phase 2)' (#35) from feat/a-core-that-cannot-fail-silently-phase-2 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 09:55:19 +00:00
jochen 31804bd8c2 Apply through one queue, and order what is applied and reported (hq to-be 45 Phase 2)
A delivery and the five-minute reconcile were two paths that applied, ordered only by a lock, and
each order it allowed was met live (issues 257, 261, 267). Now both only enqueue: one worker takes
the newest declaration held when it starts, applies it once and makes one report, and reports leave
in the order they are made.

A declaration may carry the controller's lease epoch beside its sequence; one older than what this
node applied is refused before anything is touched, counted, logged and reported. A report carries
the declaration's epoch and sequence and the host's own report sequence, kept on disk so it goes on
increasing across restarts and self-updates. Without an epoch, today's behaviour stands.
2026-10-06 11:54:10 +02:00
mesh-admin d7d93f57c5 Merge pull request 'Grant the genesis controller the self-check's ban-list question (hq to-be 45 Phase 1, D8)' (#34) from fix/phase-1-d8-grant-and-d10-version into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 08:45:16 +00:00
jochen b9774ea426 Grant the genesis controller the self-check's ban-list question (hq to-be 45 Phase 1, D8)
The controller now composes a publish grant for
mesh.seat.node-intrusion-prevention.tool.banned.*, which D8 asks every
machine; the installer's first user list must say what the controller
derives, or a new mesh's first self-check is refused it.
2026-10-06 10:44:39 +02:00
mesh-admin 1545b00a87 Merge pull request 'Say the heartbeat's interval, export the host's validator, grant the genesis controller Phase 1 (hq to-be 45)' (#33) from feat/a-core-that-cannot-fail-silently-phase-1 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 08:24:39 +00:00
jochen 6953b5bafd Say the heartbeat's interval, export the host's validator, grant the genesis controller Phase 1 (hq to-be 45)
The controller's watchdog of a machine's heartbeat (S1) is bound to three
of its intervals, and a bound the controller guessed would not move when
the interval does: the heartbeat now carries interval_seconds.

Its self-check (D1) must judge every composed declaration as the host
does, and a second validator written from the host's rules would drift
from them: the host's own parsing is exported, unchanged, as
github.com/novox/mesh-host/validate.

The installer's first user list grants what the controller now composes
for itself: its condition buckets, the condition and doctor-heartbeat
events, the bus's two consumer advisories and $SRV.INFO — or the first
controller would be refused them until the broker's machine is pushed.
2026-10-06 10:18:54 +02:00
mesh-admin 93efe41dc9 Merge pull request 'Grant the genesis controller its buckets and cancelled sets (hq to-be 45 Phase 0, issue 269)' (#32) from feat/a-core-that-cannot-fail-silently-phase-0 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 07:13:38 +00:00
jochen 1e463def6b Grant the genesis controller its work queues' cancelled sets (hq issue 269)
The controller now derives the cancelled sets' subjects; the installer's
first user list says the same, or a cancel on a new mesh times out.
2026-10-06 03:01:19 +02:00
jochen a654fa768d Grant the genesis controller its own buckets' subjects (hq to-be 45 Phase 0)
The controller keeps its calls and the hand-act log in two key-value buckets
of its own, and composes a grant to write them. The installer's first user
list must say what the controller derives, or the first controller writes
nothing until the broker's machine is pushed.
2026-10-06 02:59:51 +02:00
mesh-admin 7f98478d6d Merge pull request 'Never say a reconcile's report after a newer apply's (hq issue 267)' (#30) from fix/stale-report-overwrites into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 23:47:10 +00:00
jochen a79972577c Never say a reconcile's report after a newer apply's (hq issue 267)
A reconcile that held the machine to the kept declaration just before a
delivery arrived queued its report while the delivery's apply waited for
it; the link published the apply's report and then the reconcile's, so the
mesh's last word from the machine named the older declaration and the
release plan waited on a report it had already been given. The link now
sets aside an unasked report about a declaration other than the one it
has applied since.
2026-10-06 01:45:34 +02:00
mesh-admin 64defd47c7 Merge pull request 'Say an apply's report even when the apply ends the link (hq issue 264)' (#29) from fix/report-lost-at-self-update into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:31:47 +00:00
jochen f62ee0bc75 Say an apply's report even when the apply ends the link
A host that delivered its own successor stood aside before the report of
that apply was published, so it failed with 'context canceled' and the
release plan waited for a report that never came (novox/hq issue 264).
Reports are now published on a context the stand-aside does not cancel,
and the last apply's report is kept until the broker takes it and said
again on the next link, so a crash between apply and report is covered too.
2026-10-06 00:30:15 +02:00
mesh-admin 07430240bb Merge pull request 'Let the genesis controller hear provider standings and its seats' verbs' (#28) from feat/controller-hears-provider-standing into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:20:46 +00:00
jochen 6f86b484d0 Let the genesis controller hear provider standings and its seats' verbs
The carried user list must equal what the controller derives (its test reads
this file): add the provisioner.failing/recovered subscriptions (hq ADR 0224)
and the build seats' tool publishes it already derived (hq ADR 0219).
2026-10-06 00:13:23 +02:00
mesh-admin 24bada7a6f Merge pull request 'Hand a whole file to its new owner instead of removing it first (hq ADR 0223)' (#27) from files-forget-when-held into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 21:50:48 +00:00
jochen 762da9e05e Hand a whole file to its new owner instead of removing it first (hq ADR 0223)
The resolver file moves from resolv-conf to the uplink's holder in one apply.
Orphans go first, so the file was deleted or given back its pre-mesh original
until the new owner's turn came. Now the old record is forgotten once the new
one is recorded at the same path, and the kept original goes with it.
2026-10-05 23:35:40 +02:00
mesh-admin a192bccf62 Merge pull request 'Leave a unit alone when another declared service still holds it (hq issue 190)' (#26) from test/190-into-json-member-handover into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:31:33 +00:00
jochen f0f5873202 Keep unitKey's comment on unitKey 2026-10-05 22:27:32 +02:00
jochen c9001abdb4 Leave a unit alone when another declared service still holds it (hq issue 190)
Removing one record of a unit gave back what that record found, even while another declared
service holds the unit: when the private network's docker.service record goes and the docker
module's stays, a record that found the runtime stopped would stop it, and every container with
it, only for the docker module to start it again in the same apply. The record is forgotten
instead, and the plan says so. A test pins the registry member moving from the network's record
to the docker module's in one apply without leaving the list.
2026-10-05 22:21:21 +02:00
mesh-admin a566add815 Merge pull request 'A service is restarted only after every file it names is written (hq issue 260)' (#25) from fix/a-service-restarts-after-every-file-it-reads into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 19:57:22 +00:00
jochen 101f0c0d61 Restart a service only after every file it names is written (hq issue 260)
A service was restarted where it was declared, so one declared ahead of a
file in its restart-on was restarted before that file existed (the
resolver's zones file, a fact appended after its module's resources), and
a later change to such a file was never acted on. Each service is now
applied right after the last resource it names under restart-on or
reload-on; nothing else moves.
2026-10-05 21:55:47 +02:00
mesh-admin 89a7796afe Merge pull request 'A reconcile applies what was kept when its turn comes (hq issues 257, 261)' (#24) from fix/a-reconcile-applies-what-was-last-kept-when-its-turn-comes into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 19:43:51 +00:00
jochen ea6fe09e01 A reconcile applies what was kept when its turn comes, not when its timer fired (hq issues 257, 261)
The five-minute reconcile read the kept declaration and then waited for the link's apply; the
link keeps a declaration only once its apply ends, so the reconcile applied the older one over
it. A module assigned a moment earlier was given back, and the report named a declaration the
mesh no longer recorded as sent, which held a plan at its first machine.
2026-10-05 21:43:47 +02:00
mesh-admin 7063b183af Merge pull request 'An apply leaves a container a maintenance window holds still (hq issue 224)' (#23) from fix/an-apply-leaves-a-held-container-held into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:27:55 +00:00
jochen f08681f225 An apply leaves a container a maintenance window holds still (hq issue 224)
A while-stopped step stops its module's containers, and an apply arriving
mid-window read the stopped server as broken and recreated it running under
the step - for the store's collector, a registry taking an upload the sweep
then deletes. The scheduler now records each window under the node's state
directory before its first stop and erases it after its last start, so every
apply on the machine (daemon, reconcile by hand, installer) reports a held
container held-still and leaves it for the first apply after the window.
A window whose host died, or past six hours, holds nothing; the report says
which windows are open.
2026-10-05 18:27:04 +02:00
jschoubben 238252e152 Merge pull request 'An undeclared file is deleted only when it holds what the host wrote; otherwise moved aside (hq issue 241)' (#22) from fix/a-file-that-is-not-only-the-meshs-is-moved-aside into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 23:57:56 +00:00
jschoubben b774a0b4f9 An undeclared file is deleted only when it holds what the host wrote; otherwise moved aside (hq issue 241) 2026-10-05 01:30:42 +02:00
mesh-admin 89fce1dae3 Merge pull request 'A file the host wrote over is given back when undeclared (hq ADR 0102, 0118)' (#92) from fix/a-file-written-over-is-given-back into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:54:50 +00:00
jochen 3e11d720b4 A file written over is given its kept original back when undeclared (hq ADR 0118, 0102)
The host keeps the original of a file before writing over it (ADR 0102), but
removing the file's record deleted the file and never put the original back,
although ADR 0118 and the comment on meshMadeUnits say it does. A module writing
/etc/pacman.conf, logrotate.conf, locale.conf or vconsole.conf whole would, once
unassigned, leave the machine without the file.

removeWhole now decides, in order: no kept original (the mesh made it) is
removed as before; a file gone since is not brought back; a file changed since
the mesh last wrote it is left as it stands, as a block or JSON write-into stays
the machine's; an unreadable kept copy leaves the mesh's file in place. Otherwise
the original goes back atomically with the mode and owner it was found with,
now recorded beside Kept, and the outcome is "restored". None of it is fatal.
The plan says "restore" for such a file.

A kept original is carried only for the path it was kept from, and a file whose
path moved keeps the original at its new path first, so a moved file is never
given another path's original.
2026-10-04 12:54:16 +02:00
mesh-admin 429672b7ff Merge pull request 'User-scoped units (hq ADR 0177, to-be 38 WP6), with lingering and a manager that is not running' (#91) from feat/user-scoped-units into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:43:17 +00:00
jochen d5c365cb2b A user unit waits for its account's manager, and lingering is the account's (hq ADR 0177)
An account's manager runs only while it is logged in or lingers. A user-scoped unit
whose manager is not running is now "waiting" rather than failed, its record kept as
it was; its removal is never fatal (kept recorded, retried) and an account that is
gone is forgotten. Whether the manager runs is asked of user@<uid>.service in the
machine's manager: asking the account's own, through --machine, logs it in.

The user shape gains `linger`, set with loginctl, read back from logind's record,
and given back on removal like the shell. Unit files the mesh writes under
~/.config/systemd/user or /etc/systemd/user make that unit the mesh's, and made,
holds and found units are keyed by manager and name, so an account's unit and the
machine's of one name are two units. A service moved between managers gives the old
one back through the manager it was in. OpenRC refuses both.
2026-10-04 12:41:32 +02:00
jochen 84540e709a A unit may be user-scoped: applied through the account's own manager (hq ADR 0177)
A workstation's per-user daemons — a window manager's reload watcher, an
audio mask, a memory guard — are units in the operator account's own service
manager, and until now had no form the mesh could send (to-be 29). The
`service` shape gains `scope` ("system", the default, or "user") and `user`
(the account, named ${machine:account} by a module); a user-scoped unit
without an account, or a system unit naming one, is refused at parse.

The host reaches the account's manager as `systemctl --user --machine=<account>@`
from its own process: no environment to forge, no user to switch to. Done on
the runner rather than per system, since every system's reading of a unit
already goes through systemctl. Apply, reflect-only and removal all go through
the same manager, and the applied record carries scope and user so removal
gives the unit back to the manager it came from. It answers only while that
manager runs — a login, or lingering enabled for the account; declaring
lingering is a follow-up.

Tests: a user-scoped unit is started and enabled in the account's manager and
recorded with its scope; a system unit never sees --user; the validation of
scope and user.
2026-10-04 12:31:24 +02:00
mesh-admin 6431848342 Merge pull request 'An archive can be undeclared, and undeclaring one no longer stops the apply (hq issue 162)' (#90) from fix/162-an-archive-can-be-undeclared into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:22:39 +00:00
jochen 8470dbd8e8 An archive can be undeclared, and undeclaring one no longer stops the apply (hq issue 162)
An archive had no removal, so an unassigned one failed as an orphan and
aborted every apply after: a module with tools could not be unassigned,
and a race between two pushes froze a machine against every change.

The record now keeps what an archive unpacked: its files, the
directories the host made inside its path, whether the host made the
path itself, and the parents it made to reach it. Removal takes exactly
that away, directories only once empty, never one that was there
before; a directory that is the host's alone is renamed aside first so a
reader sees the whole bundle or none of it. Whatever cannot be removed
is said and forgotten, never fatal.

A directory found before the archive is no longer swapped away with
what was in it: the archive is moved in file by file, and one that would
write over a file the mesh did not put there is refused before anything
moves. A record from before this change learns its files from the
archive's bytes on the next apply; one already orphaned is left in
place, said and forgotten. A former target is still left in place: the
version before is what a rollback starts (ADR 0141).
2026-10-04 12:20:41 +02:00
mesh-admin 64b421b6e1 Merge pull request 'A login the mesh set is given back, and directories made inside a home are its account's (hq issue 228, to-be 41 WP1)' (#89) from feat/the-shell-and-its-environment into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 08:49:30 +00:00
jochen f2eda240ec Cite hq issue 228: 225 was taken on main while this branch was open 2026-10-04 10:30:57 +02:00
jochen 2a5f4c8270 Parents the host makes inside an owner's home are the owner's (hq ADR 0182, to-be 41)
A file or archive placed under a fresh account's home with an owner left
the parents it created, such as ~/.config or ~/.local/share, owned by
root, so the person's own programs could not write there. Parents that
already existed, and any outside the owner's home, are left as before.
2026-10-04 04:07:40 +02:00
jochen 5d5dccdd55 A login the mesh set is given back, and undeclaring one no longer stops the apply (hq issue 225)
A user had no removal, so an undeclared one failed as an orphan and
aborted every apply after. Removal now keeps the account, gives back
the shell recorded when the mesh first changed it if it is still the
mesh's and still usable, and says why otherwise (hq ADR 0176 §2).
A shell is refused before it is set unless it is executable and listed
in /etc/shells, since usermod succeeds on a missing one.
2026-10-04 03:57:34 +02:00
mesh-admin 27fb22fddb Merge pull request 'A scheduled step may hold its module's own containers still (hq ADR 0189, issue 108)' (#77) from feat/the-store-keeps-what-the-records-name into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 01:39:07 +00:00
jschoubben b602b223e1 A changed maintenance window is a changed spec (hq ADR 0189)
containerSpec says a changed cadence moves the marker so the install is
reported updated and re-established. Which containers are held still is the
same kind of statement, and a declaration that changed it while the machine
reported no change would be a machine quietly holding yesterday's containers.
2026-10-04 03:27:32 +02:00
jschoubben e8c4824ae2 A scheduled step may hold its module's own containers still (hq ADR 0189)
while-stopped names resource ids of the same module's containers; the host
stops them before the run and starts them again after it, in reverse order,
whatever the step did. The restart is deferred before the first stop and runs
on its own context, because the one real risk of this field is a window that
never closes.

Scheduled steps only: at apply the declaration is applied in order and a
run-once step already gates what follows.
2026-10-04 02:34:33 +02:00
mesh-admin 8c2e76f4c1 Merge pull request 'Read getent's exit code, not its wording (hq issue 213)' (#88) from fix/getent-not-found-is-an-answer into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 00:11:48 +00:00
jochen 8280a82ef8 Read getent's exit code, not its wording (novox/hq issue 213)
A user that does not exist yet was matched as Go's 'exit status 2', while the host's runner says
'getent exited 2', so it read as a user database that did not answer: the controller's account was
never created and the handover to its process stopped there. The runner keeps the exit underneath
its words, and a caller asks the code.
2026-10-04 02:11:43 +02:00
mesh-admin 2d5e76434b Merge pull request 'Raise a process-form controller at genesis as the container it replaces (hq issue 223)' (#87) from fix/issue-223-genesis-pivots-to-the-controllers-container into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:50:48 +00:00
jochen d9ea387680 Raise a process-form controller at genesis as the container it replaces (hq issue 223)
The controller's manifest now declares a Go bundle the host runs as a
process (novox/hq issue 213). Genesis cannot run that: the bundle is
fetched from the artifact store and compiled in a toolchain, and the mesh
makes both long after the controller. The builder, asked to build the
manifest at genesis, refuses for lack of the Go toolchain. So genesis
raises the controller as before, as a container, and the first push hands
it over to the process through `replaces` (issue 223, option b).

- Step 3 clones the controller at the commit with the carried builder's
  git and reads its manifest. In the image form (an older controller) it
  builds through the builder as before. In the process form it builds the
  repository's own Dockerfile and hands step 9 a manifest of its own
  shape: the process becomes a container with the id the process
  `replaces`, the image genesis built, host network, and every host path
  the process's env names mounted at that same path read-only. Secrets
  belong to the image's user (65534) until the process's account takes
  them over. `prepares` is dropped: the temporary controller from the
  same commit already migrated the stores, and a pinned image is
  nothing the controller can derive a step from.
- Steps 4 to 9 are unchanged: they take the manifest as they did.
- apply.ForTests lets the bootstrap's test apply a process.

The first composed declaration from the process manifest names
`mesh-controller.server`, which is what the host recorded for the genesis
container, so the first apply hands over and leaves one controller.
2026-10-04 01:47:33 +02:00
mesh-admin 99ad145bec Merge pull request 'Hand a replaced resource over to the process that replaces it (hq issue 213)' (#86) from fix/issue-213-the-controller-is-a-process into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:13:52 +00:00
jochen 2ff3b50a84 Hand a replaced resource over to the process that replaces it (hq issue 213)
The controller moves from a container to a process on the one machine
that runs it (novox/hq issue 213). Every orphan is removed before anything
is applied, so the container would go first and nothing would answer the
mesh's verbs while the process was fetched, unpacked and started — and
never again, if it did not start.

- a process may say what it `replaces`: resources the declaration no
  longer declares. Such an orphan is kept through the up-front sweep and
  removed right after the process applied and is up: active and running
  at two looks ten seconds apart, the same main process, no restart in
  between (stricter than ADR 0184's second look, which reads a unit
  waiting to restart as running). If the process failed, was skipped
  behind its module's step, or is not running, the orphan stays running
  and recorded, reported kept, and the next apply hands it over.
  Refused: naming something still declared, itself, an empty id, one
  thing named by two processes, and `replaces` on a step or a schedule.
- beyond #85's oneshot unit for a step: a step written ./name runs its
  own bundle's binary (tested), and is started, never enabled.
- a run-once process that fails gates its module, as a run-once
  container already did, so a version whose preparation failed is not
  started.
- an unchanged run-once process is not run again, and an unchanged
  scheduled one is kept up by its timer: both were "a daemon that had
  stopped" and were started on every apply.
2026-10-04 01:01:52 +02:00
mesh-admin a24670d77c Merge pull request 'Run a run-once process as its oneshot unit (design 38 WP4c)' (#85) from fix/a-run-once-step-runs-where-and-as-declared into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 22:29:10 +00:00
jochen 5fc4052a2b Run a run-once process as its oneshot unit (novox/hq design 38 WP4c)
A step was run directly: in the host's own working directory, without its env, env files or
user. A module step moved out of its container (node bootstrap/index.js) could find neither its
code nor its words. A oneshot unit carries all four as a daemon's does, and starting it waits.
2026-10-04 00:29:03 +02:00
mesh-admin a7bf0f6e39 Merge pull request 'Make an unpacked archive exactly the archive (hq issue 220)' (#84) from fix/issue-220-a-bundle-on-disk-is-exactly-the-artifact into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 22:17:57 +00:00
jochen 1c29168309 Make an unpacked archive exactly the archive (novox/hq issue 220)
Unpacked over the previous tree, a file the new archive no longer has stayed: a bundle rebuilt as
one file per entrypoint kept the old package directory. Unpack into a fresh directory and swap it
in, so a refused archive also leaves the old tree whole.
2026-10-04 00:17:50 +02:00
mesh-admin 483e02e7ef Merge pull request 'The installer's first user list lets the controller answer $SRV.STATS (hq ADR 0197)' (#83) from fix/0197-the-controller-answers-stats-too into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 21:11:25 +00:00
jochen 0650df9414 The installer's first user list lets the controller answer $SRV.STATS (hq ADR 0197) 2026-10-03 22:47:41 +02:00
mesh-admin 47fb924947 Merge pull request 'The installer's first user list lets the controller answer discovery for its seat (hq ADR 0197)' (#82) from feat/0197-the-controller-announces-itself into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:11:12 +00:00
jochen 09f84b451d The installer's first user list lets the controller answer discovery for its seat (hq ADR 0197)
The controller announces the mesh-controller seat on $SRV.PING/$SRV.INFO from genesis; the
carried account is the composed one, which the controller's test compares.
2026-10-03 22:11:01 +02:00
mesh-admin 77ae5320ad Merge pull request 'A process runs its own bundle's binary, written ./name (hq ADR 0193)' (#81) from feat/a-process-runs-its-own-binary into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:14:04 +00:00
jochen b7ede83c8c A process runs its own bundle's binary, written ./name (hq ADR 0193)
A bundle compiled to a binary runs itself, but only the host knows where it unpacked it, and the
service manager takes no path relative to the working directory. A command written ./name is that
file in the process's own unpacked bundle, made absolute in the unit.
2026-10-03 21:13:55 +02:00
mesh-admin 195fc63f6a Merge pull request 'An unchanged process keeps its record, so the node's runtime is not re-created every other cycle (hq issue 210)' (#80) from fix/issue-210-a-process-is-unchanged-the-second-time into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 11:41:34 +00:00
jochen 482d20737f An unchanged process keeps its record, so the node's runtime is not re-created every other cycle (hq issue 210)
The process applier's unchanged path returned an outcome that said nothing about what was
written; the loop recorded it like any other, erasing the digest. The next cycle found no
record and re-created the daemon, the one after found a record again, and so on: the
node's runtime restarted every ten minutes on every machine since it arrived. The outcome
now carries the digest forward, as a file's does. The test applies one process three
times and asserts the record survives an unchanged apply and no restart is asked.
2026-10-03 13:41:14 +02:00
mesh-admin 09614d94c8 Merge pull request 'A failed install says whether the package database is stale, how old it is, and what fixes it (hq issue 205)' (#79) from fix/issue-205 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:44:43 +00:00
jochen c9b963f8b9 A failed install says whether the package database is stale, how old it is, and what fixes it (hq issue 205)
pacman writes its errors to stderr, which the runner folds into the error rather than the output;
the stale-index classifier read the output alone and never saw a single 'failed retrieving file',
so a ten-week-old database on the control node reported as a wall of 404s from the mirrors. The
classifier now reads everything pacman said, knows a failed signature as the same staleness, tells a
mirror outage with a fresh database apart from it, and names the database's date and age beside the
remedy: a full upgrade by the operator, never a one-package sync, which on this distribution is a
partial upgrade. Whose job keeping the database current is stays issue 205's question.
2026-10-03 03:54:17 +02:00
mesh-admin 5a6e51f975 Merge pull request 'The first user list names the build role as node-build-agent (hq ADR 0190)' (#78) from fix/the-build-role-is-node-build-agent into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 00:53:44 +00:00
jochen 3c96683d98 The first user list carries both build seats during the handover (hq ADR 0190)
Until the retired mesh-build-machine row is dropped the controller asks and hears both roles, so
the genesis list grants both; the controller's own test of this list says so.
2026-10-03 02:53:12 +02:00
jochen 8c4e33c165 The first user list names the build role as node-build-agent (hq ADR 0190)
The controller composes the build seat's subjects as node-build-agent's now; the installer's
genesis list must say the same, or the controller's own test of that list disagrees with it.
2026-10-02 22:37:08 +02:00
mesh-admin 2a332b0e6e Merge pull request 'The mesh's own ban chain is a ban wherever it hangs (hq ADR 0186)' (#76) from fix/a-ban-list-never-holds-a-neighbour into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 16:43:09 +00:00
jschoubben b94e0f9a77 The mesh's own ban chain is a ban wherever it hangs; the front end's record is cited as 0180 (hq ADR 0186)
The legacy reader required every path into a chain of refusals to come from a built-in whose policy
accepts. The home server's ban chain hangs off the container runtime's user chain, whose forward
policy the runtime set to DROP, so the machine reported the mesh's own intrusion prevention as a
rule set the mesh did not write. A chain is a ban when every refusal names its sources and the
chain accepts nothing — the rule the nftables side already used. A chain that accepts anything is
still not a ban. Fixture captured from the machine. The citations for the uninstalled front end move
to ADR 0180, which another session's renumber had left pointing at an unrelated record.
2026-10-02 18:42:16 +02:00
mesh-admin b840ce0a77 Merge pull request 'A service the mesh asked to run is still running a moment later (hq ADR 0184)' (#75) from fix/a-service-asked-to-run-is-still-running into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 16:25:25 +00:00
jschoubben 286865dfa7 A service the mesh asked to run is still running a moment later (hq ADR 0184)
The read-back raced the failure: a service manager returns when it has started the process, and a
daemon that refuses its configuration exits a fraction of a second later, so one look saw it alive.
fail2ban took 221ms on the control node and the apply reported "restarted" onto a dead daemon while
both public machines kept no bans at all. The host looks again, after that moment. A unit still
starting is accepted at both looks; a service asked to stop is not waited on.
2026-10-02 18:16:24 +02:00
mesh-admin ca7c4a5915 Merge pull request 'A container may log to the journal (hq ADR 0179)' (#73) from feat/the-intrusion-seat-serves-its-verbs into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 15:04:02 +00:00
jschoubben b30d9c5b5a A container may log to the journal (hq ADR 0179)
A jail reads a log; a container's output went to a file of the runtime's own under a path that
changes on recreate, so no jail could read a container's service. logging: journald runs the
container with the journal as its driver, named in the spec so moving it recreates it; any other
place is refused.
2026-10-02 17:02:49 +02:00
246 changed files with 41085 additions and 773 deletions
+40 -13
View File
@@ -4,11 +4,16 @@ Tier 0 of the Novox Mesh. The one thing ever installed by hand, and the only thi
a machine.
```
scp mesh-host root@machine:/usr/local/bin/
mesh-host profile
scp nox-mesh-host root@machine:/usr/bin/
nox-mesh-host profile
```
That is the whole installation. One statically linked binary, nothing else present, no runtime
That is the whole installation. **It is a daemon**: the launcher runs it, and the operator's command is
`nox` (`mesh-cli`), on any node, from their own account (`novox/hq` ADR 0272). The subcommands below are
for installing a machine, joining it, and rescuing one no mesh can reach, run as root at the machine; a
line the mesh addresses to the operator names a `nox` line, never this binary. Once a delivered engine
proves itself, it renews the one placed by hand here to its own build, so the command on the machine is
the engine that runs it (`novox/hq` issue 356). One statically linked binary, nothing else present, no runtime
to install first ([`novox/hq` ADR 0005](https://git.novox.be/novox/hq)).
## What it is for
@@ -23,7 +28,7 @@ the host never queries the mesh database. It receives declarations and applies t
## Joining a mesh
```
mesh-host enrol --token <token> --name <what this machine is called>
nox-mesh-host enrol --token <token> --name <what this machine is called>
```
**The node generates its own identity** — an Ed25519 keypair whose private half never leaves the
@@ -52,20 +57,22 @@ and then stops, having saved nothing — so it can be run again unchanged.
connects to nothing and listens on nothing — what it applies comes from a file.
```
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match a declaration from a file
mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
mesh-host bundle show what this host carries
mesh-host owned what this host has applied and still owns
nox-mesh-host profile what this machine can be asked to do
nox-mesh-host inventory what this machine is, and what it holds
nox-mesh-host apply FILE make this machine match a declaration from a file
nox-mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
nox-mesh-host bundle show what this host carries
nox-mesh-host owned what this host has applied and still owns
nox-mesh-host hand-over DIR hand a directory used as found to the mesh, on a machine no mesh
can reach; the operator's line is `nox node hand-over <node> DIR`
--json machine-readable
--state where this node keeps what it knows
--dry-run say what applying would change, and change nothing
```
```
$ mesh-host profile
$ nox-mesh-host profile
linux/amd64
yes container-runtime 29.7.2
@@ -115,7 +122,7 @@ A host built for a machine carries its declaration **inside the binary**:
make host BUNDLE=path/to/foundation.lock
```
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
`nox-mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
first node's path — no mesh present, nothing fetched, nothing else copied onto the machine. Once
the mesh has spoken, `reconcile` holds the machine to what it last said and never to the bundle,
which genesis consumed; a bundle or a file is refused when it says the other mode than the node
@@ -151,6 +158,26 @@ carries. The claim is not. The foundation is four container services, and:
So `foundation.lock` here is a real bundle with a placeholder's content. Saying that plainly
beats shipping a host that claims a foundation it has never raised.
## mesh-cli's socket (novox/hq ADR 0272)
The engine serves `/run/nox-mesh-host/cli.sock` for `mesh-cli`, the operator's command (`internal/meshcli`). It
reads the asking account from the kernel (`SO_PEERCRED`) and asks the controller on this node's own subject; the
controller decides what the line may do, and the engine runs nothing it receives. One line per connection, each
connection's deadline set the moment it is accepted, two lines at once per account and eight for the machine,
"busy" beyond that.
**Only a login session can be the terminal.** The engine reads the asking process's cgroup and sends its login
session's scope (`session-<id>.scope` under the account's own `user-<uid>.slice`, which logind's own record in
`/run/systemd/sessions/<id>` must say is this account's, of class `user`), or nothing, read the moment the
connection is accepted; the controller makes
nothing else the terminal. A service running as the operator — the tool runner, a unit of the account's user
manager — is at most an ordinary call.
**A process outside the machine's own namespaces is refused**, whatever its user id: the peer's mount and user
namespace must be the engine's. So `mesh-cli` run inside a flatpak, a toolbox or distrobox container, or any other
container — even one running as the operator's own id — is refused with the reason; run it from the machine's own
shell.
## A capability is detected, never assumed
The reason this is the first thing built rather than a detail of it.
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// **A declaration from an older lease epoch is refused before anything is applied** (novox/hq to-be
// 45 §6, ADR 0227 rule 2). The controller that sent it lost its lease and goes on sending; this node
// applied the holder's. Refused on the host's own apply path — the one the queue's worker runs — before
// the machine is read or touched, with a report naming what was refused and what is held, and the
// kept declaration left as it was.
func TestADeclarationFromAnOlderEpochIsRefusedBeforeAnythingIsApplied(t *testing.T) {
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
_, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "a.conf")
declared := func(epoch, sequence int64) store.Declared {
body, err := json.Marshal(map[string]any{"declaration": 1, "epoch": epoch, "sequence": sequence,
"resources": []any{map[string]any{"id": "a", "type": "file", "path": target, "content": "x\n"}}})
if err != nil {
t.Fatal(err)
}
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
}
held := declared(57, 3)
if err := store.SaveDeclared(store.DeclaredPath(opts.state), held); err != nil {
t.Fatal(err)
}
stale := declared(41, 12)
report := applyAndKeep(context.Background(), opts, stale.Declaration, &stale, nil, nil)
if report.OlderThan == nil || *report.OlderThan != (link.Order{Epoch: 57, Sequence: 3}) ||
report.Order != (link.Order{Epoch: 41, Sequence: 12}) || report.Declared != digestOf(stale.Declaration) ||
report.Refused == "" {
t.Fatalf("the refusal does not name what was refused and what is held: %+v", report)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the refused declaration touched the machine")
}
kept, err := store.ReadDeclared(store.DeclaredPath(opts.state))
if err != nil || string(kept.Declaration) != string(held.Declaration) {
t.Fatal("the refused declaration replaced what this node kept")
}
}
// The queue's counts are kept beside the state and read back by the next host; unreadable is an
// error, never zero.
func TestTheNumbersSurviveTheHost(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 0 || refused != 0 {
t.Fatalf("a node that never reported read %d, %d, %v", sequence, refused, err)
}
if err := numbersBeside(state).Save(41, 2); err != nil {
t.Fatal(err)
}
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 41 || refused != 2 {
t.Fatalf("read back %d, %d, %v", sequence, refused, err)
}
if err := os.WriteFile(store.NumbersPath(state), []byte(`{"report_sequence":`), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := numbersBeside(state).Read(); err == nil {
t.Fatal("unreadable numbers were read as zero")
}
}
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// A directory used as found is never quiet (novox/hq issue 339).
//
// The engine leaves the owner and mode of a directory it did not make: changing them would be root handing a
// directory it never made to whichever account a declaration named. That is safe and, said nowhere, a module
// whose data directory it cannot write would simply fail. So each such directory is said three ways: a WARNING
// line in every apply's log; a resource of its module of kind `directory`, stated unhealthy on every look,
// which the controller raises as that module's condition naming the directory and its owner; and a way out,
// which is the operator's line at the controller's terminal — `nox node hand-over <node> <path>` on the
// control-node (novox/hq ADR 0272, issue 356) — after which the next apply gives it the declared owner and
// mode like any directory of the mesh's. The controller carries that line to this engine over the bus
// (link.AskHandOverSubject); the engine's own `hand-over` subcommand is the same act for a machine with no
// mesh to reach.
// foundState is where the node's state is, for the looks to read the directories used as found from; empty
// until the engine runs.
var foundState atomic.Value
// foundNode is this node's name in the mesh, for the way out to name it; empty until the engine is enrolled.
var foundNode atomic.Value
// handedOverFrom has the apply read the directories handed over from beside the state at statePath.
func handedOverFrom(statePath string) {
apply.HandedOver = func() map[string]bool {
all, err := store.LoadHandedOver(statePath)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v; no directory counts as handed over until it reads\n", err)
return nil
}
out := map[string]bool{}
for _, h := range all {
out[apply.HandOverKey(h.ID, h.Path)] = true
}
return out
}
}
// foundDirectories is every directory the node's state records as used as found, sorted by id.
func foundDirectories(known store.State) []store.Applied {
var out []store.Applied
for _, r := range known.Resources {
if r.Type == "directory" && r.AsFound {
out = append(out, r)
}
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// handOverLine is the operator's line that hands a directory to the mesh, with this node's name when it is
// known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's
// path is the resource's target beside the reason, never in it: the controller says a reason on the
// operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence.
func handOverLine() string {
node, _ := foundNode.Load().(string)
if node == "" {
node = "<node>"
}
return fmt.Sprintf("`nox node hand-over %s <directory>` on the control-node", node)
}
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line
// that hands it over. The path is the target, which the controller keeps as evidence.
func withFoundDirectories(h *link.Health, found []store.Applied) *link.Health {
for _, r := range found {
at := strings.LastIndex(r.ID, ".")
if at <= 0 {
continue
}
owner, mode := "unknown", "unknown"
if info, err := os.Lstat(r.Target); err == nil {
mode = fmt.Sprintf("%o", info.Mode().Perm())
if uid, gid, ok := apply.OwnerOf(info); ok {
owner = fmt.Sprintf("%d:%d", uid, gid)
}
}
declared := r.Declared
if declared == "" {
declared = "another owner or mode"
}
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.ID[:at], Resource: r.ID,
Kind: link.KindDirectory, Target: r.Target, State: link.StateUnhealthy,
Reason: fmt.Sprintf("%s owned by %s, mode %s, as found; %s was declared and not given it — %s, with its "+
"path, hands it to the mesh", link.ReasonUsedAsFound, owner, mode, declared, handOverLine()),
Since: foundSince(r).UTC()})
}
return h
}
// foundNow is the directories used as found, read from the node's state; none when it cannot be read, which
// the apply says on its own.
func foundNow() []store.Applied {
path, _ := foundState.Load().(string)
if path == "" {
return nil
}
known, err := store.Load(path)
if err != nil {
return nil
}
return foundDirectories(known)
}
// handOverFound hands one directory used as found to the mesh, on the operator's word carried by the
// controller (link.HandOverFunc) or typed at this machine. It changes nothing on disk itself; the next apply
// gives the directory its declared owner and mode, and says so.
//
// **Every value is judged, and a refusal records nothing.** The path is the directory's absolute path exactly
// as the engine states it — clean, so `..` and a doubled separator never name a directory; one this machine's
// state records as used as found; on disk a directory and not a link, reached through no link. The engine
// never follows a path it did not state: a hand-over is root giving a directory to an account at the next
// apply, and a path that resolves elsewhere would give that elsewhere.
func handOverFound(statePath, path, by string) (string, error) {
if !filepath.IsAbs(path) {
return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path)
}
if filepath.Clean(path) != path {
return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+
"separator); nothing was handed over", path)
}
known, err := store.Load(statePath)
if err != nil {
return "", err
}
var found *store.Applied
for _, r := range foundDirectories(known) {
if filepath.Clean(r.Target) == path {
r := r
found = &r
break
}
}
if found == nil {
return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " +
"the ones that are); nothing was handed over")
}
info, err := os.Lstat(path)
switch {
case err != nil:
return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err)
case info.Mode()&os.ModeSymlink != 0:
return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path)
case !info.IsDir():
return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path)
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err)
}
if resolved != path {
return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+
"(novox/hq issue 339); nothing was handed over", path, resolved)
}
if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+
"then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil
}
// handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked.
func handOverFrom(statePath string) link.HandOverFunc {
return func(path, by string) (string, error) { return handOverFound(statePath, path, by) }
}
// handOverCommand is `nox-mesh-host hand-over <dir>` at this machine, as root: the same act, for a machine
// with no mesh to reach. The operator's line is `nox node hand-over <node> <dir>` on the control-node.
func handOverCommand(opts options) error {
by := "a person at the machine"
if u, err := user.Current(); err == nil {
by = u.Username + " at the machine"
}
said, err := handOverFound(opts.state, filepath.Clean(opts.file), by)
if err != nil {
return err
}
fmt.Fprintln(opts.out, said)
return nil
}
// foundSince is when a directory was first found as it is: kept across applies, so the controller can tell one
// a send found from one found before it. A record from before that was kept says when it was last applied.
func foundSince(r store.Applied) time.Time {
if !r.FoundAt.IsZero() {
return r.FoundAt
}
return r.AppliedAt
}
func orUnsaid(s string) string {
if s == "" {
return "the declared owner and mode"
}
return s
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// Every directory used as found is its module's resource of kind directory, unhealthy, naming its owner, on
// every statement (novox/hq issue 339): the controller raises that as the module's condition. The way out it
// names is the operator's line, with this node's name (novox/hq ADR 0272, issue 356).
func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
dir := t.TempDir()
known := store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: dir, AsFound: true, Declared: "root, mode 755", AppliedAt: time.Now()},
{ID: "notes.cache", Type: "directory", Target: dir},
}}
foundNode.Store("laptop")
t.Cleanup(func() { foundNode.Store("") })
h := withFoundDirectories(&link.Health{}, foundDirectories(known))
if len(h.Resources) != 1 {
t.Fatalf("stated: %+v", h.Resources)
}
r := h.Resources[0]
if r.Module != "notes" || r.Kind != link.KindDirectory || r.State != link.StateUnhealthy || r.Target != dir ||
!strings.HasPrefix(r.Reason, link.ReasonUsedAsFound) || !strings.Contains(r.Reason, "owned by ") ||
!strings.Contains(r.Reason, "root, mode 755") {
t.Fatalf("stated as: %+v", r)
}
// The path is the target, kept as evidence, and never in the reason: the operator's channel withholds a path
// (novox/hq ADR 0234 §6), and the controller says the reason there.
if !strings.Contains(r.Reason, "`nox node hand-over laptop <directory>` on the control-node") ||
strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) {
t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason)
}
foundNode.Store("")
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`nox node hand-over <node> <directory>`") {
t.Fatalf("before enrolment the line names a placeholder: %q", r.Reason)
}
}
// realDir is a temporary directory with every link in its path resolved, so a judgement of links is about the
// links the test makes.
func realDir(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return dir
}
// A hand-over records a directory used as found, and refuses one that is not — through the engine's own
// subcommand and through the controller's ask alike, which are one judgement.
func TestHandOverRecordsOnlyAFoundDirectory(t *testing.T) {
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "mine"), 0o755); err != nil {
t.Fatal(err)
}
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true, Declared: "root, mode 755"},
{ID: "notes.mine", Type: "directory", Target: filepath.Join(dir, "mine")},
}}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := handOverCommand(options{state: statePath, file: filepath.Join(dir, "mine"), out: &out}); err == nil {
t.Fatal("a directory the mesh made was handed over")
}
if err := handOverCommand(options{state: statePath, file: found + "/", out: &out}); err != nil {
t.Fatal(err)
}
all, err := store.LoadHandedOver(statePath)
if err != nil || len(all) != 1 || all[0].Path != found || !strings.HasSuffix(all[0].By, " at the machine") {
t.Fatalf("recorded: %+v %v", all, err)
}
handedOverFrom(statePath)
if !apply.HandedOver()[apply.HandOverKey("notes.data", found)] {
t.Fatal("the apply does not see the hand-over")
}
if !strings.Contains(out.String(), "root, mode 755") {
t.Fatalf("said: %s", out.String())
}
// The controller's ask is the same judgement, recording who asked in the controller's words.
_ = os.Remove(store.HandedOverPath(statePath))
said, err := handOverFrom(statePath)(found, "jo through mesh-cli on anchor")
if err != nil || !strings.Contains(said, "by jo through mesh-cli on anchor") || !strings.Contains(said, "root, mode 755") {
t.Fatalf("the ask answered %q, %v", said, err)
}
all, _ = store.LoadHandedOver(statePath)
if len(all) != 1 || all[0].By != "jo through mesh-cli on anchor" || all[0].ID != "notes.data" {
t.Fatalf("recorded: %+v", all)
}
}
// Every value of a hand-over is judged before anything is recorded, and each refusal records nothing: a relative
// path, a path that is not the engine's statement of the directory (`..`, a doubled or trailing separator), one
// not used as found, a link at the path, a link in the path, a file, a directory that is gone.
func TestAHandOverIsRefusedForEveryPathThatIsNotTheFoundDirectory(t *testing.T) {
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
// A link at the path of a second found directory, and a found directory below a linked directory.
linked := filepath.Join(dir, "linked")
if err := os.Symlink(found, linked); err != nil {
t.Fatal(err)
}
through := filepath.Join(dir, "via", "data")
if err := os.Symlink(dir, filepath.Join(dir, "via")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "data"), 0o700); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
gone := filepath.Join(dir, "gone")
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true},
{ID: "notes.linked", Type: "directory", Target: linked, AsFound: true},
{ID: "notes.through", Type: "directory", Target: through, AsFound: true},
{ID: "notes.file", Type: "directory", Target: file, AsFound: true},
{ID: "notes.gone", Type: "directory", Target: gone, AsFound: true},
}}); err != nil {
t.Fatal(err)
}
// Each refused by its own check, which the reason names.
refused := map[string][2]string{
"a relative path": {"found", "not one"},
"a path with ..": {dir + "/x/../found", "as the engine states it"},
"a doubled separator": {dir + "//found", "as the engine states it"},
"a trailing separator": {found + "/", "as the engine states it"},
"a directory not used as found": {filepath.Join(dir, "other"), "not a directory this machine uses as found"},
"a link at the path": {linked, "is a link"},
"a link in the path": {through, "reached through a link"},
"a file": {file, "is not a directory on this machine"},
"a directory that is gone": {gone, "cannot be read on this machine"},
}
for name, c := range refused {
said, err := handOverFound(statePath, c[0], "jo")
if err == nil || said != "" {
t.Fatalf("%s (%s) was handed over: %q", name, c[0], said)
}
if !strings.Contains(err.Error(), "nothing was handed over") || !strings.Contains(err.Error(), c[1]) {
t.Fatalf("%s's refusal does not say %q and that nothing was handed over: %v", name, c[1], err)
}
}
if all, err := store.LoadHandedOver(statePath); err != nil || len(all) != 0 {
t.Fatalf("a refusal recorded something: %+v %v", all, err)
}
// The found directory itself, stated exactly, is taken.
if _, err := handOverFound(statePath, found, "jo"); err != nil {
t.Fatal(err)
}
if all, _ := store.LoadHandedOver(statePath); len(all) != 1 || all[0].Path != found {
t.Fatalf("recorded: %+v", all)
}
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"context"
"testing"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// **The generation applied is kept, and a put-back never lowers it**: after a put-back of generation 5
// over 7, a send from generation 6 is still older than what this machine applied.
func TestThePutBackDoesNotLowerTheGenerationApplied(t *testing.T) {
built, err := system.For("arch")
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
t.Skip("this machine is not one these tests can apply on")
}
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
f := machineThatApplied(t, 7)
newer := f.declared(t, map[string]any{"sequence": 9, "generation": 8}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, newer.Declaration, &newer, nil, nil); r.Refused != "" {
t.Fatalf("a newer generation was refused: %s", r.Refused)
}
back := f.declared(t, map[string]any{"sequence": 10, "generation": 5, "put_back": true}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil); r.Refused != "" {
t.Fatalf("a put-back was refused: %s", r.Refused)
}
state, err := store.Load(f.opts.state)
if err != nil || state.Generation != 8 {
t.Fatalf("the generation applied is %d after a put-back of 5 over 8 (%v)", state.Generation, err)
}
stale := f.declared(t, map[string]any{"sequence": 11, "generation": 7}, "docker.conf", f.stays)
if r := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil); r.OlderGeneration == nil {
t.Fatalf("a send from generation 7 after 8 was applied because a put-back came between: %+v", r)
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The assignment generation a declaration was composed from (novox/hq issue 234).
//
// On 2026-10-04 a declaration newer in sequence than every other, and composed from a view of the
// assignments older than the one this machine had applied, named four fewer modules — and the host,
// ordering by sequence alone, undeclared all four. The sequence orders arrival; it cannot tell a later
// send carrying earlier content. The generation can: the node-engine refuses a declaration composed from
// an older generation than the one it applied, before anything is read from the machine.
//
// These read what the report says through its JSON, as the controller does, so they run against a
// node-engine that has never heard of a generation and fail there for that reason.
// generationFixture is a machine that applied generation 7: a file the mesh declared, kept in its state,
// and the declaration it kept — signed — that named it.
type generationFixture struct {
opts options
private ed25519.PrivateKey
kept store.Declared
// stays is the file the older generation's declaration does not name.
stays string
}
func machineThatApplied(t *testing.T, generation int64) generationFixture {
t.Helper()
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
_, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
stays := filepath.Join(dir, "docker.conf")
if err := os.WriteFile(stays, []byte("kept\n"), 0o644); err != nil {
t.Fatal(err)
}
// The state as a node-engine that applied generation 7 keeps it: the file it owns, and the highest
// generation it applied. Written as JSON, so the fixture is the same bytes for any build under test.
state := map[string]any{"resources": []any{map[string]any{"id": "docker.conf", "type": "file",
"origin": store.OriginDeclared, "target": stays}}, "generation": generation}
raw, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(opts.state, raw, 0o600); err != nil {
t.Fatal(err)
}
f := generationFixture{opts: opts, private: private, stays: stays}
f.kept = f.declared(t, map[string]any{"sequence": 3, "generation": generation}, "docker.conf", stays)
if err := store.SaveDeclared(store.DeclaredPath(opts.state), f.kept); err != nil {
t.Fatal(err)
}
return f
}
// declared is a signed declaration with the envelope's keys given and one file.
func (f generationFixture) declared(t *testing.T, envelope map[string]any, id, path string) store.Declared {
t.Helper()
body := map[string]any{"declaration": 1,
"resources": []any{map[string]any{"id": id, "type": "file", "path": path, "content": "x\n"}}}
for k, v := range envelope {
body[k] = v
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return store.Declared{Declaration: raw, Signature: ed25519.Sign(f.private, raw)}
}
// reportJSON is a report as the controller reads it.
func reportJSON(t *testing.T, r link.Report) map[string]any {
t.Helper()
raw, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
// **A newer sequence from an older generation is refused, and nothing is undeclared** (issue 234).
func TestANewerSequenceFromAnOlderGenerationIsRefusedAndNothingIsUndeclared(t *testing.T) {
f := machineThatApplied(t, 7)
other := filepath.Join(filepath.Dir(f.opts.state), "sudo.conf")
stale := f.declared(t, map[string]any{"sequence": 9, "generation": 6}, "sudo.conf", other)
report := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil)
said := reportJSON(t, report)
older, _ := said["older_generation"].(map[string]any)
if older == nil || older["generation"] != float64(6) || older["applied"] != float64(7) {
t.Fatalf("the refusal does not name the generation refused and the one applied: %v", said)
}
if report.Refused == "" || !strings.Contains(report.Refused, "generation 6") ||
!strings.Contains(report.Refused, "generation 7") {
t.Errorf("the refusal is not said in words naming both generations: %q", report.Refused)
}
if report.Declared != digestOf(stale.Declaration) || report.Sequence != 9 {
t.Errorf("the refusal does not name the declaration refused, so the mesh cannot name who sent it: %+v", report)
}
if _, err := os.Stat(f.stays); err != nil {
t.Fatalf("a refused declaration undeclared what the machine applied: %v", err)
}
if _, err := os.Stat(other); err == nil {
t.Error("the refused declaration touched the machine")
}
state, err := store.Load(f.opts.state)
if err != nil {
t.Fatal(err)
}
if _, owned := state.Find("docker.conf"); !owned {
t.Error("the refused declaration took a resource out of what this machine owns")
}
kept, err := store.ReadDeclared(store.DeclaredPath(f.opts.state))
if err != nil || string(kept.Declaration) != string(f.kept.Declaration) {
t.Error("the refused declaration replaced what this node kept, so the next reconcile would apply it")
}
}
// refusedForItsGeneration says a report refused its declaration for the generation it came from.
func refusedForItsGeneration(t *testing.T, r link.Report) bool {
t.Helper()
return reportJSON(t, r)["older_generation"] != nil || strings.Contains(r.Refused, "generation")
}
// refusedAsUnread says a report refused its declaration as one this node-engine cannot read.
func refusedAsUnread(r link.Report) bool { return strings.Contains(r.Refused, "not a declaration") }
// **A gate's put-back is applied, whatever generation it carries**: it carries the generation of what it
// puts back, and says it is a put-back (issue 234).
func TestAPutBackFromAnOlderGenerationIsNotRefused(t *testing.T) {
f := machineThatApplied(t, 7)
back := f.declared(t, map[string]any{"sequence": 9, "generation": 5, "put_back": true}, "docker.conf", f.stays)
report := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a put-back was refused: %s", report.Refused)
}
}
// **A declaration that carries no generation is applied as it always was**: an older controller sends
// none, and a node-engine that refused it would strand every machine the moment the controller is older.
func TestADeclarationWithoutAGenerationIsNotRefusedForOne(t *testing.T) {
f := machineThatApplied(t, 7)
older := f.declared(t, map[string]any{"sequence": 9}, "docker.conf", f.stays)
report := applyAndKeep(context.Background(), f.opts, older.Declaration, &older, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a declaration without a generation was refused: %s", report.Refused)
}
// And the same generation again is no older.
same := f.declared(t, map[string]any{"sequence": 10, "generation": 7}, "docker.conf", f.stays)
report = applyAndKeep(context.Background(), f.opts, same.Declaration, &same, nil, nil)
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
t.Fatalf("a declaration of the generation applied was refused: %s", report.Refused)
}
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/identity"
)
// Joining through the tunnel (novox/hq ADR 0169).
//
// **The bus is never open to the internet, so a joining machine reaches it over the tunnel.** It
// makes its tunnel key first and prints the public half; the token is issued for that key, and the
// hub is told the key before the token is shown; the token carries the one peer this machine needs.
// So the tunnel can come up before the mesh has said anything else — the circle ADR 0004 broke by
// carrying the bus's address in the token is broken here by carrying the hub's.
// tunnelConfigPath and tunnelUnit are where the mesh's own declaration puts the private network, so
// the first tunnel is the same interface and unit the mesh takes over, not a second one beside it.
var (
tunnelConfigPath = "/etc/wireguard/mesh0.conf"
tunnelUnit = "wg-quick@mesh0"
// lookPath finds WireGuard's tools; a variable so a test needs none installed.
lookPath = exec.LookPath
)
// keyCommand makes this machine's tunnel key, or reads the one it already made, and prints the
// public half: what the token is issued for. Making it twice would be a token issued for a key the
// machine no longer has, so an existing key is kept.
func keyCommand(opts options) error {
path := identity.OverlayKeyPath(opts.state)
if key, err := identity.LoadOverlayKey(path); err == nil {
fmt.Println(key.Public)
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if _, err := os.Stat(identity.Path(opts.state)); err == nil {
return fmt.Errorf("this machine has joined already (%s), and its tunnel key is its own; "+
"there is no key to make", identity.Path(opts.state))
}
key, err := identity.GenerateOverlayKey()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
return fmt.Errorf("cannot write this machine's tunnel key: %w", err)
}
fmt.Println(key.Public)
fmt.Fprintln(os.Stderr, "\nthis machine's tunnel key, made here; the private half stays in "+path+".\n"+
"Issue the token for it — `token issue --new <name> --overlay-key <the line above>` — and enrol with that token.")
return nil
}
// tunnelKeyFor is the key a token through the tunnel was issued for, read from where `key` left it.
// Refused when there is none, or it is another: the hub knows only the key the token names.
func tunnelKeyFor(t *identity.TokenTunnel, state string) (identity.OverlayKey, error) {
path := identity.OverlayKeyPath(state)
key, err := identity.LoadOverlayKey(path)
if errors.Is(err, os.ErrNotExist) {
return identity.OverlayKey{}, fmt.Errorf("this token was issued for a tunnel key, and this " +
"machine has none: run `nox-mesh-host key` here first and issue the token for the key it prints")
}
if err != nil {
return identity.OverlayKey{}, err
}
if key.Public != t.Key {
return identity.OverlayKey{}, fmt.Errorf("this token was issued for the tunnel key %s, and this "+
"machine's is %s — it is another machine's token, or the key was made again; issue a new "+
"token for %s", t.Key, key.Public, key.Public)
}
return key, nil
}
// tunnelConfig is the first tunnel: this machine's address, and the hub as its one peer, reaching the
// whole private network through it. The private key is set from its file, as the mesh's own
// declaration does it, so the file holds no secret.
func tunnelConfig(t *identity.TokenTunnel, keyPath string) string {
return fmt.Sprintf(`# Written by nox-mesh-host enrol: the one peer a joining machine needs (novox/hq ADR 0169).
# The mesh's own declaration replaces this once the machine has joined.
[Interface]
Address = %s
PostUp = wg set %%i private-key %s
[Peer]
PublicKey = %s
Endpoint = %s
AllowedIPs = %s
PersistentKeepalive = 25
`, t.Address, keyPath, t.HubKey, t.HubEndpoint, t.Range)
}
// bringTheTunnelUp writes the first tunnel and starts it, so the bus the token names can be reached.
func bringTheTunnelUp(ctx context.Context, t *identity.TokenTunnel, keyPath string, run apply.Runner) error {
if _, err := lookPath("wg-quick"); err != nil {
return errors.New("joining through the tunnel needs WireGuard's tools on this machine " +
"(wireguard-tools), and wg-quick is not here")
}
if err := os.MkdirAll(filepath.Dir(tunnelConfigPath), 0o700); err != nil {
return err
}
if err := os.WriteFile(tunnelConfigPath, []byte(tunnelConfig(t, keyPath)), 0o600); err != nil {
return fmt.Errorf("cannot write the first tunnel: %w", err)
}
if out, err := run(ctx, "systemctl", "restart", tunnelUnit); err != nil {
return fmt.Errorf("the first tunnel would not start (%s): %v %s", tunnelUnit, err, strings.TrimSpace(out))
}
fmt.Printf("the tunnel to the hub is up: %s, through %s\n", t.Address, t.HubEndpoint)
return waitForTheHub(ctx, run, handshakeWithin)
}
// handshakeWithin is how long the hub has to answer the first tunnel. Issuing the token sent the hub
// this machine as a peer; the hub applies that on its own time, and a bus dialled before it has is a
// timeout that names the bus rather than the tunnel.
var handshakeWithin = 90 * time.Second
// waitForTheHub waits until the tunnel has shaken hands with the hub, so the bus is dialled over a
// tunnel that answers — and says so in the tunnel's own words when it does not.
func waitForTheHub(ctx context.Context, run apply.Runner, within time.Duration) error {
deadline := time.Now().Add(within)
for {
out, err := run(ctx, "wg", "show", "mesh0", "latest-handshakes")
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[1] != "0" {
fmt.Println("the hub answered the tunnel")
return nil
}
}
}
if time.Now().After(deadline) {
return fmt.Errorf("the hub has not answered the tunnel in %s: the token may be another machine's, "+
"the hub may not have been sent this machine as a peer, or its tunnel's port is not reachable "+
"from here", within)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/identity"
)
// `key` makes the tunnel key once and prints its public half; asked again it prints the same one,
// because a token may already have been issued for it (novox/hq ADR 0169).
func TestKeyMakesTheTunnelKeyOnceAndKeepsIt(t *testing.T) {
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
first := captureStdout(t, func() {
if err := keyCommand(opts); err != nil {
t.Fatal(err)
}
})
second := captureStdout(t, func() {
if err := keyCommand(opts); err != nil {
t.Fatal(err)
}
})
if strings.TrimSpace(first) == "" || strings.TrimSpace(first) != strings.TrimSpace(second) {
t.Fatalf("the key changed between two asks: %q then %q", first, second)
}
info, err := os.Stat(identity.OverlayKeyPath(opts.state))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("the private half is readable beyond root: %v", info.Mode().Perm())
}
}
// A token through the tunnel takes the key it was issued for, and says so when this machine has none
// or another.
func TestATokenThroughTheTunnelTakesItsOwnKey(t *testing.T) {
dir := t.TempDir()
state := filepath.Join(dir, "state.json")
tt := &identity.TokenTunnel{Key: "x", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "nox-mesh-host key") {
t.Fatalf("a machine with no key was not told to make one: %v", err)
}
captureStdout(t, func() { _ = keyCommand(options{state: state}) })
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "issued for the tunnel key x") {
t.Fatalf("another machine's token was taken: %v", err)
}
mine, _ := identity.LoadOverlayKey(identity.OverlayKeyPath(state))
tt.Key = mine.Public
if got, err := tunnelKeyFor(tt, state); err != nil || got.Public != mine.Public {
t.Fatalf("this machine's own token was refused: %v", err)
}
}
// The first tunnel is the mesh's interface and unit, with the hub as its one peer and no secret in
// the file — the same shape the mesh's declaration replaces it with.
func TestTheFirstTunnelIsTheMeshsInterfaceWithTheHubAsItsPeer(t *testing.T) {
dir := t.TempDir()
tunnelConfigPath = filepath.Join(dir, "wireguard", "mesh0.conf")
lookPath = func(string) (string, error) { return "/usr/bin/wg-quick", nil }
t.Cleanup(func() { tunnelConfigPath = "/etc/wireguard/mesh0.conf" })
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "wg" {
return "HUBKEY\t1759400000\n", nil
}
ran = append(ran, name+" "+strings.Join(args, " "))
return "", nil
}
tt := &identity.TokenTunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "HUBKEY", HubEndpoint: "198.51.100.1:51820"}
captureStdout(t, func() {
if err := bringTheTunnelUp(context.Background(), tt, "/var/lib/mesh-host/overlay.key", run); err != nil {
t.Fatal(err)
}
})
raw, err := os.ReadFile(tunnelConfigPath)
if err != nil {
t.Fatal(err)
}
conf := string(raw)
for _, want := range []string{"Address = 10.42.0.9/32", "PostUp = wg set %i private-key /var/lib/mesh-host/overlay.key",
"PublicKey = HUBKEY", "Endpoint = 198.51.100.1:51820", "AllowedIPs = 10.42.0.0/16", "PersistentKeepalive = 25"} {
if !strings.Contains(conf, want) {
t.Errorf("the first tunnel lacks %q:\n%s", want, conf)
}
}
if strings.Contains(conf, "PrivateKey") {
t.Error("the first tunnel's file holds the private key")
}
if len(ran) != 1 || ran[0] != "systemctl restart wg-quick@mesh0" {
t.Errorf("the tunnel was started as %v", ran)
}
}
// captureStdout is what fn printed to standard output.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
was := os.Stdout
os.Stdout = w
fn()
os.Stdout = was
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}
// The bus is dialled only once the hub has answered the tunnel, and a hub that never does is said
// as the tunnel's fault rather than the bus's.
func TestTheBusWaitsForTheHubToAnswer(t *testing.T) {
asked := 0
answersOnThird := func(_ context.Context, name string, args ...string) (string, error) {
asked++
if asked < 3 {
return "HUBKEY\t0\n", nil
}
return "HUBKEY\t1759400000\n", nil
}
captureStdout(t, func() {
if err := waitForTheHub(context.Background(), answersOnThird, time.Minute); err != nil {
t.Fatal(err)
}
})
never := func(context.Context, string, ...string) (string, error) { return "HUBKEY\t0\n", nil }
err := waitForTheHub(context.Background(), never, 0)
if err == nil || !strings.Contains(err.Error(), "has not answered the tunnel") {
t.Fatalf("a hub that never answered was not said: %v", err)
}
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"errors"
"fmt"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// judgeDirectories judges every directory of the declaration this node keeps, read-only (novox/hq issue 358): which
// the walk that follows no link another account could make would refuse. Nothing is made, chmodded or chowned.
func judgeDirectories(statePath string) (string, error) {
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
if errors.Is(err, store.ErrNothingDeclared) {
return "", errors.New("this node keeps no declaration, so it is declared no directory")
}
if err != nil {
return "", err
}
d, err := declaration.Parse(kept.Declaration)
if err != nil {
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
}
return apply.JudgeDirectories(d), nil
}
// judgeDirectoriesFrom is judgeDirectories as the controller asks it over the link.
func judgeDirectoriesFrom(statePath string) link.JudgeDirectoriesFunc {
return func(string) (string, error) { return judgeDirectories(statePath) }
}
// judgeDirectoriesCommand is `nox-mesh-host judge-directories` at this machine, as root: the same judgement, for a
// machine the controller cannot ask.
func judgeDirectoriesCommand(opts options) error {
said, err := judgeDirectories(opts.state)
if err != nil {
return err
}
fmt.Fprintln(opts.out, said)
return nil
}
+431
View File
@@ -0,0 +1,431 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// An access is judged by what each of its users may do (novox/hq ADR 0325 §7, §10, §12).
//
// On every look the engine judges each module's access as its module's resource of kind `access`: waiting, with a
// wait of kind access, while the mesh may not keep its folder yet or its disk is not there; unhealthy for root's
// data on its placed filesystem, and for a kept folder that gives a user less than its right — read by entry and
// mask together, on the folder and the sweep's sample under it, which the sweep puts back where it can; healthy
// otherwise. The folder is the target, never in a reason nor in a wait's words: the controller says them on the
// operator's channel, which withholds a path (ADR 0234 §6).
// judgedAccess is one access as the judge reads it.
type judgedAccess struct {
module, id, name, path string
taken bool
users []store.KeptUser
// leftover is the mesh's entries nobody wants, not an access (link.KindKeptEntries).
leftover bool
}
// accessJudge reads every access on every look. Safe for the apply and the looking loop at once.
type accessJudge struct {
mu sync.Mutex
accesses []judgedAccess
// was is each access's last verdict, with since when and how many looks in a row found it unhealthy.
was map[string]link.ResourceHealth
// reading guards inFlight: every folder whose look has not returned yet.
reading sync.Mutex
inFlight map[string]bool
}
// accesses is the one access judge on this machine.
var accesses = newAccessJudge()
func newAccessJudge() *accessJudge {
return &accessJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}}
}
// set is what the declaration just applied asks: every access of a module, not held as found, with the users of
// its folder as every access of that folder gives them. An access no longer declared is forgotten.
func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) {
var as []judgedAccess
if d != nil {
users := map[string]map[uint32]store.KeptUser{}
taken := map[string]bool{}
for _, r := range d.Resources {
if a, ok := r.(*declaration.Access); ok {
path := filepath.Clean(a.Path)
if users[path] == nil {
users[path] = map[uint32]store.KeptUser{}
}
taken[path] = taken[path] || a.Taken
for _, u := range a.Users {
had, seen := users[path][uint32(u.UID)]
if !seen || (u.Use == declaration.AccessReadWrite && had.Use != declaration.AccessReadWrite) {
users[path][uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID),
Use: u.Use, From: u.From}
}
}
}
}
for _, r := range d.Resources {
a, ok := r.(*declaration.Access)
if !ok || held[a.ID] || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) {
continue
}
// An older controller's access is judged by nothing, as before ADR 0325: no state is said of it.
if !apply.Keeps(d, a.Path) {
continue
}
at := strings.LastIndex(a.ID, ".")
if at <= 0 {
continue
}
path := filepath.Clean(a.Path)
ja := judgedAccess{module: a.ID[:at], id: a.ID, name: a.ID[at+1:], path: path, taken: taken[path]}
for _, u := range users[path] {
ja.users = append(ja.users, u)
}
sort.Slice(ja.users, func(x, y int) bool { return ja.users[x].UID < ja.users[y].UID })
as = append(as, ja)
}
}
sort.Slice(as, func(a, b int) bool { return as[a].id < as[b].id })
j.mu.Lock()
defer j.mu.Unlock()
j.accesses = as
declared := map[string]bool{}
for _, a := range as {
declared[a.id] = true
}
for id := range j.was {
if !declared[id] {
delete(j.was, id)
}
}
}
// lookBound is how long a look waits for one accessed folder to answer. A hard network mount whose server is down
// blocks a stat for ever, and one access must never stop every health statement this machine makes.
var lookBound = 5 * time.Second
// verdict is one access's judgement in a look.
type verdict struct {
state, reason string
waits []link.AccessWait
act *link.AccessActRecord
entries []link.KeptEntry
}
// judgeBounded judges one access within lookBound, outside any lock. A look that has not answered is said so;
// while it still hangs, later looks start no other on the same folder, so a dead mount costs one blocked read.
func (j *accessJudge) judgeBounded(a judgedAccess) (verdict, bool) {
j.reading.Lock()
if j.inFlight[a.path] {
j.reading.Unlock()
return verdict{}, false
}
j.inFlight[a.path] = true
j.reading.Unlock()
done := make(chan verdict, 1)
go func() {
v := judgeAccess(a)
j.reading.Lock()
delete(j.inFlight, a.path)
j.reading.Unlock()
done <- v
}()
select {
case v := <-done:
return v, true
case <-time.After(lookBound):
return verdict{}, false
}
}
// judgeAccess is one access's judgement now (ADR 0325 §7, §10, §12), the sweep of its kept folder included.
func judgeAccess(a judgedAccess) verdict {
v := apply.AccessVerdict(context.Background(), a.path, a.taken, len(a.users) > 0)
switch v.Kind {
case apply.VerdictWaitKeep:
return verdict{state: link.StateWaiting, reason: "it holds data the mesh has not taken on the filesystem " +
"it is on now, and waits for the operator's word (novox/hq ADR 0325 §7, §8)",
waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module,
What: apply.KeepWords(a.name), Placed: v.Placed}}}
case apply.VerdictWaitDisk:
return verdict{state: link.StateWaiting, reason: "the disk that holds it is not there (novox/hq ADR 0325 §9)",
waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module,
What: apply.DiskWords(a.name)}}}
case apply.VerdictRoot:
return verdict{state: link.StateUnhealthy, reason: apply.RootWords(v.Why)}
case apply.VerdictUnknown:
// Not a fault and not the operator's: said as not known, and judged again on the next look.
return verdict{state: link.StateUnknown, reason: v.Why + "; judged again on the next look"}
case apply.VerdictBroken:
// Its words carry no path (AccessVerdict says them so): the reason reaches the operator's channel.
return verdict{state: link.StateUnhealthy, reason: v.Why}
case apply.VerdictMake:
return verdict{state: link.StateUnhealthy, reason: "it is not there yet; the next apply makes it where " +
"its data lives (novox/hq ADR 0325 §9)"}
case apply.VerdictNobody:
return verdict{state: link.StateHealthy}
}
out := verdict{state: link.StateHealthy, entries: entriesOf(a.users)}
rec, _ := keptNow(a.path)
if act := rec.Act; act != nil {
out.act = &link.AccessActRecord{Act: act.Act, Access: a.id, Warrant: act.Warrant, By: act.By, State: act.State,
Started: act.Started, Ended: act.Ended, Folders: act.Folders, Files: act.Files, Kept: act.Kept,
Links: act.Links, Other: act.Other, Mounts: act.Mounts, Why: act.Why}
}
if !rec.Kept {
out.state, out.reason = link.StateStarting, "the controller says it was taken; the next apply keeps it"
return out
}
look := apply.LookKept(a.path, a.users, apply.SweepLimits)
var faults []string
if look.Failed != "" {
faults = append(faults, "the sweep could not look: "+apply.Pathless(errors.New(look.Failed)))
}
if len(look.Short) > 0 {
faults = append(faults, "the folder itself gives "+strings.Join(look.Short, ", ")+"; put back on the next "+
"apply")
}
if n := look.Sweep.Unrepaired + look.Sweep.LastUnrepaired; n > 0 {
faults = append(faults, fmt.Sprintf("%d entries under it give a user less than its right and cannot be "+
"put back: an owner's own bits, which the mesh never changes, or an entry the filesystem refused", n))
}
if len(faults) > 0 {
out.state, out.reason = link.StateUnhealthy, strings.Join(faults, "; ")
return out
}
switch {
case rec.Act != nil && rec.Act.State == link.ActRunning:
out.state, out.reason = link.StateStarting, "the take act is adding its users' entries under it"
case !sameUsersList(rec.Users, rec.Want):
out.state, out.reason = link.StateStarting, "the entries under it are being changed to its users now"
}
return out
}
// entriesOf is a kept folder's listing (ADR 0325 §4): each user, and beside the tool runner's what it stands for.
func entriesOf(users []store.KeptUser) []link.KeptEntry {
var out []link.KeptEntry
for _, u := range users {
e := link.KeptEntry{Identity: u.Identity, UID: int(u.UID), Use: u.Use, From: u.From}
if u.From == declaration.FromToolRunner {
e.Note = "the tool runner's account stands for every module whose tools use this folder, at the widest " +
"use any of them declares: a module that declared only read may write through it (novox/hq ADR " +
"0325 §4, until issue 569)"
}
out = append(out, e)
}
return out
}
func sameUsersList(a, b []store.KeptUser) bool {
if len(a) != len(b) {
return false
}
seen := map[uint32]string{}
for _, u := range a {
seen[u.UID] = u.Use
}
for _, u := range b {
if use, ok := seen[u.UID]; !ok || use != u.Use {
return false
}
}
return true
}
// keptNow is the record of one kept folder, as the engine's file holds it.
func keptNow(path string) (store.KeptFolder, bool) {
all, err := apply.KeptFolders.All()
if err != nil {
return store.KeptFolder{}, false
}
f, had := all[path]
return f, had
}
// look reads every access now. Whether any verdict changed since the last look, for the loop to say it.
func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
j.mu.Lock()
as := append([]judgedAccess(nil), j.accesses...)
j.mu.Unlock()
read := make([]verdict, len(as))
for i, a := range as {
v, answered := j.judgeBounded(a)
if !answered {
v = verdict{state: link.StateUnhealthy, reason: "its folder does not answer: judging it did not return " +
"within " + lookBound.String()}
}
read[i] = v
}
// And the entries nobody wants that could not be taken out yet, each under the access that last named its
// folder, until its drop walk has run (ADR 0325 §8).
for _, l := range apply.Leftovers() {
as = append(as, judgedAccess{module: l.Module, id: l.Access + "~entries", name: l.Name, path: l.Path,
leftover: true})
read = append(read, verdict{state: link.StateUnhealthy, reason: l.Why})
}
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(as))
for i, a := range as {
r := read[i]
kind := link.KindAccess
if a.leftover {
kind = link.KindKeptEntries
}
v := link.ResourceHealth{Module: a.module, Resource: a.id, Kind: kind, Target: a.path,
State: r.state, Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act, Entries: r.entries}
if was, had := j.was[a.id]; had && was.State == r.state && was.Reason == r.reason {
v.Since = was.Since
v.Streak = was.Streak
} else {
changed = true
}
if r.state == link.StateUnhealthy {
v.Streak++
}
j.was[a.id] = v
out = append(out, v)
}
return out, changed
}
// withAccesses adds to a statement every access, as its module's resource of kind access.
func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health {
h.Resources = append(h.Resources, vs...)
return h
}
// accessesHealthy says no access in vs is anything but healthy.
func accessesHealthy(vs []link.ResourceHealth) bool {
for _, v := range vs {
if v.State != link.StateHealthy {
return false
}
}
return true
}
// keeper runs the walks under kept folders beside the applies, one at a time (ADR 0325 §8): poked after every
// apply and every look, it walks whatever folder's entries under it are not what was declared.
type keeper struct {
running, following atomic.Bool
say func(string)
watch *apply.Watcher
}
// keeping is the one keeper on this machine.
var keeping = &keeper{say: func(string) {}}
// poke starts a reconcile of the kept folders unless one is running, and has the watch follow what is kept.
func (k *keeper) poke(ctx context.Context) {
// The watch is set beside, never in the caller's way: watching a new folder's tree reads every folder of it.
if k.following.CompareAndSwap(false, true) {
go func() {
defer k.following.Store(false)
k.follow()
}()
}
if !k.running.CompareAndSwap(false, true) {
return
}
go func() {
defer k.running.Store(false)
for _, line := range apply.ReconcileKept(ctx) {
k.say(line)
}
}()
}
// follow has the watch watch every kept folder with its users.
func (k *keeper) follow() {
if k.watch == nil {
return
}
all, err := apply.KeptFolders.All()
if err != nil {
return
}
folders := map[string][]store.KeptUser{}
for path, f := range all {
if f.Kept && len(f.Want) > 0 {
folders[path] = f.Want
}
}
k.watch.Set(folders)
}
// startKeeping opens the watch and has the keeper say what it does. A machine without the watch is said so, and
// its sweep alone finds what is made.
func startKeeping(ctx context.Context, say func(string)) {
keeping.say = say
w, err := apply.NewWatcher()
if err != nil {
say(fmt.Sprintf("the kept folders are not watched (%v): the sweep alone finds what is made in them", err))
} else {
keeping.watch = w
go w.Run(ctx)
}
keeping.poke(ctx)
}
// accessActFrom is the take act as the controller asks it over the link (ADR 0325 §8): judged against the
// declaration this node keeps, and run beside the applies.
func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc {
return func(act link.AccessAct) (string, error) {
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
if errors.Is(err, store.ErrNothingDeclared) {
return "", errors.New("this node keeps no declaration, so it is declared no access")
}
if err != nil {
return "", err
}
d, err := declaration.Parse(kept.Declaration)
if err != nil {
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, said, err := apply.TakeFolder(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By,
apply.ExecRunner)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: the take act on %s was refused: %v\n", act.Access, err)
return "", err
}
return said, nil
}
}
// withWaiting is held with every id of waiting beside it: what liveness does not judge.
func withWaiting(held, waiting map[string]bool) map[string]bool {
if len(waiting) == 0 {
return held
}
out := map[string]bool{}
for id := range held {
out[id] = true
}
for id := range waiting {
out[id] = true
}
return out
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// Row 4: the listing of a kept folder says, beside the tool runner's entry, that it stands for every module whose
// tools use the folder at the widest use (novox/hq ADR 0325 §4).
func TestTheListingSaysWhatTheToolRunnersEntryStandsFor(t *testing.T) {
entries := entriesOf([]store.KeptUser{
{Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: declaration.FromContainer},
{Identity: "nox", UID: 61003, Use: "read-write", From: declaration.FromToolRunner},
})
if entries[0].Note != "" || !strings.Contains(entries[1].Note, "widest use") {
t.Fatalf("the listing: %+v", entries)
}
}
// Row 7, the engine's part: a folder that holds data and was never taken is said waiting with one wait of kind
// access — the access by its module's own id, the part its module, and words with no path (novox/hq ADR 0325 §7).
func TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWaitOfItsOwnKind(t *testing.T) {
dir := t.TempDir()
lib := filepath.Join(dir, "movies")
if err := os.MkdirAll(filepath.Join(lib, "a film"), 0o755); err != nil {
t.Fatal(err)
}
// Run as root (the build seat's merge-check), the test's folders stand for an operator's, never root's.
if os.Geteuid() == 0 {
for _, p := range []string{dir, lib, filepath.Join(lib, "a film")} {
if err := os.Lchown(p, 65534, 65534); err != nil {
t.Fatal(err)
}
}
}
v := judgeAccess(judgedAccess{module: "radarr", id: "radarr.movies", name: "movies", path: lib,
users: []store.KeptUser{{Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: "container"}}})
if v.state != link.StateWaiting || len(v.waits) != 1 {
t.Fatalf("not waiting with one wait: %+v", v)
}
raw, _ := json.Marshal(v.waits[0])
want := `{"kind":"access","access":"movies","part":"radarr","what":"let the mesh keep the shared folder movies",` +
`"placed":"` + v.waits[0].Placed + `"}`
if string(raw) != want || !strings.HasPrefix(v.waits[0].Placed, "fs-") {
t.Fatalf("the wait reads %s", raw)
}
if strings.Contains(v.reason, dir) {
t.Fatalf("the reason names the path: %s", v.reason)
}
}
// An older controller's accesses (no users, no identities) are judged by nothing, as before ADR 0325: the statement
// carries no resource for them, waiting, unknown or kept-entries.
func TestAnOlderControllersAccessesSayNothing(t *testing.T) {
dir := t.TempDir()
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"radarr.library","type":"access","path":"` + filepath.Join(dir, "library") + `","mode":"read-write"},
{"id":"plex.movies","type":"access","path":"` + filepath.Join(dir, "absent") + `"}]}`))
if err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "library"), 0o755); err != nil {
t.Fatal(err)
}
j := newAccessJudge()
j.set(d, nil)
if vs, _ := j.look(time.Now()); len(vs) != 0 {
t.Fatalf("an older controller's accesses are judged: %+v", vs)
}
}
+874 -109
View File
File diff suppressed because it is too large Load Diff
+66 -4
View File
@@ -189,13 +189,15 @@ func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
}
}
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
// What the mesh heard — a delivery's account as much as a reconcile's — counts as said: the queue
// tells the watch every report the broker took (link.Queue.Heard).
func TestWhatTheMeshHeardCountsAsSaid(t *testing.T) {
w := &adoptionWatch{}
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
applier := w.noting(func(context.Context, []byte, []byte) link.Report { return report })
applier(context.Background(), nil, nil)
heard := w.said
heard(report)
if w.changed(report) {
t.Error("a reconcile repeated what the link had just published")
t.Error("a reconcile repeated what the mesh had just heard")
}
}
@@ -609,3 +611,63 @@ func TestTheApplysLogIsNeverNil(t *testing.T) {
t.Fatalf("the apply's detail did not reach the caller's announce: %v", said)
}
}
// Defends novox/hq issues 257 and 261: a reconcile due while the link applies a newer declaration
// applies that newer one once its turn comes — never the one kept when its timer fired. Read before
// waiting, it gave back a module assigned a second earlier, and reported a declaration the mesh no
// longer recorded as sent.
func TestAReconcileAppliesWhatWasKeptWhenItsTurnComes(t *testing.T) {
built, err := system.For("arch")
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
t.Skip("this machine is not one these tests can apply on")
}
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "a.conf")
kept := func(content string) store.Declared {
body := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` + target +
`","content":"` + content + `\n"}]}`)
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
}
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("older")); err != nil {
t.Fatal(err)
}
// The link is applying: the reconcile's timer fires and it waits its turn.
applying.Lock()
done := make(chan error, 1)
go func() {
_, err := reapplyKept(context.Background(), opts, public, nil, nil)
done <- err
}()
time.Sleep(50 * time.Millisecond)
// The link's apply ends by keeping the newer declaration, and lets go.
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("newer")); err != nil {
applying.Unlock()
t.Fatal(err)
}
applying.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(10 * time.Second):
t.Fatal("the reconcile never ran once the node was free")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "newer\n" {
t.Errorf("the reconcile applied %q, what was kept before the newer declaration", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/cli"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/meshcli"
)
// serveMeshCLI answers mesh-cli on this machine for as long as this engine serves (novox/hq ADR 0272): the account
// from the kernel, the line asked of the controller over the link open at the time. A socket that cannot be served
// is said once, and this engine goes on applying; mesh-cli then says the engine does not answer.
func serveMeshCLI(ctx context.Context, queue *link.Queue, say func(string)) {
if err := meshcli.Serve(ctx, cli.Socket, forwardOver(queue), say); err != nil {
say("mesh-cli is not answered on this machine: " + err.Error())
}
}
// forwardOver asks the controller over the queue's link, and follows the line to what came of it.
func forwardOver(queue *link.Queue) meshcli.Forward {
return func(ctx context.Context, asked meshcli.Asked) cli.Answer {
return meshcli.AskTheController(ctx, queue.AskController, asked)
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/network"
)
// The machine's networking in the engine's statement (novox/hq ADR 0241): the file judged is the one the
// uplink holder declares whole, the mesh name asked is the bus's, and the statement carries the parts.
func TestTheResolverFileJudgedIsTheOneAModuleDeclaresWhole(t *testing.T) {
d := &declaration.Declaration{Resources: []declaration.Resource{
&declaration.File{ID: "hosts.file", Type: "file", Path: "/etc/hosts", Content: "x"},
&declaration.File{ID: "networkmanager.resolv", Type: "file", Path: network.ResolvConf, Content: "nameserver 10.10.0.1\n"},
}}
content, owner, ok := declaredResolvConf(d)
if !ok || owner != "networkmanager" || content != "nameserver 10.10.0.1\n" {
t.Fatalf("got %q %q %v", content, owner, ok)
}
d.Resources[1].(*declaration.File).CreateOnce = true
if _, _, ok := declaredResolvConf(d); ok {
t.Fatal("a seed nobody holds the machine to was judged as the declared file")
}
if _, _, ok := declaredResolvConf(nil); ok {
t.Fatal("no declaration declared a file")
}
}
func TestTheMeshNameAskedIsTheBuses(t *testing.T) {
for broker, want := range map[string]string{"anchor.internal:4222": "anchor.internal", "192.0.2.10:5671": "",
"anchor.internal": "anchor.internal", "[2001:db8::1]:4222": ""} {
if got := hostOf(broker); got != want {
t.Errorf("%s: got %q, want %q", broker, got, want)
}
}
}
func TestTheStatementCarriesTheNetwork(t *testing.T) {
at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
h := healthAsReported(liveness.Statement{At: at}, &network.Statement{State: network.Unhealthy, Since: at,
Parts: []network.Part{{Part: network.PartResolvConf, State: network.Unhealthy, Writer: "FortiClient",
Owner: "networkmanager", Reason: "the resolver file was rewritten by another program", Since: at}}})
if h.Network == nil || h.Network.State != network.Unhealthy || len(h.Network.Parts) != 1 ||
h.Network.Parts[0].Writer != "FortiClient" || h.Network.Parts[0].Owner != "networkmanager" {
t.Fatalf("the statement says %+v", h.Network)
}
if h := healthAsReported(liveness.Statement{At: at}, nil); h.Network != nil {
t.Fatal("an engine with no network judge said a network")
}
}
+44 -7
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
@@ -13,9 +14,15 @@ import (
// "older" could not.
func keptWith(t *testing.T, sequence int64) store.Declared {
t.Helper()
return keptAt(t, link.Order{Sequence: sequence})
}
func keptAt(t *testing.T, order link.Order) store.Declared {
t.Helper()
body, err := json.Marshal(map[string]any{
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": sequence,
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": order.Sequence,
"epoch": order.Epoch,
})
if err != nil {
t.Fatal(err)
@@ -24,7 +31,7 @@ func keptWith(t *testing.T, sequence int64) store.Declared {
}
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
err := refuseOlder(keptWith(t, 7), nil, 5)
err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 5})
if err == nil {
t.Fatal("sequence 5 was accepted over a kept 7")
}
@@ -34,11 +41,11 @@ func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
}
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
if err := refuseOlder(keptWith(t, 7), nil, 8); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 8}); err != nil {
t.Fatalf("sequence 8 was refused over a kept 7: %v", err)
}
// Equal is the same declaration again, which reconciling is for.
if err := refuseOlder(keptWith(t, 7), nil, 7); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 7}); err != nil {
t.Fatalf("the same sequence was refused: %v", err)
}
}
@@ -46,13 +53,43 @@ func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
func TestNoOrderClaimedMeansNoOrderCompared(t *testing.T) {
// An older controller sends none; a host that received before it understood them kept none.
// Refusing on a guess would strand a node the moment the controller is older than the host.
if err := refuseOlder(keptWith(t, 7), nil, 0); err != nil {
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 0}); err != nil {
t.Fatalf("a declaration claiming no order was refused: %v", err)
}
if err := refuseOlder(keptWith(t, 0), nil, 3); err != nil {
if err := refuseOlder(keptWith(t, 0), nil, link.Order{Sequence: 3}); err != nil {
t.Fatalf("a declaration was refused against a kept one that claimed no order: %v", err)
}
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, 3); err != nil {
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, link.Order{Sequence: 3}); err != nil {
t.Fatalf("a first declaration was refused: %v", err)
}
}
// **The lease epoch decides first** (novox/hq to-be 45 §6): a declaration from a controller that lost
// its lease is refused whatever its sequence, and a new lease holder's is taken whatever its sequence.
func TestAnOlderEpochIsRefusedAndANewerTaken(t *testing.T) {
kept := keptAt(t, link.Order{Epoch: 57, Sequence: 3})
err := refuseOlder(kept, nil, link.Order{Epoch: 41, Sequence: 12})
if err == nil || !strings.Contains(err.Error(), "epoch 41") || !strings.Contains(err.Error(), "epoch 57") {
t.Fatalf("an older epoch was not refused by name: %v", err)
}
if err := refuseOlder(kept, nil, link.Order{Epoch: 58, Sequence: 1}); err != nil {
t.Fatalf("a new lease holder's first declaration was refused for its sequence: %v", err)
}
if err := refuseOlder(kept, nil, link.Order{Epoch: 57, Sequence: 2}); err == nil {
t.Fatal("a lower sequence under the same epoch was accepted")
}
// A controller with no lease — older, or rolled back to a build from before it — is today's
// behaviour: no epoch compared.
if err := refuseOlder(kept, nil, link.Order{Sequence: 4}); err != nil {
t.Fatalf("a declaration with no epoch was refused for one: %v", err)
}
if held, older := olderThanKept(kept, nil, link.Order{Epoch: 41, Sequence: 12}); !older ||
held != (link.Order{Epoch: 57, Sequence: 3}) {
t.Fatalf("olderThanKept said %v, holding %+v", older, held)
}
// What is kept cannot be read: no order to compare, so nothing is refused on a guess.
if _, older := olderThanKept(store.Declared{Declaration: []byte("garbled")}, nil,
link.Order{Epoch: 41, Sequence: 1}); older {
t.Fatal("refused against a kept declaration nobody can read")
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"context"
"errors"
"os/exec"
"sort"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// packageManagers are the tools a system's probe asks, for a system whose probe is its package manager.
var packageManagers = map[string]string{"arch": "pacman", "alpine": "apk"}
// unanswered is a probe of the package manager that failed before an apply: which tool, and its words.
type unanswered struct {
tool string
words string
}
// failedKey is how a report names the probe among what failed: one key, the same on every report, so the
// mesh counts a package manager that keeps not answering as one failure that goes on.
func (u *unanswered) failedKey() string { return "the package manager (" + u.tool + ")" }
// said is the line the apply says before it begins.
func (u *unanswered) said() string {
return u.tool + " does not answer here, so every package in this declaration fails and the rest of it is " +
"applied (novox/hq issue 233): " + u.words
}
// partial is the failure of an apply that applied everything else, when no package was declared to fail.
func (u *unanswered) partial() error {
return errors.New("applied only in part: " + u.words + "\n\nevery resource that does not need " + u.tool +
" was attempted; none here needed it, and " + u.tool + " must answer before this machine applies whole " +
"(novox/hq issue 233)")
}
// probePackageManager proves the machine is the system the host was built for, before anything is applied.
//
// **A package manager that does not answer fails only what needs it** (novox/hq issue 233). The probe was a
// refusal of the whole declaration — right for a host installed on a machine it was not built for, and a
// wedge when what the probe reads is the mesh's own: a declaration that left the package manager's module
// out removed its configuration file, and from then on every declaration was refused, the one that would
// write the file back and the node-engine's own upgrade included. A person on the machine was the only way
// back. Now the rest applies — files, units, accounts, the node-engine itself — and each package fails with
// the probe's words (the system's PackageInstalled asks the same probe first), so a later declaration can
// undo what an earlier one did. Before the rest is applied, a declaration composed from an older view of the
// assignments than this machine applied has already been refused (issue 234's generation).
//
// **A package manager that is not there at all still refuses whole.** That is the host installed on a
// machine it was not built for — the arch host on Alpine — which is what the probe exists to catch, and no
// declaration the mesh sends removes a package manager's program. Only one that is there and errors (a
// configuration file gone, a database locked) is the mesh's own fault and applies in part.
//
// A system with no package manager (android) still refuses whole: what its probe asks is the system itself,
// which no resource the mesh declares can remove, and nothing else stands between it and the wrong machine.
func probePackageManager(ctx context.Context, sys system.System) (*unanswered, error) {
err := sys.Confirm(ctx, apply.ExecRunner)
if err == nil {
return nil, nil
}
tool, ok := packageManagers[sys.Name()]
if !ok || errors.Is(err, exec.ErrNotFound) {
return nil, err
}
return &unanswered{tool: tool, words: err.Error()}, nil
}
// applyPass is one apply of the declaration over what the node's state says.
type applyPass func(known store.State) (apply.Report, store.State, error)
// applyMindingProbe applies, and **once more when the package manager did not answer and this apply wrote
// something** (novox/hq issue 233). What it wrote may be what the package manager needed — the declaration
// that names its module again writes its configuration file back — and then the packages go in now rather
// than at the next reconcile. Asked again only then: an apply that wrote nothing changed nothing the package
// manager reads. The second pass is the same declaration over the state the first left, so what the first
// applied reads unchanged and only what needs the package manager moves.
//
// It returns the last pass's account, the probe as it stands after it (nil once the package manager
// answers), and the first pass's outcomes when a second ran, for whoever counts what an apply moved.
func applyMindingProbe(ctx context.Context, sys system.System, probe *unanswered, known store.State,
pass applyPass, say func(string)) (apply.Report, store.State, error, *unanswered, []apply.Outcome) {
report, updated, err := pass(known)
if probe == nil || !wrote(report, err) {
return report, updated, err, probe, nil
}
again, refused := probePackageManager(ctx, sys)
if refused != nil || again != nil {
return report, updated, err, probe, nil
}
say(probe.tool + " answers now that this apply wrote what it reads; applying again, so its packages are " +
"installed in this apply (novox/hq issue 233)")
first := append([]apply.Outcome(nil), report.Outcomes...)
var partial *apply.Error
if errors.As(err, &partial) {
first = append(first, partial.Done.Outcomes...)
}
report, updated, err = pass(updated)
return report, updated, err, nil, first
}
// wrote is whether an apply moved anything on the machine, failed or not.
func wrote(report apply.Report, err error) bool {
if report.Changed() {
return true
}
var partial *apply.Error
return errors.As(err, &partial) && partial.Done.Changed()
}
// unapplied is which declared resources an apply did not apply, failed or not attempted, sorted; nil for a
// clean one.
func unapplied(d *declaration.Declaration, report apply.Report, err error) []string {
if err == nil {
return nil
}
done := map[string]bool{}
for _, o := range report.Outcomes {
done[o.ID] = o.Action != "skipped"
}
var partial *apply.Error
if errors.As(err, &partial) {
for _, o := range partial.Done.Outcomes {
done[o.ID] = done[o.ID] || o.Action != "skipped"
}
}
var out []string
for _, r := range d.Resources {
if !done[r.Identity()] {
out = append(out, r.Identity())
}
}
sort.Strings(out)
if len(out) == 0 {
// Failed, and every resource has an account: what failed is no resource (the firewall found, a removal).
out = []string{"(apply)"}
}
return out
}
// withUnanswered adds the probe to what a report says failed.
func withUnanswered(failed map[string]string, u *unanswered) map[string]string {
if u == nil {
return failed
}
if failed == nil {
failed = map[string]string{}
}
failed[u.failedKey()] = u.said()
return failed
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/system"
)
// Defends novox/hq issue 233: a package manager that does not answer is said even when nothing declared
// needs it — the apply is partial, never "applied" — and the report names it under one key, so the mesh
// counts it as one failure that goes on.
func TestAPackageManagerThatDoesNotAnswerIsSaidWithNoPackageDeclared(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[{"id":"pacman.conf","type":"file","path":"` + conf +
`","content":"[options]\n"}]}`)
d, err := declaration.ParseFileTrusted(raw)
if err != nil {
t.Fatal(err)
}
err = runApply(context.Background(), opts, d, raw, fromFile)
if err == nil || !strings.Contains(err.Error(), "applied only in part") || !strings.Contains(err.Error(), "could not be read") {
t.Fatalf("an apply whose package manager did not answer was not said to be partial: %v", err)
}
if _, statErr := os.Stat(conf); statErr != nil {
t.Fatalf("the file was not written: %v", statErr)
}
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" {
t.Fatalf("refused whole: %s", report.Refused)
}
why, ok := report.Failed["the package manager (pacman)"]
if !ok || !strings.Contains(why, "could not be read") {
t.Errorf("the report does not name the package manager with the probe's words: %v", report.Failed)
}
}
// A system whose probe asks the system itself, not a tool a resource needs, still refuses whole: nothing
// the mesh declares can remove it, and nothing else stands between that host and the wrong machine.
func TestASystemWithNoPackageManagerStillRefusesWhole(t *testing.T) {
t.Setenv("PATH", t.TempDir())
sys, err := system.For("android")
if err != nil {
t.Fatal(err)
}
probe, err := probePackageManager(context.Background(), sys)
if err == nil || probe != nil {
t.Fatalf("the android host's failed probe did not refuse: %v, %+v", err, probe)
}
}
// pacmanAnsweringOnce is a pacman that does not answer until conf exists, as the workstation's did until its
// configuration file was written back, and installs into a directory of its own once it does.
func pacmanAnsweringOnce(t *testing.T, conf string) {
t.Helper()
bin, installed := t.TempDir(), t.TempDir()
script := "#!/bin/sh\n" +
"[ -f '" + conf + "' ] || { echo \"error: config file " + conf + " could not be read: No such file or directory\" >&2; exit 1; }\n" +
"for a; do last=$a; done\n" +
"case \"$1\" in\n" +
"-Q) [ \"$2\" = pacman ] && exit 0; [ -f '" + installed + "'/\"$2\" ] && exit 0; exit 1;;\n" +
"-S) touch '" + installed + "'/\"$last\"; exit 0;;\n" +
"esac\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "pacman"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
}
// Defends the review of novox/hq issue 233: **the apply that writes the package manager's configuration back
// installs its packages too**. The probe is asked again once the partial apply wrote something, and when it
// answers the declaration is applied again over what the first pass left — the packages in this apply, not five
// minutes later, and the report clean.
func TestThePackagesGoInWhenTheApplyThatRestoresThePackageManagerWroteIt(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "pacman.conf")
pacmanAnsweringOnce(t, conf)
opts := options{state: filepath.Join(dir, "state.json")}
// The package first: within one pass it is asked about before the file is written, which is the order that
// leaves it to a second pass.
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.base","type":"package","package":"base"},` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"}]}`)
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" || len(report.Failed) != 0 {
t.Fatalf("the apply that made the package manager answer did not install its packages: refused %q, failed %v",
report.Refused, report.Failed)
}
if !slices.Contains(report.Applied, "pacman.base") || !slices.Contains(report.Applied, "pacman.conf") {
t.Errorf("the report does not account for both: %v", report.Applied)
}
}
// Defends decision (1) of the review of novox/hq issue 233: **a package manager that is not there at all still
// refuses whole**. That is the host installed on the wrong machine, which the probe exists to catch.
func TestAPackageManagerThatIsNotThereRefusesWhole(t *testing.T) {
t.Setenv("PATH", t.TempDir())
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
dir := t.TempDir()
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[{"id":"pacman.conf","type":"file","path":"` + conf +
`","content":"[options]\n"}]}`)
report := applyAndKeep(context.Background(), options{state: filepath.Join(dir, "state.json")}, raw, nil, nil, nil)
if !strings.Contains(report.Refused, "pacman does not answer") {
t.Fatalf("a machine with no pacman at all was not refused whole: %+v", report)
}
if _, err := os.Stat(conf); err == nil {
t.Error("the refused declaration touched the machine")
}
}
// Defends the review of novox/hq issue 233: **a re-apply whose result changed is said unasked**. A machine
// reconciles every five minutes and speaks only when its account is news; how the apply went was not part of
// it, so a package manager that stopped answering between deliveries was never said, and one that answered
// again never cleared. Now a failure is said once, the same failure again is not, and a clean re-apply after it
// is — which is what clears the mesh's condition.
func TestAReapplyWhoseResultChangedIsSaidUnasked(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "elsewhere", "pacman.conf") // not declared: the apply cannot make it answer
pacmanAnsweringOnce(t, conf)
opts := options{state: filepath.Join(dir, "state.json")}
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"motd","type":"file","path":"` + filepath.Join(dir, "motd") + `","content":"hello\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
w := &adoptionWatch{}
// Clean once, as it was before: base installed while pacman answered.
if err := os.MkdirAll(filepath.Dir(conf), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
t.Fatal(err)
}
clean := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if faulty(clean) {
t.Fatalf("the first apply was not clean: %+v", clean)
}
w.said(clean)
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
t.Error("a clean re-apply after a clean one was said, so every machine speaks every five minutes")
}
// The configuration goes: the re-apply fails, and that is news once.
if err := os.Remove(conf); err != nil {
t.Fatal(err)
}
failed := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if !faulty(failed) {
t.Fatalf("the re-apply did not fail: %+v", failed)
}
if !w.news(failed) {
t.Fatal("a re-apply that failed after a clean one was not said, so the mesh never hears the machine fails")
}
w.said(failed)
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
t.Error("the same failure was said again on the next re-apply")
}
// It comes back: the clean re-apply is news, and is what clears the mesh's condition.
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
t.Fatal(err)
}
again := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if faulty(again) {
t.Fatalf("the re-apply did not come back clean: %+v", again)
}
if !w.news(again) {
t.Fatal("a clean re-apply after a failed one was not said, so the mesh's condition never clears")
}
}
// A refusal is news too, once, and a reconcile with nothing to say after a clean one stays silent.
func TestARefusalIsSaidOnceAndNothingIsNotSaid(t *testing.T) {
w := &adoptionWatch{}
w.said(link.Report{})
if w.news(link.Report{}) {
t.Error("nothing was said again")
}
refused := link.Report{Refused: "pacman does not answer here"}
if !w.news(refused) {
t.Fatal("a refusal was not said")
}
w.said(refused)
if w.news(link.Report{Refused: "pacman does not answer here, at another time"}) {
t.Error("the same refusal in other words was said again")
}
if !w.news(link.Report{}) {
t.Error("a clean apply after a refusal was not said")
}
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// unansweringPacman puts a pacman on PATH that answers as one whose configuration file is gone, as the
// workstation's did on 2026-10-04 (novox/hq issue 233), and makes this an arch host.
func unansweringPacman(t *testing.T) {
t.Helper()
bin := t.TempDir()
script := "#!/bin/sh\necho 'error: config file /etc/pacman.conf could not be read: No such file or directory' >&2\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "pacman"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
was := builtFor
builtFor = "arch"
t.Cleanup(func() { builtFor = was })
}
// TestReplay233 replays novox/hq issue 233. The package manager's module wrote pacman's configuration
// file; a declaration that left the module out removed it; from then on pacman did not answer, and the
// node-engine refused every declaration whole — the one that named the module again and would have
// written the file back included. A person restored the file by hand an hour and a half later.
//
// Held to: a declaration that restores the file is applied while pacman does not answer. Only what needs
// pacman fails, with the probe's words; the file is written and recorded. Written only with what the
// node-engine had before the fix, so it lays over the commit before it, where it fails.
func TestReplay233(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
opts := options{state: filepath.Join(dir, "state.json")}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
report := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
if report.Refused != "" {
t.Fatalf("the declaration that restores the package manager's configuration was refused whole: %s", report.Refused)
}
if got, err := os.ReadFile(conf); err != nil || string(got) != "[options]\n" {
t.Fatalf("the file that restores the package manager was not written: %q, %v", got, err)
}
known, err := store.Load(opts.state)
if err != nil {
t.Fatal(err)
}
if _, ok := known.Find("pacman.conf"); !ok {
t.Error("the file written was not recorded, so the next apply would not know it is the mesh's")
}
words := strings.Join(failedWords(report.Failed), "\n")
if !strings.Contains(words, "pacman does not answer") || !strings.Contains(words, "could not be read") {
t.Errorf("the report does not say the package manager did not answer, in the probe's words: %q", words)
}
if !strings.Contains(words, "pacman.base") {
t.Errorf("the report does not name the package that was not applied: %q", words)
}
}
func failedWords(failed map[string]string) []string {
var out []string
for id, why := range failed {
out = append(out, id+": "+why)
}
return out
}
// Defends novox/hq issue 233 at the machine: `mesh-host apply` applies what does not need the package
// manager, and says the apply was partial with the probe's words — a non-zero exit, never "applied".
func TestApplyByHandAppliesWhatDoesNotNeedAPackageManagerThatDoesNotAnswer(t *testing.T) {
unansweringPacman(t)
dir := t.TempDir()
out := &bytes.Buffer{}
opts := options{state: filepath.Join(dir, "state.json"), out: out}
conf := filepath.Join(dir, "pacman.conf")
raw := []byte(`{"declaration":1,"resources":[` +
`{"id":"pacman.conf","type":"file","path":"` + conf + `","content":"[options]\n"},` +
`{"id":"pacman.base","type":"package","package":"base"}]}`)
d, err := declaration.ParseFileTrusted(raw)
if err != nil {
t.Fatal(err)
}
err = runApply(context.Background(), opts, d, raw, fromFile)
if _, statErr := os.Stat(conf); statErr != nil {
t.Fatalf("the file was not written: %v (the apply said %v)", statErr, err)
}
if err == nil {
t.Fatal("an apply whose package manager did not answer said it applied")
}
if !strings.Contains(err.Error(), "pacman does not answer") || !strings.Contains(err.Error(), "pacman.base") {
t.Errorf("the failure does not name the package or the probe's words: %v", err)
}
if !strings.Contains(out.String(), "pacman does not answer") {
t.Errorf("what was said before applying does not say the package manager does not answer:\n%s", out.String())
}
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/liveness"
)
// The restart count the engine says is its own since it began watching the resource, never since the state
// began (novox/hq issue 458).
func TestAnUnhealthyResourceSaysWhatItsRestartCountCounts(t *testing.T) {
r := liveness.State{Resource: liveness.Resource{ID: "messenger.own-runtime", Kind: "process",
Target: "messenger-runtime.service"}, State: liveness.Unhealthy, Reason: liveness.ReasonRestarting, Restarts: 3890}
said := unhealthyWords(r)
if !strings.Contains(said, "3890 automatic restart(s) since this engine began watching it") ||
strings.Contains(said, "counted") {
t.Fatalf("the restart count is not said as what it counts: %s", said)
}
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/units"
)
// The machine's failed units in the engine's statement (novox/hq issue 315): a module's is among the
// resources, unhealthy, as that module's unit, naming it; the machine's own beside them; and an engine
// not reading them says nothing of them.
func TestTheStatementCarriesTheFailedUnits(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
us := &units.Statement{At: at, State: units.Degraded, Failed: []units.Failed{
{Unit: "openrazer-daemon.service", Scope: units.ScopeUser, Load: "loaded", Result: "exit-code",
User: "operator", Module: "openrazer", Resource: "openrazer.daemon", Via: units.ViaPackage, Since: at, Streak: 2},
{Unit: "storage-media.mount", Scope: units.ScopeSystem, Load: "loaded", Result: "timeout", Since: at, Streak: 2},
}}
h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), us)
if len(h.Resources) != 1 {
t.Fatalf("one module's unit among the resources: %+v", h.Resources)
}
r := h.Resources[0]
if r.Module != "openrazer" || r.Resource != "openrazer.daemon" || r.Kind != link.KindUnit ||
r.Target != "openrazer-daemon.service" || r.State != link.StateUnhealthy ||
!strings.Contains(r.Reason, "account's own service manager") || !strings.Contains(r.Reason, "exit-code") {
t.Fatalf("the module's failed unit: %+v", r)
}
// Whose manager it fails in, so the controller can tell it from a fault (novox/hq ADR 0254).
if r.Account != "operator" {
t.Fatalf("the unit in the account's own manager does not name the account: %q", r.Account)
}
if h.Units == nil || h.Units.State != units.Degraded || len(h.Units.Failed) != 1 ||
h.Units.Failed[0].Unit != "storage-media.mount" || h.Units.Failed[0].Result != "timeout" {
t.Fatalf("the machine's own: %+v", h.Units)
}
if h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), nil); h.Units != nil {
t.Fatal("an engine not reading units said them")
}
if h := withUnits(healthAsReported(liveness.Statement{At: at}, nil), &units.Statement{}); h.Units != nil {
t.Fatal("a judge not yet given a declaration said units")
}
}
// Every resource that runs in an account's own service manager names the account, and so does the account
// a module put in a group (novox/hq ADR 0254): the controller reads a unit that cannot run before a new login
// by the two together. What the machine's own manager runs names none.
func TestTheStatementNamesTheAccountsManager(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
st := liveness.Statement{At: at, Resources: []liveness.State{
{Resource: liveness.Resource{Module: "openrazer", ID: "openrazer.daemon", Kind: "service",
Target: "openrazer-daemon.service", Scope: declaration.ScopeUser, User: "operator"}, State: liveness.Unhealthy,
Reason: "down", Since: at},
{Resource: liveness.Resource{Module: "docker", ID: "docker.engine", Kind: "service", Target: "docker.service",
Scope: declaration.ScopeSystem}, State: liveness.Healthy, Since: at},
}}
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{{Account: accounts.Account{Module: "openrazer",
ID: "openrazer.operator", Name: "operator", Groups: []string{"openrazer"}}, State: accounts.Unhealthy,
Reason: accounts.ReasonRelogin + ": operator is in the group openrazer", Since: at}}}
h := withAccounts(healthAsReported(st, nil), as)
got := map[string]string{}
for _, r := range h.Resources {
got[r.Resource] = r.Account
}
want := map[string]string{"openrazer.daemon": "operator", "docker.engine": "", "openrazer.operator": "operator"}
for id, account := range want {
if a, said := got[id]; !said || a != account {
t.Errorf("%s names the account %q (said %v); want %q", id, a, said, account)
}
}
}
// An account declared never to become root (novox/hq ADR 0266) is said with root never, under the contract
// that tells the controller this engine judges it; an account judged for its groups alone is not.
func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{
{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true},
State: accounts.Unhealthy, Reason: accounts.ReasonRoot + ": in the group docker, which grants root", Since: at},
{Account: accounts.Account{Module: "openrazer", ID: "openrazer.account", Name: "operator",
Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at},
}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
// At RootContract or later: an engine that also reads identities and keeps shared folders says IdentityContract
// (novox/hq ADR 0325), one above ADR 0312's GrantContract.
if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.IdentityContract ||
link.IdentityContract != link.GrantContract+1 {
t.Fatalf("contract %d", h.Contract)
}
got := map[string]string{}
for _, r := range h.Resources {
got[r.Resource] = r.Root
}
if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" {
t.Fatalf("root said: %v", got)
}
}
// A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as
// fields (novox/hq issue 361); a mount point, never a path the walk visited or found.
func TestTheStatementSaysHowFarTheSetuidSearchGot(t *testing.T) {
at := time.Date(2026, 10, 10, 1, 40, 0, 0, time.UTC)
sp := &accounts.SearchProgress{Started: at, Filesystems: 3, Done: 1, Walking: "/home", Visited: 42, Found: 2,
Kept: at, Finished: []accounts.SearchedFilesystem{{Mount: "/", Took: 90 * time.Second}}}
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{{Account: accounts.Account{Module: "claude-code",
ID: "claude-code.agent", Name: "agent", Root: true}, State: accounts.Unknown,
Reason: accounts.ReasonPending + ": the search for setuid programs, started 2026-10-10 01:27, has not finished yet",
Since: at, Search: sp}}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
r := h.Resources[0]
if !strings.HasPrefix(r.Reason, accounts.ReasonPending) || !strings.Contains(r.Reason, "1 of 3 filesystems walked, walking /home, 42 entries visited") {
t.Fatalf("the reason: %s", r.Reason)
}
s := r.Search
if s == nil || s.Filesystems != 3 || s.Done != 1 || s.Walking != "/home" || s.Visited != 42 || s.Found != 2 ||
s.Kept == nil || len(s.Finished) != 1 || s.Finished[0].Mount != "/" || *s.Finished[0].TookSeconds != 90 {
t.Fatalf("the fields: %+v", s)
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The last apply's report, kept beside the state until the mesh takes it (novox/hq issue 264).
// What a host that died between applying and reporting kept is read back by the next one exactly —
// the digest and the outcome, never a new account of the machine.
func TestTheKeptReportSurvivesTheHost(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
made := link.Report{Declared: "sha256:d1", Applied: []string{"a"}, Failed: map[string]string{"b": "no room"},
Host: "v2"}
if err := unsaidBeside(state).Keep(made); err != nil {
t.Fatal(err)
}
kept, ok, err := unsaidBeside(state).Pending()
if err != nil || !ok {
t.Fatalf("nothing kept: %v", err)
}
if !reflect.DeepEqual(kept, made) {
t.Fatalf("kept %+v, made %+v", kept, made)
}
// Taken about another declaration, it stays; taken about this one, it goes.
if err := unsaidBeside(state).Said("sha256:other"); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); !ok {
t.Fatal("a report about another declaration cleared this one")
}
if err := unsaidBeside(state).Said("sha256:d1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.UnsaidPath(state)); !os.IsNotExist(err) {
t.Fatalf("the report was taken and is still kept: %v", err)
}
}
// A node that never applied anything has nothing to say again; and an apply refused before its
// declaration was read clears an older report, which would otherwise be re-said after it.
func TestNothingKeptWhenNothingWasApplied(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
if _, ok, err := unsaidBeside(state).Pending(); ok || err != nil {
t.Fatalf("a node that applied nothing has a report to say: %v %v", ok, err)
}
if err := unsaidBeside(state).Keep(link.Report{Declared: "sha256:d1"}); err != nil {
t.Fatal(err)
}
if err := unsaidBeside(state).Keep(link.Report{Refused: "forged"}); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); ok {
t.Fatal("an older report is kept after a later declaration was refused")
}
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
"github.com/novox/mesh-host/internal/witness"
)
// The node-engine's part in core upgrades that roll back (novox/hq to-be 45 §8, ADR 0227 rule 8):
// proving its own build to the launcher that witnesses it, witnessing the controller and the node
// tools it places, and saying every verdict on its reports.
// standingRollbacks is every verdict that still stands on this machine: the launcher's about this
// host, and the engine's about the processes it witnesses. Said on every report.
func standingRollbacks(statePath string) []link.Rollback {
var out []link.Rollback
verdicts, err := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
}
for _, v := range verdicts {
out = append(out, link.Rollback{Component: link.ComponentEngine, From: v.From, To: v.To,
Outcome: v.Outcome, Why: v.Why, At: v.At})
}
return append(out, witness.Standing(apply.DaemonRoot())...)
}
// rolledBackHosts are the host versions the launcher will not start again, so this host never stands
// aside for one.
func rolledBackHosts(statePath string) []string {
verdicts, _ := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
return upgrade.RolledBackVersions(verdicts)
}
// proving is this host waiting for the mesh to take a report it made under its own build — the
// evidence its launcher's trial waits for — and acting on it once.
type proving struct {
statePath string
version string
say func(string)
once sync.Once
}
// unproved says whether this host's version is not yet known-good here: a host on trial.
func unproved(statePath, version string) bool {
known, err := upgrade.ReadKnownGood(upgrade.KnownGoodPath(statePath))
return err != nil || known != version
}
// heard is told every account of the machine the mesh has taken. The first one about a declaration,
// made by this build, proves it.
func (p *proving) heard(r link.Report) {
if r.Declared == "" || r.Refused != "" || r.Host != p.version {
return
}
p.once.Do(func() {
retired, err := upgrade.Proved(p.statePath, upgrade.VersionsDir(""), p.version)
if err != nil {
p.say(fmt.Sprintf("this host reported under its own build %s, and proving it was not complete: %v", p.version, err))
}
if len(retired) > 0 {
p.say(fmt.Sprintf("host %s is proved; retired the host version(s) %s", p.version, strings.Join(retired, ", ")))
}
renewFallback(p.version, p.say)
renewLauncher(p.say)
})
}
// renewFallback makes the engine placed by hand — the launcher's fallback, and the `nox-mesh-host` on this
// machine's PATH — a copy of this proved build when it is not one (novox/hq issue 356). On the same
// evidence known-good is written on, and said when it happens or cannot: a stale command that answers
// for a build that is not running would otherwise stay until somebody typed it.
func renewFallback(version string, say func(string)) {
self, err := os.Executable()
if err != nil {
say("the engine placed by hand could not be renewed: this build does not know its own path: " + err.Error())
return
}
fallback := upgrade.FallbackPath()
renewed, err := upgrade.RenewFallback(self, fallback)
switch {
case err != nil:
say(fmt.Sprintf("the engine placed by hand at %s is not this build, and could not be renewed: %v", fallback, err))
case renewed:
say(fmt.Sprintf("renewed the engine placed by hand at %s to this build, %s: the command on this machine is the "+
"engine that runs it", fallback, version))
}
}
// The launcher, as the service manager runs it.
const (
launcherPath = upgrade.DefaultLibexec + "/launch"
hostUnit = "nox-mesh-host.service"
)
// renewLauncher asks the service manager to restart this host's service when the launcher running
// it was replaced on disk since it started (novox/hq to-be 45 §8): a delivered launcher otherwise
// takes effect only at the next boot, and the witness it carries with it. A launcher from
// this one on runs its successor itself, at the host's next clean exit; this is for the launcher
// before it. Said, and only when it is certain: the parent is the launcher, and the file it reads
// is gone from under it.
func renewLauncher(say func(string)) {
if _, err := os.Stat("/run/systemd/system"); err != nil {
return
}
if !launcherReplaced(os.Getppid(), "/proc", launcherPath) {
return
}
say("the launcher running this host was replaced on disk; asking the service manager to run the new one")
if _, err := apply.ExecRunner(context.Background(), "systemctl", "restart", "--no-block", hostUnit); err != nil {
say("could not ask for the new launcher, so it runs from the next boot: " + err.Error())
}
}
// launcherReplaced is whether process pid is a shell reading the launcher at path, from a file that
// is no longer there — the one a delivery renamed a new launcher over.
func launcherReplaced(pid int, proc, path string) bool {
if pid <= 1 {
return false
}
base := filepath.Join(proc, strconv.Itoa(pid))
cmdline, err := os.ReadFile(filepath.Join(base, "cmdline"))
if err != nil || !strings.Contains(string(cmdline), path) {
return false
}
fds, err := os.ReadDir(filepath.Join(base, "fd"))
if err != nil {
return false
}
for _, fd := range fds {
target, err := os.Readlink(filepath.Join(base, "fd", fd.Name()))
if err == nil && target == path+" (deleted)" {
return true
}
}
return false
}
// watchWhatThisHostPlaced judges the core builds this host placed, until ctx ends (to-be 45 §8).
func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Queue, say func(string)) {
host, _ := os.Hostname()
w := &witness.Watcher{
Root: apply.DaemonRoot(), Node: node, Host: host,
Asker: func() link.Asker { return queue.Asker() },
Run: witness.Runner(apply.ExecRunner),
Hold: func(f func()) {
applying.Lock()
defer applying.Unlock()
f()
},
// Said now, not at the next report: the verdict is on every report from here, and the mesh
// is asked for one at once.
Concluded: func(link.Rollback) { queue.ReportAsked() },
Say: say,
}
w.Watch(ctx)
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
)
// The first account the mesh takes from this build, about a declaration, proves it to the launcher;
// nothing else does — a refusal, a report about no declaration, another build's report (to-be 45 §8).
func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
t.Setenv("MESH_HOST_LIBEXEC", t.TempDir())
// Never the machine's own engine placed by hand: proving renews it (issue 356).
fallback := filepath.Join(t.TempDir(), "nox-mesh-host")
if err := os.WriteFile(fallback, []byte("a stale engine placed by hand"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv(upgrade.FallbackVar, fallback)
var said []string
p := &proving{statePath: state, version: "2.0", say: func(s string) { said = append(said, s) }}
for _, r := range []link.Report{
{Host: "2.0", Refused: "no"},
{Host: "2.0"},
{Host: "1.0", Declared: "abc"},
} {
p.heard(r)
if !unproved(state, "2.0") {
t.Fatalf("%+v proved this build", r)
}
if got, _ := os.ReadFile(fallback); string(got) != "a stale engine placed by hand" {
t.Fatalf("%+v renewed the engine placed by hand", r)
}
}
p.heard(link.Report{Host: "2.0", Declared: "abc"})
if unproved(state, "2.0") {
t.Fatalf("an account of a declaration under this build did not prove it (%v)", said)
}
if got, _ := upgrade.ReadKnownGood(upgrade.KnownGoodPath(state)); got != "2.0" {
t.Fatalf("known-good is %q", got)
}
// **Proved, the engine placed by hand becomes this build** (issue 356): the command on the machine's PATH is
// the engine that runs it, and that is said.
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
want, _ := os.ReadFile(self)
if got, _ := os.ReadFile(fallback); len(want) == 0 || string(got) != string(want) {
t.Fatalf("the engine placed by hand was not renewed to this build (%d bytes, want %d)", len(got), len(want))
}
renewed := false
for _, s := range said {
renewed = renewed || strings.Contains(s, "renewed the engine placed by hand at "+fallback)
}
if !renewed {
t.Fatalf("the renewal was not said: %q", said)
}
}
// Every verdict that stands is said, and a reconcile that has one says it unasked.
func TestAStandingRollbackIsSaidOnEveryReport(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
line := "2.0\t1.0\t1759744800\trolled-back\tit did not report within 600s of starting\n"
if err := os.WriteFile(upgrade.RolledBackPath(state), []byte(line), 0o644); err != nil {
t.Fatal(err)
}
got := standingRollbacks(state)
if len(got) == 0 || got[0].Component != link.ComponentEngine || got[0].From != "2.0" || got[0].To != "1.0" ||
got[0].Outcome != link.RolledBack {
t.Fatalf("what the report says is %+v", got)
}
if !worthSaying(link.Report{Rollbacks: got}) {
t.Fatal("a reconcile with a rollback standing does not say it")
}
if adoptionFingerprint(link.Report{Rollbacks: got}) == adoptionFingerprint(link.Report{}) {
t.Fatal("a rollback reached or ended is not news to the reconcile")
}
}
// The launcher running this host is known to have been replaced only when it certainly was: a shell
// reading the launcher, from a file renamed over. Asked of a real process, not a model of one.
func TestAReplacedLauncherIsSeen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launch")
if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 5\necho done\n"), 0o755); err != nil {
t.Fatal(err)
}
shell := exec.Command("sh", path)
if err := shell.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = shell.Process.Kill(); _ = shell.Wait() })
time.Sleep(200 * time.Millisecond)
if launcherReplaced(shell.Process.Pid, "/proc", path) {
t.Fatal("a launcher still on disk reads as replaced")
}
// Delivered as the mesh writes a file: a new one renamed over it.
if err := os.WriteFile(path+".new", []byte("#!/bin/sh\necho new\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+".new", path); err != nil {
t.Fatal(err)
}
if !launcherReplaced(shell.Process.Pid, "/proc", path) {
fds, _ := os.ReadDir(filepath.Join("/proc", strconv.Itoa(shell.Process.Pid), "fd"))
t.Skipf("this shell does not keep its script open (%d fds), so a replaced launcher cannot be seen here", len(fds))
}
if launcherReplaced(shell.Process.Pid, "/proc", filepath.Join(dir, "other")) {
t.Fatal("a process reading another script reads as this launcher")
}
}
+1 -1
View File
@@ -161,7 +161,7 @@
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
{
"id": "broker",
+2 -1
View File
@@ -3,13 +3,14 @@ module github.com/novox/mesh-host
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+6
View File
@@ -1,3 +1,9 @@
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+320
View File
@@ -0,0 +1,320 @@
// Package accounts is the node-engine reading whether an account has, where it runs, the groups a module
// put it in (novox/hq ADR 0252, issue 247).
//
// **A group takes effect at the next login.** The apply puts the account in a group in the machine's
// database at once. Every process already running keeps the groups it started with: the account's own
// service manager, and every unit it starts. The lighting daemon's module puts the operator's account in
// `openrazer`, and the daemon, started by that manager, still refuses to start, for as long as the
// session that began before lasts. Nothing said why.
//
// On every look the engine reads, for each account a module declares groups for:
//
// 1. whether the user database lists the account in each group. One it does not is the apply's to put
// right, and is said as `not in the group`;
// 2. whether the account's own service manager runs, and if it does, the groups that process holds,
// read from the process itself. A group the database lists and the running manager lacks is said as
// **relogin needed**, with what to do.
//
// No running manager is healthy: nobody is logged in, and the next login takes the groups.
//
// Each verdict is the declaring module's, as a resource of kind `account`, beside what liveness says: the
// controller raises it as the module's condition on two statements in a row, and clears it on the first
// healthy one.
//
// **And an account declared never to become root without a person** (`root: never`, novox/hq ADR 0266) —
// the account agent sessions run as on a machine where they must not reach root by themselves — is judged
// on every look for whether it can anyway: by its uid, by a group that grants root to its members, by any
// sudo rule at all, or by reading a secret the mesh placed for another account (the tool runner's bus
// credential among them, which carries every co-hosted module's grants). Judged first, whether or not
// anybody is logged in: a way to root does not wait for a session. Any way found is unhealthy, its reason
// starting ReasonRoot and naming every way; a question the machine did not answer is unknown, never
// healthy. This is the fact the controller reads to tell a machine where no agent can become root from one
// where an agent can.
//
// **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service
// manager's `show` of the account's manager unit, and that process's status file. It never starts the
// account's manager, which asking that manager itself would.
package accounts
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The states, in the words liveness says them (the controller reads them alike).
const (
Healthy = "healthy"
Unhealthy = "unhealthy"
Unknown = "unknown"
)
// ReasonRelogin starts the reason of an account whose running session lacks a group it is in.
const ReasonRelogin = "relogin needed"
// ReasonRoot starts the reason of an account declared never to become root without a person that can
// (novox/hq ADR 0266); every way found follows it.
const ReasonRoot = "can become root without a person"
// RootGroups are the groups whose members become root by membership alone, without a sudo rule naming
// them: the administrators' groups a distribution's own rules or polkit treat as root, the container
// runtime's socket (a container with the host's root mounted), the raw disks, and the virtualisation
// daemons that start a guest with the host's devices — and the kernel's memory (kmem) and the password hashes
// (shadow), either of which is root a step away (novox/hq ADR 0312 §4 review). A closed list, so what the judge
// calls "grants root" is written down and reviewable rather than guessed per machine.
var RootGroups = []string{"root", "wheel", "sudo", "admin", "docker", "disk", "lxd", "incus-admin", "libvirt",
"kmem", "shadow"}
// Account is one user resource of a module that declares groups.
type Account struct {
Module string
// ID is the user resource's id; Name the account.
ID string
Name string
Groups []string
// Root is true for an account declared never to become root without a person (novox/hq ADR 0266).
Root bool
// Secrets are the paths of every secret the declaration places for another account, judged for
// whether this one can read them; only for a Root account.
Secrets []string
}
// Of is every account a declaration has a module put in a group. held is every resource an adopted
// machine holds as found, by id: its groups are not the mesh's yet. A resource the mesh declares in its
// own right (no module) is not judged here.
func Of(d *declaration.Declaration, held map[string]bool) []Account {
if d == nil {
return nil
}
var out []Account
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
root := ok && u.Root == declaration.RootNever
if !ok || (len(u.Groups) == 0 && !root) || held[u.ID] || u.Name == "" {
continue
}
at := strings.LastIndex(u.ID, ".")
if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) {
continue
}
a := Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, Groups: append([]string(nil), u.Groups...), Root: root}
if root {
a.Secrets = secretsOf(d, u.Name)
}
out = append(out, a)
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// secretsOf is the path of every secret file the declaration places for anybody but account: sealed whole,
// or with sealed values in its content. One the account owns is its own to read.
func secretsOf(d *declaration.Declaration, account string) []string {
var out []string
for _, r := range d.Resources {
f, ok := r.(*declaration.File)
if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account {
continue
}
out = append(out, f.Path)
}
sort.Strings(out)
return out
}
// Session is what an account's own service manager holds.
type Session struct {
// Running is whether the manager runs.
Running bool
// Has is, of the groups asked about, those the running manager holds.
Has map[string]bool
}
// Reader is what a look asks the machine. An error is "could not be read": said unknown, never healthy.
type Reader interface {
// InDatabase is every group the user database lists an account in.
InDatabase(ctx context.Context, account string) ([]string, error)
// Session is the account's own service manager: whether it runs, and which of groups it holds.
Session(ctx context.Context, account string, groups []string) (Session, error)
// Escalation is every way the account can become root without a person, in words — its uid, a group
// of RootGroups, a sudo rule, a secret of secrets it can read — and none when there is none. Reads
// only (novox/hq ADR 0266).
Escalation(ctx context.Context, account string, secrets []string) ([]string, error)
}
// Verdict is one account's state as a statement says it.
type Verdict struct {
Account
State string
Reason string
Since time.Time
Streak int
// Search is how far the setuid search got, while the verdict waits for it (novox/hq issue 361); nil
// otherwise. Never part of Reason: it moves every minute, and a verdict that changed every minute would
// be said in the journal every minute.
Search *SearchProgress
}
// Statement is one look at every account.
type Statement struct {
At time.Time
Accounts []Verdict
}
// Healthy says no account in it is anything but healthy.
func (s Statement) Healthy() bool {
for _, v := range s.Accounts {
if v.State != Healthy {
return false
}
}
return true
}
// Judge reads every account's groups on every look. Safe for the apply and the looking loop at once.
type Judge struct {
reader Reader
Now func() time.Time
mu sync.Mutex
accounts []Account
kept map[string]*Verdict
last Statement
}
// New is a judge reading through r.
func New(r Reader) *Judge {
return &Judge{reader: r, Now: time.Now, kept: map[string]*Verdict{}}
}
// Set is what the declaration just applied asks. An account no longer asked for is forgotten.
func (j *Judge) Set(as []Account) {
j.mu.Lock()
defer j.mu.Unlock()
j.accounts = append([]Account(nil), as...)
declared := map[string]bool{}
for _, a := range as {
declared[a.ID] = true
}
for id := range j.kept {
if !declared[id] {
delete(j.kept, id)
}
}
}
// Last is the statement of the last look.
func (j *Judge) Last() Statement {
j.mu.Lock()
defer j.mu.Unlock()
return j.last
}
// Look reads every account once, and answers the statement and whether any verdict changed since the
// last look.
func (j *Judge) Look(ctx context.Context) (Statement, bool) {
j.mu.Lock()
defer j.mu.Unlock()
now := j.Now()
st := Statement{At: now}
changed := len(j.last.Accounts) != len(j.accounts)
for _, a := range j.accounts {
state, reason, search := j.judge(ctx, a)
k := j.kept[a.ID]
if k == nil || k.State != state || k.Reason != reason {
changed = true
k = &Verdict{Account: a, State: state, Reason: reason, Since: now}
j.kept[a.ID] = k
}
k.Account, k.Search = a, search
if state == Unhealthy {
k.Streak++
} else {
k.Streak = 0
}
st.Accounts = append(st.Accounts, *k)
}
j.last = st
return st, changed
}
// judge is one account's verdict on one look, and how far the setuid search got while it waits for one.
func (j *Judge) judge(ctx context.Context, a Account) (string, string, *SearchProgress) {
if a.Root {
ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets)
var notJudged *SetuidNotJudged
switch {
case len(ways) > 0:
// A way found is a way found, whatever else could not be judged; said beside it.
reason := ReasonRoot + ": " + strings.Join(ways, "; ")
if errors.As(err, &notJudged) {
reason += "; " + firstLine(err.Error())
}
var progress *SearchProgress
if notJudged != nil {
progress = notJudged.Progress
}
return Unhealthy, reason, progress
case errors.As(err, &notJudged):
// The setuid search has no result yet: not judged, said as such — the same words on every look
// until it has one, never a search that stalls the look (novox/hq ADR 0266).
return Unknown, firstLine(err.Error()), notJudged.Progress
case err != nil:
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error()), nil
}
if len(a.Groups) == 0 {
// Declared for root alone: nothing of a session to read.
return Healthy, "", nil
}
}
in, err := j.reader.InDatabase(ctx, a.Name)
if err != nil {
return Unknown, "the user database could not be read: " + firstLine(err.Error()), nil
}
listed := map[string]bool{}
for _, g := range in {
listed[g] = true
}
var missing, inDB []string
for _, g := range a.Groups {
if listed[g] {
inDB = append(inDB, g)
} else {
missing = append(missing, g)
}
}
if len(missing) > 0 {
return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why",
strings.Join(missing, ", "), a.Name), nil
}
s, err := j.reader.Session(ctx, a.Name, inDB)
if err != nil {
return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error()), nil
}
if !s.Running {
// Nobody is logged in, and the account does not linger: the next login takes every group.
return Healthy, "", nil
}
var lacking []string
for _, g := range inDB {
if !s.Has[g] {
lacking = append(lacking, g)
}
}
if len(lacking) > 0 {
return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+
"log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", ")), nil
}
return Healthy, "", nil
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}
+165
View File
@@ -0,0 +1,165 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The account judge (novox/hq ADR 0252, "how it is checked"): an account in a group its running session
// lacks is unhealthy with "relogin needed"; the same account with no session, or a session that has the
// group, is healthy; one the database does not list in the group is unhealthy saying so; and everything
// asked of the machine is a read.
// machine is a fake: the database's groups, the manager's process and its status file.
type machine struct {
t *testing.T
proc string
inDB string
pid string
held string // the Groups line of the manager's status
asked []string
failsID bool
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
switch {
case name == "id":
if m.failsID {
return "", errors.New("id exited 1")
}
return m.inDB + "\n", nil
case name == "getent" && args[0] == "passwd":
return "operator:x:1500:1500::/home/operator:/bin/zsh\n", nil
case name == "getent" && args[0] == "group":
switch args[1] {
case "openrazer":
return "openrazer:x:964:operator\n", nil
case "wheel":
return "wheel:x:998:operator\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "systemctl":
return m.pid + "\n", nil
}
return "", errors.New("not a command the judge may run")
}
func (m *machine) status() {
m.t.Helper()
if m.pid == "" || m.pid == "0" {
return
}
dir := filepath.Join(m.proc, m.pid)
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
body := "Name:\tsystemd\nUid:\t1500\t1500\t1500\t1500\nGid:\t1500\t1500\t1500\t1500\nGroups:\t" + m.held + "\n"
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(body), 0o644); err != nil {
m.t.Fatal(err)
}
}
var razer = Account{Module: "openrazer", ID: "openrazer.account", Name: "operator", Groups: []string{"openrazer"}}
func look(t *testing.T, m *machine) Verdict {
t.Helper()
m.status()
j := New(Exec{Run: m.run, Proc: m.proc})
j.Now = func() time.Time { return time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) }
j.Set([]Account{razer})
st, _ := j.Look(t.Context())
if len(st.Accounts) != 1 {
t.Fatalf("the statement: %+v", st)
}
return st.Accounts[0]
}
func TestAGroupTheRunningSessionLacksSaysReloginNeeded(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998"}
v := look(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRelogin) || !strings.Contains(v.Reason, "openrazer") ||
!strings.Contains(v.Reason, "log out") {
t.Fatalf("a session without the group: %+v", v)
}
if v.Module != "openrazer" || v.ID != "openrazer.account" {
t.Errorf("the verdict is not the module's: %+v", v)
}
for _, a := range m.asked {
read := strings.HasPrefix(a, "id -nG ") || strings.HasPrefix(a, "getent ") ||
strings.HasPrefix(a, "systemctl show --property=MainPID --value user@")
if !read {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
}
func TestASessionThatHasTheGroupIsHealthy(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998 964"}
if v := look(t, m); v.State != Healthy || v.Reason != "" {
t.Fatalf("a session with the group: %+v", v)
}
}
func TestNoSessionIsHealthyTheNextLoginTakesTheGroup(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "0"}
if v := look(t, m); v.State != Healthy {
t.Fatalf("no session: %+v", v)
}
}
func TestAnAccountTheDatabaseDoesNotListIsUnhealthySayingSo(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel", pid: "4242", held: "998"}
v := look(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, "not in the group openrazer") {
t.Fatalf("not in the group: %+v", v)
}
}
func TestADatabaseThatDoesNotAnswerIsUnknownNeverHealthy(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), failsID: true}
if v := look(t, m); v.State != Unknown {
t.Fatalf("an unread database: %+v", v)
}
}
func TestOnlyAModulesAccountWithGroupsIsJudged(t *testing.T) {
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]},
{"id":"docker.account","type":"user","name":"operator","groups":["docker"]},
{"id":"meshown","type":"user","name":"mesh","groups":["x"]}
]}`))
if err != nil {
t.Fatal(err)
}
got := Of(d, map[string]bool{"docker.account": true})
if len(got) != 1 || got[0].ID != "openrazer.account" || got[0].Module != "openrazer" {
t.Fatalf("judged: %+v", got)
}
}
func TestAVerdictThatHoldsCountsItsStreakAndAChangeIsSaid(t *testing.T) {
m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "4242", held: "998"}
m.status()
j := New(Exec{Run: m.run, Proc: m.proc})
j.Set([]Account{razer})
_, changed := j.Look(t.Context())
st, again := j.Look(t.Context())
if !changed || again || st.Accounts[0].Streak != 2 {
t.Fatalf("first look changed %v, second %v, streak %d", changed, again, st.Accounts[0].Streak)
}
m.held = "998 964"
m.status()
st, changed = j.Look(t.Context())
if !changed || st.Accounts[0].State != Healthy || st.Accounts[0].Streak != 0 {
t.Fatalf("after the new login: changed %v, %+v", changed, st.Accounts[0])
}
}
+287
View File
@@ -0,0 +1,287 @@
package accounts
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
)
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner).
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`,
// the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an
// account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds
// both).
type Exec struct {
Run Runner
// Proc is where the process table is; empty is /proc. A test points it at a directory of its own.
Proc string
// Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own.
Stat func(path string) (FileMode, error)
// Lstat is a path's own owner and mode, never through a link, with its type bits in Perm (fs.ModeSymlink,
// fs.ModeDir); nil is the machine's own (os.Lstat). Whether an account can reach a setuid program is read
// with it at every judgement (novox/hq issue 361).
Lstat func(path string) (FileMode, error)
// ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's.
ReadFile func(path string) ([]byte, error)
ReadDir func(path string) ([]fs.DirEntry, error)
ACL func(path string) ([]ACLEntry, error)
// Cache keeps the search for setuid programs between looks; nil searches on every look.
Cache *SetuidCache
// SearchRun runs the setuid search's commands (the package manager it asks); nil is Run. A
// serving engine gives Idle, so they yield to everything else on the machine, as the walk does.
SearchRun Runner
// Walk carries a walk for setuid programs on; nil is the engine's own (walker). A test gives its own.
Walk func(ctx context.Context, p *SetuidProgress, checkpoint func()) error
// Now is the clock the cache is kept by; nil is the machine's.
Now func() time.Time
}
// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits.
type FileMode struct {
UID, GID int
Perm fs.FileMode
}
// Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge
// looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database
// lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other
// bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns.
// sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read.
// The parent directories are not walked, so a file the bits allow and a directory hides is still said:
// the judge errs toward saying a way that is not, never toward missing one that is.
func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) {
var ways []string
uidOut, err := e.Run(ctx, "id", "-u", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
uid, err := strconv.Atoi(strings.TrimSpace(uidOut))
if err != nil {
return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account)
}
if uid == 0 {
ways = append(ways, "its uid is 0")
}
names, err := e.InDatabase(ctx, account)
if err != nil {
return nil, err
}
for _, g := range names {
if slices.Contains(RootGroups, g) {
ways = append(ways, "in the group "+g+", which grants root")
}
}
listed, err := e.Run(ctx, "sudo", "-l", "-U", account)
rules, err := SudoRules(listed, err)
if err != nil {
return nil, err
}
if len(rules) > 0 {
ways = append(ways, "sudo grants it: "+strings.Join(rules, ", "))
}
gidsOut, err := e.Run(ctx, "id", "-G", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
}
gids := map[int]bool{}
for _, f := range strings.Fields(gidsOut) {
if n, err := strconv.Atoi(f); err == nil {
gids[n] = true
}
}
if len(secrets) > 0 {
stat := e.Stat
if stat == nil {
stat = statOf
}
for _, path := range secrets {
m, err := stat(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err)
}
if Readable(m, uid, gids) {
ways = append(ways, "it can read the secret "+path)
}
}
}
more, err := e.moreWays(ctx, account, uid, names, gids, secrets)
var notJudged *SetuidNotJudged
if err != nil && !errors.As(err, &notJudged) {
return nil, err
}
// Every way found, and — when the setuid search has no result yet — that it is not judged.
return append(ways, more...), err
}
// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as
// the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member,
// the others' for anybody else.
func Readable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o400 != 0
case gids[m.GID]:
return m.Perm&0o040 != 0
default:
return m.Perm&0o004 != 0
}
}
// SudoRules is the rules `sudo -l -U <account>` lists, from what it printed and how it ended: none when
// the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may
// run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at
// all, so a rule that asks for a password the account was never given is still a rule somebody can give
// it one for. Output that says neither is an error: unread is never none.
func SudoRules(out string, err error) ([]string, error) {
if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) {
return nil, nil
}
text := out
if err != nil {
text += "\n" + err.Error()
}
if strings.Contains(text, "is not allowed to run sudo") {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("sudo did not list the account's rules: %w", err)
}
var rules []string
listing := false
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "may run the following commands") {
listing = true
continue
}
if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" {
rules = append(rules, strings.TrimSpace(line))
}
}
if !listing {
return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out))
}
return rules, nil
}
func statOf(path string) (FileMode, error) {
info, err := os.Stat(path)
if err != nil {
return FileMode{}, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
}
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil
}
// lstatOf is a path's own owner and mode, its type bits kept.
func lstatOf(path string) (FileMode, error) {
info, err := os.Lstat(path)
if err != nil {
return FileMode{}, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
}
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode()}, nil
}
// InDatabase is `id -nG`: every group the user database lists the account in.
func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) {
out, err := e.Run(ctx, "id", "-nG", account)
if err != nil {
return nil, err
}
return strings.Fields(out), nil
}
// Session reads the account's own manager, user@<uid>.service, from the machine's manager — never from
// the account's, which asking would start — and the groups its process holds, from its status file.
func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) {
passwd, err := e.Run(ctx, "getent", "passwd", account)
if err != nil {
return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
fields := strings.Split(strings.TrimSpace(passwd), ":")
if len(fields) < 7 || fields[2] == "" {
return Session{}, fmt.Errorf("the user database gave no number for %q", account)
}
shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service")
if err != nil {
return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err)
}
pid := strings.TrimSpace(shown)
if pid == "" || pid == "0" {
return Session{}, nil
}
held, err := e.heldBy(pid)
if err != nil {
return Session{}, err
}
s := Session{Running: true, Has: map[string]bool{}}
for _, g := range groups {
entry, err := e.Run(ctx, "getent", "group", g)
if err != nil {
return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err)
}
parts := strings.Split(strings.TrimSpace(entry), ":")
if len(parts) < 3 {
return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g)
}
s.Has[g] = held[parts[2]]
}
return s, nil
}
// heldBy is every group id a process holds, from the Groups line of its status file.
func (e Exec) heldBy(pid string) (map[string]bool, error) {
proc := e.Proc
if proc == "" {
proc = "/proc"
}
raw, err := os.ReadFile(filepath.Join(proc, pid, "status"))
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid)
}
if err != nil {
return nil, err
}
// Its supplementary groups, and its own group, which the supplementary list need not repeat.
held := map[string]bool{}
named := false
for _, line := range strings.Split(string(raw), "\n") {
if rest, ok := strings.CutPrefix(line, "Groups:"); ok {
named = true
for _, gid := range strings.Fields(rest) {
held[gid] = true
}
}
if rest, ok := strings.CutPrefix(line, "Gid:"); ok {
if f := strings.Fields(rest); len(f) > 0 {
held[f[0]] = true
}
}
}
if !named {
return nil, fmt.Errorf("process %s's status names no groups", pid)
}
return held, nil
}
+78
View File
@@ -0,0 +1,78 @@
//go:build linux
package accounts
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
// ioprioIdle is the idle I/O class (IOPRIO_CLASS_IDLE, 3, shifted by IOPRIO_CLASS_SHIFT, 13): the process gets
// the disk only when nothing else asks for it, where the disk's scheduler honours classes (BFQ does).
const ioprioIdle = 3 << 13
// Idle runs a command as CLocale does, at the lowest priority the machine has (novox/hq issue 361): CPU nice
// 19 and the idle I/O class, set on the process as soon as it has started — so the search for setuid programs,
// which walks every file and runs as long as that takes, yields to everything else. It dies with the engine
// (the parent-death signal), so a restarted engine never leaves a walk running beside its own. A priority it
// could not lower is a failure: the command is killed rather than walk the disks at full priority.
func Idle(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
// The parent-death signal follows the thread that started the child, not the process: held on this
// thread until the child has ended, so the runtime retiring a thread never kills a walk midway.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
pid := cmd.Process.Pid
if err := lower(pid); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return "", fmt.Errorf("%s: its priority could not be lowered, so it was stopped: %w", name, err)
}
err := cmd.Wait()
out := stdout.String()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return out, fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
}
return out, fmt.Errorf("%s: %w", name, err)
}
return out, nil
}
// lower sets a process's CPU nice to 19 and its I/O class to idle.
func lower(pid int) error {
if err := unix.Setpriority(unix.PRIO_PROCESS, pid, 19); err != nil {
return fmt.Errorf("nice: %w", err)
}
if _, _, errno := unix.Syscall(unix.SYS_IOPRIO_SET, 1 /* IOPRIO_WHO_PROCESS */, uintptr(pid), ioprioIdle); errno != 0 {
return fmt.Errorf("I/O class: %w", errno)
}
return nil
}
// LowerThisThread puts the calling goroutine on a thread of its own at nice 19 and the idle I/O class, for the
// walk for setuid programs, which runs in the engine (novox/hq issue 361): on Linux both are a thread's, so the
// engine's other work keeps its priority. The goroutine is locked to the thread and never unlocked, so the
// runtime ends the thread with it rather than hand a lowered thread to other work; a command it starts inherits
// the priority too.
func LowerThisThread() error {
runtime.LockOSThread()
return lower(unix.Gettid())
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package accounts
import "context"
// Idle is CLocale where the machine offers no idle class this engine sets (novox/hq issue 361).
func Idle(ctx context.Context, name string, args ...string) (string, error) {
return CLocale(ctx, name, args...)
}
// LowerThisThread is nothing where the machine offers no per-thread priority this engine sets.
func LowerThisThread() error { return nil }
+302
View File
@@ -0,0 +1,302 @@
package accounts
import (
"context"
"errors"
"fmt"
"io/fs"
"os/exec"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each
// way to root is found and said, none is healthy whether or not anybody is logged in, an unanswered question
// is unknown, and the judge only reads.
// agentMachine is a fake machine for the escalation question: the account's uid, its groups by name and
// number, what sudo lists, and the secrets' owners and modes.
type agentMachine struct {
uid string
groups string
gids string
sudo string
sudoErr error
files map[string]FileMode
failsID bool
asked []string
// texts are files' contents (doas's, polkit's); dirs a directory's files by name; acls a file's ACL;
// setuid what the walk finds, one per line, and packaged the paths a package owns.
texts map[string]string
dirs map[string][]string
acls map[string][]ACLEntry
setuid string
findErr error
packaged map[string]bool
// proc is /proc's files, aSafeProc unless a test changes them; findArgs sees the filesystems walked.
proc map[string]string
findArgs func([]string)
// pacmanCalls counts the package manager's calls: one for every path found.
pacmanCalls int
// nodes are paths' own owners and modes (Lstat); any other is root's, a directory searchable by all, or a
// file the walk found, setuid and executable by all.
nodes map[string]FileMode
}
func (m *agentMachine) lstat(path string) (FileMode, error) {
if n, ok := m.nodes[path]; ok {
return n, nil
}
if strings.Contains(m.setuid, path+"\n") {
return FileMode{Perm: fs.ModeSetuid | 0o755}, nil
}
return FileMode{Perm: fs.ModeDir | 0o755}, nil
}
func (m *agentMachine) readFile(path string) ([]byte, error) {
if t, ok := m.texts[path]; ok {
return []byte(t), nil
}
if t, ok := m.proc[path]; ok {
return []byte(t), nil
}
return nil, fs.ErrNotExist
}
// aSafeProc is what a machine with nothing to say has in /proc: links protected, nothing on the runtimes'
// ports, one root filesystem.
func aSafeProc() map[string]string {
return map[string]string{
"/proc/sys/fs/protected_hardlinks": "1\n",
"/proc/sys/fs/protected_symlinks": "1\n",
"/proc/net/tcp": " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" +
" 0: 0100007F:1092 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n",
"/proc/mounts": "/dev/sda2 / ext4 rw,relatime 0 0\nproc /proc proc rw,nosuid 0 0\ntmpfs /tmp tmpfs rw,nosuid 0 0\n",
}
}
func (m *agentMachine) readDir(path string) ([]fs.DirEntry, error) {
names, ok := m.dirs[path]
if !ok {
return nil, fs.ErrNotExist
}
var out []fs.DirEntry
for _, n := range names {
out = append(out, fakeEntry(n))
}
return out, nil
}
type fakeEntry string
func (f fakeEntry) Name() string { return string(f) }
func (f fakeEntry) IsDir() bool { return false }
func (f fakeEntry) Type() fs.FileMode { return 0 }
func (f fakeEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrNotExist }
func (m *agentMachine) acl(path string) ([]ACLEntry, error) {
if _, ok := m.files[path]; !ok {
return nil, fs.ErrNotExist
}
return m.acls[path], nil
}
// walk is the walk for setuid programs on this machine: m.setuid found, then m.findErr.
func (m *agentMachine) walk(_ context.Context, p *SetuidProgress, _ func()) error {
m.asked = append(m.asked, "walk "+strings.Join(p.Mounts, " "))
if m.findArgs != nil {
m.findArgs(p.Mounts)
}
for _, l := range strings.Split(m.setuid, "\n") {
if l = strings.TrimSpace(l); l != "" {
p.Found = append(p.Found, l)
}
}
return m.findErr
}
func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
m.asked = append(m.asked, line)
switch {
case m.failsID && name == "id":
return "", errors.New("id exited 1: no such user")
case line == "id -u agent":
return m.uid + "\n", nil
case line == "id -nG agent":
return m.groups + "\n", nil
case line == "id -G agent":
return m.gids + "\n", nil
case line == "sudo -l -U agent":
return m.sudo, m.sudoErr
case name == "pacman" && len(args) >= 2 && args[0] == "-Qo":
m.pacmanCalls++
var out, errs []string
for _, p := range args[1:] {
if m.packaged[p] {
out = append(out, p+" is owned by somepackage 1.0-1")
} else {
errs = append(errs, "error: No package owns "+p)
}
}
said := strings.Join(out, "\n")
if len(errs) > 0 {
return said, errors.New("pacman exited 1: " + strings.Join(errs, "\n"))
}
return said, nil
}
return "", errors.New("not a command the judge may run: " + line)
}
func (m *agentMachine) stat(path string) (FileMode, error) {
if f, ok := m.files[path]; ok {
return f, nil
}
return FileMode{}, fs.ErrNotExist
}
const notAllowed = "User agent is not allowed to run sudo on box.\n"
var agent = Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true,
Secrets: []string{"/var/lib/mesh/node-tools/broker"}}
func clean() *agentMachine {
return &agentMachine{uid: "1600", groups: "agent", gids: "1600", sudo: notAllowed,
files: map[string]FileMode{"/var/lib/mesh/node-tools/broker": {UID: 1500, GID: 1500, Perm: 0o600}},
proc: aSafeProc()}
}
func lookAgent(t *testing.T, m *agentMachine) Verdict {
t.Helper()
j := New(Exec{Run: m.run, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl, Lstat: m.lstat})
j.Set([]Account{agent})
st, _ := j.Look(t.Context())
if len(st.Accounts) != 1 {
t.Fatalf("the statement: %+v", st)
}
for _, a := range m.asked {
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "walk /") &&
!strings.HasPrefix(a, "pacman -Qo ") {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
return st.Accounts[0]
}
func TestAnAgentAccountWithNoWayToRootIsHealthyWithNobodyLoggedIn(t *testing.T) {
if v := lookAgent(t, clean()); v.State != Healthy || v.Reason != "" || !v.Root {
t.Fatalf("no way to root: %+v", v)
}
}
func TestEachWayToRootIsSaid(t *testing.T) {
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"uid 0", func(m *agentMachine) { m.uid = "0" }, "its uid is 0"},
{"docker", func(m *agentMachine) { m.groups = "agent docker" }, "in the group docker, which grants root"},
{"wheel", func(m *agentMachine) { m.groups = "agent wheel" }, "in the group wheel, which grants root"},
{"sudo", func(m *agentMachine) {
m.sudo = "Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) NOPASSWD: ALL\n"
}, "sudo grants it: (ALL) NOPASSWD: ALL"},
{"secret by others", func(m *agentMachine) {
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o644}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
{"secret by group", func(m *agentMachine) {
m.gids = "1600 1500"
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o640}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestEveryWayIsSaidAtOnce(t *testing.T) {
m := clean()
m.groups = "agent docker"
m.sudo = "User agent may run the following commands on box:\n (ALL) ALL\n"
v := lookAgent(t, m)
if !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, "(ALL) ALL") {
t.Fatalf("both ways: %+v", v)
}
}
func TestAnUnansweredQuestionIsUnknownNeverHealthy(t *testing.T) {
m := clean()
m.failsID = true
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread database: %+v", v)
}
m = clean()
m.sudo = "something sudo never says\n"
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread sudo: %+v", v)
}
}
func TestASecretNotThereYetIsNoWay(t *testing.T) {
m := clean()
delete(m.files, "/var/lib/mesh/node-tools/broker")
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("no secret placed: %+v", v)
}
}
func TestSudoRules(t *testing.T) {
none := []struct {
out string
err error
}{
{notAllowed, nil},
{notAllowed, errors.New("sudo exited 1: ")},
{"", fmt.Errorf("sudo: %w", exec.ErrNotFound)},
}
for _, c := range none {
if rules, err := SudoRules(c.out, c.err); err != nil || len(rules) != 0 {
t.Errorf("%q, %v: rules %v, err %v", c.out, c.err, rules, err)
}
}
rules, err := SudoRules("Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) /usr/bin/systemctl\n (root) NOPASSWD: /usr/bin/true\n", nil)
if err != nil || len(rules) != 2 || rules[0] != "(ALL) /usr/bin/systemctl" {
t.Fatalf("two rules: %v, %v", rules, err)
}
if _, err := SudoRules("", errors.New("sudo exited 1: sudo: unable to resolve host")); err == nil {
t.Error("a failing sudo that said neither was read as no rules")
}
}
func TestReadable(t *testing.T) {
m := FileMode{UID: 1500, GID: 1500, Perm: 0o600}
if Readable(m, 1600, map[int]bool{1600: true}) || !Readable(m, 1500, nil) || !Readable(m, 0, nil) {
t.Fatal("owner bits")
}
}
func TestOfJudgesARootNeverAccountWithNoGroupsAndItsSecrets(t *testing.T) {
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"claude-code.agent","type":"user","name":"agent","root":"never"},
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
{"id":"node-tools.need-broker","type":"file","path":"/var/lib/mesh/broker","sealed":"x","owner":"operator"},
{"id":"claude-code.own","type":"file","path":"/home/agent/own","sealed":"x","owner":"agent"},
{"id":"claude-code.plain","type":"file","path":"/etc/plain","content":"x"}
]}`))
if err != nil {
t.Fatal(err)
}
got := Of(d, nil)
if len(got) != 1 || got[0].ID != "claude-code.agent" || !got[0].Root ||
len(got[0].Secrets) != 1 || got[0].Secrets[0] != "/var/lib/mesh/broker" {
t.Fatalf("judged: %+v", got)
}
}
+635
View File
@@ -0,0 +1,635 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// SetuidCache keeps the search for setuid programs, which walks every local filesystem, and runs it in the
// background (novox/hq ADR 0266, issue 361): a look never waits for it, and the search itself is never cut
// short.
//
// - **A verdict comes only from a complete search, while it is fresh**: one that walked everything and had the
// package manager answer for every path found, started less than FreshFor ago. Before the first completes,
// or once the last is older, the answer is "not judged yet" (ReasonPending) while a search runs, and "not
// judged" (ReasonIncomplete) after one failed. Never "none found" from a search that did not finish.
// - **It runs to completion**, once at a time, with no bound, on a thread of its own at idle priority (Lower):
// a fresh one when the last complete one is Every old, when SearchAgain asks (an apply changed what root
// controls), or when a filesystem appeared that the last one did not walk. One that fails is tried again
// after a back-off that doubles from FailedBackoff up to MaxBackoff.
// - **Resumable**: the walk's progress is kept in ProgressPath every CheckpointEvery, and a search started
// again — after an engine restart, an update, a reboot or a failure — carries on from it while it is
// younger than FreshFor. A search SearchAgain asked for never resumes an older walk: it starts from nothing.
// - **Kept** in Path, beside the engine's other state, with when it started and finished: an engine restart
// serves the last complete search rather than judging nothing until a new walk ends.
//
// Why a complete search stays sound for a day and more, and a resumed walk is sound, is said in the rootsearch
// package and setuid_walk.go: only root makes a setuid-root file, never the agent account.
type SetuidCache struct {
// Every and FreshFor are rootsearch's when zero.
Every, FreshFor time.Duration
// Path is the file the last complete search is kept in, and ProgressPath the walk under way; empty keeps
// either in memory only. The engine's own state directory, which only root writes: a file there that is
// not a regular file the engine's account owns and alone may write is not read (keptFile).
Path, ProgressPath string
// Lower is called on the search's own goroutine before it walks (LowerThisThread on a serving engine); a
// priority it cannot lower fails the search rather than walk the disks at full priority. Nil lowers nothing.
Lower func() error
// Say is where a search's end and a kept file refused are said; nil says nothing.
Say func(string)
mu sync.Mutex
wg sync.WaitGroup
loaded bool
last *setuidResult // the last complete search
unfinished *SetuidProgress // a walk that did not finish, to resume
progress *SearchProgress // how far the running search got, as of its last checkpoint
running bool
gen int // counts SearchNow: a search started under an older one is thrown away
cancel context.CancelFunc // ends the running search's walk
startedAt time.Time // when the search running, or the last one, was started (a resumed one: its start)
attemptAt time.Time // when the running or last search was started or resumed
endedAt time.Time // when the last search ended, on the same clock
again bool // asked to search again since the running or last one started
failedAt time.Time
failed error
backoff time.Duration
starts int // how many searches were started or resumed, for a test
}
// setuidResult is one complete search, as kept.
type setuidResult struct {
// Started and Finished are on the caller's clock: Finished is Started plus how long the search took.
Started time.Time `json:"started"`
Finished time.Time `json:"finished"`
// Mounts are the filesystems it walked; Found the setuid-root programs no package owns.
Mounts Paths `json:"mounts"`
Found Paths `json:"found"`
}
const (
// FailedBackoff is the first wait after a search failed, doubling to MaxBackoff.
FailedBackoff = time.Hour
MaxBackoff = 6 * time.Hour
// AgainAfter is the least time between the starts of two searches that SearchAgain or a new filesystem
// asked for: a run of applies does not walk the disks back to back.
AgainAfter = time.Hour
// clockSkew is how far in the future a kept search may say it started and still judge: a clock set back
// further makes it no verdict, never a fresh one.
clockSkew = 5 * time.Minute
// SetuidSearchFile and SetuidProgressFile are the files' names beside the engine's state: the last
// complete search, and the walk under way.
SetuidSearchFile = "setuid-search.json"
SetuidProgressFile = "setuid-search-progress.json"
)
// SetuidNotJudged is the search's answer when no complete search judges: none has finished yet, the last one is
// older than FreshFor, or every one since failed. The judge says the way is not judged, never that there is none.
type SetuidNotJudged struct {
// Pending is true while a search runs; false after one failed.
Pending bool
Since time.Time
Err error
// Last is when the last complete search started, when there is one too old to judge.
Last time.Time
// Progress is how far the running search got, as of its last checkpoint: nil when none runs.
Progress *SearchProgress
}
// SearchProgress is how far a running search got, as of its last checkpoint, for anybody to read (novox/hq
// issue 361): counts, times and mount points only. **Never a path the walk visited or found**: the cursor and
// the files found are under accounts' homes as often as not, and stay in the engine's state and journal.
type SearchProgress struct {
// Started is when the search began, on the cache's clock.
Started time.Time
// Filesystems is how many it walks; Done how many it has walked to their end; Walking the mount point of
// the one it walks now, empty when every one is walked.
Filesystems, Done int
Walking string
// Visited is how many entries it has gone past; Found how many setuid- or setgid-root files it found so
// far, before the package manager is asked about them.
Visited int64
Found int
// AtLeast says Visited is a lower bound: the walk was begun by an engine that did not count.
AtLeast bool
// Kept is when its progress was last kept, zero before the first time.
Kept time.Time
// Finished is every filesystem walked to its end, in order, and how long its walk took.
Finished []SearchedFilesystem
}
// SearchedFilesystem is one filesystem a search walked to its end. Took is negative when not known: walked by
// an engine before it counted.
type SearchedFilesystem struct {
Mount string
Took time.Duration
}
// progressOf is p as anybody may read it. Counts and mount points, no I/O.
func progressOf(p *SetuidProgress, kept time.Time) *SearchProgress {
sp := &SearchProgress{Started: p.Started, Filesystems: len(p.Mounts), Done: len(p.Done), Visited: p.Visited,
Found: len(p.Found), Kept: kept, AtLeast: !p.Counted}
for i, m := range p.Done {
took := time.Duration(-1)
if i < len(p.Took) {
took = p.Took[i]
}
sp.Finished = append(sp.Finished, SearchedFilesystem{Mount: m, Took: took})
}
for _, m := range p.Mounts {
if !slices.Contains(p.Done, m) {
sp.Walking = m
break
}
}
return sp
}
// Words are the progress in one line, as the verdict says it beside "not judged yet".
func (sp *SearchProgress) Words() string {
if sp == nil {
return ""
}
s := fmt.Sprintf("progress: %d of %d filesystems walked", sp.Done, sp.Filesystems)
if sp.Walking != "" {
s += ", walking " + sp.Walking
}
atLeast := ""
if sp.AtLeast {
atLeast = "at least "
}
s += fmt.Sprintf(", %s%d entries visited, %d setuid- or setgid-root files found so far", atLeast, sp.Visited, sp.Found)
if sp.Kept.IsZero() {
s += ", not kept yet"
} else {
s += ", kept " + sp.Kept.Local().Format("2006-01-02 15:04:05")
}
var took []string
for _, f := range sp.Finished {
if f.Took < 0 {
took = append(took, f.Mount+" in a time not known")
} else {
took = append(took, f.Mount+" in "+f.Took.Round(time.Second).String())
}
}
if len(took) > 0 {
s += "; walked " + strings.Join(took, ", ")
}
return s
}
func (e *SetuidNotJudged) Error() string {
stale := ""
if !e.Last.IsZero() {
stale = fmt.Sprintf("; the last complete one, started %s, is too old to judge", e.Last.Local().Format("2006-01-02 15:04"))
}
if e.Pending {
return fmt.Sprintf("%s: the search for setuid programs, started %s, has not finished yet%s", ReasonPending,
e.Since.Local().Format("2006-01-02 15:04"), stale)
}
return fmt.Sprintf("%s: the search for setuid programs did not complete (last tried %s: %v); it is tried "+
"again later%s", ReasonIncomplete, e.Since.Local().Format("2006-01-02 15:04"), e.Err, stale)
}
// The reasons of a root verdict the setuid search could not answer.
const (
ReasonPending = rootsearch.ReasonPending
ReasonIncomplete = rootsearch.ReasonIncomplete
)
func (c *SetuidCache) every() time.Duration {
if c.Every > 0 {
return c.Every
}
return rootsearch.Every
}
func (c *SetuidCache) freshFor() time.Duration {
if c.FreshFor > 0 {
return c.FreshFor
}
return rootsearch.FreshFor
}
// SearchAgain asks for a new search at the next look: an apply changed what root controls (an account, a group,
// a sudo rule, a package, an action). The last complete search keeps judging until it is too old: only root acts
// through an apply, and the agent account cannot make a setuid-root file. Nil c is nothing to ask.
func (c *SetuidCache) SearchAgain() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.again = true
// The walk under way, or kept, started before root acted: never carried on, even by an engine that
// restarts before the next search starts (the review of 2026-10-10). It keeps no progress from now on.
c.unfinished = nil
c.forgetProgress()
}
// SearchNow throws away the last complete search, kept or in memory, and any walk under way or kept, and has
// the next look start a full search at once, past AgainAfter and any back-off (novox/hq issue 361): the
// operator changed by hand what the last one found, which no apply says. Until the new one completes the
// verdict is "not judged yet". Asked at the controller's terminal alone (`nox node setuid-search <node>`): a
// search can never make a machine free wrongly, but asked again and again it would keep a machine unjudged
// and its disks busy. by is who asked, for the journal. Nil c has nothing to throw away.
func (c *SetuidCache) SearchNow(by string) string {
if c == nil {
return "this engine keeps no search for setuid programs"
}
c.mu.Lock()
defer c.mu.Unlock()
c.loaded = true // nothing kept is read after this: it is thrown away
what := "no complete search was kept"
if c.last != nil {
what = "the complete search started " + c.last.Started.Local().Format("2006-01-02 15:04") + " is thrown away"
}
if c.running {
what += ", and the search running since " + c.startedAt.Local().Format("2006-01-02 15:04") + " is ended"
if c.cancel != nil {
c.cancel()
}
}
c.gen++
c.last, c.unfinished, c.progress = nil, nil, nil
c.failed, c.failedAt, c.backoff, c.again = nil, time.Time{}, 0, false
c.forgetProgress()
if c.Path != "" {
if err := os.Remove(c.Path); err != nil && !errors.Is(err, fs.ErrNotExist) {
c.say("the search for setuid programs could not forget the complete search it kept: " + err.Error())
}
}
said := "a new search for setuid programs starts at the next look, asked by " + by + ": " + what +
"; until it completes, whether an account can become root through one is not judged"
c.say(said)
return said
}
// SetuidSearch is one search: it carries p on to its end — walking what p has not walked, calling checkpoint
// as it goes so its progress can be kept — and answers the setuid-root programs no package owns.
type SetuidSearch func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error)
// Look answers the setuid-root programs no package owns as the last complete search found them, and when it
// started — while it is fresh — and starts a search in the background when one is due; it never waits for one.
// mounts are the filesystems a new search walks. Nil c searches in place, for a caller that wants to.
//
// **One clock: the caller's.** A search's time is the `now` it was started at, never the wall clock read when it
// finished: due-ness compares a later `now` with it, and mixing the two clocks made a search never due again
// whenever the caller's clock ran behind the wall clock (found while checking issue 356). Measured from the
// start, a result reads at most one search older than it is, which errs towards not judging.
func (c *SetuidCache) Look(now time.Time, mounts []string, search SetuidSearch) ([]string, time.Time, error) {
if c == nil {
p := &SetuidProgress{Started: now, Mounts: slices.Clone(mounts), Counted: true}
found, err := search(context.Background(), p, func() {})
return found, now, err
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.loaded {
c.loaded = true
c.last = c.loadResult()
c.unfinished = c.loadProgress()
}
var age time.Duration
if c.last != nil {
age = now.Sub(c.last.Started)
}
// Fresh only while it walked every filesystem a search would walk now: one mounted since is not judged
// by a search that never saw it.
fresh := c.last != nil && age > -clockSkew && age < c.freshFor() && covers(c.last.Mounts, mounts)
// Asked for (an apply, a new filesystem): at most once an AgainAfter, counted from when the last search
// ended, so a walk of hours is not followed by another at once, apply after apply.
gap := now.Sub(c.attemptAt)
if !c.endedAt.IsZero() {
gap = now.Sub(c.endedAt)
}
due := !c.running && (c.failed == nil || now.Sub(c.failedAt) >= c.backoff) &&
(!fresh || age >= c.every() || (c.again && (c.attemptAt.IsZero() || gap >= AgainAfter)))
if due {
c.start(now, mounts, search)
}
switch {
case fresh:
return slices.Clone(c.last.Found), c.last.Started, nil
case c.running:
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt, Last: c.lastStarted(),
Progress: c.progress}
case c.failed != nil:
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed, Last: c.lastStarted()}
default:
// Not fresh, not running, never failed: cannot be, as not fresh is due. Never a verdict.
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: now, Last: c.lastStarted()}
}
}
func (c *SetuidCache) lastStarted() time.Time {
if c.last == nil {
return time.Time{}
}
return c.last.Started
}
// resumable is the walk that did not finish, when a search may carry it on: younger than FreshFor, and no
// SearchAgain since — an apply that moved root's things wants a walk that starts after it. Anything else is
// thrown away. c.mu held.
func (c *SetuidCache) resumable(now time.Time) *SetuidProgress {
p := c.unfinished
c.unfinished = nil
if p == nil {
return nil
}
age := now.Sub(p.Started)
if c.again || age <= -clockSkew || age >= c.freshFor() {
c.forgetProgress()
return nil
}
return p
}
// start runs one search in the background, with no bound: a walk that did not finish carried on, or a new one.
// c.mu held.
func (c *SetuidCache) start(now time.Time, mounts []string, search SetuidSearch) {
p := c.resumable(now)
resumed := p != nil
if p == nil {
p = &SetuidProgress{ID: fmt.Sprintf("%x", now.UnixNano()), Started: now, Mounts: slices.Clone(mounts),
Cursor: map[string]string{}, Counted: true}
}
c.running, c.startedAt, c.attemptAt, c.again = true, p.Started, now, false
c.starts++
// What a look says until the first checkpoint: where the kept walk was, or nothing walked.
kept := p.Kept
c.progress = progressOf(p, kept)
if resumed {
c.say(fmt.Sprintf("the search for setuid programs started %s carries on where it was (%d of %d "+
"filesystems walked)", p.Started.Local().Format("2006-01-02 15:04"), len(p.Done), len(p.Mounts)))
}
began := time.Now()
gen := c.gen
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.wg.Add(1)
go func() {
defer c.wg.Done()
var found []string
var err error
if c.Lower != nil {
// The goroutine stays on its lowered thread until it ends, and the thread ends with it.
if lerr := c.Lower(); lerr != nil {
err = fmt.Errorf("its priority could not be lowered, so it did not walk: %w", lerr)
}
}
if err == nil {
unkept := false
found, err = search(ctx, p, func() {
c.mu.Lock()
superseded := c.again || c.gen != gen
c.mu.Unlock()
if !superseded { // root acted since this walk started: its progress is not to be carried on
at := time.Now()
p.Kept = at
kerr := c.keepProgress(p)
// Superseded while it wrote (SearchAgain, SearchNow), after they forgot the kept walk: forgotten
// again here, so no engine restarted before this walk ends carries it on (the review of
// 2026-10-10). Written outside the lock, so a look never waits on the disk.
c.mu.Lock()
if c.again || c.gen != gen {
c.forgetProgress()
}
c.mu.Unlock()
if kerr != nil {
p.Kept = kept // not kept: the file still says the time before
if !unkept {
unkept = true
c.say("the search for setuid programs could not keep how far it got, and starts over " +
"if the engine stops: " + kerr.Error())
}
} else {
kept = at
}
}
// What a look says from now: computed from p here, on the walk's own goroutine, never read
// by a look while the walk changes it.
sp := progressOf(p, kept)
c.mu.Lock()
if c.gen == gen {
c.progress = sp
}
c.mu.Unlock()
})
}
took := time.Since(began)
cancel()
c.mu.Lock()
defer c.mu.Unlock()
c.running, c.progress, c.cancel = false, nil, nil
c.endedAt = now.Add(took)
if c.gen != gen {
// Thrown away by SearchNow while it ran: neither a result nor a failure, and nothing to carry on. The
// next look starts the search asked for.
c.endedAt = time.Time{}
// A checkpoint that read its generation just before SearchNow may have kept this walk again.
c.forgetProgress()
c.say(fmt.Sprintf("the search for setuid programs started %s was ended, as asked (%s this time)",
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second)))
return
}
if err != nil {
// The back-off counts from when it failed.
c.failed, c.failedAt = err, c.endedAt
c.backoff = min(max(2*c.backoff, FailedBackoff), MaxBackoff)
if !c.again { // superseded by an ask to search again: never carried on
c.unfinished = p
}
c.say(fmt.Sprintf("the search for setuid programs started %s did not complete (%s this time): %v",
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second), err))
return
}
r := &setuidResult{Started: p.Started, Finished: now.Add(took), Mounts: p.Mounts, Found: found}
c.last, c.failed, c.backoff = r, nil, 0
if err := c.keepResult(r); err != nil {
c.say("the search for setuid programs could not be kept, and judges from memory until the engine " +
"restarts: " + err.Error())
}
c.forgetProgress()
// The paths in the engine's journal alone: the verdict says one in a home by count.
named := ""
if len(found) > 0 {
named = ": " + strings.Join(found, ", ")
}
c.say(fmt.Sprintf("the search for setuid programs started %s completed (%s this time): %d no package "+
"owns%s", p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second), len(found), named))
}()
}
func (c *SetuidCache) say(s string) {
if c.Say != nil {
c.Say(s)
}
}
// covers says every filesystem a search would walk now was walked.
func covers(walked, now []string) bool {
for _, m := range now {
if !slices.Contains(walked, m) {
return false
}
}
return true
}
// Wait is for a test: until the search running, if any, has finished.
func (c *SetuidCache) Wait() { c.wg.Wait() }
func (c *SetuidCache) keepResult(r *setuidResult) error { return keepFile(c.Path, r) }
func (c *SetuidCache) keepProgress(p *SetuidProgress) error { return keepFile(c.ProgressPath, p) }
// forgetProgress removes the kept walk: finished, or thrown away.
func (c *SetuidCache) forgetProgress() {
if c.ProgressPath == "" {
return
}
if err := os.Remove(c.ProgressPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
c.say("the search for setuid programs could not forget the walk it kept: " + err.Error())
}
}
// keepFile writes v to path: a new file beside it, readable by its owner alone, renamed over it — a link at
// path is replaced, never followed. Empty path keeps nothing.
func keepFile(path string, v any) error {
if path == "" {
return nil
}
raw, err := json.Marshal(v)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
}
// loadResult reads the kept complete search, or nil: none kept, or one that cannot be believed, said why.
func (c *SetuidCache) loadResult() *setuidResult {
var r setuidResult
if !c.loadFile(c.Path, "complete search", &r) {
return nil
}
if r.Started.IsZero() || r.Finished.Before(r.Started) || len(r.Mounts) == 0 {
c.say("the kept search for setuid programs says no complete search, and a new one runs")
return nil
}
return &r
}
// loadProgress reads the kept walk, or nil.
func (c *SetuidCache) loadProgress() *SetuidProgress {
var p SetuidProgress
if !c.loadFile(c.ProgressPath, "walk under way", &p) {
return nil
}
if p.Started.IsZero() || len(p.Mounts) == 0 {
c.say("the kept walk for setuid programs says no walk, and it starts over")
c.forgetProgress()
return nil
}
if p.Cursor == nil {
p.Cursor = map[string]string{}
}
return &p
}
func (c *SetuidCache) loadFile(path, what string, v any) bool {
if path == "" {
return false
}
raw, err := keptFile(path)
if errors.Is(err, fs.ErrNotExist) {
return false
}
if err == nil {
if err = json.Unmarshal(raw, v); err != nil {
err = fmt.Errorf("%s is not JSON: %w", path, err)
}
}
if err != nil {
c.say(fmt.Sprintf("the kept %s for setuid programs is not read, and a new one runs: %v", what, err))
return false
}
return true
}
// keptFile reads a kept file, believing it only as a regular file, not followed through a link, owned by the
// engine's own account and writable by it alone, in a directory that is the same: anything else could have been
// written by somebody other than root, and a search it says is no search.
func keptFile(path string) ([]byte, error) {
if err := ownedAlone(filepath.Dir(path), true); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if err := ownedAloneInfo(path, info, false); err != nil {
return nil, err
}
return io.ReadAll(io.LimitReader(f, 64<<20))
}
func ownedAlone(path string, dir bool) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
return ownedAloneInfo(path, info, dir)
}
func ownedAloneInfo(path string, info fs.FileInfo, dir bool) error {
if dir && !info.IsDir() || !dir && !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a plain %s", path, map[bool]string{true: "directory", false: "file"}[dir])
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("%s's owner could not be read", path)
}
if int(st.Uid) != os.Geteuid() {
return fmt.Errorf("%s is owned by uid %d, not the engine's %d", path, st.Uid, os.Geteuid())
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("%s may be written by others than its owner (mode %o)", path, info.Mode().Perm())
}
return nil
}
+447
View File
@@ -0,0 +1,447 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// The search for setuid programs runs to completion and judges from its last complete result while it is fresh
// (novox/hq issue 361): never a bound that ends it, never a verdict from a search that did not finish or is too
// old, a new search after root acted, and the result kept beside the engine's state, believed only as root's.
var at = time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
var root = []string{"/"}
// gate is a search that waits until released; each search started hands its context on started. found and
// err are read when it is released.
type gate struct {
release chan struct{}
started chan context.Context
found []string
err error
}
func newGate() *gate {
return &gate{release: make(chan struct{}, 16), started: make(chan context.Context, 16)}
}
func (g *gate) search(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
g.started <- ctx
<-g.release
return g.found, g.err
}
func (g *gate) finish(c *SetuidCache) { g.release <- struct{}{}; c.Wait() }
// starts is how many searches c started.
func starts(c *SetuidCache) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.starts
}
func notJudged(t *testing.T, err error, pending bool) *SetuidNotJudged {
t.Helper()
var nj *SetuidNotJudged
if !errors.As(err, &nj) || nj.Pending != pending {
t.Fatalf("want not judged (pending %v), got %v", pending, err)
}
want := ReasonIncomplete
if pending {
want = ReasonPending
}
if !strings.HasPrefix(err.Error(), want) {
t.Fatalf("the words start %q: %v", want, err)
}
return nj
}
func TestTheSearchRunsToCompletionWithNoBound(t *testing.T) {
c := &SetuidCache{}
g := newGate()
if _, _, err := c.Look(at, root, g.search); err == nil {
t.Fatal("a look before the first search finished judged")
}
ctx := <-g.started
if _, ok := ctx.Deadline(); ok {
t.Fatal("the search was given a deadline: it must run until it has walked everything")
}
// Hours later, still running: still pending, never killed, never a second walk beside it.
_, _, err := c.Look(at.Add(5*time.Hour), root, g.search)
notJudged(t, err, true)
if starts(c) != 1 || ctx.Err() != nil {
t.Fatalf("a running search was started again or stopped: %d searches, %v", starts(c), ctx.Err())
}
g.found = []string{"/opt/x"}
g.finish(c)
found, when, err := c.Look(at.Add(5*time.Hour), root, g.search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("the complete search judges, from its start: %v %v %v", found, when, err)
}
}
func TestAFreshCompleteSearchJudgesUntilItIsDue(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every-time.Minute), root, g.search); err != nil {
t.Fatalf("a complete search within a day: %v", err)
}
if starts(c) != 1 {
t.Fatalf("searched again before it was due: %d", starts(c))
}
// Due: a new search starts, and the last complete one judges meanwhile.
if _, _, err := c.Look(at.Add(rootsearch.Every), root, g.search); err != nil {
t.Fatalf("while the daily search runs, the last complete one still judges: %v", err)
}
if starts(c) != 2 {
t.Fatalf("the daily search did not start: %d", starts(c))
}
g.finish(c)
}
func TestAStaleSearchIsNotJudgedNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.Look(at.Add(rootsearch.Every), root, g.search) // the daily one, which does not finish
_, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search)
nj := notJudged(t, err, true)
if !nj.Last.Equal(at) || !strings.Contains(err.Error(), "too old to judge") {
t.Fatalf("a stale search says when the last complete one started: %v", err)
}
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.FreshFor), root, g.search); err != nil {
t.Fatalf("once the new search completed it judges again: %v", err)
}
}
func TestAnIncompleteSearchIsNeverFree(t *testing.T) {
c := &SetuidCache{}
g := newGate()
g.err = errors.New("the search for setuid programs did not complete its walk")
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Minute), root, g.search)
notJudged(t, err, false)
if starts(c) != 1 {
t.Fatalf("tried again within its back-off: %d", starts(c))
}
c.Look(at.Add(FailedBackoff+time.Second), root, g.search)
if starts(c) != 2 {
t.Fatalf("not tried again after its back-off: %d", starts(c))
}
g.finish(c)
// A stale complete search and a failed new one: still not judged, never the stale one.
c = &SetuidCache{}
g = newGate()
c.Look(at, root, g.search)
g.finish(c)
g.err = errors.New("pacman could not say who owns the setuid programs found")
c.Look(at.Add(rootsearch.Every), root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(rootsearch.Every+time.Hour), root, g.search); err != nil {
t.Fatalf("a failed search leaves the fresh complete one judging: %v", err)
}
_, _, err = c.Look(at.Add(rootsearch.FreshFor), root, g.search)
if err == nil {
t.Fatal("a stale search judged after the newer one failed")
}
g.finish(c)
}
func TestAnApplyThatMovedRootSearchesAgain(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
c.SearchAgain()
if _, _, err := c.Look(at.Add(time.Minute), root, g.search); err != nil || starts(c) != 1 {
t.Fatalf("asked again within AgainAfter of the last search's end: %v, %d searches", err, starts(c))
}
if _, _, err := c.Look(at.Add(AgainAfter+time.Second), root, g.search); err != nil {
t.Fatalf("the last complete search judges while the new one runs: %v", err)
}
if starts(c) != 2 {
t.Fatalf("an apply that moved root did not search again: %d", starts(c))
}
// Asked again while one runs: one more after it, never two at once.
c.SearchAgain()
c.Look(at.Add(AgainAfter+time.Minute), root, g.search)
if starts(c) != 2 {
t.Fatalf("a second search started beside the running one: %d", starts(c))
}
g.finish(c) // ends, on the cache's clock, at about AgainAfter+1s
c.Look(at.Add(2*AgainAfter), root, g.search)
if starts(c) != 2 {
t.Fatal("a search started again less than AgainAfter after the last one ended")
}
c.Look(at.Add(2*AgainAfter+2*time.Second), root, g.search)
if starts(c) != 3 {
t.Fatalf("the ask made while a search ran was dropped: %d", starts(c))
}
g.finish(c)
var nilCache *SetuidCache
nilCache.SearchAgain()
}
// A filesystem the last complete search did not walk is not judged by it: not judged yet, and a search starts.
func TestAFilesystemTheSearchDidNotWalkIsNotJudgedByIt(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
_, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("a new filesystem was not searched: %d", starts(c))
}
g.finish(c)
if _, _, err := c.Look(at.Add(time.Hour), []string{"/", "/srv"}, g.search); err != nil {
t.Fatalf("once walked it judges: %v", err)
}
}
// An ask to search again forgets the walk under way, so an engine restarted before the next search does not
// carry on a walk that started before root acted.
func TestAnAskToSearchAgainIsNotLostToARestart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
first.SearchAgain() // an apply moved root's things; then the engine restarts
restarted := paths()
var with SetuidProgress
restarted.Look(at.Add(time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(time.Hour)) || len(with.Found) != 0 {
t.Fatalf("the walk from before the apply was carried on: %+v", with)
}
}
// A cursor whose name is not UTF-8 is kept as its bytes: JSON strings would change it, and the resumed walk
// would skip what it stood before.
func TestACursorOfAnyBytesIsKeptExactly(t *testing.T) {
p := &SetuidProgress{Started: at, Mounts: Paths{"/"}, Cursor: Cursors{"/": "/home/agent/\x80/x"},
Found: Paths{"/opt/\xff"}}
var back SetuidProgress
fromJSON(t, mustJSON(t, p), &back)
if back.Cursor["/"] != "/home/agent/\x80/x" || back.Found[0] != "/opt/\xff" {
t.Fatalf("kept as %q, %q", back.Cursor["/"], back.Found[0])
}
}
func TestTheCompleteSearchIsKeptAndReadAfterARestart(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
g.found = []string{"/opt/x"}
c.Look(at, root, g.search)
g.finish(c)
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("kept readable by its owner alone: %v %v", info, err)
}
restarted := &SetuidCache{Path: path}
found, when, err := restarted.Look(at.Add(time.Hour), root, newGate().search)
if err != nil || len(found) != 1 || !when.Equal(at) {
t.Fatalf("a restarted engine judges from the kept search: %v %v %v", found, when, err)
}
restarted.Wait()
// And not once it is too old.
again := &SetuidCache{Path: path}
g2 := newGate()
if _, _, err := again.Look(at.Add(rootsearch.FreshFor), root, g2.search); err == nil {
t.Fatal("a kept search too old to judge judged")
}
g2.finish(again)
}
func TestAKeptSearchNotRootsAloneIsNotRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, SetuidSearchFile)
c := &SetuidCache{Path: path}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
for name, spoil := range map[string]func() error{
"writable by others": func() error { return os.Chmod(path, 0o666) },
"a link": func() error {
other := filepath.Join(dir, "elsewhere.json")
if err := os.Rename(path, other); err != nil {
return err
}
return os.Symlink(other, path)
},
"its directory writable by others": func() error { return os.Chmod(dir, 0o777) },
} {
c = &SetuidCache{Path: path}
g = newGate()
c.Look(at, root, g.search)
g.finish(c) // a fresh, good one kept again
os.Chmod(dir, 0o700)
if err := spoil(); err != nil {
t.Fatal(err)
}
var said []string
r := &SetuidCache{Path: path, Say: func(s string) { said = append(said, s) }}
gr := newGate()
if _, _, err := r.Look(at.Add(time.Minute), root, gr.search); err == nil {
t.Fatalf("%s: a kept search that is not root's alone judged", name)
}
if len(said) == 0 {
t.Fatalf("%s: refusing the kept search said nothing", name)
}
gr.finish(r)
os.Chmod(dir, 0o700)
os.Remove(path)
}
}
func TestAKeptSearchFromTheFutureIsNotFresh(t *testing.T) {
c := &SetuidCache{}
g := newGate()
c.Look(at, root, g.search)
g.finish(c)
if _, _, err := c.Look(at.Add(-time.Hour), root, g.search); err == nil {
t.Fatal("a clock set back an hour read a search as fresh")
}
g.finish(c)
}
func TestTheIdleRunnerLowersItsCommand(t *testing.T) {
if _, err := exec.LookPath("nice"); err != nil {
t.Skip("no nice here")
}
out, err := Idle(t.Context(), "sh", "-c", "sleep 0.3; nice")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "19" {
t.Fatalf("the search's commands run at nice %q, want 19", strings.TrimSpace(out))
}
if _, err := Idle(t.Context(), "sh", "-c", "echo gone >&2; exit 1"); err == nil ||
!strings.HasPrefix(err.Error(), "sh exited 1: gone") {
t.Fatalf("its failure is worded as CLocale's: %v", err)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}
func fromJSON(t *testing.T, raw []byte, v any) {
t.Helper()
if err := json.Unmarshal(raw, v); err != nil {
t.Fatal(err)
}
}
// interrupted is a search that walks to a cursor, keeps it, and never returns: the engine stopped there.
func interrupted(seen chan<- *SetuidProgress) SetuidSearch {
return func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Found = append(p.Found, "/opt/a")
p.Cursor["/"] = "/opt/a"
checkpoint()
seen <- p
select {} // the engine stops here
}
}
func TestASearchInterruptedByARestartCarriesOnAndIsDatedByItsStart(t *testing.T) {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
first := paths()
first.Look(at, root, interrupted(seen))
<-seen
// The engine restarts two hours later.
restarted := paths()
var resumedWith SetuidProgress
done := make(chan struct{})
_, _, err := restarted.Look(at.Add(2*time.Hour), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
resumedWith = *p
close(done)
return p.Found, nil
})
notJudged(t, err, true)
<-done
restarted.Wait()
if !resumedWith.Started.Equal(at) || resumedWith.Cursor["/"] != "/opt/a" || len(resumedWith.Found) != 1 {
t.Fatalf("the restarted search did not carry on the walk: %+v", resumedWith)
}
found, when, err := restarted.Look(at.Add(2*time.Hour), root, newGate().search)
if err != nil || !when.Equal(at) || len(found) != 1 {
t.Fatalf("the complete search is dated by its start, not its resumption: %v %v %v", found, when, err)
}
if _, err := os.Stat(filepath.Join(dir, SetuidProgressFile)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the finished walk's progress is still kept: %v", err)
}
}
func TestAWalkTooOldOrAskedAgainStartsOver(t *testing.T) {
for name, c := range map[string]struct {
after time.Duration
again bool
}{"older than it may judge": {rootsearch.FreshFor, false}, "an apply asked for a new one": {time.Hour, true}} {
dir := t.TempDir()
paths := func() *SetuidCache {
return &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
}
seen := make(chan *SetuidProgress, 1)
paths().Look(at, root, interrupted(seen))
<-seen
restarted := paths()
if c.again {
restarted.SearchAgain()
}
var with SetuidProgress
restarted.Look(at.Add(c.after), root, func(ctx context.Context, p *SetuidProgress, _ func()) ([]string, error) {
with = *p
return nil, nil
})
restarted.Wait()
if !with.Started.Equal(at.Add(c.after)) || len(with.Cursor) != 0 || len(with.Found) != 0 {
t.Fatalf("%s: the old walk was carried on: %+v", name, with)
}
}
}
func TestASearchWhosePriorityCannotBeLoweredDoesNotWalk(t *testing.T) {
c := &SetuidCache{Lower: func() error { return errors.New("not permitted") }}
walked := false
c.Look(at, root, func(context.Context, *SetuidProgress, func()) ([]string, error) { walked = true; return nil, nil })
c.Wait()
_, _, err := c.Look(at.Add(time.Minute), root, newGate().search)
notJudged(t, err, false)
if walked {
t.Fatal("it walked at full priority")
}
}
+175
View File
@@ -0,0 +1,175 @@
package accounts
// The walk for setuid programs yields to a busy disk (novox/hq issue 433). It asks before each directory and each
// file it reads, so no burst between two looks is longer than PaceLook, however large a directory. The walk is put in the idle I/O class
// (idle_linux.go), but only a disk scheduler that schedules by class honours that (BFQ does). The control-node's
// disks run mq-deadline, which does not, and there the walk read at full priority: on 2026-10-11 it kept two
// spinning disks saturated for sixteen minutes, the bus whose store shares those disks stalled for half a minute
// twice, and the controller lost its lease each time. So the walk also paces itself by the machine's I/O pressure
// (PSI), which reads the same whatever the scheduler.
//
// It looks every PaceLook at the share of the time since its last look in which some task waited on I/O — from
// the kernel's cumulative stall counter, not its ten-second average, which moves only every two seconds and would
// let the walk read blind that long. Above PaceAbove it pauses, PaceStep at a time, until the share falls below.
//
// **The walk keeps at least a tenth of the time, whatever the pressure** (the review of #79): a run of pauses is
// at most PaceRatio times as long as the walk read since the one before. The control-node's own load sits near
// PaceAbove with no walk running, so a walk that paused for as long as the pressure stayed high would take days,
// outlive the age a search judges for, and leave the machine unjudged — the fault issue 361 repaired. With the
// floor a walk takes at most 1 + PaceRatio times as long as it would unpaced, and its bursts are PaceLook long.
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
)
const (
// PaceAbove is the share of the time since the last look, in percent, in which some task waited on I/O, above
// which the walk pauses.
PaceAbove = 10.0
// PaceLook is how long the walk reads between two looks at the pressure: the longest it reads blind.
PaceLook = 250 * time.Millisecond
// PaceStep is one pause, after which the pressure is looked at again.
PaceStep = 500 * time.Millisecond
// PaceRatio bounds a run of pauses to this many times the time the walk read since the run before: the walk
// keeps at least 1/(1+PaceRatio) of the time.
PaceRatio = 9
// PaceAtMost is the longest run of pauses, whatever the walk read before it.
PaceAtMost = 30 * time.Second
// ioPressureFile is where the kernel says the machine's I/O pressure.
ioPressureFile = "/proc/pressure/io"
)
// pacer is what the walk asks before it reads a directory. A nil pacer never pauses.
type pacer struct {
// path is the pressure file read, kept so a test can see the engine's own walker reads the machine's.
path string
// stalled is the cumulative time some task waited on I/O (PSI's "some total").
stalled func() (time.Duration, error)
sleep func(context.Context, time.Duration) error
now func() time.Time
say func(string)
lastLook time.Time // when the pressure was last looked at
readSince time.Time // when the walk last began to read, after a run of pauses or at its start
prevAt time.Time // the time of the last sample of the counter
prevTotal time.Duration // the counter at that sample
saidBlind bool
}
// newPacer paces by the machine's own I/O pressure, and says through say what it cannot do.
func newPacer(say func(string)) *pacer {
return &pacer{
path: ioPressureFile,
stalled: func() (time.Duration, error) { return readIOStalled(ioPressureFile) },
sleep: sleepCtx,
now: time.Now,
say: say,
}
}
// yield pauses while the machine's I/O pressure is above PaceAbove: looking at most every PaceLook, pausing
// PaceStep at a time, a run of pauses at most PaceRatio times what the walk read since the last run and never more
// than PaceAtMost. It ends early only with ctx, whose error ends the walk as incomplete. Pressure that cannot be
// read pauses nothing, and is said once.
func (pc *pacer) yield(ctx context.Context) error {
if pc == nil || pc.stalled == nil {
return nil
}
now := pc.now()
if pc.readSince.IsZero() {
pc.readSince = now
}
if !pc.lastLook.IsZero() && now.Sub(pc.lastLook) < PaceLook {
return nil
}
pc.lastLook = now
share, known, err := pc.sample(now)
if err != nil || !known || share <= PaceAbove {
return nil
}
budget := min(PaceAtMost, PaceRatio*now.Sub(pc.readSince))
for paused := time.Duration(0); paused < budget; {
step := min(PaceStep, budget-paused)
if err := pc.sleep(ctx, step); err != nil {
return err
}
paused += step
share, known, err = pc.sample(pc.now())
if err != nil || (known && share <= PaceAbove) {
break
}
}
resumed := pc.now()
pc.readSince, pc.lastLook = resumed, resumed
return nil
}
// sample is the share of the time since the last sample, in percent, in which some task waited on I/O; known is
// false for the first sample, which only sets where the next one counts from.
func (pc *pacer) sample(now time.Time) (float64, bool, error) {
total, err := pc.stalled()
if err != nil {
if !pc.saidBlind && pc.say != nil {
pc.say(fmt.Sprintf("the walk for setuid programs cannot read the machine's I/O pressure (%v), so it does "+
"not pace itself: where the disk's scheduler ignores the idle class, it may load the disk at full "+
"priority", err))
}
pc.saidBlind = true
return 0, false, err
}
prevAt, prevTotal := pc.prevAt, pc.prevTotal
pc.prevAt, pc.prevTotal = now, total
elapsed := now.Sub(prevAt)
if prevAt.IsZero() || elapsed <= 0 || total < prevTotal {
return 0, false, nil
}
return 100 * float64(total-prevTotal) / float64(elapsed), true, nil
}
// readIOStalled is the "some total" of a PSI file: the time, cumulative since boot, in which some task waited on I/O.
func readIOStalled(path string) (time.Duration, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return parseIOStalled(b)
}
func parseIOStalled(b []byte) (time.Duration, error) {
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) == 0 || fields[0] != "some" {
continue
}
for _, f := range fields[1:] {
if v, ok := strings.CutPrefix(f, "total="); ok {
us, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("the I/O pressure's total is not a count of microseconds: %q", v)
}
return time.Duration(us) * time.Microsecond, nil
}
}
}
return 0, fmt.Errorf("the I/O pressure names no \"some\" line with a total")
}
// sleepCtx waits d, or until ctx ends.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
+302
View File
@@ -0,0 +1,302 @@
package accounts
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/rootsearch"
)
// The walk for setuid programs yields to a busy disk by the machine's I/O pressure (novox/hq issue 433), because
// the idle I/O class it is put in is ignored by a disk scheduler that does not schedule by class, as the
// control-node's mq-deadline does not — and it keeps a tenth of the time whatever the pressure, so it still ends.
// fakePace is a pacer whose stall counter, sleeps and clock a test controls.
type fakePace struct {
// shares is the stall share, in percent, of each interval between two reads of the counter, in turn; the last
// is said again once they run out. The first read only sets the counter's start.
shares []float64
i int
total time.Duration
lastRead time.Time
reads int
clock time.Time
// tick moves the clock on at every look at it, as a walk reading directories takes time; zero does not.
tick time.Duration
slept []time.Duration
said []string
fail error
}
func newFake(shares ...float64) *fakePace {
return &fakePace{shares: shares, clock: time.Unix(1_000_000, 0)}
}
func (f *fakePace) pacer() *pacer {
return &pacer{
stalled: func() (time.Duration, error) {
if f.fail != nil {
return 0, f.fail
}
f.reads++
if !f.lastRead.IsZero() {
s := f.shares[min(f.i, len(f.shares)-1)]
f.i++
f.total += time.Duration(float64(f.clock.Sub(f.lastRead)) * s / 100)
}
f.lastRead = f.clock
return f.total, nil
},
sleep: func(ctx context.Context, d time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
f.slept = append(f.slept, d)
f.clock = f.clock.Add(d)
return nil
},
now: func() time.Time {
f.clock = f.clock.Add(f.tick)
return f.clock
},
say: func(s string) { f.said = append(f.said, s) },
}
}
func (f *fakePace) paused() time.Duration {
var total time.Duration
for _, d := range f.slept {
total += d
}
return total
}
func TestTheWalkPausesWhileTheDiskIsUnderPressureAndGoesOnWhenItEases(t *testing.T) {
f := newFake(60, 40, 3)
pc := f.pacer()
if err := pc.yield(t.Context()); err != nil { // the first look sets where the counter counts from
t.Fatal(err)
}
f.clock = f.clock.Add(300 * time.Millisecond) // the walk reads
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
// 60 % since the first look: pause; 40 % during the first pause: pause again; 3 %: go on.
if want := []time.Duration{PaceStep, PaceStep}; !slices.Equal(f.slept, want) {
t.Fatalf("slept %v, want %v", f.slept, want)
}
}
func TestTheFirstLookOnlySetsWhereTheNextCountsFrom(t *testing.T) {
f := newFake(99)
if err := f.pacer().yield(t.Context()); err != nil {
t.Fatal(err)
}
if len(f.slept) != 0 {
t.Fatalf("slept %v on the first look, which has nothing to compare", f.slept)
}
}
func TestTheWalkDoesNotPauseOnAQuietDisk(t *testing.T) {
f := newFake(0.4)
pc := f.pacer()
for range 10 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
}
if len(f.slept) != 0 {
t.Fatalf("slept %v on a quiet disk", f.slept)
}
}
// The control-node's own load sits near the bound with no walk running: the walk must still end (issue 361).
func TestTheWalkKeepsATenthOfTheTimeWhateverThePressure(t *testing.T) {
f := newFake(100)
pc := f.pacer()
var read time.Duration
for range 400 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
read += PaceLook
}
if f.paused() == 0 {
t.Fatal("never paused under constant pressure")
}
if f.paused() > PaceRatio*read {
t.Fatalf("paused %v while it read %v: more than %d times as long", f.paused(), read, PaceRatio)
}
}
// What the floor promises against the ages a search is judged by: the walk that took 16m31s unpaced on the
// control-node on 2026-10-11, slowed to its worst, still completes well inside the time the controller stays quiet
// while it waits for a search. A guard on the constants only: that the code keeps to the ratio is proven by
// TestTheWalkKeepsATenthOfTheTimeWhateverThePressure.
func TestTheSlowestPacedWalkStillJudgesInTime(t *testing.T) {
const unpaced = 16*time.Minute + 31*time.Second
worst := (1 + PaceRatio) * unpaced
if worst >= rootsearch.Quiet {
t.Fatalf("a walk paced to its worst takes %v, not less than the %v the controller waits quietly", worst,
rootsearch.Quiet)
}
}
func TestARunOfPausesIsAtMostPaceAtMost(t *testing.T) {
f := newFake(100)
pc := f.pacer()
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(10 * time.Minute) // read a long time without a look: the ratio would allow 90 minutes
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
if f.paused() != PaceAtMost {
t.Fatalf("paused %v in a row, want %v", f.paused(), PaceAtMost)
}
}
func TestTheWalkLooksAtThePressureAtMostEveryPaceLook(t *testing.T) {
f := newFake(1)
pc := f.pacer()
for range 5 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook / 10)
}
if f.reads != 1 {
t.Fatalf("read the pressure %d times within %v, want once", f.reads, PaceLook)
}
f.clock = f.clock.Add(PaceLook)
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
if f.reads != 2 {
t.Fatalf("read the pressure %d times after %v, want twice", f.reads, PaceLook)
}
}
// Pressure that cannot be read pauses nothing, and is said once, with what it means, rather than at every look.
func TestAPressureThatCannotBeReadIsSaidOnce(t *testing.T) {
f := newFake(0)
f.fail = errors.New("no such file")
pc := f.pacer()
for range 3 {
if err := pc.yield(t.Context()); err != nil {
t.Fatal(err)
}
f.clock = f.clock.Add(PaceLook)
}
if len(f.slept) != 0 || len(f.said) != 1 {
t.Fatalf("slept %v, said %q; want no pause and one line", f.slept, f.said)
}
if !strings.Contains(f.said[0], "full priority") {
t.Errorf("the line does not say what it means for the disk: %q", f.said[0])
}
}
// The engine's own walk is paced by the machine's pressure, and says through the search's own channel.
func TestTheEnginesWalkIsPacedByTheMachinesPressure(t *testing.T) {
var said []string
w := engineWalker(nil, func(s string) { said = append(said, s) })
if w.pace == nil || w.pace.path != ioPressureFile || w.pace.stalled == nil {
t.Fatalf("the engine's walker is not paced by %s: %+v", ioPressureFile, w.pace)
}
w.pace.say("x")
if len(said) != 1 {
t.Fatal("the engine's walker does not say through the search's channel")
}
}
// many is a tree of enough directories that a walk reaches a look at the pressure partway.
func many(t *testing.T) string {
names := []string{"bin/", "bin/su!", "bin/ls", "sbin/", "sbin/mount!", "usr/lib/helper%"}
for _, d := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
names = append(names, "usr/share/"+d+"/")
}
return tree(t, names...)
}
// A walk ended while it pauses is an incomplete walk, never one that found nothing.
func TestAWalkEndedWhileItPausesIsIncomplete(t *testing.T) {
top := many(t)
f := newFake(80)
f.tick = 100 * time.Millisecond
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
pc := f.pacer()
// The engine stops while the walk waits for the disk: the context ends inside the pause, not before the walk.
pc.sleep = func(ctx context.Context, d time.Duration) error {
f.slept = append(f.slept, d)
cancel()
return ctx.Err()
}
w := mine()
w.pace = pc
err := w.walk(ctx, &SetuidProgress{Mounts: []string{top}}, func() {})
if len(f.slept) == 0 {
t.Fatal("the walk never paused, so this proves nothing about a walk ended while it pauses")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("a walk ended while it paused answered %v", err)
}
}
// Paced, the walk finds what it finds unpaced: pausing changes when, never what.
func TestAPacedWalkFindsWhatAnUnpacedWalkFinds(t *testing.T) {
top := many(t)
plain := walkAll(t, mine(), top)
f := newFake(80, 2)
f.tick = 100 * time.Millisecond
w := mine()
w.pace = f.pacer()
paced := walkAll(t, w, top)
if !slices.Equal(plain, paced) {
t.Fatalf("paced walk found %v, unpaced %v", paced, plain)
}
if len(f.slept) == 0 {
t.Fatal("the walk read its directories without asking the pacer: it never paused under pressure")
}
}
func TestTheIOPressureIsReadAsTheKernelWritesIt(t *testing.T) {
got, err := parseIOStalled([]byte("some avg10=26.24 avg60=20.07 avg300=27.47 total=123456789\n" +
"full avg10=21.90 avg60=15.77 avg300=22.47 total=98765432\n"))
if err != nil || got != 123456789*time.Microsecond {
t.Fatalf("read %v, %v; want 123456789 µs", got, err)
}
for _, bad := range []string{"", "full avg10=1.00 avg60=0 avg300=0 total=1\n", "some avg10=1 total=x\n"} {
if _, err := parseIOStalled([]byte(bad)); err == nil {
t.Errorf("%q was read as a pressure", bad)
}
}
}
// One directory of many files is paced as it is read, not only between directories: a file's own details may be
// read from the disk too.
func TestADirectoryOfManyFilesIsPacedAsItIsRead(t *testing.T) {
names := []string{"bin/"}
for i := range 20 {
names = append(names, "bin/f"+strings.Repeat("x", i+1))
}
top := tree(t, names...)
f := newFake(80, 2)
f.tick = 100 * time.Millisecond
w := mine()
w.pace = f.pacer()
walkAll(t, w, top)
// Two directories alone would be looked at once, 200 ms apart, within one look: no pause at all.
if len(f.slept) == 0 {
t.Fatal("a directory of twenty files was read without a pause under pressure")
}
}
+218
View File
@@ -0,0 +1,218 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// How far a running search got is readable through the verdict (novox/hq issue 361): counts, times and mount
// points, counted once across a resume, and never a path the walk visited or found.
// A walk stopped midway and resumed counts every entry once, as the walk that was never stopped does, and says
// how long each filesystem took.
func TestTheProgressCountsEachEntryOnceAcrossAResume(t *testing.T) {
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
other := tree(t, "x/1", "y!")
whole := &SetuidProgress{Mounts: []string{top, other}}
if err := mine().walk(t.Context(), whole, func() {}); err != nil {
t.Fatal(err)
}
// top and the 14 entries below it, and other and its 3.
if whole.Visited != 15+4 || len(whole.Took) != 2 || whole.Took[0] < 0 || whole.Walking != 0 {
t.Fatalf("the whole walk: visited %d, took %v, walking %v", whole.Visited, whole.Took, whole.Walking)
}
w := mine()
w.every = 0
ctx, stop := context.WithCancel(t.Context())
p := &SetuidProgress{Mounts: []string{top, other}, Counted: true}
var kept []byte
err := w.walk(ctx, p, func() {
kept = mustJSON(t, p)
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
stop()
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("the walk was stopped: %v", err)
}
var resumed SetuidProgress
fromJSON(t, kept, &resumed)
if resumed.Visited == 0 || resumed.Visited >= whole.Visited || resumed.Walking < 0 {
t.Fatalf("the kept walk counted %d of %d", resumed.Visited, whole.Visited)
}
if err := mine().walk(t.Context(), &resumed, func() {}); err != nil {
t.Fatal(err)
}
if resumed.Visited != whole.Visited || len(resumed.Took) != 2 || resumed.Took[0] < 0 {
t.Fatalf("the resumed walk counted %d entries (the whole one %d), took %v", resumed.Visited,
whole.Visited, resumed.Took)
}
}
// A walk kept before the times were counted has them not known, never shifted onto the next filesystem, and
// its entries visited are a lower bound; the filesystem it was part-way through took a time not known.
func TestAWalkKeptWithoutTimesSaysThemNotKnown(t *testing.T) {
top, other := tree(t, "a"), tree(t, "b")
p := &SetuidProgress{Mounts: []string{top, other}, Done: Paths{top}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 2 || p.Took[0] >= 0 || p.Took[1] < 0 {
t.Fatalf("took %v", p.Took)
}
if w := progressOf(p, time.Time{}).Words(); !strings.Contains(w, top+" in a time not known") ||
!strings.Contains(w, "at least ") {
t.Fatalf("the words: %s", w)
}
}
// A look while the search runs says how far it got at its last checkpoint, with no path it visited or found.
func TestALookSaysHowFarTheRunningSearchGotAndNoPath(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{ProgressPath: filepath.Join(dir, SetuidProgressFile)}
secret := "/home/someone/private/rootshell"
checkpointed, release := make(chan struct{}), make(chan struct{})
search := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
p.Mounts = Paths{"/", "/home", "/boot"}
p.Done, p.Took = Paths{"/"}, []time.Duration{90 * time.Minute}
p.Cursor["/home"] = "/home/someone/private"
p.Found = Paths{"/usr/bin/odd", secret}
p.Visited = 1234567
checkpoint()
close(checkpointed)
<-release
return nil, nil
}
_, _, err := c.Look(at, root, search)
nj := notJudged(t, err, true)
if nj.Progress == nil || nj.Progress.Visited != 0 || nj.Progress.Walking != "/" {
t.Fatalf("before the first checkpoint: %+v", nj.Progress)
}
<-checkpointed
_, _, err = c.Look(at.Add(time.Minute), root, search)
nj = notJudged(t, err, true)
sp := nj.Progress
if sp == nil || sp.Filesystems != 3 || sp.Done != 1 || sp.Walking != "/home" || sp.Visited != 1234567 ||
sp.Found != 2 || sp.Kept.IsZero() || len(sp.Finished) != 1 || sp.Finished[0].Took != 90*time.Minute {
t.Fatalf("the progress: %+v", sp)
}
words := sp.Words()
for _, want := range []string{"1 of 3 filesystems walked", "walking /home", "1234567 entries visited",
"2 setuid- or setgid-root files found so far", "kept " + sp.Kept.Local().Format("2006-01-02 15:04:05"),
"walked / in 1h30m0s"} {
if !strings.Contains(words, want) {
t.Errorf("the words lack %q: %s", want, words)
}
}
for _, leak := range []string{"someone", "private", "odd", "rootshell"} {
if strings.Contains(words, leak) || strings.Contains(err.Error(), leak) {
t.Errorf("a path leaks (%q): %s / %v", leak, words, err)
}
}
close(release)
c.Wait()
if _, _, err := c.Look(at.Add(2*time.Minute), root, search); err != nil {
t.Fatalf("the complete search judges: %v", err)
}
}
// The progress moves every minute; the verdict does not change for it, so the journal says it once.
type waiting struct{ progress *SearchProgress }
func (w *waiting) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (w *waiting) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (w *waiting) Escalation(context.Context, string, []string) ([]string, error) {
return nil, &SetuidNotJudged{Pending: true, Since: at, Progress: w.progress}
}
func TestTheProgressMovesWithoutChangingTheVerdict(t *testing.T) {
r := &waiting{progress: &SearchProgress{Filesystems: 2, Visited: 10}}
j := New(r)
j.Set([]Account{{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}})
st, changed := j.Look(t.Context())
if !changed || st.Accounts[0].State != Unknown || st.Accounts[0].Search.Visited != 10 {
t.Fatalf("first look: changed %v, %+v", changed, st.Accounts[0])
}
r.progress = &SearchProgress{Filesystems: 2, Visited: 20}
st, changed = j.Look(t.Context())
if changed || st.Accounts[0].Search.Visited != 20 || strings.Contains(st.Accounts[0].Reason, "progress") {
t.Fatalf("second look: changed %v, %+v", changed, st.Accounts[0])
}
}
// Asked at the terminal, a search starts afresh at the next look: the last complete one is thrown away, kept or
// in memory, and a running one is ended and neither judges nor is carried on.
func TestSearchNowThrowsAwayTheLastSearchAndStartsAFullOne(t *testing.T) {
dir := t.TempDir()
c := &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
g := newGate()
g.found = []string{"/home/someone/rootshell"}
c.Look(at, root, g.search)
<-g.started
g.finish(c)
if found, _, err := c.Look(at.Add(time.Hour), root, g.search); err != nil || len(found) != 1 {
t.Fatalf("the complete search judges: %v %v", found, err)
}
said := c.SearchNow("jo at the controller's terminal")
if !strings.Contains(said, "thrown away") || strings.Contains(said, "someone") {
t.Fatalf("said: %s", said)
}
if _, err := os.Stat(c.Path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the kept search is still there: %v", err)
}
// The next look starts a full search at once, though the last ended only an hour ago.
ended := make(chan error, 1)
running := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
if len(p.Done) != 0 || len(p.Cursor) != 0 {
t.Errorf("not a full search: %+v", p)
}
checkpoint()
<-ctx.Done()
ended <- ctx.Err()
return nil, ctx.Err()
}
_, _, err := c.Look(at.Add(time.Hour+time.Minute), root, running)
notJudged(t, err, true)
if starts(c) != 2 {
t.Fatalf("searches started: %d", starts(c))
}
// Asked again while it runs: it is ended, judges nothing, fails nothing, and is not carried on.
c.SearchNow("jo")
if err := <-ended; !errors.Is(err, context.Canceled) {
t.Fatalf("the running search was not ended: %v", err)
}
c.Wait()
if _, err := os.Stat(c.ProgressPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the ended walk is still kept: %v", err)
}
g2 := newGate()
_, _, err = c.Look(at.Add(time.Hour+2*time.Minute), root, g2.search)
notJudged(t, err, true)
<-g2.started
g2.finish(c)
if starts(c) != 3 {
t.Fatalf("searches started: %d", starts(c))
}
if _, _, err := c.Look(at.Add(time.Hour+3*time.Minute), root, g2.search); err != nil {
t.Fatalf("the new search judges: %v", err)
}
}
func TestAFilesystemPartWalkedByAnEngineThatDidNotCountTookATimeNotKnown(t *testing.T) {
top := tree(t, "a", "b")
p := &SetuidProgress{Mounts: []string{top}, Cursor: Cursors{top: filepath.Join(top, "a")}}
if err := mine().walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
if len(p.Took) != 1 || p.Took[0] >= 0 {
t.Fatalf("took %v", p.Took)
}
}
+115
View File
@@ -0,0 +1,115 @@
package accounts
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// A setuid program no package owns counts against an account only where it can execute it, read at every
// judgement from the modes on the way (novox/hq issue 361): the operator's copies in a home of mode 0700 are no
// way for the agent account, and become one the moment the home is opened, without a new search.
const homeFind = "/home/op/usr/bin/mount"
func homeMachine(home FileMode) *agentMachine {
m := clean()
m.setuid = homeFind + "\n"
m.nodes = map[string]FileMode{"/home/op": home}
return m
}
func TestReachabilityDecidesWhetherAFindCounts(t *testing.T) {
op := func(perm fs.FileMode) FileMode { return FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | perm} }
for _, c := range []struct {
name string
set func(*agentMachine)
counted bool
}{
{"a home of 0700", func(m *agentMachine) {}, false},
{"a home of 0755", func(m *agentMachine) { m.nodes["/home/op"] = op(0o755) }, true},
{"a home its group may search, the agent in it", func(m *agentMachine) {
m.nodes["/home/op"], m.gids = op(0o710), "1600 1500"
}, true},
{"a home its group may search, the agent not in it", func(m *agentMachine) { m.nodes["/home/op"] = op(0o710) }, false},
{"an ACL on the home", func(m *agentMachine) {
m.files["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: 0o700}
m.acls = map[string][]ACLEntry{"/home/op": {{}}}
}, true},
{"a link on the way", func(m *agentMachine) {
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeSymlink | 0o777}
}, true},
{"a file the agent may not execute", func(m *agentMachine) {
m.nodes["/home/op"] = op(0o755)
m.nodes[homeFind] = FileMode{Perm: fs.ModeSetuid | 0o4750}
}, false},
} {
t.Run(c.name, func(t *testing.T) {
m := homeMachine(FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700})
c.set(m)
v := lookAgent(t, m)
if c.counted != (v.State == Unhealthy) {
t.Fatalf("counted %v: %+v", c.counted, v)
}
if strings.Contains(v.Reason, "/home/op") {
t.Fatalf("a path in a home was said: %s", v.Reason)
}
if c.counted && !strings.Contains(v.Reason, "a setuid-root program no package owns in an account's home, which agent can execute") {
t.Fatalf("the reason: %s", v.Reason)
}
})
}
}
// The search keeps every find; whether one counts is read again at every look.
func TestAHomeOpenedAfterTheSearchCountsWithoutANewSearch(t *testing.T) {
m := homeMachine(FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700})
c := &SetuidCache{}
j := New(Exec{Run: m.run, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl,
Lstat: m.lstat, Cache: c})
j.Set([]Account{agent})
j.Look(t.Context())
c.Wait()
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Healthy {
t.Fatalf("a find in a closed home: %+v", st.Accounts[0])
}
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o755}
st, _ := j.Look(t.Context())
if st.Accounts[0].State != Unhealthy || starts(c) != 1 {
t.Fatalf("the home opened: %+v, %d searches", st.Accounts[0], starts(c))
}
m.nodes["/home/op"] = FileMode{UID: 1500, GID: 1500, Perm: fs.ModeDir | 0o700}
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Healthy || starts(c) != 1 {
t.Fatalf("the home closed again: %+v", st.Accounts[0])
}
}
// On the machine's own modes, as their owner (the directories above a test's are its own): a directory without
// its owner's search bit hides what is in it.
func TestExecutableReadsTheMachinesModes(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root searches every directory")
}
top := tree(t, "home/bin/x!")
home := filepath.Join(top, "home")
me := os.Getuid()
noACL := func(string) ([]ACLEntry, error) { return nil, nil }
if err := os.Chmod(home, 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(home, 0o700) })
if Executable(filepath.Join(home, "bin/x"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file in a directory its account may not search was executable")
}
if err := os.Chmod(home, 0o700); err != nil {
t.Fatal(err)
}
if !Executable(filepath.Join(home, "bin/x"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file every directory opens was not executable")
}
if Executable(filepath.Join(home, "bin/gone"), me, map[int]bool{}, lstatOf, noACL) {
t.Fatal("a file gone was executable")
}
}
+313
View File
@@ -0,0 +1,313 @@
package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
)
// The walk for setuid programs, resumable (novox/hq issue 361).
//
// The walk is the engine's own, in Go rather than find, so it can say how far it got: filepath.WalkDir visits
// every filesystem's tree depth first, each directory's entries in lexical order, so the walk's progress is one
// cursor per filesystem — the last path it visited — and everything before the cursor in that order is done.
// The progress is kept every CheckpointEvery beside the engine's state (SetuidCache), so an engine restarted,
// updated or rebooted midway carries on from its cursor rather than starting over: on the control-node the
// whole walk takes far longer than an engine runs between updates.
//
// **Why a resumed walk is sound.** Only root can make a setuid- or setgid-root file: the agent account can
// neither set the bit on root's file nor give its own file to root. A file that appears behind the cursor while
// the walk runs, or while it waits to be resumed, was made by root — and root's acts through the mesh start a
// new search (apply.MovesRoot), whose walk starts from nothing. A walk is dated by its start, and one resumed
// more than FreshFor after its start is thrown away and begun again.
// SetuidProgress is one walk, as far as it got.
type SetuidProgress struct {
// ID names the walk; Started is when it began, on the cache's clock: the search is dated by it.
ID string `json:"id"`
Started time.Time `json:"started"`
// Mounts are the filesystems it walks, root first; Done those walked to their end.
Mounts Paths `json:"mounts"`
Done Paths `json:"done"`
// Cursor is, for a filesystem being walked, the last path visited.
Cursor Cursors `json:"cursor"`
// Found is every setuid- or setgid-root regular file found so far, before the package manager is asked.
Found Paths `json:"found"`
// Visited is how many entries the walk has gone past: each once, across a resume, as it is kept with the
// cursor it matches.
Visited int64 `json:"visited"`
// Took is how long the walk spent in each of Done, in its order (negative: not known, a walk kept by an
// engine before it counted); Walking how long it has spent in the filesystem it walks now. Time the
// engine did not run is in neither.
Took []time.Duration `json:"took"`
Walking time.Duration `json:"walking"`
// Kept is when this progress was kept, as the cache wrote it; zero before the first time.
Kept time.Time `json:"kept"`
// Counted is a walk counted from its start: false for one kept by an engine before it counted, whose
// Visited is then a lower bound and whose filesystem under way took a time not known (the review of
// 2026-10-10).
Counted bool `json:"counted"`
}
// Paths are paths kept as their bytes. **Never as JSON strings**: encoding/json replaces bytes that are not
// UTF-8 with U+FFFD, and a cursor so changed sorts after the names it stood before — a resumed walk would skip
// them unread (the review of 2026-10-10). A name of any bytes is any account's to make.
type Paths []string
func (ps Paths) MarshalJSON() ([]byte, error) {
raw := make([][]byte, len(ps))
for i, p := range ps {
raw[i] = []byte(p)
}
return json.Marshal(raw)
}
func (ps *Paths) UnmarshalJSON(b []byte) error {
var raw [][]byte
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*ps = make(Paths, len(raw))
for i, r := range raw {
(*ps)[i] = string(r)
}
return nil
}
// Cursors are each filesystem's cursor, kept as bytes as Paths are.
type Cursors map[string]string
type keptCursor struct {
Mount []byte `json:"mount"`
Path []byte `json:"path"`
}
func (cs Cursors) MarshalJSON() ([]byte, error) {
raw := []keptCursor{}
for m, p := range cs {
raw = append(raw, keptCursor{Mount: []byte(m), Path: []byte(p)})
}
slices.SortFunc(raw, func(a, b keptCursor) int { return strings.Compare(string(a.Mount), string(b.Mount)) })
return json.Marshal(raw)
}
func (cs *Cursors) UnmarshalJSON(b []byte) error {
var raw []keptCursor
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*cs = Cursors{}
for _, r := range raw {
(*cs)[string(r.Mount)] = string(r.Path)
}
return nil
}
// CheckpointEvery is how often a walk's progress is kept.
const CheckpointEvery = time.Minute
// prunedPaths are never walked: the kernel's own trees, and the container runtimes' layers.
var prunedPaths = []string{"/proc", "/sys", "/var/lib/docker", "/var/lib/containers"}
// walker walks for setuid programs. rootUID and rootGID are the owner and group a match has: root's (0), but
// for a test. mountPoints are every filesystem mounted on the machine (MountPoints): a directory that is one is
// another filesystem, left without being read — a FUSE mount root cannot read, or a network mount whose
// server is away, would fail or hang the walk (the review of 2026-10-10).
type walker struct {
rootUID, rootGID uint32
every time.Duration
mountPoints map[string]bool
// pace is asked before each directory is read (setuid_pace.go); nil never pauses.
pace *pacer
}
// walk carries p on to its end, calling checkpoint every w.every and after each filesystem. It ends early only
// with an error — a directory it could not read, or ctx ended — which is an incomplete walk, never "none".
// A file or directory that vanished while it walked is not an error: what still exists was walked.
func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func()) error {
if p.Cursor == nil {
p.Cursor = Cursors{}
}
// One Took for each of Done: a walk kept before they were counted has them not known.
for len(p.Took) < len(p.Done) {
p.Took = append(p.Took, -1)
}
p.Took = p.Took[:len(p.Done)]
if !p.Counted && len(p.Cursor) > 0 {
p.Walking = -1 // walked partly by an engine that did not count its time
}
last := time.Now()
for _, m := range p.Mounts {
if slices.Contains(p.Done, m) {
continue
}
if err := w.walkMount(ctx, p, m, checkpoint, &last); err != nil {
return err
}
p.Done = append(p.Done, m)
p.Took = append(p.Took, p.Walking)
p.Walking = 0
delete(p.Cursor, m)
checkpoint()
last = time.Now()
}
// Looked at again: a file found that is gone, or no longer setuid-root, is no answer the package manager
// can give, and would fail every search that resumes this walk (the review of 2026-10-10).
var still Paths
for _, f := range p.Found {
info, err := os.Lstat(f)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s again: %w", f, err)
}
if w.setuidRoot(info) {
still = append(still, f)
}
}
slices.Sort(still)
p.Found = slices.Compact(still)
return nil
}
func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string, checkpoint func(), last *time.Time) error {
top, err := os.Lstat(mount)
if err != nil {
return fmt.Errorf("the filesystem at %s could not be read: %w", mount, err)
}
dev, ok := deviceOf(top)
if !ok || !top.IsDir() {
return fmt.Errorf("the filesystem at %s is not a directory whose device could be read", mount)
}
var cursor []string
if c := p.Cursor[mount]; c != "" {
cursor = components(c)
}
// How long it walks this filesystem, carried on from what a resumed walk had spent in it.
began, before := time.Now(), p.Walking
spent := func() time.Duration {
if before < 0 {
return -1
}
return before + time.Since(began)
}
defer func() { p.Walking = spent() }()
return filepath.WalkDir(mount, func(path string, d fs.DirEntry, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil // vanished while it walked
}
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
at := components(path)
if cursor != nil && walkOrder(at, cursor) < 0 && !isPrefix(at, cursor) {
// Before the cursor and not on its way: visited by the walk this resumes.
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
switch {
case d.IsDir():
if path != mount && (slices.Contains(prunedPaths, path) || w.mountPoints[path]) {
return filepath.SkipDir
}
info, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return filepath.SkipDir
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
if d, ok := deviceOf(info); !ok || d != dev {
return filepath.SkipDir // another filesystem, walked as its own or not at all (find's -xdev)
}
// Before its entries are read: a busy disk is waited for here (novox/hq issue 433).
if err := w.pace.yield(ctx); err != nil {
return err
}
case d.Type().IsRegular():
// A file's own details may be read from the disk too: a directory of many files is paced as it is read.
if err := w.pace.yield(ctx); err != nil {
return err
}
info, err := d.Info() // the entry's own, never through a link
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
}
if w.setuidRoot(info) {
p.Found = append(p.Found, path)
}
}
// The cursor only moves on: the directories on the way back to it are visited again, and are before it.
// Counted here alone, so an entry visited again on the way back to the cursor is counted once.
if cursor == nil || walkOrder(at, cursor) > 0 {
p.Cursor[mount], cursor = path, at
p.Visited++
}
if time.Since(*last) >= w.every {
p.Walking = spent()
checkpoint()
*last = time.Now()
}
return nil
})
}
// setuidRoot is a regular file setuid and owned by root, or setgid to root's group.
func (w walker) setuidRoot(info fs.FileInfo) bool {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || !info.Mode().IsRegular() {
return false
}
return uint32(st.Uid) == w.rootUID && info.Mode()&fs.ModeSetuid != 0 ||
uint32(st.Gid) == w.rootGID && info.Mode()&fs.ModeSetgid != 0
}
func deviceOf(info fs.FileInfo) (uint64, bool) {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Dev), true
}
// components are a path's names, in order; "/" has none.
func components(path string) []string {
path = strings.Trim(filepath.Clean(path), "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
// walkOrder compares two paths in the order WalkDir visits them: name by name, each in lexical order, a
// directory before what is in it.
func walkOrder(a, b []string) int {
for i := 0; i < len(a) && i < len(b); i++ {
if c := strings.Compare(a[i], b[i]); c != 0 {
return c
}
}
return len(a) - len(b)
}
// isPrefix says a is b or a directory b is in.
func isPrefix(a, b []string) bool {
return len(a) <= len(b) && slices.Equal(a, b[:len(a)])
}
+213
View File
@@ -0,0 +1,213 @@
package accounts
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
)
// The engine's own walk for setuid programs (novox/hq issue 361): what it finds, what it never follows, and that
// a walk interrupted carries on from its cursor.
// tree makes files under a directory of its own: a name ending in "/" a directory, "!" setuid, "%" setgid.
func tree(t *testing.T, names ...string) string {
t.Helper()
top := t.TempDir()
for _, n := range names {
mode := os.FileMode(0o755)
switch {
case strings.HasSuffix(n, "/"):
if err := os.MkdirAll(filepath.Join(top, n), 0o755); err != nil {
t.Fatal(err)
}
continue
case strings.HasSuffix(n, "!"):
n, mode = strings.TrimSuffix(n, "!"), mode|os.ModeSetuid
case strings.HasSuffix(n, "%"):
n, mode = strings.TrimSuffix(n, "%"), mode|os.ModeSetgid
}
p := filepath.Join(top, n)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, mode); err != nil {
t.Fatal(err)
}
}
return top
}
// mine is a walker for which this test's own account is root.
func mine() walker {
return walker{rootUID: uint32(os.Getuid()), rootGID: uint32(os.Getgid()), every: time.Hour}
}
func walkAll(t *testing.T, w walker, top string) []string {
t.Helper()
p := &SetuidProgress{Mounts: []string{top}}
if err := w.walk(t.Context(), p, func() {}); err != nil {
t.Fatal(err)
}
return rel(top, p.Found)
}
func rel(top string, paths []string) []string {
var out []string
for _, p := range paths {
out = append(out, strings.TrimPrefix(p, top+"/"))
}
return out
}
func TestTheWalkFindsSetuidAndSetgidRootFilesAndFollowsNoLink(t *testing.T) {
top := tree(t, "bin/plain", "bin/rootshell!", "sbin/group%", "deep/a/b/c/x!")
if err := os.Symlink(filepath.Join(top, "bin/rootshell"), filepath.Join(top, "link")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(top, "bin"), filepath.Join(top, "linkdir")); err != nil {
t.Fatal(err)
}
found := walkAll(t, mine(), top)
if strings.Join(found, ",") != "bin/rootshell,deep/a/b/c/x,sbin/group" {
t.Fatalf("found %v", found)
}
if got := walkAll(t, walker{rootUID: 4242424, rootGID: 4242424, every: time.Hour}, top); len(got) != 0 {
t.Fatalf("files nobody's root owns were found: %v", got)
}
}
func TestAWalkThatCannotReadADirectoryIsIncomplete(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads every directory")
}
top := tree(t, "open/x!", "closed/y!")
if err := os.Chmod(filepath.Join(top, "closed"), 0); err != nil {
t.Fatal(err)
}
defer os.Chmod(filepath.Join(top, "closed"), 0o755)
p := &SetuidProgress{Mounts: []string{top}}
if err := mine().walk(t.Context(), p, func() {}); err == nil {
t.Fatal("a walk that could not read a directory was complete")
}
}
func TestWalkOrderIsNameByName(t *testing.T) {
// "/a/b" is walked before "/a-c" although '-' sorts before '/': the walk goes name by name.
if walkOrder(components("/a/b"), components("/a-c")) >= 0 {
t.Fatal("/a/b is walked before /a-c")
}
if walkOrder(components("/a"), components("/a/b")) >= 0 || !isPrefix(components("/a"), components("/a/b")) {
t.Fatal("a directory is walked before what is in it")
}
if len(components("/")) != 0 {
t.Fatal("/ has no names")
}
}
// A walk stopped midway carries on from its cursor: what was before the cursor is not walked again, what is after
// it is, and every file of the first walk is found.
func TestAnInterruptedWalkCarriesOnFromItsCursor(t *testing.T) {
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
w := mine()
w.every = 0 // a checkpoint at every path
ctx, stop := context.WithCancel(t.Context())
p := &SetuidProgress{Mounts: []string{top}}
var kept []byte
visits := 0
err := w.walk(ctx, p, func() {
visits++
kept = mustJSON(t, p)
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
stop() // the engine stops here
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("the walk was stopped: %v", err)
}
var resumed SetuidProgress
fromJSON(t, kept, &resumed)
if !strings.HasSuffix(resumed.Cursor[top], "/b/c/4") {
t.Fatalf("the kept cursor is %q", resumed.Cursor[top])
}
// Meanwhile, a file appears before the cursor and one after it.
for _, n := range []string{"a/late!", "e/late!"} {
tree2(t, top, n)
}
w.every = time.Hour
if err := w.walk(t.Context(), &resumed, func() {}); err != nil {
t.Fatal(err)
}
got := rel(top, resumed.Found)
want := []string{"a-b/3", "a/1", "b/c/4", "b/d", "c/6", "d/7", "e/late"}
if !slices.Equal(got, want) {
t.Fatalf("the resumed walk found %v, want %v: the first walk's finds kept, nothing before the cursor "+
"walked again, everything after it walked", got, want)
}
}
func tree2(t *testing.T, top, n string) {
t.Helper()
p := filepath.Join(top, strings.TrimSuffix(n, "!"))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, 0o755|os.ModeSetuid); err != nil {
t.Fatal(err)
}
}
// A search's commands and walk run on a lowered thread while the rest of the engine keeps its priority.
func TestTheSearchRunsOnALoweredThreadOfItsOwn(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
var err error
go func() {
defer wg.Done()
err = LowerThisThread()
}()
wg.Wait()
if err != nil {
t.Fatalf("the thread could not be lowered: %v", err)
}
}
// Another filesystem's mount point is left without being read, and a file found that is gone, or no longer
// setuid-root, when the walk ends is not asked about.
func TestTheWalkLeavesMountPointsAndDropsWhatWentMeanwhile(t *testing.T) {
top := tree(t, "fuse/secret!", "bin/x!", "bin/y!")
w := mine()
w.mountPoints = map[string]bool{filepath.Join(top, "fuse"): true}
w.every = 0
p := &SetuidProgress{Mounts: []string{top}}
removed := false
if err := w.walk(t.Context(), p, func() {
if !removed && strings.HasSuffix(p.Cursor[top], "/bin/y") {
removed = true
os.Remove(filepath.Join(top, "bin/x"))
}
}); err != nil {
t.Fatal(err)
}
if got := rel(top, p.Found); strings.Join(got, ",") != "bin/y" {
t.Fatalf("found %v", got)
}
}
func TestAMountPointIsReadAsTheKernelEscapesIt(t *testing.T) {
mp := MountPoints("/dev/a /mnt/a\\040b ext4 rw 0 0\n/dev/b /mnt/back\\134slash ext4 rw 0 0\n")
if !mp["/mnt/a b"] || !mp[`/mnt/back\slash`] {
t.Fatalf("read %v", mp)
}
}
+810
View File
@@ -0,0 +1,810 @@
package accounts
// The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of
// 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it,
// and a way added here is a line added there.
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"golang.org/x/sys/unix"
)
// Judged is every way to root the judge looks for, in the words its reasons use.
var Judged = []string{
"its uid is 0",
"membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "),
"any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)",
"any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)",
"any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " +
"/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted",
"a polkit rule that grants every account: a rule of a .rules file that answers polkit.Result.YES and names " +
"no user and no group, or a .pkla section whose Identity is unix-user:* or unix-group:* with a Result of " +
"yes — each rule and section judged on its own, comments taken out",
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
"POSIX ACL",
"a container runtime's API listening on TCP port 2375 or 2376 (/proc/net/tcp, /proc/net/tcp6), which any " +
"local account reaches",
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
"a program that no installed package owns and is setuid with owner root, or setgid with group root, " +
"whoever owns it, on every local filesystem mounted without nosuid (container and image layers, " +
"network and pseudo filesystems excepted) — searched in the background at most hourly, its last result " +
"served with when it was found; until a search has finished, the verdict says not judged",
"hard links or symbolic links to other accounts' files left unprotected by the kernel " +
"(fs.protected_hardlinks or fs.protected_symlinks is 0)",
}
// NotJudged is what the judge does not look for: each is a way to root it would miss.
var NotJudged = []string{
"a root-run unit, timer, cron entry or script the account can write",
"a directory on root's PATH, or in /etc/profile.d, the account can write",
"root's or the operator's ssh keys or authorized_keys readable or writable by it",
"a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " +
"refuses links there)",
"file capabilities (setcap) on a program",
"a setuid program a package installed that has a flaw of its own",
"a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)",
"a polkit rule that grants every account by logic the text does not show (a JavaScript condition " +
"other than a named user or group), or every account with an active local session",
"a container runtime's API on a TCP port other than 2375 and 2376, or on a unix socket not named here",
"a setuid program on a filesystem not judged: a container or image layer (overlay, squashfs), a network " +
"or FUSE filesystem",
}
// RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root.
var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock",
"/run/containerd/containerd.sock"}
// DoasConfigs and PolkitDirs are where those grants live.
var (
DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"}
PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"}
)
// ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write
// once the ACL's mask is applied.
type ACLEntry struct {
User bool
ID int
Read, Write bool
}
// The tags and bits of the kernel's ACL xattr.
const (
aclUser = 0x02
aclGroup = 0x08
aclMask = 0x10
)
// ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the
// named users' and groups' entries are answered with the mask applied.
func ParseACL(raw []byte) ([]ACLEntry, error) {
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw))
}
type entry struct {
tag, perm uint16
id uint32
}
var es []entry
mask := uint16(7)
for at := 4; at < len(raw); at += 8 {
e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]),
binary.LittleEndian.Uint32(raw[at+4:])}
if e.tag == aclMask {
mask = e.perm
}
es = append(es, e)
}
var out []ACLEntry
for _, e := range es {
if e.tag != aclUser && e.tag != aclGroup {
continue
}
p := e.perm & mask
out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0})
}
return out, nil
}
// aclOf is a file's ACL from the machine: none when it has none.
func aclOf(path string) ([]ACLEntry, error) {
buf := make([]byte, 1024)
n, err := unix.Getxattr(path, "system.posix_acl_access", buf)
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) {
return nil, nil
}
if err != nil {
return nil, &os.PathError{Op: "getxattr", Path: path, Err: err}
}
return ParseACL(buf[:n])
}
// grantsByACL says whether an ACL entry gives the account read (or write).
func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool {
for _, e := range acl {
if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) {
if (write && e.Write) || (!write && e.Read) {
return true
}
}
}
return false
}
// Writable is Readable's twin for the write bits.
func Writable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o200 != 0
case gids[m.GID]:
return m.Perm&0o020 != 0
default:
return m.Perm&0o002 != 0
}
}
// DoasRules is every line of a doas configuration that permits the account or one of its groups.
func DoasRules(conf string, account string, groups []string) []string {
var out []string
for _, line := range strings.Split(conf, "\n") {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) < 2 || f[0] != "permit" {
continue
}
i := 1
for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" ||
f[i] == "setenv" || strings.HasPrefix(f[i], "{")) {
if strings.HasPrefix(f[i], "{") {
for i < len(f) && !strings.HasSuffix(f[i], "}") {
i++
}
}
i++
}
if i >= len(f) {
continue
}
who := f[i]
if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) {
out = append(out, strings.TrimSpace(line))
}
}
return out
}
// PolkitNames says whether a polkit rule or authority file names the account or one of its groups.
func PolkitNames(text, account string, groups []string) bool {
needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`}
for _, g := range groups {
needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`)
}
for _, n := range needles {
if strings.Contains(text, n) {
return true
}
}
return false
}
// PolkitGrantsEveryone says whether a polkit rule or authority file grants every account, naming none: a .rules
// file one of whose rules answers polkit.Result.YES with no condition on a user or a group, or a .pkla one of
// whose sections has an Identity of every user or every group with a Result of yes.
//
// **Each rule is judged on its own, comments taken out first** (the third review of 2026-10-08): a rule naming a
// user elsewhere in the file, or a user named only in a comment, must not hide another rule's unconditional
// yes. A rule's text is read, not run: a condition the text does not show is listed under NotJudged.
func PolkitGrantsEveryone(path, text string) bool {
if strings.HasSuffix(path, ".pkla") {
var kept []string
for _, line := range strings.Split(text, "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") || strings.HasPrefix(t, ";") {
continue
}
kept = append(kept, line)
}
for _, section := range strings.Split(strings.Join(kept, "\n"), "[") {
var every, yes bool
for _, line := range strings.Split(section, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "Identity":
for _, id := range strings.Split(v, ";") {
if id = strings.TrimSpace(id); id == "unix-user:*" || id == "unix-group:*" {
every = true
}
}
case "ResultAny", "ResultActive", "ResultInactive":
if v == "yes" {
yes = true
}
}
}
if every && yes {
return true
}
}
return false
}
for _, rule := range polkitRules(stripJSComments(text)) {
if !strings.Contains(rule, "polkit.Result.YES") {
continue
}
// A condition on a user or a group names whom it grants; one on an active local session grants only an
// account with a seat, which an agent started through sudo has not (listed under NotJudged).
conditioned := false
for _, condition := range []string{".user", "isInGroup", "unix-user:", "unix-group:", ".active", ".local"} {
if strings.Contains(rule, condition) {
conditioned = true
break
}
}
if !conditioned {
return true
}
}
return false
}
// polkitRules is each rule of a .rules file: the text from one polkit.addRule to the next. Text before the first
// is no rule.
func polkitRules(text string) []string {
parts := strings.Split(text, "addRule(")
if len(parts) < 2 {
return nil
}
return parts[1:]
}
// stripJSComments takes /* … */ and // … comments out of a rule file, leaving what is in strings alone.
func stripJSComments(text string) string {
var b strings.Builder
var quote byte
for i := 0; i < len(text); i++ {
c := text[i]
switch {
case quote != 0:
b.WriteByte(c)
if c == '\\' && i+1 < len(text) {
i++
b.WriteByte(text[i])
} else if c == quote {
quote = 0
}
case c == '"' || c == '\'' || c == '`':
quote = c
b.WriteByte(c)
case c == '/' && i+1 < len(text) && text[i+1] == '*':
end := strings.Index(text[i+2:], "*/")
if end < 0 {
return b.String()
}
i += end + 3
case c == '/' && i+1 < len(text) && text[i+1] == '/':
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return b.String()
}
i += end - 1
default:
b.WriteByte(c)
}
}
return b.String()
}
// RuntimeAPIPorts are the ports a container runtime's API listens on by convention: 2375 plain, 2376 TLS.
var RuntimeAPIPorts = []int{2375, 2376}
// ListeningPorts is every local TCP port in the listening state, from /proc/net/tcp's or tcp6's text.
func ListeningPorts(text string) []int {
var out []int
for i, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if i == 0 || len(f) < 4 || f[3] != "0A" {
continue
}
_, port, ok := strings.Cut(f[1], ":")
if !ok {
continue
}
var n int
if _, err := fmt.Sscanf(port, "%X", &n); err == nil {
out = append(out, n)
}
}
return out
}
// pseudoFS are filesystem types no setuid program is installed on, or that are a container's or an image's
// own layers: not searched.
var pseudoFS = map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true,
"cgroup": true, "cgroup2": true, "securityfs": true, "pstore": true, "bpf": true, "debugfs": true,
"tracefs": true, "mqueue": true, "hugetlbfs": true, "configfs": true, "fusectl": true, "autofs": true,
"binfmt_misc": true, "efivarfs": true, "overlay": true, "squashfs": true, "nsfs": true, "ramfs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "9p": true, "virtiofs": true}
// SuidMounts is every local filesystem a setuid program could run from: the mount points of /proc/mounts whose
// type is not pseudo, network or a container's layer, and that are not mounted nosuid. Root first.
func SuidMounts(text string) []string {
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 4 || pseudoFS[f[2]] || strings.HasPrefix(f[2], "fuse") {
continue
}
at := unescapeMount(f[1])
if strings.HasPrefix(at, "/var/lib/docker") || strings.HasPrefix(at, "/var/lib/containers") ||
strings.HasPrefix(at, "/proc") || strings.HasPrefix(at, "/sys") {
continue
}
if contains(strings.Split(f[3], ","), "nosuid") || seen[at] {
continue
}
seen[at] = true
out = append(out, at)
}
sort.Slice(out, func(i, j int) bool { return out[i] == "/" || (out[j] != "/" && out[i] < out[j]) })
return out
}
// MountPoints is every mount point /proc/mounts lists, of any type: the walk leaves each it meets inside
// another filesystem without reading it.
func MountPoints(text string) map[string]bool {
out := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
out[unescapeMount(f[1])] = true
}
}
return out
}
// unescapeMount is a mount point as /proc/mounts escapes it — a space, a tab, a newline and a backslash as
// three octal digits after a backslash — made a path again.
func unescapeMount(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1:i+4]) {
b.WriteByte((s[i+1]-'0')<<6 | (s[i+2]-'0')<<3 | (s[i+3] - '0'))
i += 3
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isOctal(s string) bool {
if len(s) != 3 {
return false
}
for i := 0; i < 3; i++ {
if s[i] < '0' || s[i] > '7' {
return false
}
}
return true
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// setuidSearch is every setuid- or setgid-root regular file on the filesystems of p that no installed package
// owns: walked by the engine (setuid_walk.go, resumable from p), then asked of the package manager through the
// search's runner (Exec.SearchRun). **No bound of its own** (novox/hq issue 361): it runs until it has walked
// everything. A walk that could not complete — a directory it could not read — is an unanswered question, never
// "none"; a file that vanished while it walked is not.
func (e Exec) setuidSearch(ctx context.Context, p *SetuidProgress, checkpoint func(), mountPoints map[string]bool) ([]string, error) {
walk := e.Walk
if walk == nil {
var say func(string)
if e.Cache != nil {
say = e.Cache.Say
}
walk = engineWalker(mountPoints, say).walk
}
if err := walk(ctx, p, checkpoint); err != nil {
return nil, fmt.Errorf("the walk for setuid programs did not complete: %w", err)
}
if e.SearchRun != nil {
e.Run = e.SearchRun
}
found := slices.Clone(p.Found)
slices.Sort(found)
return e.unpackaged(ctx, slices.Compact(found))
}
// engineWalker is the walk the engine runs: checkpointed every CheckpointEvery, leaving other filesystems unread,
// and paced by the machine's I/O pressure (setuid_pace.go, novox/hq issue 433), saying through say what it cannot do.
func engineWalker(mountPoints map[string]bool, say func(string)) walker {
return walker{every: CheckpointEvery, mountPoints: mountPoints, pace: newPacer(say)}
}
// errorLines are the lines a command said on its error stream, as the runner words a failure: "<name> exited N:
// <its words>", the prefix taken off the first line. Empty lines are dropped.
func errorLines(err error, name string) []string {
words := err.Error()
if strings.HasPrefix(words, name+" exited") {
if i := strings.Index(words, ": "); i >= 0 {
words = words[i+2:]
}
}
var out []string
for _, l := range strings.Split(words, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// unpackaged is every path no installed package owns: pacman asked about all of them in one call, else apk
// about each. Never a guess: a path neither said owned nor said unowned is an unanswered question.
func (e Exec) unpackaged(ctx context.Context, paths []string) ([]string, error) {
if len(paths) == 0 {
return nil, nil
}
out, err := e.Run(ctx, "pacman", append([]string{"-Qo"}, paths...)...)
if err == nil || !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
owned := map[string]bool{}
for _, l := range strings.Split(out, "\n") {
if p, _, ok := strings.Cut(strings.TrimSpace(l), " is owned by "); ok {
owned[p] = true
}
}
unowned := map[string]bool{}
if err != nil {
// pacman exits 1 when a path is owned by nothing, and says which on its error stream.
for _, l := range errorLines(err, "pacman") {
if _, p, ok := strings.Cut(l, "No package owns "); ok {
unowned[strings.TrimSpace(p)] = true
} else {
return nil, fmt.Errorf("pacman could not say who owns the setuid programs found: %s", l)
}
}
}
var out []string
for _, p := range paths {
switch {
case owned[p]:
case unowned[p]:
out = append(out, p)
default:
return nil, fmt.Errorf("pacman said nothing of who owns %s", p)
}
}
return out, nil
}
var none []string
for _, p := range paths {
said, err := e.Run(ctx, "apk", "info", "-W", p)
if err != nil {
return nil, fmt.Errorf("no package manager could say who owns %s: %w", p, err)
}
if !strings.Contains(said, " is owned by ") {
none = append(none, p)
}
}
return none, nil
}
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool,
secrets []string) ([]string, error) {
read := e.ReadFile
if read == nil {
read = os.ReadFile
}
readDir := e.ReadDir
if readDir == nil {
readDir = os.ReadDir
}
acl := e.ACL
if acl == nil {
acl = aclOf
}
stat := e.Stat
if stat == nil {
stat = statOf
}
var ways []string
// doas.
for _, conf := range DoasConfigs {
raw, err := read(conf)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err)
}
for _, r := range DoasRules(string(raw), account, groups) {
ways = append(ways, "doas permits it: "+r)
}
}
// polkit.
for _, dir := range PolkitDirs {
var named, everyone []string
err := walkFiles(readDir, dir, func(path string) error {
raw, err := read(path)
if err != nil {
return err
}
if PolkitNames(string(raw), account, groups) {
named = append(named, path)
} else if PolkitGrantsEveryone(path, string(raw)) {
everyone = append(everyone, path)
}
return nil
})
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err)
}
for _, p := range named {
ways = append(ways, "a polkit rule names it or a group of it: "+p)
}
for _, p := range everyone {
ways = append(ways, "a polkit rule grants every account: "+p)
}
}
// The container runtimes' sockets.
seen := map[string]bool{}
for _, s := range RuntimeSockets {
// Two names of one socket are one socket. A test that gives its own files names them as they are.
real, err := filepath.EvalSymlinks(s)
if e.Stat != nil {
real, err = s, nil
}
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read: %w", s, err)
}
if seen[real] {
continue
}
seen[real] = true
m, err := stat(real)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err)
}
a, err := acl(real)
if err != nil {
return nil, err
}
if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) {
ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root")
}
}
// The secrets' ACLs: the bits were judged already.
for _, path := range secrets {
a, err := acl(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err)
}
if grantsByACL(a, uid, gids, false) {
ways = append(ways, "an ACL lets it read the secret "+path)
}
}
// setuid programs no package owns.
now := time.Now
if e.Now != nil {
now = e.Now
}
mountsText, err := read("/proc/mounts")
if err != nil {
return nil, fmt.Errorf("the mounted filesystems could not be read: %w", err)
}
mounts := SuidMounts(string(mountsText))
if len(mounts) == 0 {
return nil, errors.New("no filesystem a setuid program could run from was found in /proc/mounts")
}
// In the background, the last complete result served while it is fresh (SetuidCache): never a stall. No
// fresh complete result is said beside the other ways, which are judged all the same.
mountPoints := MountPoints(string(mountsText))
unowned, searchedAt, searchErr := e.Cache.Look(now(), mounts,
func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
return e.setuidSearch(ctx, p, checkpoint, mountPoints)
})
var notJudged *SetuidNotJudged
if searchErr != nil && !errors.As(searchErr, &notJudged) {
return nil, searchErr
}
// Counted only where the account can execute it, read now from the paths' modes, never kept with the
// search: an account may open its own home to others later without root, and no search would see it.
lstat := e.Lstat
if lstat == nil {
lstat = lstatOf
}
started := " (search started " + searchedAt.Local().Format("2006-01-02 15:04") + ")"
inHomes := 0
for _, p := range unowned {
if !Executable(p, uid, gids, lstat, acl) {
continue
}
if inAHome(p) {
inHomes++ // its path stays in the engine's journal: a home's names are its owner's
continue
}
ways = append(ways, "a setuid-root program no package owns, which "+account+" can execute: "+p+started)
}
switch {
case inHomes == 1:
ways = append(ways, "a setuid-root program no package owns in an account's home, which "+account+
" can execute"+started)
case inHomes > 1:
ways = append(ways, fmt.Sprintf("%d setuid-root programs no package owns in accounts' homes, which %s can "+
"execute%s", inHomes, account, started))
}
// The kernel's protection of links: off, any account may link another's file where root then acts on it.
for _, sysctl := range []string{"protected_hardlinks", "protected_symlinks"} {
raw, err := read("/proc/sys/fs/" + sysctl)
if err != nil {
return nil, fmt.Errorf("fs.%s could not be read: %w", sysctl, err)
}
if strings.TrimSpace(string(raw)) == "0" {
what := map[string]string{"protected_hardlinks": "hard links", "protected_symlinks": "symbolic links"}[sysctl]
ways = append(ways, fmt.Sprintf("%s to other accounts' files are not protected (fs.%s=0)", what, sysctl))
}
}
// A container runtime's API on TCP, which any account on the machine reaches.
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
raw, err := read(table)
if errors.Is(err, fs.ErrNotExist) && table == "/proc/net/tcp6" {
continue // no IPv6 on this machine
}
if err != nil {
return nil, fmt.Errorf("the listening ports in %s could not be read: %w", table, err)
}
for _, port := range ListeningPorts(string(raw)) {
for _, api := range RuntimeAPIPorts {
if port == api {
ways = append(ways, fmt.Sprintf("a container runtime's API listens on TCP port %d, which any "+
"local account reaches", port))
}
}
}
}
if notJudged != nil {
// The other ways are judged; this one is said as not judged, never as none.
return ways, notJudged
}
return ways, nil
}
// Executable says whether an account of uid, in the groups gids, can execute path as the kernel decides it:
// every directory from the root down searchable for it, and the file executable for it, each by the owner's
// bits for its owner, the group's for a member, the others' for anybody else (novox/hq issue 361). It errs
// towards a way: a link on the way, an ACL on any of them, or a mode that could not be read is executable.
// A path gone is not: there is nothing to execute.
func Executable(path string, uid int, gids map[int]bool, lstat func(string) (FileMode, error),
acl func(string) ([]ACLEntry, error)) bool {
if uid == 0 {
return true
}
at := "/"
steps := []string{at}
for _, name := range components(path) {
at = filepath.Join(at, name)
steps = append(steps, at)
}
for i, p := range steps {
m, err := lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return false
}
if err != nil || m.Perm&fs.ModeSymlink != 0 {
return true
}
if a, err := acl(p); (err != nil && !errors.Is(err, fs.ErrNotExist)) || len(a) > 0 {
return true
}
bit := fs.FileMode(0o001)
switch {
case m.UID == uid:
bit = 0o100
case gids[m.GID]:
bit = 0o010
}
if m.Perm&bit == 0 {
return false
}
if i < len(steps)-1 && !m.Perm.IsDir() {
return false // a file on the way: the path names nothing
}
}
return true
}
// homes are where accounts' homes are: a path under one is said by count alone, its name its owner's.
var homes = []string{"/home/", "/root/", "/var/home/"}
func inAHome(path string) bool {
for _, h := range homes {
if strings.HasPrefix(path, h) {
return true
}
}
return false
}
// walkFiles calls fn for every regular file below dir, through readDir.
func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error {
entries, err := readDir(dir)
if err != nil {
return err
}
for _, en := range entries {
p := filepath.Join(dir, en.Name())
if en.IsDir() {
if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
continue
}
if err := fn(p); err != nil {
return err
}
}
return nil
}
// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one
// language whatever the machine's is; stdin closed, output captured.
func CLocale(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
}
return string(out), fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
+305
View File
@@ -0,0 +1,305 @@
package accounts
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
"time"
)
// The ways beyond uid, groups and sudo (novox/hq ADR 0266, the review of 2026-10-08): doas, polkit, a
// runtime's socket, an ACL on a secret, a setuid program no package owns — each said; each unread question
// unknown; and the judge's commands in the C locale.
func TestEachFurtherWayToRootIsSaid(t *testing.T) {
const broker = "/var/lib/mesh/node-tools/broker"
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"doas by name", func(m *agentMachine) {
m.texts = map[string]string{"/etc/doas.conf": "permit persist operator\npermit nopass agent as root\n"}
}, "doas permits it: permit nopass agent as root"},
{"doas by group", func(m *agentMachine) {
m.groups = "agent builders"
m.texts = map[string]string{"/etc/opendoas.conf": "permit :builders # the builders\n"}
}, "doas permits it: permit :builders"},
{"polkit by name", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"10-agent.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/10-agent.rules": `polkit.addRule(function(a, s) { if (s.user == "agent") return polkit.Result.YES; });`}
}, "a polkit rule names it or a group of it: /etc/polkit-1/rules.d/10-agent.rules"},
{"polkit by group", func(m *agentMachine) {
m.groups = "agent network"
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-nm.rules"}}
m.texts = map[string]string{"/usr/share/polkit-1/rules.d/50-nm.rules": `if (subject.isInGroup("network")) return polkit.Result.YES;`}
}, "a polkit rule names it or a group of it: /usr/share/polkit-1/rules.d/50-nm.rules"},
{"docker socket by group bits", func(m *agentMachine) {
m.gids = "1600 970"
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
}, "it can write the container runtime's socket /run/docker.sock"},
{"docker socket by ACL", func(m *agentMachine) {
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.acls = map[string][]ACLEntry{"/run/docker.sock": {{User: true, ID: 1600, Read: true, Write: true}}}
}, "it can write the container runtime's socket /run/docker.sock"},
{"secret by ACL", func(m *agentMachine) {
m.acls = map[string][]ACLEntry{broker: {{User: false, ID: 1600, Read: true}}}
}, "an ACL lets it read the secret " + broker},
{"polkit for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"00-all.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/00-all.rules": `polkit.addRule(function(action, subject) { return polkit.Result.YES; });`}
}, "a polkit rule grants every account: /etc/polkit-1/rules.d/00-all.rules"},
{"pkla for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/localauthority": {"all.pkla"}}
m.texts = map[string]string{"/etc/polkit-1/localauthority/all.pkla": "[all]\nIdentity=unix-user:*\nAction=*\nResultAny=yes\n"}
}, "a polkit rule grants every account: /etc/polkit-1/localauthority/all.pkla"},
{"docker on TCP", func(m *agentMachine) {
m.proc["/proc/net/tcp6"] = " sl local_address rem_address st\n 0: 00000000000000000000000000000000:0947 00000000000000000000000000000000:0000 0A 0\n"
}, "a container runtime's API listens on TCP port 2375"},
{"hard links unprotected", func(m *agentMachine) {
m.proc["/proc/sys/fs/protected_hardlinks"] = "0\n"
}, "hard links to other accounts' files are not protected (fs.protected_hardlinks=0)"},
{"setuid on a second filesystem", func(m *agentMachine) {
m.proc["/proc/mounts"] += "/dev/sdb1 /srv xfs rw 0 0\n/dev/sdc1 /data ext4 rw,nosuid 0 0\n"
m.setuid = "/srv/bin/rootshell\n"
m.findArgs = func(args []string) {
if len(args) != 2 || args[0] != "/" || args[1] != "/srv" {
panic(fmt.Sprintf("searched %v", args))
}
}
}, "a setuid-root program no package owns, which agent can execute: /srv/bin/rootshell"},
{"setuid no package owns", func(m *agentMachine) {
m.setuid = "/usr/bin/sudo\n/usr/local/bin/rootshell\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true}
}, "a setuid-root program no package owns, which agent can execute: /usr/local/bin/rootshell"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestWhatGrantsSomebodyElseIsNoWay(t *testing.T) {
m := clean()
m.texts = map[string]string{"/etc/doas.conf": "permit operator\npermit :wheel\n# permit agent\n",
"/usr/share/polkit-1/rules.d/50-default.rules": `polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`}
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-default.rules"}}
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("the operator's and wheel's grants, a socket of another group, packaged setuid programs: %+v", v)
}
}
func TestDoasWithoutSudoIsJudgedFromItsRules(t *testing.T) {
m := clean()
m.sudo, m.sudoErr = "", fmt.Errorf("sudo: %w", exec.ErrNotFound)
m.texts = map[string]string{"/etc/doas.conf": "permit nopass agent\n"}
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.Contains(v.Reason, "doas permits it") {
t.Fatalf("no sudo is no sudo rule, and doas is read for itself: %+v", v)
}
}
func TestAFurtherQuestionUnansweredIsUnknown(t *testing.T) {
m := clean()
m.findErr = errors.New("find: interrupted")
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("a search that did not finish: %+v", v)
}
m = clean()
m.setuid = "/usr/local/bin/x\n"
j := New(Exec{Run: func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" || name == "apk" {
return "", fmt.Errorf("%s: %w", name, exec.ErrNotFound)
}
return m.run(ctx, name, args...)
}, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl, Lstat: m.lstat})
j.Set([]Account{agent})
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Unknown {
t.Fatalf("no package manager to ask: %+v", st.Accounts[0])
}
}
func TestANotJudgedSearchStillSaysAWayFound(t *testing.T) {
j := New(fakeReader{ways: []string{"in the group docker, which grants root"}, err: &SetuidNotJudged{Pending: true, Since: time.Now()}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ := j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unhealthy || !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, ReasonPending) {
t.Fatalf("a way found is unhealthy, with the search's state beside it: %+v", v)
}
j = New(fakeReader{err: &SetuidNotJudged{Since: time.Now(), Err: errors.New("timeout")}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ = j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unknown || !strings.HasPrefix(v.Reason, ReasonIncomplete) {
t.Fatalf("nothing found and the search incomplete: unknown, said so: %+v", v)
}
}
type fakeReader struct {
ways []string
err error
}
func (f fakeReader) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (f fakeReader) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (f fakeReader) Escalation(context.Context, string, []string) ([]string, error) {
return f.ways, f.err
}
func TestParseACL(t *testing.T) {
entry := func(tag, perm uint16, id uint32) []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint16(b, tag)
binary.LittleEndian.PutUint16(b[2:], perm)
binary.LittleEndian.PutUint32(b[4:], id)
return b
}
raw := []byte{2, 0, 0, 0}
raw = append(raw, entry(0x01, 6, 0xffffffff)...)
raw = append(raw, entry(0x02, 6, 1600)...) // user agent rw
raw = append(raw, entry(0x04, 4, 0xffffffff)...)
raw = append(raw, entry(0x08, 6, 970)...) // group 970 rw
raw = append(raw, entry(0x10, 4, 0xffffffff)...) // mask r--
raw = append(raw, entry(0x20, 0, 0xffffffff)...)
acl, err := ParseACL(raw)
if err != nil || len(acl) != 2 {
t.Fatalf("%v %v", acl, err)
}
if !acl[0].User || acl[0].ID != 1600 || !acl[0].Read || acl[0].Write {
t.Fatalf("the mask takes write away: %+v", acl[0])
}
if grantsByACL(acl, 1600, nil, true) || !grantsByACL(acl, 1601, map[int]bool{970: true}, false) {
t.Fatal("grants")
}
if _, err := ParseACL([]byte{2, 0, 0}); err == nil {
t.Fatal("a short ACL")
}
}
func TestTheJudgesCommandsRunInTheCLocale(t *testing.T) {
t.Setenv("LC_ALL", "de_DE.UTF-8")
t.Setenv("LANG", "de_DE.UTF-8")
out, err := CLocale(t.Context(), "sh", "-c", `echo "$LC_ALL $LANG"`)
if err != nil || strings.TrimSpace(out) != "C C" {
t.Fatalf("%q %v", out, err)
}
if _, err := CLocale(t.Context(), "sh", "-c", "echo nope >&2; exit 3"); err == nil ||
!strings.Contains(err.Error(), "exited 3: nope") {
t.Fatalf("an exit is said with its words: %v", err)
}
}
func TestTheJudgedListIsWrittenDown(t *testing.T) {
if len(Judged) < 8 || len(NotJudged) == 0 {
t.Fatal("what is judged and what is not are both written down")
}
}
func TestDistributionRulesForASeatOrForSomebodyAreNotEveryone(t *testing.T) {
for _, rule := range []string{
`polkit.addRule(function(a, s) { if (s.local && s.active) return polkit.Result.YES; });`,
`polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) return polkit.Result.YES; });`,
`polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`,
} {
if PolkitGrantsEveryone("/x.rules", rule) {
t.Errorf("not every account: %s", rule)
}
}
if PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n") {
t.Error("a pkla for wheel is not every account")
}
}
func TestAMachineMissingWhatTheJudgeReadsIsUnknown(t *testing.T) {
for _, file := range []string{"/proc/sys/fs/protected_hardlinks", "/proc/net/tcp", "/proc/mounts"} {
m := clean()
delete(m.proc, file)
if v := lookAgent(t, m); v.State != Unknown {
t.Errorf("%s unread: %+v", file, v)
}
}
}
func TestSuidMountsLeaveOutNosuidPseudoAndContainerLayers(t *testing.T) {
got := SuidMounts("/dev/sdb1 /srv xfs rw 0 0\n/dev/sda2 / ext4 rw 0 0\noverlay /var/lib/docker/overlay2/x/merged overlay rw 0 0\n" +
"/dev/sdc1 /data ext4 rw,nosuid 0 0\nproc /proc proc rw 0 0\nhost:/x /mnt/nfs nfs4 rw 0 0\n/dev/sdd1 /my\\040disk ext4 rw 0 0\n")
if strings.Join(got, "|") != "/|/my disk|/srv" {
t.Fatalf("got %q", got)
}
}
// Each polkit rule is judged on its own, comments taken out (the third review of 2026-10-08).
func TestAnUnconditionalPolkitYesIsNotMaskedByAnotherRuleOrAComment(t *testing.T) {
cases := []struct {
name, text string
every bool
}{
{"another rule names a user", `polkit.addRule(function(a, s) { if (s.user == "operator") return polkit.Result.YES; });
polkit.addRule(function(a, s) { return polkit.Result.YES; });`, true},
{"a user named only in a comment", `// subject.user == "x"
polkit.addRule(function(a, s) { /* isInGroup("wheel") */ return polkit.Result.YES; });`, true},
{"the yes only in a comment", `polkit.addRule(function(a, s) { // return polkit.Result.YES;
return polkit.Result.NO; });`, false},
{"a condition in the same rule", `polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) { return polkit.Result.YES; } });`, false},
{"a comment marker inside a string", `polkit.addRule(function(a, s) { var u = "http://x"; return polkit.Result.YES; });`, true},
}
for _, c := range cases {
if got := PolkitGrantsEveryone("/etc/polkit-1/rules.d/x.rules", c.text); got != c.every {
t.Errorf("%s: grants every account %v, want %v", c.name, got, c.every)
}
}
if PolkitGrantsEveryone("/x.pkla", "# [a]\n# Identity=unix-user:*\n# ResultAny=yes\n") {
t.Error("a .pkla section only in comments grants nobody")
}
if !PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n[b]\nIdentity=unix-user:*\nResultActive=yes\n") {
t.Error("each .pkla section on its own")
}
}
// The package manager is asked about every path found in one call, and answers each: owned, or owned by nothing.
func TestThePackageManagerIsAskedOnceForEveryPathFound(t *testing.T) {
m := clean()
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n/usr/local/bin/x\n/opt/y\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
search := func(e Exec) ([]string, error) {
return e.setuidSearch(t.Context(), &SetuidProgress{Mounts: []string{"/"}}, func() {}, nil)
}
found, err := search(Exec{Run: m.run, Walk: m.walk})
if err != nil {
t.Fatal(err)
}
if m.pacmanCalls != 1 {
t.Errorf("pacman was asked %d times for four paths", m.pacmanCalls)
}
if strings.Join(found, ",") != "/opt/y,/usr/local/bin/x" {
t.Errorf("unowned: %v", found)
}
// An error pacman says that is not "owned by nothing" is no answer.
odd := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" {
return "", errors.New("pacman exited 1: error: could not open database")
}
return m.run(ctx, name, args...)
}
if _, err := search(Exec{Run: odd, Walk: m.walk}); err == nil {
t.Error("a package manager that could not answer was read as an answer")
}
// A walk that did not complete is no answer either.
m.findErr = errors.New("could not read /mnt/data/private: permission denied")
if _, err := search(Exec{Run: m.run, Walk: m.walk}); err == nil {
t.Error("a walk that did not complete was read as an answer")
}
}
-23
View File
@@ -3,7 +3,6 @@ package apply
import (
"context"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
@@ -32,28 +31,6 @@ func TestAnAccessPresentIsConfirmedAndNothingIsChanged(t *testing.T) {
}
}
// Absent is refused, not created. A bind mount whose source does not exist is made by the
// container runtime as root, with whatever mode it picks — the silent wrong-ownership
// 04-ISSUES/026 records. So the host says plainly that the operator must provide the path, rather
// than conjuring a directory it does not own.
func TestAnAccessThatIsAbsentIsRefusedClearlyAndNotCreated(t *testing.T) {
missing := t.TempDir() + "/media/library" // named, never created
d := declare(t, `{"id":"lib","type":"access","path":"`+missing+`"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, noServices, nil, nil)
if err == nil {
t.Fatal("an absent operator-owned path was accepted, and would be created as root by the runtime")
}
if !strings.Contains(err.Error(), "the operator must provide") ||
!strings.Contains(err.Error(), "does not own") {
t.Fatalf("the refusal does not say whose the path is: %v", err)
}
if _, statErr := os.Stat(missing); statErr == nil {
t.Fatal("the host created the path it does not own")
}
}
// Undeclaring an access never removes the path. Unassigning the module that reached the media
// library must not delete the library — that is the data loss ADR 0030 exists to prevent, on a
// directory the mesh never made. The record is dropped; the operator's data is left exactly as it
+242
View File
@@ -0,0 +1,242 @@
package apply
// The POSIX ACL a kept folder carries, as the kernel stores it (novox/hq ADR 0325 §5, issue 567).
//
// **Read and written as the kernel's own extended attribute, never through setfacl.** Linux keeps a file's access
// ACL in the attribute system.posix_acl_access and a folder's default ACL in system.posix_acl_default, each a
// little-endian header (version 2) followed by entries of a tag, a permission and an id. The engine reads and
// writes them on a descriptor it opened itself (fgetxattr, fsetxattr), so a name swapped for a link between the
// look and the change changes nothing, and no program is run per file of a library of millions.
//
// **What the mesh changes is narrow.** It adds or sets one named-user entry per uid it keeps, widens the mask to
// what the entries need, and on a file with no ACL yet writes the owning group's entry as exactly the group bits
// the mode had: owner, owning group, the owner's bits, others' bits and every other entry stay as found.
import (
"encoding/binary"
"errors"
"fmt"
"sort"
)
// The attributes and the tags of the kernel's ACL format (linux/posix_acl_xattr.h).
const (
aclAccessXattr = "system.posix_acl_access"
aclDefaultXattr = "system.posix_acl_default"
aclVersion = 2
aclUserObj = 0x01
aclUser = 0x02
aclGroupObj = 0x04
aclGroup = 0x08
aclMask = 0x10
aclOther = 0x20
// aclUndefinedID is the id of an entry that names nobody: the owner, the owning group, the mask, others.
aclUndefinedID = 0xFFFFFFFF
)
// The permission bits of one entry.
const (
permRead = 4
permWrite = 2
permExec = 1
)
// aclEntry is one entry: its tag, its permission bits (rwx as 4, 2, 1) and, for a named user or group, its id.
type aclEntry struct {
Tag uint16
Perm uint16
ID uint32
}
// acl is a whole ACL as the kernel holds it.
type acl []aclEntry
// decodeACL reads an attribute's bytes. An attribute that is not version 2, or not whole entries, is refused.
func decodeACL(raw []byte) (acl, error) {
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
return nil, fmt.Errorf("an ACL attribute of %d bytes is not a header and whole entries", len(raw))
}
if v := binary.LittleEndian.Uint32(raw); v != aclVersion {
return nil, fmt.Errorf("an ACL attribute of version %d, and the engine reads version %d", v, aclVersion)
}
out := make(acl, 0, (len(raw)-4)/8)
for at := 4; at < len(raw); at += 8 {
out = append(out, aclEntry{
Tag: binary.LittleEndian.Uint16(raw[at:]),
Perm: binary.LittleEndian.Uint16(raw[at+2:]),
ID: binary.LittleEndian.Uint32(raw[at+4:]),
})
}
return out, nil
}
// encode is the attribute's bytes, the entries in the order the kernel requires: by tag, then by id.
func (a acl) encode() []byte {
sorted := append(acl(nil), a...)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].Tag != sorted[j].Tag {
return sorted[i].Tag < sorted[j].Tag
}
return sorted[i].ID < sorted[j].ID
})
out := make([]byte, 4, 4+8*len(sorted))
binary.LittleEndian.PutUint32(out, aclVersion)
for _, e := range sorted {
var b [8]byte
binary.LittleEndian.PutUint16(b[0:], e.Tag)
binary.LittleEndian.PutUint16(b[2:], e.Perm)
binary.LittleEndian.PutUint32(b[4:], e.ID)
out = append(out, b[:]...)
}
return out
}
// minimalACL is the ACL a mode alone stands for: the owner's, the owning group's and others' bits.
func minimalACL(mode uint32) acl {
return acl{
{Tag: aclUserObj, Perm: uint16(mode>>6) & 7, ID: aclUndefinedID},
{Tag: aclGroupObj, Perm: uint16(mode>>3) & 7, ID: aclUndefinedID},
{Tag: aclOther, Perm: uint16(mode) & 7, ID: aclUndefinedID},
}
}
// find is the index of the entry with tag and id, or -1.
func (a acl) find(tag uint16, id uint32) int {
for i, e := range a {
if e.Tag == tag && (e.ID == id || (tag != aclUser && tag != aclGroup)) {
return i
}
}
return -1
}
// perm is the bits of the entry with tag and id, and whether there is one.
func (a acl) perm(tag uint16, id uint32) (uint16, bool) {
if i := a.find(tag, id); i >= 0 {
return a[i].Perm, true
}
return 0, false
}
// check refuses an ACL the kernel would: one owner's, owning group's and others' entry each, and a mask whenever
// a named entry is there.
func (a acl) check() error {
count := map[uint16]int{}
named := false
for _, e := range a {
count[e.Tag]++
if e.Tag == aclUser || e.Tag == aclGroup {
named = true
}
}
if count[aclUserObj] != 1 || count[aclGroupObj] != 1 || count[aclOther] != 1 || count[aclMask] > 1 {
return errors.New("an ACL without exactly one owner's, owning group's and others' entry")
}
if named && count[aclMask] != 1 {
return errors.New("an ACL with named entries and no mask")
}
return nil
}
// keptACL is a with the mesh's entries made: for each uid of need its entry set to exactly its bits, and the mask
// widened by what those entries need — never narrowed, so an app's wider mask stays. A mask already there keeps
// what it gave every other entry: one holding a bit the widening adds loses that bit alone; with none yet (a file the mode alone described) the mask starts at the owning
// group's bits, which minimalACL gave its entry, so the owning group keeps exactly the right its group bits gave.
// Nothing else is changed.
func keptACL(a acl, need map[uint32]uint16) acl {
out := append(acl(nil), a...)
var widen uint16
for uid, bits := range need {
widen |= bits
if i := out.find(aclUser, uid); i >= 0 {
out[i].Perm = bits
} else {
out = append(out, aclEntry{Tag: aclUser, Perm: bits, ID: uid})
}
}
if i := out.find(aclMask, 0); i >= 0 {
old := out[i].Perm
out[i].Perm |= widen
if added := out[i].Perm &^ old; added != 0 {
// **A wider mask widens nobody else.** The owning group's entry and every named entry the mesh does
// not set here were limited by the old mask: a file made at 0644 under a default ACL carries the
// owning group's rwx with a mask of r--. One mask serves them all, so an entry holding a bit the mask
// now adds cannot keep its effective right without losing that bit: exactly those bits are taken off
// it, and nothing else of it changes. An entry holding none of them is not touched at all.
for j := range out {
e := &out[j]
_, ours := need[e.ID]
if (e.Tag == aclGroupObj || e.Tag == aclGroup || (e.Tag == aclUser && !ours)) && e.Perm&added != 0 {
e.Perm &^= added
}
}
}
return out
}
if len(need) == 0 {
return out
}
group, _ := out.perm(aclGroupObj, 0)
return append(out, aclEntry{Tag: aclMask, Perm: group | widen, ID: aclUndefinedID})
}
// withoutUsers is a with the named-user entry of each uid of drop taken out, and nothing else changed: the mask
// stays as it was, so no other entry loses a right.
func withoutUsers(a acl, drop map[uint32]bool) acl {
out := make(acl, 0, len(a))
for _, e := range a {
if e.Tag == aclUser && drop[e.ID] {
continue
}
out = append(out, e)
}
return out
}
// effective is the right a uid has through a, given the file's owner and mode: the owner's bits for the owner,
// else its named entry limited by the mask, else nothing a named entry gives (the owning group's and others'
// rights are not the mesh's to count on). An ACL with no mask limits nothing.
func effective(a acl, uid, owner uint32, mode uint32) uint16 {
if uid == owner {
return uint16(mode>>6) & 7
}
bits, ok := a.perm(aclUser, uid)
if !ok {
return 0
}
if mask, has := a.perm(aclMask, 0); has {
bits &= mask
}
return bits
}
// needOn is what a use needs on a folder (read and enter; with read-write, write too) or on a file (read; with
// read-write, write): entering means nothing on a file, and an execute bit given to data would be a right nobody
// asked for.
func needOn(readWrite, folder bool) uint16 {
bits := uint16(permRead)
if readWrite {
bits |= permWrite
}
if folder {
bits |= permExec
}
return bits
}
// permWords is bits as ls writes them: "rwx", "r-x".
func permWords(bits uint16) string {
out := []byte("---")
if bits&permRead != 0 {
out[0] = 'r'
}
if bits&permWrite != 0 {
out[1] = 'w'
}
if bits&permExec != 0 {
out[2] = 'x'
}
return string(out)
}
+76
View File
@@ -0,0 +1,76 @@
package apply
// What is never placed below an agent's home (novox/hq ADR 0266 §7).
//
// An account that never becomes root owns every directory in its home, so it can swap a link into the path
// between the engine's check and the engine's write. The engine writes a whole file there through a
// descriptor that followed no link (home_links.go), but it unpacks an archive, and writes into an existing
// file, by path. So below the home of an account the declaration says never becomes root, neither is placed.
// The catalogue's own check (checks/agenthome) refuses either under `${machine:agent-home}`, so no module
// asks; this is the engine refusing it again where it applies. The machine's own trees, other accounts'
// homes and a directory's owner below a home are placement_guard.go's (novox/hq issue 339).
import (
"fmt"
"path/filepath"
"github.com/novox/mesh-host/internal/declaration"
)
// AgentHomeRefusedError is an archive or a written-into file the engine will not place below an agent's home.
type AgentHomeRefusedError struct {
Path, What, Home string
}
func (e *AgentHomeRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s below %s, the home of an account that never becomes root, which "+
"owns every parent there and could swap a link in between the check and the write (novox/hq ADR 0266); "+
"nothing was touched", e.Path, e.What, e.Home)
}
// refuseUnderAgentHome says why a resource is not placed below one of agentHomes; nil when it may be.
func refuseUnderAgentHome(r declaration.Resource, agentHomes []string) error {
what := ""
switch res := r.(type) {
case *declaration.Archive:
what = "an archive unpacked"
case *declaration.File:
if res.Into != "" {
what = "a file written into (" + res.Into + ")"
}
}
if what == "" {
return nil
}
path := filepath.Clean(r.Target())
for _, p := range []string{path, resolved(path)} {
for _, home := range agentHomes {
if atOrBelow(p, home) {
return &AgentHomeRefusedError{Path: path, What: what, Home: home}
}
}
}
return nil
}
// rootNeverHomes is the home of every account the declaration says never becomes root, from the user
// database; an account not made yet has no home to protect.
func rootNeverHomes(d *declaration.Declaration) []string {
if d == nil {
return nil
}
homes := accountsOfHomes()
var out []string
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
if !ok || u.Root != declaration.RootNever {
continue
}
for home, a := range homes {
if a.Name == u.Name {
out = append(out, home)
}
}
}
return out
}
+36
View File
@@ -0,0 +1,36 @@
package apply
// Defends novox/hq ADR 0266 §7: no archive is unpacked and no file is written into below the home of an account
// the declaration says never becomes root; a whole file there still passes.
import (
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) {
was := accountsOfHomes
accountsOfHomes = func() map[string]homeAccount {
return map[string]homeAccount{"/home/agent": {"agent", 1001}, "/home/operator": {"operator", 1000}}
}
t.Cleanup(func() { accountsOfHomes = was })
agent := []string{"/home/agent"}
if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/agent/.local/x", Owner: "agent"}, agent); err == nil {
t.Error("an archive below an agent's home is refused")
}
if err := refuseUnderAgentHome(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/.claude.json", Owner: "agent", Into: "json", Content: "{}"}, agent); err == nil {
t.Error("a file written into below an agent's home is refused")
}
if err := refuseUnderAgentHome(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/x", Owner: "agent", Content: "x"}, agent); err != nil {
t.Errorf("a whole file there passes: %v", err)
}
if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/operator/x", Owner: "operator"}, agent); err != nil {
t.Errorf("an archive in a home whose account may become root is placement_guard.go's to judge, not this: %v", err)
}
d := parse(t, `{"declaration":1,"resources":[{"id":"c.agent","type":"user","name":"agent","root":"never"},`+
`{"id":"c.op","type":"user","name":"operator"}]}`)
if got := rootNeverHomes(d); len(got) != 1 || got[0] != "/home/agent" {
t.Errorf("the agent's home is known by the user database: %v", got)
}
}
+1391 -133
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -313,6 +313,7 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
// running. A unit that starts and immediately dies satisfies the command.
started := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "show" {
if started {
return "LoadState=loaded\nActiveState=failed\n", nil // started, then died
@@ -337,6 +338,7 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=loaded\nActiveState=reticent\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -371,6 +373,7 @@ func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
commands = append(commands, strings.Join(args, " "))
if args[0] == "show" {
return "LoadState=loaded\nActiveState=active\n", nil
@@ -413,6 +416,7 @@ func TestAServiceRecordsTheStateItWasFoundInOnceAndCarriesIt(t *testing.T) {
active := "inactive"
enabled := "disabled"
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "show":
return "LoadState=loaded\nActiveState=" + active + "\n", nil
@@ -464,6 +468,7 @@ func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
// Absence read as satisfaction — 04-ISSUES/007 wearing a different hat, and the mirror of
// the degraded-init bug the capability detector had.
absent := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=not-found\nActiveState=inactive\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -486,6 +491,7 @@ func TestAMaskedUnitIsRefused(t *testing.T) {
// Masked means someone deliberately made it unstartable. Applying over that would undo a
// decision the host did not make and cannot see the reason for.
masked := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "LoadState=masked\nActiveState=inactive\n", nil
}
d := parse(t, `{"declaration":1,"resources":[
@@ -506,6 +512,7 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
// already true.
var stopped bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "show" {
return "LoadState=not-found\nActiveState=inactive\n", nil
}
@@ -557,6 +564,7 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
// answer would silently reinstall on a machine whose package manager is broken, or report
// "installed nothing" as success. The apply must fail instead.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "", errors.New("pacman: error: could not lock database")
}
d := parseTrusted(t, `{"declaration":1,"resources":[
@@ -575,6 +583,7 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
func TestAnInstalledPackageIsNotReinstalled(t *testing.T) {
var installed bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if args[0] == "-S" {
installed = true
}
@@ -602,6 +611,7 @@ func TestAPackageIsNeverUninstalled(t *testing.T) {
// machine may have had it before the mesh ever saw it. Undeclaring is not "remove it".
var uninstalled bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if len(args) > 0 && (args[0] == "-R" || args[0] == "-Rs") {
uninstalled = true
}
@@ -635,6 +645,7 @@ func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) {
// database is, so "is it already there" is a question only the declaration can ask.
var ran bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "create-db" {
ran = true
}
@@ -661,6 +672,7 @@ func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) {
// this repository's most expensive failure shape. Here the command "succeeds" every time
// and verify never passes.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "has-db" {
return "", errors.New("no such database")
}
@@ -687,6 +699,7 @@ func TestAnActionRunsInsideTheContainerItNames(t *testing.T) {
// there is any mesh to ask.
var sawExec bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" && args[0] == "exec" && args[1] == "store" {
sawExec = true
return "", nil
@@ -710,6 +723,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
// still running. A container whose entrypoint dies satisfies the command exactly as one
// that came up does — which is the read-back rule, in the place it matters most.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case args[0] == "info":
return "27.0\n", nil
@@ -748,6 +762,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -786,6 +801,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
var touched bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -831,6 +847,7 @@ func TestAContainerIsRecreatedWhenARestartOnResourceChanged(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
@@ -886,6 +903,7 @@ func TestRestartOnFiresOnlyForResourcesThatChanged(t *testing.T) {
func systemctlStub(t *testing.T, load, active, enabled string, verbs *[]string) Runner {
t.Helper()
return func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "show":
return "LoadState=" + load + "\nActiveState=" + active + "\n", nil
@@ -1043,6 +1061,7 @@ func TestTheRuntimeProbeIsPerRuntime(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var probedWith string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != tc.present {
return "", errors.New("not installed")
}
@@ -1071,6 +1090,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
// machine already has (novox/hq research 012), so hardcoding one contradicts it.
var calledWith []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" {
return "", errors.New("not installed")
}
@@ -1107,6 +1127,7 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) {
// Naming what was tried, because "docker: command not found" on a machine that deliberately
// runs podman sends the reader looking for the wrong thing.
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
return "", errors.New("not installed")
}
d := parseTrusted(t, `{"declaration":1,"resources":[
@@ -1250,6 +1271,7 @@ func TestAServiceIsNotRestartedByAChangeItDoesNotName(t *testing.T) {
// before and after one.
func recordingServices(commands *[]string) Runner {
return func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
line := name + " " + strings.Join(args, " ")
*commands = append(*commands, line)
switch {
@@ -1398,6 +1420,7 @@ func TestAFailedActionStopsWhatFollows(t *testing.T) {
func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -1433,6 +1456,7 @@ func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
func TestAContainerGivenNoNamesIsRunAsBefore(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -1477,6 +1501,7 @@ func TestResourcesAreAppliedInTheOrderTheyWereDeclared(t *testing.T) {
var order []string
done := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
order = append(order, name+" "+strings.Join(args, " "))
// `verify` is the idempotency check, so it has to fail before the step and pass after —
// a stub that always succeeds means every action is already done and nothing ever runs,
@@ -1565,6 +1590,12 @@ func TestADirectoryHoldingAnythingTheMeshDidNotPutThereIsKept(t *testing.T) {
for _, o := range report.Outcomes {
if o.Action == "kept" && strings.Contains(o.Detail, "did not put there") {
said = true
// And it says how data goes, which is never by hand on this line's word (novox/hq ADR
// 0233): a person's `cleanup delete`, after a last restore point.
if strings.Contains(o.Detail, "by hand") || !strings.Contains(o.Detail, "never deletes data") ||
!strings.Contains(o.Detail, "cleanup delete") {
t.Errorf("the kept directory is said as %q", o.Detail)
}
}
}
if !said {
@@ -1621,6 +1652,7 @@ func TestAContainerStaleFromAnEarlierApplyIsReplaced(t *testing.T) {
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
+120 -13
View File
@@ -35,11 +35,11 @@ import (
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
body, err := fetch(ctx, r.Source)
body, err := fetchMinding(ctx, r.Source, away)
if err != nil {
return out, err
}
@@ -56,26 +56,28 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
out.wrote = got
// Already what it should be. The digest is the whole identity of an archive, so a matching
// record means the unpacked tree came from these exact bytes.
if previous.Wrote == got {
// record means the unpacked tree came from these exact bytes — at this path: a record of the
// same bytes somewhere else says nothing about what is here.
if previous.Wrote == got && previous.Target == r.Path {
if _, err := os.Stat(r.Path); err == nil {
owned, err := ownedBy(r.Path, r.Owner)
owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), r.Owner)
if err == nil && owned {
// What it unpacked is carried, or — on a record from before the host kept it — read
// from the archive now, so the record can say it from here on (novox/hq issue 162).
out.unpacked, err = stillUnpacked(body, r.Path, previous.Unpacked)
if err != nil {
return out, err
}
return out, nil
}
}
}
if err := os.MkdirAll(r.Path, 0o755); err != nil {
return out, err
}
written, err := unpack(body, r.Path)
unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path))
if err != nil {
return out, err
}
if err := ownAll(r.Path, r.Owner); err != nil {
return out, err
}
out.unpacked = &unpacked
out.Action = "updated"
if previous.Wrote == "" {
out.Action = "created"
@@ -84,6 +86,111 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
return out, nil
}
// replaceWith makes the directory exactly the archive (novox/hq issue 220), and says what it put
// there (novox/hq issue 162).
//
// **The tree on disk is the archive and nothing else — of what the mesh put there.** The digest is
// the whole identity of what is unpacked here, so a file the previous archive had and this one does
// not must go. Unpacked over the old tree, it stayed: a bundle rebuilt as one file per entrypoint
// kept the package directory of the version before, which code could still import, and a fix that
// removed a file worked on a fresh machine only. So the archive is unpacked into a fresh directory
// beside the old one, owned, and swapped in by rename. A running process keeps the files it has open,
// and the old tree is removed only once the new one is in place. A failed unpack leaves the old tree
// untouched.
//
// **What the mesh did not put there is never swapped away** (novox/hq issue 162, ADR 0030). The
// swap is for a directory that is the host's own: one it made, holding nothing but what the mesh
// put there. A directory that was there before the archive, or that something else has written
// into since, is the machine's: the archive's files are moved into it one by one, what the previous
// archive placed and this one does not is taken out, and everything else is left as it is. A file
// the archive would write over that the mesh did not put there refuses the archive before anything
// is moved — unless it already holds exactly the archive's bytes.
func replaceWith(body []byte, path, owner string, o ours) (store.Unpacked, int, error) {
parent := filepath.Dir(path)
madeParents, err := makeDirsSaying(parent, 0o755, owner)
if err != nil {
return store.Unpacked{}, 0, err
}
parents := joinParents(madeParents, o.parents)
fresh := path + ".unpacking"
replaced := path + ".replaced"
// What an interrupted earlier attempt — or removal — left beside the directory.
for _, leftover := range []string{fresh, replaced, path + ".removing"} {
if err := os.RemoveAll(leftover); err != nil {
return store.Unpacked{}, 0, err
}
}
if err := os.Mkdir(fresh, 0o755); err != nil {
return store.Unpacked{}, 0, err
}
written, err := unpack(body, fresh)
if err == nil {
err = ownAll(fresh, owner)
}
var files, dirs []string
if err == nil {
files, dirs, err = treeOf(fresh)
}
if err != nil {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
info, err := os.Lstat(path)
existed := err == nil
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
if existed && !info.IsDir() {
// Swapped, it would be deleted: a file at the path is nothing an archive put there.
os.RemoveAll(fresh)
return store.Unpacked{}, written, fmt.Errorf(
"%s is there and is not a directory, and the mesh did not put it there; nothing was unpacked", path)
}
foreign := 0
if existed && !o.all {
if foreign, err = foreignIn(path, o.paths); err != nil {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
}
if existed && (foreign > 0 || !(o.made || o.all)) {
u, err := mergeInto(fresh, path, owner, files, dirs, o)
os.RemoveAll(fresh)
if err != nil {
return store.Unpacked{}, written, err
}
u.Parents = parents
return u, written, nil
}
hadOne := true
if err := os.Rename(path, replaced); err != nil {
if !os.IsNotExist(err) {
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
hadOne = false
}
if err := os.Rename(fresh, path); err != nil {
if hadOne {
// Put the old tree back rather than leave nothing at the path.
os.Rename(replaced, path)
}
os.RemoveAll(fresh)
return store.Unpacked{}, written, err
}
// The directory is the host's own: it made it, now or before, and nothing else is in it.
u := store.Unpacked{Files: files, Dirs: dirs, Made: true, Parents: parents}
if hadOne {
if err := os.RemoveAll(replaced); err != nil {
return u, written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
}
}
return u, written, nil
}
func fetch(ctx context.Context, source string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
@@ -95,7 +202,7 @@ func fetch(ctx context.Context, source string) ([]byte, error) {
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", source, response.Status)
return nil, statusError{source: source, code: response.StatusCode, status: response.Status}
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
if err != nil {
+261
View File
@@ -0,0 +1,261 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 162: an archive can be undeclared. What it unpacked is gone, anything that
// was in its directory beforehand is still there, and the apply that removed it applied everything
// else in the same declaration.
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func archiveDecl(t *testing.T, id, path string, files map[string]string, more string) (string, string) {
t.Helper()
body, digest := anArchive(t, files)
return `{"id":"` + id + `","type":"archive","source":"` + serving(t, body) + `","digest":"` + digest +
`","path":"` + path + `"}` + more, digest
}
func TestUnassigningAnArchiveRemovesExactlyWhatItUnpacked(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "bundles", "notes", "tools")
archive, _ := archiveDecl(t, "notes.tools", target,
map[string]string{"index.js": "x", "lib/one.js": "1", "lib/two.js": "2"},
`,{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("notes.tools"); rec.Unpacked == nil || !rec.Unpacked.Made ||
len(rec.Unpacked.Files) != 3 || len(rec.Unpacked.Parents) != 2 {
t.Fatalf("what the archive unpacked was not recorded: %+v", rec.Unpacked)
}
// Unassigned, and the same push declares something new: both happen.
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"},
{"id":"other.conf","type":"file","path":"`+dir+`/other.conf","content":"b"}`)
report, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("undeclaring an archive stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "removed" {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "other.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("the archive is still on record")
}
// The directory it was unpacked into and the parents the host made to reach it, all gone.
if exists(filepath.Join(dir, "bundles")) {
t.Fatal("what the host made for the archive is still there")
}
if !exists(filepath.Join(dir, "notes.conf")) {
t.Fatal("a file the archive did not place was removed")
}
if entries, _ := os.ReadDir(dir); len(entries) != 2 {
t.Fatalf("%d entries left in the directory", len(entries))
}
}
func TestADirectoryFoundBeforeTheArchiveKeepsWhatWasInIt(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "powerlevel10k")
if err := os.MkdirAll(filepath.Join(target, "lib"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(target, "mine.zsh"), []byte("somebody's"), 0o644)
os.WriteFile(filepath.Join(target, "lib", "mine.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target,
map[string]string{"p10k.zsh": "theme", "lib/theme.zsh": "lib", "gitstatus/gs": "gs"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Applying over a directory that was there keeps what was in it.
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the apply: %q", kept, got)
}
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "theme.zsh")); string(got) != "lib" {
t.Fatalf("lib/theme.zsh is %q", got)
}
rec, _ := state.Find("shell.theme")
if rec.Unpacked == nil || rec.Unpacked.Made || strings.Join(rec.Unpacked.Dirs, ",") != "gitstatus" {
t.Fatalf("recorded as %+v", rec.Unpacked)
}
// A new version without one of its files: that file goes, nothing else does.
archive, _ = archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme 2"}, "")
if _, state, err = Apply(context.Background(), archHost(t), declare(t, archive), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "lib", "theme.zsh")) || exists(filepath.Join(target, "gitstatus")) {
t.Fatal("the previous version's files are still there")
}
if !exists(filepath.Join(target, "lib", "mine.zsh")) {
t.Fatal("a file the mesh did not put there went with the previous version")
}
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "shell.theme"); o.Action != "removed" || !strings.Contains(o.Detail, "there before") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) {
t.Fatal("the archive's file is still there")
}
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the removal: %q", kept, got)
}
}
}
// A file the archive would write over that the mesh did not put there refuses the archive, and the
// directory is left exactly as it was.
func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme", "x": "x"}, "")
if _, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil); err == nil || !strings.Contains(err.Error(), "did not put there") {
t.Fatalf("written over: %v", err)
}
if got, _ := os.ReadFile(filepath.Join(target, "p10k.zsh")); string(got) != "somebody's" {
t.Fatalf("p10k.zsh is %q", got)
}
if exists(filepath.Join(target, "x")) {
t.Fatal("part of a refused archive was moved in")
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the directory", len(entries))
}
}
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "t", "lib/a.zsh": "a"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Somebody writes into the directory the host made.
os.WriteFile(filepath.Join(target, "lib", "local.zsh"), []byte("mine"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "shell.theme")
if o.Action != "removed" || !strings.Contains(o.Detail, "did not put there") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) || exists(filepath.Join(target, "lib", "a.zsh")) {
t.Fatal("the archive's files are still there")
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "local.zsh")); string(got) != "mine" {
t.Fatalf("a file the archive did not place: %q", got)
}
}
// An archive recorded before the host kept what it unpacked cannot be told from anything else in
// its directory: it is left in place, said and forgotten, and the apply goes on.
func TestAnArchiveRecordedBeforeItsFilesWereKeptIsLeftAndForgotten(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "index.js"), []byte("x"), 0o644)
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: "sha256:old", Origin: store.OriginDeclared},
}}
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("an archive from before stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "forgotten" || !strings.Contains(o.Detail, "left in place") {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "notes.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("still on record")
}
if !exists(filepath.Join(target, "index.js")) {
t.Fatal("removed without a record of what it unpacked")
}
}
// One recorded before, still declared and unchanged, is read from its own bytes on the next apply,
// so it can be undeclared from then on: the whole directory when it holds exactly the archive, only
// the archive's files when it holds anything else.
func TestAnArchiveRecordedBeforeLearnsWhatItUnpacked(t *testing.T) {
for _, extra := range []bool{false, true} {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
files := map[string]string{"index.js": "x", "lib/a.js": "a"}
archive, digest := archiveDecl(t, "notes.tools", target, files, "")
for name, body := range files {
os.MkdirAll(filepath.Dir(filepath.Join(target, name)), 0o755)
os.WriteFile(filepath.Join(target, name), []byte(body), 0o644)
}
if extra {
os.WriteFile(filepath.Join(target, "lib", "local.js"), []byte("mine"), 0o644)
}
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: digest, Origin: store.OriginDeclared},
}}
report, state, err := Apply(context.Background(), archHost(t), declare(t, archive), known,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "unchanged" {
t.Fatalf("an unchanged archive was %s", o.Action)
}
rec, _ := state.Find("notes.tools")
if rec.Unpacked == nil || len(rec.Unpacked.Files) != 2 || rec.Unpacked.Made == extra {
t.Fatalf("extra=%v: learned %+v", extra, rec.Unpacked)
}
if _, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "index.js")) || exists(filepath.Join(target, "lib", "a.js")) {
t.Fatalf("extra=%v: the archive's files are still there", extra)
}
if exists(target) != extra {
t.Fatalf("extra=%v: the directory is there: %v", extra, exists(target))
}
if extra && !exists(filepath.Join(target, "lib", "local.js")) {
t.Fatal("a file the archive did not place was removed")
}
}
}
+68
View File
@@ -0,0 +1,68 @@
package apply
import (
"context"
"os"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 220: the tree on disk is exactly the archive. A file the previous archive had and
// this one does not is gone, and nothing is left beside the directory.
func TestAnArchiveReplacesTheTreeItWasUnpackedOver(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old", "node_modules/dep/index.js": "dep"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
second, secondDigest := anArchive(t, map[string]string{"index.js": "one file"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, second)+
`","digest":"`+secondDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "one file" {
t.Fatalf("index.js is %q", got)
}
if _, err := os.Stat(dir + "/tools/node_modules"); err == nil {
t.Fatal("the previous archive's directory is still there")
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
names := []string{}
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("beside the tree: %v", names)
}
}
// A tree is replaced only by one that unpacked whole: an archive refused halfway leaves the old
// tree as it was.
func TestARefusedArchiveLeavesTheTreeItWouldHaveReplaced(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
bad, badDigest := anArchive(t, map[string]string{"index.js": "new", "../../escaped": "no"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, bad)+
`","digest":"`+badDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err == nil {
t.Fatal("an escaping archive was accepted")
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "old" {
t.Fatalf("index.js is %q after a refused archive", got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the tree after a refused archive", len(entries))
}
}
+506
View File
@@ -0,0 +1,506 @@
package apply
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-host/internal/store"
)
// What an archive put on the machine, and taking exactly that away (novox/hq issue 162).
//
// An archive unpacks many files into a directory the mesh did not necessarily make, so undeclaring
// one has a real question in it: remove what the archive put there, or remove the directory? The
// second deletes whatever else lives there — for the host's own versions directory, every other
// delivered version. So the host records what each archive unpacked and whether it made the
// directory, and removal takes away exactly that: the files the archive placed, then the
// directories the host made for them once they are empty. Never a file the archive did not place,
// never a directory that was there before, never one that still holds anything else. It is the
// rule every other kind follows: the mesh gives back what it found (ADR 0118), and data outlives
// the mesh that declared it (ADR 0030).
// ours is what of the tree at an archive's path the record says is the mesh's.
type ours struct {
// all is a record from before the host kept what an archive unpacked: since the swap of issue
// 220 the tree at the path was the archive and nothing else, so the whole of it is taken for
// the mesh's, as the swap that follows has always taken it.
all bool
// paths are the files and directories the previous archive put there, relative to the path.
paths map[string]bool
files []string
dirs []string
made bool
// parents are the directories above the path the host made to reach it, deepest first.
parents []string
}
// oursFrom reads the record of the archive before this apply, for this path only: a record of the
// same archive at a path it has moved from says nothing about what is at the new one.
func oursFrom(previous store.Applied, path string) ours {
if previous.Wrote == "" || previous.Target != path {
return ours{}
}
u := previous.Unpacked
if u == nil {
return ours{all: true, made: true}
}
o := ours{paths: map[string]bool{}, files: u.Files, dirs: u.Dirs, made: u.Made, parents: u.Parents}
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
o.paths[rel] = true
}
return o
}
// ownershipProbe is what says whether an archive is still its owner's. The directory, when the host
// made it; one of the archive's own files when the directory was there before — that one is held as
// found (ADR 0182), so its owner is never the archive's to judge.
func ownershipProbe(path string, u *store.Unpacked) string {
if u != nil && !u.Made && len(u.Files) > 0 {
return filepath.Join(path, u.Files[0])
}
return path
}
// stillUnpacked is what an unchanged archive has on the machine. The record's, when it has one; on
// a record from before the host kept it, read from the archive's own bytes now — and the directory
// is taken for the host's only when it holds exactly the archive and nothing else, which is what the
// swap of issue 220 leaves. Otherwise the directory is kept for somebody's, and only the archive's
// files are recorded as its.
func stillUnpacked(body []byte, path string, recorded *store.Unpacked) (*store.Unpacked, error) {
if recorded != nil {
kept := *recorded
return &kept, nil
}
files, dirs, err := listArchive(body)
if err != nil {
return nil, err
}
u := &store.Unpacked{Files: pathsOf(files)}
if exactly, err := holdsExactly(path, files, dirs); err == nil && exactly {
u.Dirs = pathsOf(dirs)
u.Made = true
}
return u, nil
}
// listArchive reads what an archive holds without unpacking it: each file's digest by its path, and
// every directory, named or implied, relative to where it unpacks. Refused on the same terms as
// unpack, so a listing never names a path an unpack would not write.
func listArchive(body []byte) (map[string]string, map[string]bool, error) {
zipped, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("this is not a gzipped tar: %w", err)
}
defer zipped.Close()
files, dirs := map[string]string{}, map[string]bool{}
reader := tar.NewReader(zipped)
for {
header, err := reader.Next()
if err == io.EOF {
return files, dirs, nil
}
if err != nil {
return nil, nil, err
}
rel := filepath.Clean(header.Name)
if rel == "." {
continue
}
if !insideRel(rel) {
return nil, nil, fmt.Errorf("%s names a path outside the archive", header.Name)
}
for d := filepath.Dir(rel); d != "."; d = filepath.Dir(d) {
dirs[filepath.ToSlash(d)] = true
}
switch header.Typeflag {
case tar.TypeDir:
dirs[filepath.ToSlash(rel)] = true
case tar.TypeReg:
sum := sha256.New()
if _, err := io.Copy(sum, io.LimitReader(reader, maxArchive)); err != nil {
return nil, nil, err
}
files[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil))
default:
return nil, nil, fmt.Errorf("%s is a %c, and this host unpacks only files and directories",
header.Name, header.Typeflag)
}
}
}
// holdsExactly is whether a directory holds the archive's files with the archive's bytes, its
// directories, and nothing else.
func holdsExactly(root string, files map[string]string, dirs map[string]bool) (bool, error) {
seen := 0
exact := true
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
switch {
case d.IsDir():
if !dirs[rel] {
exact = false
return filepath.SkipAll
}
case d.Type().IsRegular():
want, ok := files[rel]
if !ok || digestOfFile(path) != want {
exact = false
return filepath.SkipAll
}
seen++
default:
exact = false
return filepath.SkipAll
}
return nil
})
if err != nil {
return false, err
}
return exact && seen == len(files), nil
}
func digestOfFile(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
sum := sha256.New()
if _, err := io.Copy(sum, file); err != nil {
return ""
}
return hex.EncodeToString(sum.Sum(nil))
}
// treeOf is every file and directory under root, relative to it, slash-separated and sorted.
func treeOf(root string) (files, dirs []string, err error) {
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return err
}
if d.IsDir() {
dirs = append(dirs, filepath.ToSlash(rel))
} else {
files = append(files, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(files)
sort.Strings(dirs)
if files == nil {
files = []string{}
}
return files, dirs, err
}
// foreignIn counts what under root the mesh did not put there. A directory that is not the mesh's
// counts once, with everything in it.
func foreignIn(root string, mine map[string]bool) (int, error) {
count := 0
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return err
}
if !mine[filepath.ToSlash(rel)] {
count++
if d.IsDir() {
return filepath.SkipDir
}
}
return nil
})
return count, err
}
// mergeInto moves a freshly unpacked archive into a directory that is not only the mesh's, one entry
// at a time, and takes out what the previous archive placed that this one does not. Everything the
// mesh did not put there stays as it is. Collisions are looked for before anything is moved, so a
// refused archive leaves the directory exactly as it was.
func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.Unpacked, error) {
var collisions []string
for _, rel := range dirs {
info, err := os.Lstat(filepath.Join(path, filepath.FromSlash(rel)))
if err == nil && !info.IsDir() && !o.paths[rel] {
collisions = append(collisions, rel)
}
}
for _, rel := range files {
dest := filepath.Join(path, filepath.FromSlash(rel))
info, err := os.Lstat(dest)
switch {
case err != nil:
case o.paths[rel] && !info.IsDir():
case info.IsDir():
if !o.paths[rel] {
collisions = append(collisions, rel)
} else if n, err := foreignIn(dest, o.paths); err != nil || n > 0 {
collisions = append(collisions, rel)
}
case !info.Mode().IsRegular() ||
digestOfFile(dest) != digestOfFile(filepath.Join(fresh, filepath.FromSlash(rel))):
// Already holding exactly the archive's bytes is not a collision: it is what an
// interrupted earlier apply of this same archive left, or the same file either way.
collisions = append(collisions, rel)
}
}
if len(collisions) > 0 {
shown := collisions
if len(shown) > 5 {
shown = shown[:5]
}
return store.Unpacked{}, fmt.Errorf("%s already holds %d path(s) the archive would write over "+
"and the mesh did not put there (%s); nothing was unpacked, and what is there is left as it "+
"is (novox/hq issue 162)", path, len(collisions), strings.Join(shown, ", "))
}
var made []string
for _, rel := range dirs {
dest := filepath.Join(path, filepath.FromSlash(rel))
info, err := os.Lstat(dest)
if err == nil && info.IsDir() {
if o.paths[rel] {
made = append(made, rel)
}
continue
}
if err == nil {
// The previous archive's file where this one has a directory.
if err := os.Remove(dest); err != nil {
return store.Unpacked{}, err
}
}
mode := os.FileMode(0o755)
if from, err := os.Stat(filepath.Join(fresh, filepath.FromSlash(rel))); err == nil {
mode = from.Mode().Perm()
}
if err := os.Mkdir(dest, mode); err != nil {
return store.Unpacked{}, err
}
if err := own(dest, owner); err != nil {
return store.Unpacked{}, err
}
made = append(made, rel)
}
for _, rel := range files {
dest := filepath.Join(path, filepath.FromSlash(rel))
if info, err := os.Lstat(dest); err == nil && info.IsDir() {
// The previous archive's directory where this one has a file, holding nothing else.
if err := os.RemoveAll(dest); err != nil {
return store.Unpacked{}, err
}
}
if err := os.Rename(filepath.Join(fresh, filepath.FromSlash(rel)), dest); err != nil {
return store.Unpacked{}, err
}
}
// What the previous archive placed and this one does not.
now := map[string]bool{}
for _, rel := range append(append([]string{}, files...), dirs...) {
now[rel] = true
}
for _, rel := range o.files {
if now[rel] {
continue
}
if err := os.Remove(filepath.Join(path, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) {
return store.Unpacked{}, err
}
}
for _, rel := range deepestFirst(o.dirs) {
if now[rel] {
continue
}
dest := filepath.Join(path, filepath.FromSlash(rel))
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
// Still holding something the mesh did not put there: kept, and still the host's to
// take away once it is empty.
made = append(made, rel)
}
}
sort.Strings(made)
return store.Unpacked{Files: files, Dirs: made, Made: o.made}, nil
}
// removeArchive is what undeclaring an archive does (novox/hq issue 162): exactly the files it
// unpacked, then the directories the host made for them once they are empty.
//
// **Never fatal.** An archive that could not be removed stopped the whole apply, on every apply
// after, until it was declared again — so every module with tools was un-unassignable, and a race
// between two pushes froze a machine against every other change. Whatever cannot be taken away is
// said, left in place, and forgotten, as a former target is (issue 194).
//
// **Removed whole when it is the host's own, and in one step.** A directory the host made that
// holds nothing but the archive is renamed aside and then removed: a reader — the runtime serving a
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
// open stays readable until it closes it. The runtime is told the module went by its own membership,
// not by the files disappearing.
func removeArchive(a store.Applied) (string, string, error) {
if store.IsFormer(a.ID) {
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
// open; the launcher retires the host's own versions, not the apply (issue 194).
return "forgotten", "a former target left in place: only an archive the declaration dropped is " +
"taken away (novox/hq issues 162, 194)", nil
}
u := a.Unpacked
if u == nil {
return "forgotten", "left in place: recorded before the host kept what an archive unpacked, so " +
"its files cannot be told from anything else there (novox/hq issue 162)", nil
}
root := filepath.Clean(a.Target)
mine := map[string]bool{}
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
if !insideRel(filepath.FromSlash(rel)) {
return "forgotten", fmt.Sprintf("left in place: its record names %q, which is not inside %s",
rel, root), nil
}
mine[rel] = true
}
info, err := os.Lstat(root)
if os.IsNotExist(err) {
removeParents(root, u.Parents)
return "forgotten", "no longer there", nil
}
if err != nil {
return "forgotten", fmt.Sprintf("left in place: %v", err), nil
}
if !info.IsDir() {
return "forgotten", "left in place: no longer a directory, so not what the archive was unpacked into", nil
}
foreign, err := foreignIn(root, mine)
if err != nil {
return "forgotten", fmt.Sprintf("left in place: cannot read what is in it: %v", err), nil
}
if u.Made && foreign == 0 {
aside := root + ".removing"
if err := os.RemoveAll(aside); err == nil {
if err := os.Rename(root, aside); err == nil {
if err := os.RemoveAll(aside); err != nil {
return "forgotten", fmt.Sprintf("taken out of place, and what it unpacked could not be "+
"removed from %s: %v — remove it by hand", aside, err), nil
}
removeParents(root, u.Parents)
return "removed", fmt.Sprintf("no longer declared; the %d file(s) it unpacked, and the "+
"directory the host made for them", len(u.Files)), nil
}
}
// A rename that could not be made is taken file by file instead.
}
removed := 0
var failed []string
for _, rel := range u.Files {
err := os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
switch {
case err == nil:
removed++
case os.IsNotExist(err):
default:
failed = append(failed, err.Error())
}
}
for _, rel := range deepestFirst(u.Dirs) {
// Only once empty: what is still inside is somebody's.
_ = os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
}
detail := fmt.Sprintf("no longer declared; %d file(s) it unpacked removed", removed)
switch {
case u.Made && os.Remove(root) == nil:
removeParents(root, u.Parents)
detail += ", and the directory the host made for them"
case u.Made:
left, _ := os.ReadDir(root)
detail += fmt.Sprintf("; the directory is kept: %d item(s) inside that the mesh did not put there",
len(left))
default:
detail += "; the directory is kept: it was there before the archive"
}
if len(failed) > 0 {
// Said and not fatal: fatal, the record would stay and fail the same way on every apply
// after — the very wedge this removal exists to end.
return "forgotten", detail + "; could not remove, and left in place: " + strings.Join(failed, "; "), nil
}
return "removed", detail, nil
}
// removeParents takes away the directories above an archive the host made to reach it, deepest
// first, each only once it is empty and only if it is above the archive's directory.
func removeParents(root string, parents []string) {
for _, p := range parents {
clean := filepath.Clean(p)
if !filepath.IsAbs(clean) || !strings.HasPrefix(root, clean+string(os.PathSeparator)) {
continue
}
_ = os.Remove(clean)
}
}
// joinParents is the parents made now and those recorded before, deepest first, once each.
func joinParents(now, before []string) []string {
seen := map[string]bool{}
var out []string
for _, p := range append(append([]string{}, now...), before...) {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
return out
}
// insideRel is whether a relative path stays inside the directory it is relative to.
func insideRel(rel string) bool {
clean := filepath.Clean(rel)
return clean != "." && !filepath.IsAbs(clean) && clean != ".." &&
!strings.HasPrefix(clean, ".."+string(os.PathSeparator))
}
func deepestFirst(rels []string) []string {
out := append([]string{}, rels...)
sort.Slice(out, func(i, j int) bool {
return strings.Count(out[i], "/") > strings.Count(out[j], "/") ||
(strings.Count(out[i], "/") == strings.Count(out[j], "/") && out[i] > out[j])
})
return out
}
func pathsOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+1 -1
View File
@@ -183,7 +183,7 @@ func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
} else if mode, err = modeOf(r.Mode, mode); err != nil {
return out, err
}
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil {
return out, err
}
if err := writeAtomically(real, []byte(next), mode); err != nil {
+180
View File
@@ -0,0 +1,180 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq ADR 0223: the machine's resolver file moves from the module that wrote it to the uplink's
// holder, and the two records meet in one apply — the old undeclared, the new declared at the same
// path. Every orphan is removed before anything is applied, so without a handover the file was
// deleted (or given back the original the mesh once wrote over) and every resource applied before the
// new one ran on a machine without it. Handed over, the file is never absent, the record going is
// forgotten only once the new one is recorded, and what was kept of the machine's original goes with
// the file to its new owner.
const (
theOriginal = "nameserver 192.0.2.53\n"
firstOwners = "# the mesh, as the first module wrote it\nnameserver 10.42.0.1\n"
newOwners = "# the mesh, as the uplink writes it\nnameserver 10.42.0.1\nnameserver 10.42.0.3\n"
)
// heldBy is a declaration whose file at path is the module's, with a service declared ahead of it
// so the apply does something on the machine before the file's turn comes.
func heldBy(t *testing.T, path, module, content string) string {
t.Helper()
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"%[1]s.service","type":"service","unit":"%[1]s.service","state":"running"},
{"id":"%[1]s.fact-resolvers","type":"file","path":%[2]q,"mode":"0644","content":%[3]q}
]}`, module, path, content)
}
// watching is a service manager that answers every unit running, and fails the test whenever the
// file is not there while it is asked something — the moment between an orphan's removal and the
// new owner's turn.
func watching(t *testing.T, path string) Runner {
t.Helper()
var commands []string
services := recordingServices(&commands)
return func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if _, err := os.Stat(path); err != nil {
t.Errorf("the file was not there while %s %s ran: %v", name, strings.Join(args, " "), err)
}
return services(ctx, name, args...)
}
}
func TestAWholeFileIsHandedToItsNewOwnerNotRemoved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "resolv.conf")
if err := os.WriteFile(path, []byte(theOriginal), 0o644); err != nil {
t.Fatal(err)
}
keep := KeepIn(filepath.Join(dir, "kept"))
apply := func(raw string, known store.State) (Report, store.State) {
t.Helper()
report, state, err := ApplyKeeping(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, watching(t, path), nil, nil, keep)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
return report, state
}
// The first owner writes over the machine's file, keeping the original.
_, state := apply(heldBy(t, path, "resolv-conf", firstOwners), store.State{})
first, _ := state.Find("resolv-conf.fact-resolvers")
if first.Kept == "" {
t.Fatal("the machine's original was not kept before the first write")
}
// The uplink takes it over in one apply.
report, state := apply(heldBy(t, path, "networkmanager", newOwners), state)
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
for _, o := range report.Outcomes {
if o.ID == "resolv-conf.fact-resolvers" && o.Action != "forgotten" {
t.Errorf("the record going was %q, not forgotten: %s", o.Action, o.Detail)
}
}
if _, still := state.Find("resolv-conf.fact-resolvers"); still {
t.Error("the record going is still recorded after its file was handed over")
}
now, _ := state.Find("networkmanager.fact-resolvers")
if now.Kept != first.Kept {
t.Errorf("the new owner keeps the original at %q; the first kept it at %q", now.Kept, first.Kept)
}
if kept, _ := os.ReadFile(now.Kept); string(kept) != theOriginal {
t.Errorf("what the new owner would give back is %q, not the machine's original", kept)
}
if aside, _ := filepath.Glob(path + ".removed-*"); len(aside) > 0 {
t.Errorf("the file was moved aside on the way: %v", aside)
}
// Steady from here, and undeclared the machine's original comes back — not the first owner's.
report, state = apply(heldBy(t, path, "networkmanager", newOwners), state)
for _, o := range report.Outcomes {
if o.ID == "networkmanager.fact-resolvers" && o.Action != "unchanged" {
t.Errorf("a second apply was %q: %s", o.Action, o.Detail)
}
}
if _, _, err := ApplyKeeping(context.Background(), archHost(t), somethingElse(t), state,
store.OriginDeclared, recordingServices(new([]string)), nil, nil, keep); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != theOriginal {
t.Errorf("undeclaring the new owner left %q; the machine's original goes back", got)
}
}
// A file the mesh made where there was none is handed over the same way, and is still the mesh's —
// undeclared, it goes.
func TestAFileTheMeshMadeIsHandedOverAndStillGoesWithItsLastOwner(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
run := watching(t, path)
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_, state, err = Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "dhcpcd", newOwners)),
state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
if _, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared,
recordingServices(new([]string)), nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("the mesh's own file stayed after its last owner was undeclared: %v", err)
}
}
// A file edited on the machine since the first owner wrote it is still corrected and said so by the
// new owner: the handover judges drift against what this host last wrote there.
func TestAHandedOverFileChangedOnTheMachineIsSaidCorrected(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_ = os.WriteFile(path, []byte("nameserver 1.1.1.1\n"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "systemd-networkd", newOwners)),
state, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, o := range report.Outcomes {
if o.ID == "systemd-networkd.fact-resolvers" && o.Action != "corrected" {
t.Errorf("a handed-over file changed on the machine was %q: %s", o.Action, o.Detail)
}
}
}
// The preview says the same before it happens.
func TestThePlanHandsAFileOverRatherThanRemovingIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, step := range Plan(parse(t, heldBy(t, path, "networkmanager", newOwners)), state, store.OriginDeclared) {
if step.ID == "resolv-conf.fact-resolvers" && (step.Verb != "forget" || !strings.Contains(step.Why, "networkmanager.fact-resolvers")) {
t.Errorf("the plan would %s the file being handed over: %s", step.Verb, step.Why)
}
}
}
+7 -13
View File
@@ -10,11 +10,12 @@ import (
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
// the version before it as a former target of the archive that delivered it; an archive has no
// removal (issue 162), and the refusal stopped every machine applying anything. A former target of
// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails,
// as 162 has it.
// removal then (issue 162), and the refusal stopped every machine applying anything. A former
// target of an archive is left in place, said, and forgotten: the version before is what a rollback
// starts (ADR 0141).
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "docker" && args[0] == "info" {
return "29.0.0\n", nil
}
@@ -62,14 +63,7 @@ func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
}
// An archive the declaration dropped is a different matter: nothing can undo it, and saying
// it was would report an effect the host declined to have (issue 162).
dropped := store.State{Resources: []store.Applied{
{ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared},
}}
only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil ||
!strings.Contains(err.Error(), "no way to remove") {
t.Fatalf("a dropped archive was passed over: %v", err)
}
// An archive the declaration dropped is taken away since issue 162; one recorded before the
// host kept what it unpacked is left in place and forgotten, never fatal — that case is
// archive_removal_test.go's.
}
+257
View File
@@ -0,0 +1,257 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/store"
)
// A directory used as found is never quiet, and a person at the machine can hand it over (novox/hq issue 339).
func TestAFoundDirectoryIsSaidAndCanBeHandedOver(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
var said []string
log := func(line string) { said = append(said, line) }
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, log, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(said, "\n"), "WARNING notes.data") {
t.Fatalf("the apply did not warn of a directory used as found: %q", said)
}
if a, _ := state.Find("notes.data"); !a.AsFound || !strings.Contains(a.Declared, "755") {
t.Fatalf("the record does not say what was declared: %+v", a)
}
// Said again at the next apply: a warning once is a warning missed.
said = nil
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, log, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(said, "\n"), "WARNING notes.data") {
t.Fatalf("the next apply did not warn again: %q", said)
}
// Handed over: the next apply gives it the declared mode and it is the mesh's.
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o755 {
t.Fatalf("a directory handed over was not given its declared mode: %o", info.Mode().Perm())
}
if a, _ := state.Find("notes.data"); a.AsFound {
t.Fatal("a directory handed over is still recorded as found")
}
}
// The review's additions to the guard: the spool, /opt, the container runtimes' data wherever the runtime keeps
// it, and an account's .ssh as an access or a mount, or as a directory anywhere but its own home.
func TestTheRuntimesDataAndAnySSHAreRefused(t *testing.T) {
cfg := filepath.Join(t.TempDir(), "daemon.json")
if err := os.WriteFile(cfg, []byte(`{"data-root":"/storage/docker"}`), 0o644); err != nil {
t.Fatal(err)
}
before := runtimeConfigs
runtimeConfigs = runtimeFiles{daemonJSON: cfg}
t.Cleanup(func() { runtimeConfigs = before })
for _, kind := range []placing{placingDirectory, placingAccess, placingMount} {
for _, path := range []string{"/var/spool/cron", "/opt/app", "/var/lib/docker/volumes/x",
"/var/lib/containers/storage", "/storage/docker", "/storage/docker/overlay2"} {
if err := refusePath(path, kind, "notes", ""); err == nil {
t.Errorf("%v at %s was taken", kind, path)
}
}
}
for _, path := range []string{"/srv/backup/.ssh", "/srv/backup/.ssh/keys"} {
for _, kind := range []placing{placingDirectory, placingAccess, placingMount} {
if err := refusePath(path, kind, "notes", ""); err == nil {
t.Errorf("%v at %s was taken", kind, path)
}
}
}
if err := refusePath("/storage/dockerish", placingAccess, "notes", ""); err != nil {
t.Errorf("a path beside the data root: %v", err)
}
}
// When a directory was found is kept from the first apply that found it (novox/hq issue 339): the controller
// tells a directory a send found from one found before it by that time, so it must not move with every apply.
func TestWhenADirectoryWasFoundIsKept(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
first, _ := state.Find("notes.data")
if first.FoundAt.IsZero() {
t.Fatal("no time the directory was found")
}
time.Sleep(10 * time.Millisecond)
_, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if again, _ := state.Find("notes.data"); !again.FoundAt.Equal(first.FoundAt) {
t.Fatalf("found at %v, then %v: the time moved with the apply", first.FoundAt, again.FoundAt)
}
}
// A hand-over is for the resource that was used as found there: another declaration that later points another
// resource at the same path is not handed anything (novox/hq issue 339).
func TestAHandOverIsForTheResourceThatAskedForIt(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
other := `{"id":"other.data","type":"directory","path":"` + found + `","mode":"0755"}`
if _, _, err := Apply(context.Background(), archHost(t), declare(t, other), store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a hand-over for notes.data gave other.data the directory: mode %o", info.Mode().Perm())
}
}
// **A handed-over directory is changed through no link, at the moment of use** (review of issue 356). The
// hand-over refused a link when it was recorded; between then and the apply its parent is swapped for a link to
// a victim tree. The apply refuses, and the victim's directory keeps its mode — and so does a directory whose own
// name became a link, and one whose grandparent did.
func TestAHandedOverDirectoryIsChangedThroughNoLink(t *testing.T) {
for _, c := range []struct {
swap string
applyOnce bool
}{{"parent", false}, {"grandparent", false}, {"itself", false}, {"parent", true}, {"grandparent", true}} {
swap, applyOnce := c.swap, c.applyOnce
name := swap
if applyOnce {
name += " after an apply gave it its owner"
}
t.Run(name, func(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
found := filepath.Join(dir, "top", "parent", "data")
if err := os.MkdirAll(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); !a.AsFound {
t.Fatalf("not used as found: %+v", a)
}
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
if applyOnce {
// The hand-over takes: the directory is given its mode, and the record is no longer as found.
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); a.AsFound {
t.Fatalf("still as found after the hand-over took: %+v", a)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o755 {
t.Fatalf("the hand-over did not take: %o", info.Mode().Perm())
}
}
// The victim: a tree of the same shape somewhere else, its data directory mode 0700.
victim := filepath.Join(dir, "victim", "top", "parent", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
var swapped, target string
switch swap {
case "parent":
swapped, target = filepath.Join(dir, "top", "parent"), filepath.Join(dir, "victim", "top", "parent")
case "grandparent":
swapped, target = filepath.Join(dir, "top"), filepath.Join(dir, "victim", "top")
case "itself":
swapped, target = found, victim
}
if err := os.Rename(swapped, swapped+".real"); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, swapped); err != nil {
t.Fatal(err)
}
_, _, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil)
if err == nil {
t.Fatal("a handed-over directory reached through a link was applied")
}
// Refused by the path's re-check before any descriptor is opened (a link at the name itself is refused
// earlier still, by the directory applier's own look at it).
want := "reached through a link"
if swap == "itself" {
want = "symbolic link"
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("refused as %v; want %q", err, want)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim's directory was given the declared mode through the link: %o", info.Mode().Perm())
}
})
}
}
// The check at the moment of use, without the path's re-check: a component swapped for a link is refused by the
// descriptor walk itself, whatever was resolved a moment before.
func TestGiveHandedOverFollowsNoLink(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
victim := filepath.Join(dir, "victim", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dir, "victim"), filepath.Join(dir, "parent")); err != nil {
t.Fatal(err)
}
if _, _, _, err := giveHandedOver(filepath.Join(dir, "parent", "data"), 0o755, 0, 0, false); err == nil {
t.Fatal("a directory reached through a link was changed")
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim was changed: %o", info.Mode().Perm())
}
mode, _, _, err := giveHandedOver(victim, 0o750, 0, 0, false)
if err != nil || mode.Perm() != 0o700 {
t.Fatalf("the directory itself: before %o, %v", mode, err)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o750 {
t.Fatalf("the directory itself was not given its mode: %o", info.Mode().Perm())
}
}
+3 -2
View File
@@ -59,6 +59,7 @@ func TestAUnitWhoseFileTheMeshWroteIsStoppedWhateverItsRecordSays(t *testing.T)
m := unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
fileThereAtStop := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && args[0] == "stop" {
_, err := os.Stat(unitFile)
fileThereAtStop = err == nil
@@ -122,8 +123,8 @@ func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) {
made := meshMadeUnits(known)
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
if made[unit] != want {
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
if made[unitKey("", "", unit)] != want {
t.Errorf("%s: made %v, want %v", unit, made[unitKey("", "", unit)], want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package apply
import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A unit the mesh found running and enabled, and held stopped and disabled by its own declaration, is
// given back running and enabled when that declaration goes (the zfs module holds the distribution's
// scrub timer stopped while its own timer scrubs the pool; unassigning it must not leave the pool with no
// schedule at all). The report says so.
func TestAUnitTheMeshStoppedAndDisabledIsGivenBackRunningAndEnabled(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{
"zfs-scrub-weekly@storage.timer": {active: "active", enabled: "enabled"},
})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"zfs.distribution-weekly","type":"service","unit":"zfs-scrub-weekly@storage.timer","state":"stopped","boot":"disabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
if u := m.units["zfs-scrub-weekly@storage.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Fatalf("the fixture did not stop and disable it: %s and %s", u.active, u.enabled)
}
report, _, err := applyOn(t, nothingButA(t), copyOf(t, state), m)
if err != nil {
t.Fatal(err)
}
if u := m.units["zfs-scrub-weekly@storage.timer"]; u.active != "active" || u.enabled != "enabled" {
t.Errorf("given back as %s and %s; it was found running and enabled", u.active, u.enabled)
}
said := ""
for _, r := range report.Outcomes {
if r.ID == "zfs.distribution-weekly" {
said = r.Action + ": " + r.Detail
}
}
if !strings.HasPrefix(said, "restored: ") || !strings.Contains(said, "started") || !strings.Contains(said, "enabled at boot") {
t.Errorf("the report says %q", said)
}
}
// Only what the mesh's own declaration did is undone: a unit the mesh held stopped that was found stopped
// stays stopped, and one found running that the mesh never stopped is left running as before.
func TestOnlyWhatTheMeshsDeclarationStoppedIsStartedAgain(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{
"found-stopped.timer": {active: "inactive", enabled: "disabled"},
"left-running.timer": {active: "active", enabled: "enabled"},
})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"a","type":"service","unit":"found-stopped.timer","state":"stopped","boot":"disabled"},
{"id":"b","type":"service","unit":"left-running.timer","state":"running","boot":"enabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
t.Fatal(err)
}
if u := m.units["found-stopped.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("found stopped and disabled, given back as %s and %s", u.active, u.enabled)
}
if u := m.units["left-running.timer"]; u.active != "active" || u.enabled != "enabled" {
t.Errorf("found running, given back as %s and %s", u.active, u.enabled)
}
}
// A record from before the host kept what it declared says nothing about who stopped the unit: it is
// left as it is, never started.
func TestARecordWithoutWhatWasDeclaredStartsNothing(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"old.timer": {active: "inactive", enabled: "disabled"}})
state := store.State{}
state.Record(store.Applied{ID: "old", Type: "service", Target: "old.timer", Origin: store.OriginDeclared,
Found: &store.FoundUnit{Unit: "old.timer", State: "running", Boot: "enabled"}})
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
t.Fatal(err)
}
if u := m.units["old.timer"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("an old record started %s and %s", u.active, u.enabled)
}
}
// The plan says it before the apply does it.
func TestThePlanSaysAStoppedUnitIsStartedAgain(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"zfs-scrub-weekly@storage.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"zfs-scrub-weekly@storage.timer","state":"stopped","boot":"disabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
said := ""
for _, s := range Plan(parse(t, nothingButA(t)), state, store.OriginDeclared) {
if s.ID == "w" {
said = s.Verb + ": " + s.Why
}
}
if !strings.HasPrefix(said, "restore: ") || !strings.Contains(said, "restore if still as the mesh held it") ||
strings.Contains(said, "so it is started") {
t.Errorf("the plan says %q", said)
}
}
// Third review: on removal a unit is started again only when it is exactly as the mesh held it, stopped and
// disabled. A masked unit, or one somebody started or enabled since, is left alone, and the report says why.
func TestAMaskedOrChangedUnitIsLeftAloneOnRemoval(t *testing.T) {
for _, c := range []struct {
name, active, enabled, says string
}{
{"masked", "inactive", "masked", "masked"},
{"enabled since", "inactive", "enabled", "not the stopped and disabled"},
{"started since", "active", "disabled", "not the stopped and disabled"},
} {
m := unitsMachine(map[string]*fakeUnit{"w.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"w.timer","state":"stopped","boot":"disabled"}]}`, store.State{}, m)
if err != nil {
t.Fatal(err)
}
m.units["w.timer"].active, m.units["w.timer"].enabled = c.active, c.enabled
report, _, err := applyOn(t, nothingButA(t), state, m)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if u := m.units["w.timer"]; u.active != c.active || u.enabled != c.enabled {
t.Errorf("%s: changed to %s and %s", c.name, u.active, u.enabled)
}
said := ""
for _, o := range report.Outcomes {
if o.ID == "w" {
said = o.Action + ": " + o.Detail
}
}
if !strings.HasPrefix(said, "forgotten: ") || !strings.Contains(said, c.says) {
t.Errorf("%s: the report says %q", c.name, said)
}
}
}
// A restore that fails — the start refused, or the unit not running after it — waits: the record stays,
// the report says so, and no apply is made fatal by it.
func TestAFailedRestoreWaitsAndIsNeverFatal(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{"w.timer": {active: "active", enabled: "enabled"}})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"w","type":"service","unit":"w.timer","state":"stopped","boot":"disabled"}]}`, store.State{}, m)
if err != nil {
t.Fatal(err)
}
m.units["w.timer"].wontStart = true
for i := 0; i < 2; i++ {
report, next, err := applyOn(t, nothingButA(t), state, m)
if err != nil {
t.Fatalf("apply %d failed: %v", i+1, err)
}
said := ""
for _, o := range report.Outcomes {
if o.ID == "w" {
said = o.Action + ": " + o.Detail
}
}
if !strings.Contains(said, "wait") || !strings.Contains(said, "not running") {
t.Errorf("apply %d: the report says %q", i+1, said)
}
if _, kept := next.Find("w"); !kept {
t.Errorf("apply %d: the record went, so nothing would try again", i+1)
}
state = next
}
}
+230
View File
@@ -0,0 +1,230 @@
package apply
import (
"context"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// An account's groups, from any module (novox/hq ADR 0252, issue 247).
//
// **A module that needs the account in a group declares the account with that group, and nothing else
// of it.** The shell's module sets the account's shell; the lighting daemon's module puts the same
// account in `openrazer`; the container runtime's in `docker`. A shell is one value and stays one
// module's (the controller refuses two); a group is added, so several modules' groups never contradict.
//
// **The mesh takes back only what it gave.** A group the account was in before is the machine's or the
// operator's, and stays when every resource that named it goes. A group the mesh put the account in is
// recorded on the resource that put it there, and given back when that resource stops asking for it —
// unless another declared resource still asks for the same group, which keeps it.
//
// **A group takes effect at the next login.** The machine's group database changes at once; every
// process already running, the account's own service manager and everything it started included, keeps
// the groups it started with. So the outcome says a new login is needed, and the node-engine's account
// judge (internal/accounts) says so on every look until the account's running manager has the group.
// Wanted is every group a declaration asks an account to be in, and which resources ask: account →
// group → resource ids.
type Wanted map[string]map[string][]string
// groupsWanted reads every user resource's groups from a declaration.
func groupsWanted(resources []declaration.Resource) Wanted {
w := Wanted{}
for _, r := range resources {
u, ok := r.(*declaration.User)
if !ok || u.Name == "" {
continue
}
for _, g := range u.Groups {
if w[u.Name] == nil {
w[u.Name] = map[string][]string{}
}
w[u.Name][g] = append(w[u.Name][g], u.ID)
}
}
return w
}
// othersAsk is every resource other than one that asks for an account to be in a group.
func (w Wanted) othersAsk(account, group, except string) []string {
var others []string
for _, id := range w[account][group] {
if id != except {
others = append(others, id)
}
}
return others
}
// applyGroups makes the account be in every group the resource declares, and takes it out of every group
// this resource put it in and no longer asks for. What the mesh put the account in is recorded on out.
func applyGroups(ctx context.Context, sys system.System, r *declaration.User, run Runner,
previous store.Applied, wanted Wanted, handed Handed, out *Outcome) error {
// What this resource put the account in before, for this account only: a declaration that renamed its
// user says nothing about the new one's groups.
var added []string
if previous.Target == r.Name {
added = append(added, previous.Groups...)
}
if len(r.Groups) == 0 && len(added) == 0 {
return nil
}
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
if err != nil {
return err
}
already := setOf(in)
declared := setOf(r.Groups)
var put []string
for _, want := range r.Groups {
if already[want] {
// A record that went handed it here: the mesh's, and this resource's to take back (novox/hq ADR 0312 §5).
if handed.has(r.Name, want) && !contains(added, want) {
added = append(added, want)
}
continue
}
exists, err := system.GroupExists(ctx, system.Runner(run), want)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%q was not put in the group %q: this machine has no such group yet. The package "+
"that makes it is not installed, or is declared after the account", r.Name, want)
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil {
return err
}
already[want] = true
put = append(put, want)
if !contains(added, want) {
added = append(added, want)
}
}
// What this resource put the account in and no longer asks for.
var kept, said []string
for _, g := range added {
switch {
case declared[g]:
kept = append(kept, g)
case !already[g]:
// Taken out since, by a person: nothing to give back.
case len(wanted.othersAsk(r.Name, g, r.ID)) > 0:
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g,
strings.Join(wanted.othersAsk(r.Name, g, r.ID), ", ")))
default:
gone, why := leaveGroup(ctx, sys, r.Name, g, run)
if !gone {
kept = append(kept, g)
}
said = append(said, why)
}
}
out.groups = kept
if len(put) > 0 {
said = append([]string{fmt.Sprintf("put in %s; a session that began before has %s only after a new "+
"login", strings.Join(put, ", "), them(len(put)))}, said...)
}
if len(put) > 0 || len(said) > 0 {
if out.Action == "unchanged" {
out.Action = "updated"
}
out.Detail = joinDetail(out.Detail, strings.Join(said, "; "))
}
return nil
}
// giveGroupsBack is removeUser's groups: every group the mesh put the account in, taken back unless
// another declared resource still asks for it. Never fatal, for the shell's reason: a removal that failed
// would stay recorded and fail the same way on every apply after. Empty when there was nothing to say.
//
// A group left because another declared resource still asks for it is handed to that resource (handed), which
// records it as the mesh's, so it is taken back once nobody asks (novox/hq ADR 0312 §5).
func giveGroupsBack(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted,
handed Handed) (bool, string) {
if len(a.Groups) == 0 {
return false, ""
}
in, err := system.GroupsOf(ctx, system.Runner(run), a.Target)
if err != nil {
return false, fmt.Sprintf("the groups the mesh put it in (%s) were not given back: %v",
strings.Join(a.Groups, ", "), err)
}
already := setOf(in)
gave := false
var said []string
for _, g := range a.Groups {
if !already[g] {
continue
}
if others := wanted.othersAsk(a.Target, g, a.ID); len(others) > 0 {
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(others, ", ")))
handed.hand(a.Target, g)
continue
}
gone, why := leaveGroup(ctx, sys, a.Target, g, run)
gave = gave || gone
said = append(said, why)
}
return gave, strings.Join(said, "; ")
}
// leaveGroup takes an account out of one group the mesh put it in, read back from the machine, and says
// what came of it.
func leaveGroup(ctx context.Context, sys system.System, account, group string, run Runner) (bool, string) {
l, ok := sys.(system.GroupLeaver)
if !ok {
return false, fmt.Sprintf("left in %s: this machine's system cannot take an account out of one group", group)
}
if err := l.RemoveUserFromGroup(ctx, system.Runner(run), account, group); err != nil {
return false, fmt.Sprintf("left in %s: %v", group, err)
}
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, fmt.Sprintf("taken out of %s, and the group database could not be read back: %v", group, err)
}
if setOf(in)[group] {
return false, fmt.Sprintf("taken out of %s, and the group database still lists it there", group)
}
return true, fmt.Sprintf("taken out of %s, which the mesh had put it in", group)
}
func setOf(items []string) map[string]bool {
s := map[string]bool{}
for _, i := range items {
s[i] = true
}
return s
}
func contains(items []string, want string) bool {
for _, i := range items {
if i == want {
return true
}
}
return false
}
func them(n int) string {
if n == 1 {
return "it"
}
return "them"
}
func joinDetail(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}
+233
View File
@@ -0,0 +1,233 @@
package apply
import (
"context"
"errors"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0252 and issue 247: a module puts the operator's account in a group by declaring
// the account with that group alone; the account's other groups are never touched; a new login is said;
// and the mesh gives back only a group it put the account in, and only when nobody declared still asks.
// groupDB is a fake user and group database: the account's groups, the groups the machine has, and every
// command it was asked.
type groupDB struct {
account string
in map[string]bool
exists map[string]bool
asked []string
}
func newGroupDB(in []string, exists ...string) *groupDB {
g := &groupDB{account: "operator", in: map[string]bool{}, exists: map[string]bool{}}
for _, x := range in {
g.in[x], g.exists[x] = true, true
}
for _, x := range exists {
g.exists[x] = true
}
return g
}
func (g *groupDB) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
g.asked = append(g.asked, name+" "+strings.Join(args, " "))
switch {
case name == "getent" && args[0] == "passwd":
if args[1] == g.account {
return g.account + ":x:1500:1500::/home/" + g.account + ":/bin/bash\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "getent" && args[0] == "group":
if g.exists[args[1]] {
return args[1] + ":x:900:\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "id":
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ") + "\n", nil
case name == "usermod" && args[0] == "--append":
if !g.exists[args[2]] {
return "", errors.New("usermod exited 6: group '" + args[2] + "' does not exist")
}
g.in[args[2]] = true
case name == "gpasswd" && args[0] == "--delete":
delete(g.in, args[2])
}
return "", nil
}
func (g *groupDB) groups() string {
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ")
}
func applyGroupsOf(t *testing.T, g *groupDB, known store.State, resources ...string) (Report, store.State, error) {
t.Helper()
if len(resources) == 0 {
resources = []string{`{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}`}
}
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+
strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, g.run, nil, nil)
}
const (
razer = `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]}`
docker = `{"id":"docker.account","type":"user","name":"operator","groups":["docker"]}`
shell = `{"id":"zsh.login","type":"user","name":"operator","groups":[]}`
)
func TestAModulePutsTheAccountInAGroupAndSaysANewLoginIsNeeded(t *testing.T) {
g := newGroupDB([]string{"wheel", "plugdev"}, "openrazer")
report, state, err := applyGroupsOf(t, g, store.State{}, shell, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer plugdev wheel" {
t.Fatalf("the account's groups are %q", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "updated" || !strings.Contains(o.Detail, "put in openrazer") || !strings.Contains(o.Detail, "new login") {
t.Errorf("the outcome did not say the group and the new login: %+v", o)
}
a, _ := state.Find("openrazer.account")
if strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v, want the one group the mesh added", a.Groups)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") && !strings.HasPrefix(asked, "usermod --append --groups openrazer ") {
t.Errorf("usermod was asked something other than appending the one group: %q", asked)
}
}
// Applied again: nothing to do, and the record still says the mesh added it.
report, state, err = applyGroupsOf(t, g, state, shell, razer)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "openrazer.account"); o.Action != "unchanged" {
t.Errorf("a second apply changed something: %+v", o)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("a second apply lost what the mesh added: %v", a.Groups)
}
}
func TestAGroupTheAccountWasAlreadyInIsNeverTakenBack(t *testing.T) {
g := newGroupDB([]string{"wheel", "openrazer"})
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("openrazer.account"); len(a.Groups) != 0 {
t.Fatalf("a group found was recorded as the mesh's: %v", a.Groups)
}
if _, _, err := applyGroupsOf(t, g, state); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer wheel" {
t.Errorf("undeclaring took a found group: %q", got)
}
}
func TestTheGroupTheMeshAddedIsGivenBackWhenItsModuleGoes(t *testing.T) {
g := newGroupDB([]string{"wheel"}, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
report, state, err := applyGroupsOf(t, g, state)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "wheel" {
t.Errorf("the account's groups after its module went: %q, want wheel alone", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "restored" || !strings.Contains(o.Detail, "taken out of openrazer") {
t.Errorf("the removal did not say the group was given back: %+v", o)
}
if _, still := state.Find("openrazer.account"); still {
t.Error("the record stayed")
}
}
func TestAGroupAnotherResourceStillAsksForStays(t *testing.T) {
both := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","video"]}`
video := `{"id":"media.account","type":"user","name":"operator","groups":["video"]}`
g := newGroupDB(nil, "openrazer", "video")
_, state, err := applyGroupsOf(t, g, store.State{}, both, video)
if err != nil {
t.Fatal(err)
}
report, _, err := applyGroupsOf(t, g, state, video)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "video" {
t.Errorf("the account's groups: %q, want video kept for media and openrazer given back", got)
}
if o := outcomeOf(report, "openrazer.account"); !strings.Contains(o.Detail, "media.account still asks for") {
t.Errorf("the removal did not say why video stayed: %+v", o)
}
}
func TestAGroupNoLongerDeclaredIsGivenBackWhileTheAccountStaysDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer", "input")
two := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","input"]}`
_, state, err := applyGroupsOf(t, g, store.State{}, two)
if err != nil {
t.Fatal(err)
}
_, state, err = applyGroupsOf(t, g, state, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("the account's groups: %q, want input given back", got)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v", a.Groups)
}
}
func TestAGroupThatDoesNotExistYetFailsTheResourceSayingSo(t *testing.T) {
g := newGroupDB([]string{"wheel"})
_, _, err := applyGroupsOf(t, g, store.State{}, razer)
if err == nil || !strings.Contains(err.Error(), "no such group yet") {
t.Fatalf("a missing group was not said: %v", err)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") {
t.Errorf("usermod was run for a group the machine does not have: %q", asked)
}
}
}
func TestAGroupAPersonTookTheAccountOutOfSinceIsPutBackWhileDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
delete(g.in, "openrazer")
if _, _, err := applyGroupsOf(t, g, state, razer); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("a declared group was not put back: %q", got)
}
}
+321
View File
@@ -0,0 +1,321 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 213: the controller moves from a container to a process on the one machine that
// runs it. Every orphan is removed before anything is applied, so without a handover the container
// went first and nothing answered the mesh's verbs while the process was fetched, unpacked and
// started — and for ever, if it did not start. A process that `replaces` the container is applied
// first; the container goes only once the process is running a moment later.
// aMachine fakes the service manager and the container runtime: it records every command, answers
// `systemctl show` with whether the process is running, and has a container until it is removed.
type aMachine struct {
commands []string
running bool // what `systemctl show` says of the process once it was started
started bool
container bool
timer bool // whether a timer, once started, is up
crashing bool // up at the first look, waiting to restart at the next
looks int
}
func (m *aMachine) run(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
line := name + " " + strings.Join(args, " ")
m.commands = append(m.commands, line)
switch {
case name == "systemctl" && len(args) > 0 && (args[0] == "restart" || args[0] == "start"):
m.started = true
case name == "systemctl" && len(args) > 0 && args[0] == "is-active" && strings.HasSuffix(args[len(args)-1], ".timer"):
if m.timer {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "is-active":
if m.started && m.running {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "show":
m.looks++
if m.crashing && m.started {
// Up at the first look; waiting to be started again, a new process, at the second.
if m.looks == 1 {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=activating\nSubState=auto-restart\nMainPID=0\nNRestarts=1\n", nil
}
if m.started && m.running {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=inactive\nSubState=dead\nMainPID=0\nNRestarts=0\n", nil
case name == "docker" && len(args) > 1 && args[0] == "rm":
m.container = false
case name == "docker" && len(args) > 1 && args[0] == "container" && args[1] == "inspect":
if !m.container {
return "", errors.New("no such container")
}
return "true\t", nil
}
return "", nil
}
func (m *aMachine) index(prefix string) int {
for i, c := range m.commands {
if strings.HasPrefix(c, prefix) {
return i
}
}
return -1
}
// theController is a machine whose controller ran as a container, recorded, and a declaration that
// runs it as a process from a bundle served here instead.
func theController(t *testing.T, digest, source, extra string) (store.State, string) {
t.Helper()
known := store.State{Resources: []store.Applied{{
Origin: store.OriginDeclared, ID: "mesh-controller.server", Type: "container", Target: "mesh-controller",
}}}
return known, `{"declaration":1,"resources":[
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller","source":"` + source +
`","digest":"` + digest + `","run":["./mesh-controller","serve"]` + extra + `}]}`
}
func onAMachine(t *testing.T) {
t.Helper()
serviceSettle, handoverSettle = 0, 0
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = t.TempDir(), t.TempDir()
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
}
func TestAContainerAProcessReplacesGoesOnlyOnceTheProcessRuns(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the handover failed: %v", err)
}
started, removed := m.index("systemctl restart mesh-controller.service"), m.index("docker rm -f mesh-controller")
if started < 0 || removed < 0 {
t.Fatalf("the process was not started or the container not removed: %v", m.commands)
}
if removed < started {
t.Fatalf("the container was removed before its replacement was started — a window with "+
"nothing answering: %v", m.commands)
}
if looked := m.index("systemctl show mesh-controller.service"); looked < 0 || looked > removed {
t.Errorf("the container was removed without looking whether the process runs: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the container's outcome is %+v, want removed", o)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the host still records the container it removed")
}
if _, has := after.Find("mesh-controller.controller"); !has {
t.Error("the process was not recorded")
}
}
// The case the handover exists for: the replacement does not stay up. The container keeps
// answering, stays recorded so a later apply hands it over, and the apply says why it failed.
func TestAContainerIsKeptWhenItsReplacementDoesNotRun(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: false, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement that is not running was reported as a clean apply")
}
if !strings.Contains(err.Error(), "mesh-controller.server") {
t.Errorf("the failure does not name what was kept: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though its replacement is not running: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten, so no later apply would ever remove it")
}
// The next apply finds the process up and finishes the handover.
m.running, m.commands = true, nil
report, after, err = Apply(context.Background(), archHost(t), parse(t, raw), after,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the second apply failed: %v", err)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the second apply did not hand over: %+v (%v)", o, m.commands)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the container is still recorded after the handover")
}
}
// A replacement that never applied — its bundle is not what was declared — touches nothing, and
// what it replaces keeps running.
func TestAContainerIsKeptWhenItsReplacementFailsToApply(t *testing.T) {
onAMachine(t)
body, _ := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, "sha256:"+strings.Repeat("b", 64), serving(t, body),
`,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement whose bundle did not match was reported applied")
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though nothing replaced it: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
// Without `replaces` nothing changes: an orphan goes before anything is applied, as it always has.
// Kept as a test because it is the window the field exists to close.
func TestWithoutReplacesAnOrphanStillGoesFirst(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), ``)
m := &aMachine{running: true, container: true}
if _, _, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if removed, started := m.index("docker rm -f mesh-controller"), m.index("systemctl restart mesh-controller.service"); removed < 0 || removed > started {
t.Fatalf("an orphan nothing replaces was not removed first: %v", m.commands)
}
}
// novox/hq issue 213, beyond what the oneshot unit (process_step_test.go) already holds: a step
// written `./name` runs its own bundle's binary — the controller's preparation is its own binary —
// and a step is started, never enabled.
func TestAStepRunsItsOwnBundlesBinaryAndIsNotEnabled(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
m := &aMachine{running: true}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+serving(t, body)+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatalf("the step failed: %v", err)
}
for _, c := range m.commands {
if strings.HasPrefix(c, "./") || strings.HasPrefix(c, "systemctl enable") {
t.Errorf("the step was run directly or enabled: %v", m.commands)
}
}
unit, err := os.ReadFile(filepath.Join(unitDir, "mesh-controller-prepare.service"))
if err != nil {
t.Fatal(err)
}
want := "ExecStart=" + filepath.Join(daemonRoot, "mesh-controller-prepare", "mesh-controller") + " prepare"
if !strings.Contains(string(unit), want) {
t.Errorf("the step does not run its own bundle's binary (%q):\n%s", want, unit)
}
}
func TestAStepThatFailsGatesItsModule(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
src := serving(t, body)
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && len(args) > 1 && args[0] == "start" {
return "", errors.New("Job for mesh-controller-prepare.service failed")
}
if name == "systemctl" && len(args) > 0 && args[0] == "restart" {
t.Errorf("the module's process was started after its step failed")
}
return "", nil
}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true},
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","serve"]}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
if err == nil {
t.Fatal("a failed step was reported as a clean apply")
}
if o := outcomeOf(report, "mesh-controller.controller"); o.Action != "skipped" {
t.Errorf("the process after a failed step was %+v, want skipped", o)
}
}
// A completed step is done: applied again unchanged, it is not run again — its service is never up
// between runs, and reading that as "a daemon that stopped" re-ran the controller's preparation on
// every apply. Nor is a scheduled run started off its cadence; its timer is what is kept up.
func TestACompletedStepIsNotRunAgainAndAScheduleIsItsTimer(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"job": "#!/bin/sh\n"})
src := serving(t, body)
for _, mode := range []string{`"run-once":true`, `"schedule":"0 3 * * *"`} {
// The service of either is never up between runs; a scheduled one's timer is.
m := &aMachine{running: false, timer: true}
d := declare(t, `{"id":"m.job","type":"process","name":"m-job","source":"`+src+`","digest":"`+digest+
`","run":["./job"],`+mode+`}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: first apply: %v", mode, err)
}
m.commands = nil
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: second apply: %v", mode, err)
}
if m.index("systemctl start m-job.service") >= 0 || m.index("systemctl restart m-job.service") >= 0 {
t.Errorf("%s: an unchanged apply ran the job again: %v", mode, m.commands)
}
if o := outcomeOf(report, "m.job"); o.Action != "unchanged" {
t.Errorf("%s: an unchanged apply reported %+v", mode, o)
}
}
}
// A replacement crash-looping between its restarts is not up, though the service manager calls it
// "activating" — the state the host's ordinary second look accepts as running. Removing the
// container on that reading would leave nothing answering.
func TestAContainerIsKeptWhenItsReplacementIsCrashLooping(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{crashing: true, container: true}
_, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "auto-restart") {
t.Fatalf("a crash-looping replacement was accepted: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed for a replacement that keeps dying: %v", m.commands)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
+84
View File
@@ -0,0 +1,84 @@
package apply
import (
"context"
"slices"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The node-engine owns every verdict, and nothing else sets a container's check (novox/hq ADR 0240 rule 3,
// "how it is checked"): a declared command becomes the container's check with the declared timing, an
// adopted image check keeps the image's command under the declared timing, and a check the engine makes
// itself — http, tcp — sets nothing on the container and recreates nothing.
func TestADeclaredCommandBecomesTheContainersCheckAndNothingElseSetsOne(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
ranWith := func(health string) []string {
t.Helper()
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
field := ""
if health != "" {
field = `,"health":` + health
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"db","type":"container","name":"db","image":"`+pinned+`","ports":["31001:5432"]`+field+`}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if ran == nil {
t.Fatal("the container was not run")
}
return ran
}
healthFlags := func(args []string) []string {
var out []string
for i, a := range args {
if strings.HasPrefix(a, "--health") || a == "--no-healthcheck" {
out = append(out, a)
if i+1 < len(args) {
out = append(out, args[i+1])
}
}
}
return out
}
exec := healthFlags(ranWith(`{"kind":"exec","command":"pg_isready -q","interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`))
want := []string{"--health-cmd", "pg_isready -q", "--health-interval", "30s", "--health-timeout", "5s",
"--health-retries", "3", "--health-start-period", "1m0s"}
if !slices.Equal(exec, want) {
t.Errorf("a declared command was run as %v, want %v", exec, want)
}
adopted := healthFlags(ranWith(`{"kind":"runtime","interval":"20s","timeout":"3s","looks":2,"grace":"30s"}`))
if slices.Contains(adopted, "--health-cmd") || !slices.Contains(adopted, "20s") || !slices.Contains(adopted, "2") {
t.Errorf("an adopted image check was run as %v: the image's command under the declared timing", adopted)
}
for _, h := range []string{"", `{"kind":"http","endpoint":"web","port":31001,"path":"/","interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`,
`{"kind":"tcp","port":31001,"interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"}`} {
if got := healthFlags(ranWith(h)); len(got) > 0 {
t.Errorf("a container whose check the engine makes itself (%q) was given the runtime's: %v", h, got)
}
}
// Declaring an http check recreates nothing; declaring a command does.
plain := &declaration.Container{ID: "db", Name: "db", Image: pinned}
withHTTP := *plain
withHTTP.Health = &declaration.Health{Kind: "http", Port: 31001, Path: "/", Interval: "30s", Timeout: "5s", Looks: 3, Grace: "1m0s"}
withExec := *plain
withExec.Health = &declaration.Health{Kind: "exec", Command: "true", Interval: "30s", Timeout: "5s", Looks: 3, Grace: "1m0s"}
if containerSpec(plain, inputs{}) != containerSpec(&withHTTP, inputs{}) {
t.Error("declaring an http check would recreate the container")
}
if containerSpec(plain, inputs{}) == containerSpec(&withExec, inputs{}) {
t.Error("declaring a command would not reach a running container: it is fixed when the container is made")
}
}
+62 -9
View File
@@ -109,9 +109,30 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
}
}
case *declaration.Service:
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
if res.Stateless() || recordedUnit(known, res) {
continue
}
key := "unit:" + unitKey(res.Scope, res.User, res.Unit)
run := run
if res.UserScoped() {
// In the account's own manager (novox/hq ADR 0177), and only asked while it runs. With
// it not running, what can be known is whether somebody put the unit's file where that
// manager loads an administrator's units from — and the mesh's own file there is not
// somebody's. An account the machine does not have has no unit to find.
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
if err != nil {
continue
}
if away != "" {
for _, path := range userUnitFiles(res.User, res.Unit) {
if present(path) && !meshWroteWhole(known, path) {
seen.is[key] = true
}
}
continue
}
run = managerFor(res.Scope, res.User, run)
}
// **Found is a unit somebody put on this machine, or one the machine uses.**
//
// Where it comes from first: a unit the service manager loads from outside /usr —
@@ -129,14 +150,14 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
continue
}
if from, ok := sys.(unitFiles); ok {
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(path) {
seen.is["unit:"+res.Unit] = true
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(known, path) {
seen.is[key] = true
continue
}
}
boot, _ := sys.ServiceBoot(ctx, run, res.Unit)
if state == "running" || boot == "enabled" {
seen.is["unit:"+res.Unit] = true
seen.is[key] = true
}
case *declaration.Container:
if known.Recorded(string(declaration.TypeContainer), res.Name) {
@@ -177,12 +198,26 @@ type unitFiles interface {
}
// installedByHand is whether a unit file is one somebody put on this machine rather than one a
// package ships: anywhere but /usr, where distributions keep what they install.
func installedByHand(path string) bool {
// package ships: anywhere but /usr, where distributions keep what they install — and not one this
// host wrote whole. That covers an account's units (novox/hq ADR 0177): one under the account's
// ~/.config/systemd/user or /etc/systemd/user is somebody's, unless the mesh wrote it there.
func installedByHand(known store.State, path string) bool {
if path == "" {
return false
}
return !strings.HasPrefix(filepath.Clean(path), "/usr/")
return !strings.HasPrefix(filepath.Clean(path), "/usr/") && !meshWroteWhole(known, path)
}
// recordedUnit is whether this host has a record of a service in the same manager as res, under the
// same name (novox/hq ADR 0177): an account's unit and the machine's of one name are two units.
func recordedUnit(known store.State, res *declaration.Service) bool {
want := unitKey(res.Scope, res.User, res.Unit)
for _, r := range known.Resources {
if r.Type == string(declaration.TypeService) && unitKey(r.Scope, r.User, r.Target) == want {
return true
}
}
return false
}
func present(path string) bool {
@@ -374,7 +409,7 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
case *declaration.User:
isFound = before.has("user:" + res.Name)
case *declaration.Service:
isFound = before.has("unit:" + res.Unit)
isFound = before.has("unit:" + unitKey(res.Scope, res.User, res.Unit))
}
}
if !isFound {
@@ -388,7 +423,11 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
// A held service is not started, stopped, enabled or restarted — but a reload stops nothing,
// so one the module names still happens (novox/hq ADR 0102, ADR 0103).
if svc, ok := r.(*declaration.Service); ok && svc.State == "running" {
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 {
// In the unit's own manager, and nothing to reload in one that is not running (novox/hq ADR
// 0177): the state read below then fails, and the reload is not attempted.
away, awayErr := managerAway(ctx, sys, svc.Scope, svc.User, run)
run := managerFor(svc.Scope, svc.User, run)
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 && awayErr == nil && away == "" {
if state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" {
reloader, can := sys.(serviceReloader)
if !can {
@@ -711,6 +750,20 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
}
detail = "the user was found on the machine; its shell and groups are kept until " + module + " is taken"
case *declaration.Service:
if res.UserScoped() {
// Read in the account's own manager, and not at all while it is not running (novox/hq
// ADR 0177): held as found, with nothing to compare until it runs.
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
if err != nil {
return out, h, err
}
if away != "" {
detail = "its unit was found in " + res.User + "'s own manager, which is not running; " +
"kept as found until " + module + " is taken"
break
}
run = managerFor(res.Scope, res.User, run)
}
state, err := sys.ServiceState(ctx, run, res.Unit)
switch {
case err != nil && !already:
+24
View File
@@ -107,7 +107,28 @@ type fakeContainer struct {
networks, mounts, ports []string
}
// oldShape is a systemctl call as these fakes were written to read it: the verb, the unit, then the
// options. The host passes `--` before every unit (third review of mesh-catalog #147), which
// internal/system's own test holds; the fakes read the unit where they always did.
func oldShape(name string, args []string) []string {
if name != "systemctl" || len(args) < 2 || args[len(args)-2] != "--" {
return args
}
v := 0
for v < len(args) && strings.HasPrefix(args[v], "-") {
v++
}
if v >= len(args)-2 {
return args
}
out := append([]string{}, args[:v+1]...)
out = append(out, args[len(args)-1])
return append(out, args[v+1:len(args)-2]...)
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
if name == "systemctl" {
return m.systemctl(args)
@@ -907,6 +928,8 @@ func TestMountingTheMachinesOwnPlumbingIsNotFoundData(t *testing.T) {
type unreachable struct{ asked []string }
func (u *unreachable) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
u.asked = append(u.asked, name+" "+strings.Join(args, " "))
if name == "docker" && args[0] == "info" {
return "27.0\n", nil
@@ -958,6 +981,7 @@ func TestAHeldContainerStaysHeldWhenTheRuntimeCannotAnswer(t *testing.T) {
func TestAVolumeTheRuntimeCannotBeAskedAboutStopsTheContainer(t *testing.T) {
dir := t.TempDir()
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case name == "docker" && args[0] == "info":
return "27.0\n", nil
+161
View File
@@ -0,0 +1,161 @@
package apply
// A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08).
//
// The node-engine runs as root and places files and directories under homes: the operator's shell
// configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that
// replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and
// mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of
// their own, that account is exactly the one that must not become root.
//
// So for a path strictly below a home:
//
// - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said
// in words, and nothing is done to it — before anything is read, written, chowned or chmodded;
// - **the owner and mode are set through a descriptor opened without following a link**, each component
// opened from the one above it (homes_linux.go), so a link put in place after the check is not followed
// either; a link that is itself the thing to own is owned as a link, never its target;
// - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within
// that directory, so neither the file nor a parent can be swapped for a link between check and write.
//
// The home itself is the machine's, made by root, and is followed as it is. A path outside every home is
// handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is).
//
// Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow
// account, and every home under /home. A service account's home under /var/lib is a module's own directory,
// and is left as it was.
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// homes is every home of a person's or an agent's account on this machine, as placement_guard.go reads them
// from the user database. A variable so a test can name the homes it made.
var homes = func() []string {
var out []string
for home := range accountsOfHomes() {
out = append(out, home)
}
// In one order: the user database is read into a map, and a map's order is not one.
sort.Strings(out)
return out
}
// homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none.
func homeAbove(path string) string {
path = filepath.Clean(path)
hs := homes()
sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) })
for _, h := range hs {
if strings.HasPrefix(path, h+string(os.PathSeparator)) {
return h
}
}
return ""
}
// LinkUnderHomeError is a resource refused because a component below a home is a symbolic link.
type LinkUnderHomeError struct {
Path, Link, Home string
}
func (e *LinkUnderHomeError) Error() string {
return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+
"follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+
"directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link)
}
// refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a
// symbolic link. Components that do not exist yet end the walk: what is missing is made without following.
func refuseLinksUnderHome(path string) error {
home := homeAbove(path)
if home == "" {
return nil
}
rel, err := filepath.Rel(home, filepath.Clean(path))
if err != nil {
return err
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
info, err := os.Lstat(at)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return nil
}
// HardLinkUnderHomeError is a regular file below a home with more than one link: the account that owns the
// home may have linked a file it does not own there, and root acting on it by name would act on that file
// (novox/hq ADR 0266). Refused, whatever the resource.
type HardLinkUnderHomeError struct {
Path string
Links uint64
}
func (e *HardLinkUnderHomeError) Error() string {
return fmt.Sprintf("%s has %d links: below a home a file with more than one is not owned, chmodded or read "+
"as root, since one of its names may be a file the home's account does not own", e.Path, e.Links)
}
// chmodDirPath is chmodPath for a directory resource: below a home, what is there must be a directory.
func chmodDirPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnderAs(home, path, mode, kindDir)
}
// statPath is os.Stat, except below a home, where it never follows a link.
func statPath(path string) (os.FileInfo, error) {
if homeAbove(path) != "" {
return os.Lstat(path)
}
return os.Stat(path)
}
// chmodPath sets a mode; below a home through a descriptor that followed no link.
func chmodPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnder(home, path, mode)
}
// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is
// owned as a link.
func chownPath(path string, uid, gid int) error {
home := homeAbove(path)
if home == "" {
return os.Chown(path, uid, gid)
}
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
return os.Lchown(path, uid, gid)
}
return chownUnder(home, path, uid, gid)
}
// readPath reads a file; below a home without following a link.
func readPath(path string) ([]byte, error) {
home := homeAbove(path)
if home == "" {
return os.ReadFile(path)
}
return readUnder(home, path)
}
+255
View File
@@ -0,0 +1,255 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0266 (the review of 2026-10-08): below a home, the node-engine — root — follows no
// symbolic link an account can put there: not to chown or chmod, not to write, not to make a directory.
// aLinkedHome is a home the test names as one, with `.claude` replaced by a link to a directory outside it,
// as an account would to have root change /etc.
func aLinkedHome(t *testing.T) (home, outside string) {
t.Helper()
root := t.TempDir()
home = filepath.Join(root, "home", "agent")
outside = filepath.Join(root, "etc")
for _, d := range []string{home, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("root's"), 0o600); err != nil {
t.Fatal(err)
}
was := homes
homes = func() []string { return []string{home} }
t.Cleanup(func() { homes = was })
return home, outside
}
func link(t *testing.T, target, at string) {
t.Helper()
if err := os.Symlink(target, at); err != nil {
t.Fatal(err)
}
}
func applyOneResource(t *testing.T, resource string) error {
t.Helper()
d := declare(t, resource)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
return err
}
func mustBeLinkRefusal(t *testing.T, err error) {
t.Helper()
var refused *LinkUnderHomeError
if !errors.As(err, &refused) {
t.Fatalf("refused as a link under a home, got %v", err)
}
if !strings.Contains(err.Error(), "never follows a link") {
t.Fatalf("said in words: %v", err)
}
}
func modeOfPath(t *testing.T, p string) os.FileMode {
t.Helper()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return info.Mode().Perm()
}
func TestADirectoryUnderAHomeThatIsALinkIsRefusedAndItsTargetUntouched(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"claude-code.agent-home","type":"directory","path":"`+
filepath.Join(home, ".claude")+`","mode":"0700"}`)
mustBeLinkRefusal(t, err)
if m := modeOfPath(t, outside); m != 0o755 {
t.Fatalf("the link's target was chmodded to %o", m)
}
}
func TestAFileThroughALinkedParentUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".claude", "shadow")+
`","content":"the mesh's\n","mode":"0644"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
}
func TestAFileThatIsItselfALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, filepath.Join(outside, "shadow"), filepath.Join(home, ".zshrc"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".zshrc")+
`","content":"x\n"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
}
func TestADirectoryAndAFileUnderAHomeAreMadeAsBefore(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, ".claude")
if err := applyOneResource(t, `{"id":"m.d","type":"directory","path":"`+dir+`","mode":"0700"}`); err != nil {
t.Fatal(err)
}
if m := modeOfPath(t, dir); m != 0o700 {
t.Fatalf("mode %o", m)
}
file := filepath.Join(home, ".config", "mesh", "env.sh")
if err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+file+`","content":"A=1\n","mode":"0640"}`); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(file); string(got) != "A=1\n" || modeOfPath(t, file) != 0o640 {
t.Fatalf("written %q mode %o", got, modeOfPath(t, file))
}
}
// The descriptor half: a link put in place after any check is still not followed.
func TestTheDescriptorCallsFollowNoLinkBelowAHome(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude"), 0o777))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude", "shadow"), 0o777))
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
mustBeLinkRefusal(t, writeAtomically(filepath.Join(home, ".claude", "new"), []byte("x"), 0o644))
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
t.Fatal("written through the link")
}
if _, err := makeDirsSaying(filepath.Join(home, ".claude", "a", "b"), 0o755, ""); err == nil {
t.Fatal("made directories through the link")
}
if _, err := os.Stat(filepath.Join(outside, "a")); !os.IsNotExist(err) {
t.Fatal("made a directory through the link")
}
if _, err := readPath(filepath.Join(home, ".claude", "shadow")); err == nil {
t.Fatal("read through the link")
}
me := os.Getuid()
// A link itself is owned as a link, never its target.
if err := chownPath(filepath.Join(home, ".claude"), me, os.Getgid()); err != nil {
t.Fatalf("a link is owned as a link: %v", err)
}
}
func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
}
}
func TestAPathOutsideEveryHomeIsHandledAsBefore(t *testing.T) {
_, outside := aLinkedHome(t)
elsewhere := filepath.Join(filepath.Dir(outside), "srv")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
link(t, outside, filepath.Join(elsewhere, "linked"))
if homeAbove(filepath.Join(elsewhere, "linked", "x")) != "" {
t.Fatal("not below a home")
}
if err := refuseLinksUnderHome(filepath.Join(elsewhere, "linked", "x")); err != nil {
t.Fatal("a system path may be a link the machine set up; only a home's are refused")
}
}
func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
dir := t.TempDir()
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/bash\n"+
"postgres:x:968:968::/var/lib/postgres:/usr/bin/nologin\n"+
"nobody:x:65534:65534::/:/usr/bin/nologin\n"+
"operator:x:1000:1000::/home/operator:/bin/zsh\n"+
"agent:x:1001:1001::/home/agent:/bin/bash\n"+
"svc:x:990:990::/home/svc:/usr/bin/nologin\n"), 0o644); err != nil {
t.Fatal(err)
}
was := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = was })
got := strings.Join(homes(), ",")
if got != "/home/agent,/home/operator,/home/svc" {
t.Fatalf("homes %s", got)
}
if homeAbove("/home/agent/.claude") != "/home/agent" || homeAbove("/home/agent") != "" ||
homeAbove("/var/lib/postgres/data") != "" || homeAbove("/root/.ssh") != "" {
t.Fatal("strictly below a person's or an agent's home, and nothing else")
}
}
// A hard link below a home to a file the home's account does not own is never owned, chmodded or read by
// name: fstat on the opened descriptor counts its links first (the re-review of 2026-10-08).
func TestAHardLinkedFileBelowAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
shadow := filepath.Join(outside, "shadow")
linked := filepath.Join(home, ".claude")
if err := os.Link(shadow, linked); err != nil {
t.Skipf("no hard link here: %v", err)
}
var hard *HardLinkUnderHomeError
if err := chmodPath(linked, 0o777); !errors.As(err, &hard) {
t.Fatalf("chmod of a hard-linked file below a home is refused, got %v", err)
}
if err := chownPath(linked, os.Getuid(), os.Getgid()); !errors.As(err, &hard) {
t.Fatalf("chown of it is refused, got %v", err)
}
if _, err := readPath(linked); !errors.As(err, &hard) {
t.Fatalf("reading it is refused, got %v", err)
}
if m := modeOfPath(t, shadow); m != 0o600 {
t.Fatalf("the other file's mode changed: %o", m)
}
// As the directory resource sees it: a file where ~/.claude should be is left alone.
if err := chmodDirPath(linked, 0o700); err == nil || !strings.Contains(err.Error(), "where a directory was expected") {
t.Fatalf("a file where a directory was expected is refused, got %v", err)
}
}
func TestADirectoryWhereAFileWasExpectedAndAFifoAreRefused(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, "d")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if _, err := readPath(dir); err == nil || !strings.Contains(err.Error(), "where a file was expected") {
t.Fatalf("a directory where a file was expected is refused, got %v", err)
}
fifo := filepath.Join(home, "f")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("no fifo here: %v", err)
}
if err := chmodPath(fifo, 0o644); err == nil || !strings.Contains(err.Error(), "neither a file nor a directory") {
t.Fatalf("a fifo is refused, got %v", err)
}
if err := chmodDirPath(dir, 0o700); err != nil {
t.Fatalf("a directory as a directory passes: %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
package apply
import (
"context"
"os"
osuser "os/user"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0182 and to-be 41: a parent the host makes inside an owner's home is the
// owner's, one that was there is held as found, and one outside the home is made as before.
// aHome gives the account running the test a home in a directory the test owns, and writes down
// every directory the host gives to whom. The account's own name, so what the host chowns resolves
// without being root; the record, so what was given is told apart from what was merely made.
func aHome(t *testing.T) (home, owner string, given map[string]string) {
t.Helper()
me, err := osuser.Current()
if err != nil {
t.Skip("no current user to own anything")
}
home = t.TempDir()
given = map[string]string{}
wasHome, wasOwn := homeOf, ownMade
homeOf = func(name string) (string, error) {
if name == me.Username {
return home, nil
}
return wasHome(name)
}
ownMade = func(path, owner string) error {
given[path] = owner
return wasOwn(path, owner)
}
t.Cleanup(func() { homeOf, ownMade = wasHome, wasOwn })
return home, me.Username, given
}
func givenPaths(given map[string]string) []string {
var paths []string
for p := range given {
paths = append(paths, p)
}
sort.Strings(paths)
return paths
}
func TestAFileUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
home, owner, given := aHome(t)
target := filepath.Join(home, ".config", "mesh", "environment.sh")
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
want := []string{filepath.Join(home, ".config"), filepath.Join(home, ".config", "mesh")}
if got := givenPaths(given); strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("given to the owner: %v, want %v", got, want)
}
for _, p := range want {
if given[p] != owner {
t.Errorf("%s given to %q", p, given[p])
}
}
}
func TestAnArchiveUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
home, owner, given := aHome(t)
body, digest := anArchive(t, map[string]string{"p10k.zsh": "theme"})
target := filepath.Join(home, ".local", "share", "powerlevel10k")
d := declare(t, `{"id":"shell.theme","type":"archive","source":"`+serving(t, body)+
`","digest":"`+digest+`","path":"`+target+`","owner":"`+owner+`"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
for _, p := range []string{filepath.Join(home, ".local"), filepath.Join(home, ".local", "share")} {
if given[p] != owner {
t.Errorf("%s, made by the host, was not given to the owner: %v", p, givenPaths(given))
}
}
}
func TestAParentThatWasThereIsHeldAsFound(t *testing.T) {
home, owner, given := aHome(t)
config := filepath.Join(home, ".config")
if err := os.Mkdir(config, 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(config, "mesh", "environment.sh")
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if _, touched := given[config]; touched {
t.Error("a parent that was already there was given to the owner")
}
if info, _ := os.Stat(config); info.Mode().Perm() != 0o700 {
t.Errorf("a parent that was already there changed mode: %o", info.Mode().Perm())
}
if given[filepath.Join(config, "mesh")] != owner {
t.Errorf("the parent the host made was not given to the owner: %v", givenPaths(given))
}
}
func TestAParentOutsideTheHomeIsMadeAsBefore(t *testing.T) {
_, owner, given := aHome(t)
target := filepath.Join(t.TempDir(), "var", "lib", "module", "settings.conf")
d := parse(t, `{"declaration":1,"resources":[{"id":"module.conf","type":"file","path":"`+target+
`","content":"a=1\n","owner":"`+owner+`"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if len(given) != 0 {
t.Errorf("parents outside the owner's home were given to it: %v", givenPaths(given))
}
}
+269
View File
@@ -0,0 +1,269 @@
//go:build linux
package apply
// The descriptor half of home_links.go: below a home, every component is opened from the one above it with
// O_NOFOLLOW, so a link is refused by the kernel at the moment of use, not only at the check before it.
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
)
// openDirUnder opens the directory rel names below home, following no link below the home.
func openDirUnder(home, dir string) (int, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return -1, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, &os.PathError{Op: "open", Path: home, Err: err}
}
if rel == "." {
return fd, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if err != nil {
return -1, linkOr(at, home, dir, "open", err)
}
fd = next
}
return fd, nil
}
// linkOr says a refused link in the mesh's words, and any other failure as the system's.
func linkOr(at, home, path, op string, err error) error {
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
if info, lerr := os.Lstat(at); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return &os.PathError{Op: op, Path: at, Err: err}
}
// What a caller expects to find at a path below a home: either, a directory, or a regular file.
const (
kindAny = iota
kindDir
kindFile
)
// openUnder opens the file or directory at path below home, following no link, for its metadata.
func openUnder(home, path string) (int, error) { return openUnderAs(home, path, kindAny) }
// openUnderAs opens path below home as what the caller expects, and refuses what root must not act on
// (novox/hq ADR 0266): a directory is opened with O_DIRECTORY|O_NOFOLLOW; whatever was opened is judged by
// fstat on the descriptor itself, before any fchown, fchmod or read — so a name swapped after a check is
// judged as what it now is. Refused: anything but a directory or a regular file (a device, a fifo, a
// socket), a kind other than the one expected, and a regular file with more than one link. The account that
// owns the home can hard-link a file it does not own (root's, where fs.protected_hardlinks is off) into its
// home; owned or chmodded by name, root would hand that file over.
func openUnderAs(home, path string, want int) (int, error) {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return -1, err
}
defer unix.Close(dir)
base := filepath.Base(path)
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_CLOEXEC
fd := -1
if want != kindFile {
fd, err = unix.Openat(dir, base, flags|unix.O_DIRECTORY, 0)
if errors.Is(err, unix.ENOTDIR) && want == kindAny {
fd, err = unix.Openat(dir, base, flags, 0)
}
} else {
fd, err = unix.Openat(dir, base, flags, 0)
}
if err != nil {
err = linkOr(path, home, path, "open", err)
var link *LinkUnderHomeError
if want == kindDir && !errors.As(err, &link) && errors.Is(err, unix.ENOTDIR) {
return -1, fmt.Errorf("%s is not a directory where a directory was expected: below a home it is left alone", path)
}
return -1, err
}
if err := judgeOpened(fd, path, want); err != nil {
unix.Close(fd)
return -1, err
}
return fd, nil
}
// judgeOpened is openUnderAs's verdict on what the descriptor holds.
func judgeOpened(fd int, path string, want int) error {
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return &os.PathError{Op: "fstat", Path: path, Err: err}
}
switch st.Mode & unix.S_IFMT {
case unix.S_IFDIR:
if want == kindFile {
return fmt.Errorf("%s is a directory where a file was expected: below a home it is left alone", path)
}
case unix.S_IFREG:
if want == kindDir {
return fmt.Errorf("%s is a file where a directory was expected: below a home it is left alone", path)
}
if st.Nlink > 1 {
return &HardLinkUnderHomeError{Path: path, Links: uint64(st.Nlink)}
}
default:
return fmt.Errorf("%s is neither a file nor a directory: below a home it is left alone", path)
}
return nil
}
func chmodUnder(home, path string, mode os.FileMode) error {
return chmodUnderAs(home, path, mode, kindAny)
}
func chmodUnderAs(home, path string, mode os.FileMode, want int) error {
fd, err := openUnderAs(home, path, want)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return &os.PathError{Op: "chmod", Path: path, Err: err}
}
return nil
}
func chownUnder(home, path string, uid, gid int) error {
fd, err := openUnder(home, path)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchown(fd, uid, gid); err != nil {
return &os.PathError{Op: "chown", Path: path, Err: err}
}
return nil
}
func readUnder(home, path string) ([]byte, error) {
fd, err := openUnderAs(home, path, kindFile)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), path)
defer f.Close()
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, fmt.Errorf("%s is not a regular file", path)
}
return io.ReadAll(f)
}
// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor
// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does.
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: home, Err: err}
}
defer func() { unix.Close(fd) }()
var made []string
if rel == "." {
return nil, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil {
made = append([]string{at}, made...)
} else if !errors.Is(err, unix.EEXIST) {
return made, &os.PathError{Op: "mkdir", Path: at, Err: err}
}
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return made, linkOr(at, home, dir, "open", err)
}
unix.Close(fd)
fd = next
}
return made, nil
}
// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW
// under a name of its own, given its mode, and renamed over the file within that directory.
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return err
}
defer unix.Close(dir)
name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid())
fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
if err != nil {
return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err}
}
f := os.NewFile(uintptr(fd), name)
_, werr := f.Write(content)
if werr == nil {
werr = f.Sync()
}
if werr == nil {
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
werr = &os.PathError{Op: "chmod", Path: path, Err: err}
}
}
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr == nil {
if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil {
werr = &os.PathError{Op: "rename", Path: path, Err: err}
}
}
if werr != nil {
_ = unix.Unlinkat(dir, name, 0)
}
return werr
}
// giveHandedOver gives a directory the operator handed to the mesh its declared mode and owner (novox/hq issue
// 356, issue 339) through one descriptor opened from / with no link followed on any component (openUnderAs with /
// as the home), judged a directory by fstat on that descriptor, and changed with fchmod and fchown on it. The
// hand-over refused a link when it was recorded; this refuses one at the moment of use, so a component swapped for
// a link between the record and the apply cannot carry root's chmod and chown to wherever it points. Returns the
// mode and owner the directory had before.
func giveHandedOver(path string, mode os.FileMode, uid, gid int, chown bool) (before os.FileMode, beforeUID, beforeGID int, err error) {
fd, err := openUnderAs("/", path, kindDir)
if err != nil {
return 0, 0, 0, err
}
defer unix.Close(fd)
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return 0, 0, 0, &os.PathError{Op: "fstat", Path: path, Err: err}
}
before, beforeUID, beforeGID = os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return 0, 0, 0, &os.PathError{Op: "chmod", Path: path, Err: err}
}
if chown && (beforeUID != uid || beforeGID != gid) {
if err := unix.Fchown(fd, uid, gid); err != nil {
return 0, 0, 0, &os.PathError{Op: "chown", Path: path, Err: err}
}
}
return before, beforeUID, beforeGID, nil
}
+84
View File
@@ -0,0 +1,84 @@
//go:build !linux
package apply
// Where there is no openat with O_NOFOLLOW to rely on, the check before use is all: a link below a home is
// refused, and the call is made by path. The node-engine runs on Linux; this keeps the package building.
import (
"os"
"path/filepath"
"github.com/novox/mesh-host/internal/declaration"
)
func chmodUnder(home, path string, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func chmodUnderAs(home, path string, mode os.FileMode, _ int) error {
return chmodUnder(home, path, mode)
}
const kindDir = 1
func chownUnder(home, path string, uid, gid int) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Lchown(path, uid, gid)
}
func readUnder(home, path string) ([]byte, error) {
if err := refuseLinksUnderHome(path); err != nil {
return nil, err
}
return os.ReadFile(path)
}
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
if err := refuseLinksUnderHome(dir); err != nil {
return nil, err
}
var made []string
for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) {
if _, err := os.Lstat(d); err == nil {
break
}
made = append(made, d)
}
return made, os.MkdirAll(dir, mode)
}
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return writeAtomicallyByPath(path, content, mode)
}
// giveHandedOver is refused where there is no openat with O_NOFOLLOW: a handed-over directory is never changed by
// path (novox/hq issue 356). The node-engine runs on Linux.
func giveHandedOver(path string, _ os.FileMode, _, _ int, _ bool) (os.FileMode, int, int, error) {
return 0, 0, 0, &PlacementRefusedError{Path: path, Why: "a handed-over directory is changed only through a " +
"descriptor that followed no link, which this system cannot open"}
}
// judgeMeshDir refuses where there is no openat with O_NOFOLLOW (novox/hq issue 358). The node-engine runs on
// Linux.
func judgeMeshDir(path string) error {
return &PlacementRefusedError{Path: path, Why: "a mesh directory is judged only by a walk that follows no link " +
"another account could make, which this system cannot do"}
}
// meshDirsByDescriptor is false where there is no openat with O_NOFOLLOW: a mesh directory is changed by path, as
// before novox/hq issue 358. The node-engine runs on Linux.
const meshDirsByDescriptor = false
func applyMeshDirectory(r *declaration.Directory, out Outcome, _ os.FileMode, _ bool) (Outcome, error) {
return out, &PlacementRefusedError{Path: r.Path, Why: "a mesh directory is changed only through a descriptor " +
"that followed no link another account could make, which this system cannot open"}
}
+253
View File
@@ -0,0 +1,253 @@
package apply
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// Every module is given an identity of its own (novox/hq ADR 0325 §3, §11, issue 567).
//
// The controller allocates each module a user and a group number once, from a range of its own, the same on every
// machine, and declares them as `mesh-<module>`. The engine makes the group and the account with exactly those
// numbers — a system account: no login shell, no home, no password — before anything of the declaration is
// applied, so a directory the module owns can be given to it and a container told to run as it.
//
// **Never root** (§11): a number that is 0, or a gid a root group has here (by its number and by its name), is
// refused for the whole declaration before anything is made or started, naming why — for an account and for an
// image alike. **Never another's**: a name that exists with other numbers, or numbers another name holds, is
// refused for that identity alone, naming whose, and its module is not attempted; the rest of the machine is.
//
// **What it made, and only that, goes** when the identity is no longer declared: the record says whether the
// engine made the account, the group or both, and an account or a group that was there before is never removed.
// accountsTool is how the engine reads and changes the machine's accounts: the system's own tools through the
// runner, which is what a test stands in for.
type accountsTool interface {
CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int) error
CreateSystemGroup(ctx context.Context, run system.Runner, name string, gid int) error
RemoveAccount(ctx context.Context, run system.Runner, name string) error
RemoveGroup(ctx context.Context, run system.Runner, name string) error
}
// entry is one line the user or group database gives for a key: its name and its numbers.
type entry struct {
name string
id, prim int
}
// lookUp asks the database (passwd or group) for key, a name or a number. Absent is (entry{}, false, nil); a
// database that does not answer is an error, never read as absent (system.LookUpUser's rule).
func lookUp(ctx context.Context, run Runner, database, key string) (entry, bool, error) {
out, err := run(ctx, "getent", database, key)
if err != nil {
if code, ok := system.ExitCode(err); ok && code == 2 {
return entry{}, false, nil
}
return entry{}, false, fmt.Errorf("the %s database did not answer about %s: %w", database, key, err)
}
fields := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ":")
if len(fields) < 3 {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
e := entry{name: fields[0], prim: -1}
if e.id, err = strconv.Atoi(fields[2]); err != nil {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
if database == "passwd" && len(fields) >= 4 {
if e.prim, err = strconv.Atoi(fields[3]); err != nil {
e.prim = -1
}
}
return e, true, nil
}
// refuseRootIdentities is every identity and every container's run-as that is root's (§11), judged before
// anything of the declaration is applied: one is enough to refuse it whole, so nothing is made or started.
func refuseRootIdentities(d *declaration.Declaration) error {
var problems []string
for _, id := range d.Identities {
if err := rootIdentity(id.UID, id.GID); err != nil {
problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err))
}
}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.RunAs != nil {
if err := rootIdentity(c.RunAs.UID, c.RunAs.GID); err != nil {
problems = append(problems, fmt.Sprintf("the container %s is told to run as uid %d, gid %d: %v",
c.ID, c.RunAs.UID, c.RunAs.GID, err))
}
}
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
return fmt.Errorf("%s; nothing of this declaration was made or started", strings.Join(problems, "; "))
}
// applyIdentity makes one identity as declared, or refuses it naming whose the name or the numbers are. What it
// made is recorded on known; an identity already right is unchanged.
func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, known *store.State,
run Runner) (Outcome, error) {
out := Outcome{ID: "identity " + id.Name, Type: "identity", Target: id.Name, Action: "unchanged"}
user, hasUser, err := lookUp(ctx, run, "passwd", id.Name)
if err != nil {
return out, err
}
group, hasGroup, err := lookUp(ctx, run, "group", id.Name)
if err != nil {
return out, err
}
byUID, uidUsed, err := lookUp(ctx, run, "passwd", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.GID))
if err != nil {
return out, err
}
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0325 §3)"
switch {
case hasUser && (user.id != id.UID || user.prim != id.GID):
return out, fmt.Errorf("the account %s is here with uid %d and group %d, and the mesh allocated uid %d, "+
"gid %d%s", id.Name, user.id, user.prim, id.UID, id.GID, never)
case hasGroup && group.id != id.GID:
return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d%s", id.Name,
group.id, id.GID, never)
case uidUsed && byUID.name != id.Name:
return out, fmt.Errorf("uid %d is the account %s's here, and the mesh allocated it to %s%s", id.UID,
byUID.name, id.Name, never)
case gidUsed && byGID.name != id.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.GID,
byGID.name, id.Name, never)
}
rec := madeIdentity(known, id.Name)
rec.Module, rec.Name, rec.UID, rec.GID = id.Module, id.Name, id.UID, id.GID
var made []string
if !hasGroup {
if err := tool.CreateSystemGroup(ctx, run, id.Name, id.GID); err != nil {
return out, err
}
rec.MadeGroup = true
made = append(made, fmt.Sprintf("the group (gid %d)", id.GID))
}
if !hasUser {
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID); err != nil {
recordIdentity(known, rec)
return out, err
}
rec.MadeUser = true
made = append(made, fmt.Sprintf("the account (uid %d), with no login, no home and no password", id.UID))
}
if rec.At.IsZero() {
rec.At = time.Now().UTC()
}
recordIdentity(known, rec)
if len(made) > 0 {
out.Action, out.Detail = "created", "made "+strings.Join(made, " and ")
} else {
out.Detail = "here with its numbers"
}
return out, nil
}
// madeIdentity is what the engine recorded of name, or an empty record.
func madeIdentity(known *store.State, name string) store.MadeIdentity {
for _, m := range known.Identities {
if m.Name == name {
return m
}
}
return store.MadeIdentity{}
}
// recordIdentity records what was made of one identity, in place of any record before.
func recordIdentity(known *store.State, rec store.MadeIdentity) {
for i, m := range known.Identities {
if m.Name == rec.Name {
known.Identities[i] = rec
return
}
}
if rec.MadeUser || rec.MadeGroup {
known.Identities = append(known.Identities, rec)
}
}
// removeIdentities removes what the engine made of every identity no longer declared — the account, then the
// group — and only while it still has the numbers the engine gave it. Never fatal: what could not be removed
// stays recorded, said, for the next apply.
func removeIdentities(ctx context.Context, tool accountsTool, declared []declaration.Identity, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, id := range declared {
wanted[id.Name] = true
}
var out []Outcome
var keep []store.MadeIdentity
for _, m := range known.Identities {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "identity " + m.Name, Type: "identity", Target: m.Name, Action: "removed"}
var said []string
if m.MadeUser {
if u, has, err := lookUp(ctx, run, "passwd", m.Name); err != nil || (has && u.id != m.UID) {
said = append(said, "the account was left: it no longer has the uid the mesh gave it, or the "+
"database did not answer")
m.MadeUser = false
} else if has {
if err := tool.RemoveAccount(ctx, run, m.Name); err != nil {
said = append(said, "the account could not be removed: "+err.Error())
} else {
m.MadeUser = false
said = append(said, "removed the account")
}
} else {
m.MadeUser = false
}
}
if m.MadeGroup && !m.MadeUser {
if g, has, err := lookUp(ctx, run, "group", m.Name); err != nil || (has && g.id != m.GID) {
said = append(said, "the group was left: it no longer has the gid the mesh gave it, or the "+
"database did not answer")
m.MadeGroup = false
} else if has {
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
said = append(said, "the group could not be removed: "+err.Error())
} else {
m.MadeGroup = false
said = append(said, "removed the group")
}
} else {
m.MadeGroup = false
}
}
if m.MadeUser || m.MadeGroup {
o.Action = "waiting"
keep = append(keep, m)
}
o.Detail = "no longer declared: " + strings.Join(said, "; ")
out = append(out, o)
}
known.Identities = keep
return out
}
// toolFor is the accounts tool of a system, or why it has none.
func toolFor(sys system.System) (accountsTool, error) {
if t, ok := sys.(accountsTool); ok {
return t, nil
}
return nil, errors.New("this system makes no module identity: it has no way to make a system account")
}
+287
View File
@@ -0,0 +1,287 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with
// the numbers the controller allocated, a container told to run as it, and never a root number.
// accountsMachine is a user and group database the runner answers from, and every command that changed it.
type accountsMachine struct {
users, groups map[string][2]int // name → id, primary gid (a group's second is unused)
ran []string
}
func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
switch name {
case "getent":
db, key := args[0], args[1]
table := m.users
if db == "group" {
table = m.groups
}
for n, ids := range table {
if n == key || strconv.Itoa(ids[0]) == key {
if db == "group" {
return n + ":x:" + strconv.Itoa(ids[0]) + ":\n", nil
}
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::/:/usr/bin/nologin\n", nil
}
}
return "", errors.New("getent exited 2")
case "groupadd":
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
case "useradd":
m.users[args[len(args)-1]] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
case "userdel":
delete(m.users, args[0])
case "groupdel":
delete(m.groups, args[0])
default:
return "", nil
}
m.ran = append(m.ran, line)
return "", nil
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration {
t.Helper()
d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` +
resources + `]}`))
if err != nil {
t.Fatal(err)
}
return d
}
// Row 3: the account made as a system account with exactly its numbers, recorded, and removed — only what was
// made — when no longer declared.
func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing.T) {
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
dir := t.TempDir()
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`)
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatal(err)
}
want := []string{
"groupadd --system --gid 61001 mesh-radarr",
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir / --shell /usr/bin/nologin mesh-radarr",
}
if strings.Join(m.ran, "\n") != strings.Join(want, "\n") {
t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n"))
}
if o := outcomeOf(report, "identity mesh-radarr"); o.Action != "created" {
t.Errorf("the identity's outcome: %+v", o)
}
if len(known.Identities) != 1 || !known.Identities[0].MadeUser || !known.Identities[0].MadeGroup {
t.Fatalf("what was made is not recorded: %+v", known.Identities)
}
// Applied again: nothing is made twice.
m.ran = nil
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if len(m.ran) != 0 {
t.Fatalf("an identity already here was made again: %v", m.ran)
}
// Undeclared, with a group the machine had before for another identity: only what was made goes.
m.groups["mesh-sonarr"] = [2]int{61002, 0}
other := withIdentities(t, `{"module":"sonarr","name":"mesh-sonarr","uid":61002,"gid":61002}`,
`{"id":"x.dir","type":"directory","path":"`+dir+`/x"}`)
if _, known, err = Apply(context.Background(), archHost(t), other, known, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if _, has := m.users["mesh-radarr"]; has {
t.Error("an identity no longer declared kept its account")
}
if _, has := m.groups["mesh-radarr"]; has {
t.Error("an identity no longer declared kept its group")
}
if rec := madeIdentity(&known, "mesh-sonarr"); rec.MadeGroup {
t.Error("a group that was there before is recorded as the mesh's")
}
}
// Row 3: a name that exists with other numbers, or numbers another name holds, is refused naming whose, and the
// module is not attempted; the rest of the machine is.
func TestAnIdentityWhoseNameOrNumbersAreAnothersIsRefused(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
users, groups map[string][2]int
says string
}{
{users: map[string][2]int{"mesh-radarr": {1500, 1500}}, groups: map[string][2]int{}, says: "uid 1500"},
{users: map[string][2]int{"someone": {61001, 100}}, groups: map[string][2]int{}, says: "someone"},
{users: map[string][2]int{}, groups: map[string][2]int{"media": {61001, 0}}, says: "media"},
} {
m := &accountsMachine{users: tc.users, groups: tc.groups}
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/radarr"},`+
`{"id":"plex.config","type":"directory","path":"`+dir+`/plex"}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), tc.says) {
t.Errorf("not refused naming %s: %v", tc.says, err)
}
if len(m.ran) != 0 {
t.Errorf("something was made for an identity that is another's: %v", m.ran)
}
if o := outcomeOf(report, "radarr.config"); o.Action != "skipped" {
t.Errorf("the module of a refused identity was attempted: %+v", o)
}
if _, err := os.Stat(filepath.Join(dir, "plex")); err != nil {
t.Errorf("the rest of the machine was not applied: %v", err)
}
os.RemoveAll(filepath.Join(dir, "plex"))
}
}
// Row 11: a declaration naming a root group for an account or an image is refused with its reason, and nothing is
// made or started. A number 0 is refused on arrival, by the parser.
func TestNoModuleIdentityIsRoot(t *testing.T) {
was := RootGroupIDs
RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} }
defer func() { RootGroupIDs = was }()
dir := t.TempDir()
for _, d := range []*declaration.Declaration{
withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+
`{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+
`"identity":{"mode":"run","uid":61001,"gid":10}}`),
} {
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "wheel") || !strings.Contains(err.Error(), "never") {
t.Errorf("a root group was not refused naming why: %v", err)
}
if len(m.ran) != 0 {
t.Errorf("something was made or started: %v", m.ran)
}
if _, err := os.Stat(filepath.Join(dir, "config")); err == nil {
t.Error("a resource of a declaration naming a root group was applied")
}
}
for _, raw := range []string{
`{"declaration":1,"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]}`,
`{"declaration":1,"resources":[{"id":"m.app","type":"container","name":"m","image":"` + pinned + `",` +
`"identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":0}}]}`,
} {
if _, err := declaration.Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "never root") {
t.Errorf("a number 0 was not refused on arrival: %v", err)
}
}
}
// Row 3: a container declaring identity is told it — by the variables its image reads, or the runtime's user
// option — and a changed identity is a container recreated.
func TestAContainerIsToldItsModulesIdentity(t *testing.T) {
env := &declaration.Container{ID: "radarr.app", Name: "radarr", Image: pinned, RunAs: &declaration.ContainerIdentity{
Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID", UID: 61001, GID: 61001}}
run := &declaration.Container{ID: "plex.app", Name: "plex", Image: pinned, RunAs: &declaration.ContainerIdentity{
Mode: declaration.IdentityByRun, UID: 61002, GID: 61002}}
if got := strings.Join(foregroundRunArgs(env, "x"), " "); !strings.Contains(got, "--env PUID=61001 --env PGID=61001") {
t.Errorf("an image told by environment was not: %s", got)
}
if got := strings.Join(foregroundRunArgs(run, "x"), " "); !strings.Contains(got, "--user 61002:61002") {
t.Errorf("an image told by the runtime's user option was not: %s", got)
}
plain := *env
plain.RunAs = nil
moved := *env
moved.RunAs = &declaration.ContainerIdentity{Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID",
UID: 61009, GID: 61009}
if containerSpec(env, inputs{}) == containerSpec(&plain, inputs{}) ||
containerSpec(env, inputs{}) == containerSpec(&moved, inputs{}) {
t.Error("an identity told, or renumbered, does not recreate the container")
}
}
// ADR 0325 §5 supersedes ADR 0312's group grant: what a grant put an account in is taken back on the first apply of
// the access, and nothing is granted again.
func TestWhatAGroupGrantPutAnAccountInIsTakenBack(t *testing.T) {
dir := t.TempDir()
groups := "nox media"
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
switch name {
case "id":
return groups + "\n", nil
case "gpasswd":
groups = "nox"
}
return "", nil
}
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write","grant":{"account":"nox"}}`)
report, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if groups != "nox" || !strings.Contains(strings.Join(ran, "\n"), "gpasswd --delete nox media") {
t.Fatalf("the grant's group was not taken back: %v", ran)
}
if o := outcomeOf(report, "importer.books"); o.Action != "updated" || !strings.Contains(o.Detail, "taken back") {
t.Errorf("the take-back is not said: %+v", o)
}
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 {
t.Errorf("the record still holds the grant's groups: %+v", rec)
}
}
// Review L7: a group the grant put an account in that could not be left stays recorded, and is tried again.
func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) {
dir := t.TempDir()
refuse := true
groups := "nox media"
run := func(_ context.Context, name string, args ...string) (string, error) {
switch name {
case "id":
return groups + "\n", nil
case "gpasswd":
if refuse {
return "", errors.New("gpasswd exited 1: locked")
}
groups = "nox"
}
return "", nil
}
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write"}`)
_, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := known.Find("importer.books"); rec.User != "nox" || len(rec.Groups) != 1 {
t.Fatalf("a group that could not be left was forgotten: %+v", rec)
}
refuse = false
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 || groups != "nox" {
t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups)
}
}
+1
View File
@@ -19,6 +19,7 @@ import (
// not catch a regression back to it — this one refuses to, on purpose.
func dockerLikeByKind(containers map[string]bool) func(context.Context, string, ...string) (string, error) {
return func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("unexpected program: " + name)
}
+1 -1
View File
@@ -132,7 +132,7 @@ func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) {
mode = m
}
}
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil {
return out, err
}
if err := writeAtomically(r.Path, want, mode); err != nil {
+129
View File
@@ -0,0 +1,129 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 190, ADR 0222: the private network stops writing the mesh's registry into the
// container runtime's file, and the runtime's own module writes the same member. Both are moved in
// one apply, and the machine never loses the member: the network's record goes first (its member
// leaves the list), the runtime's module is applied after (the member is added back, now recorded
// as the runtime's), and the daemon is reloaded once, never stopped, disabled or restarted — even
// though the record going says the unit was found stopped and disabled.
const theRegistry = "anchor.internal:5100"
func runtimeDecl(t *testing.T, path string, network bool) *declaration.Declaration {
t.Helper()
var resources []string
if network {
resources = append(resources,
fmt.Sprintf(`{"id":"networking.registry-trust","type":"file","path":%q,"into":"json","content":%q}`,
path, `{"insecure-registries":["`+theRegistry+`"]}`),
`{"id":"networking.registry-trust-reload","type":"service","unit":"docker.service","state":"running",
"reload-on":["networking.registry-trust"]}`)
}
resources = append(resources,
fmt.Sprintf(`{"id":"docker.daemon","type":"file","path":%q,"into":"json","content":%q}`,
path, `{"live-restore":true,"insecure-registries":["`+theRegistry+`"]}`),
`{"id":"docker.runtime","type":"service","unit":"docker.service","state":"running","boot":"enabled",
"reload-on":["docker.daemon"]}`)
return parse(t, `{"declaration":1,"resources":[`+strings.Join(resources, ",")+`]}`)
}
func TestTheRegistryMovesToTheRuntimesModuleWithoutLeavingTheList(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.json")
if err := os.WriteFile(path, []byte(`{"insecure-registries":["192.0.2.7:5000"]}`), 0o644); err != nil {
t.Fatal(err)
}
var commands []string
run := recordingServices(&commands)
// Both declare it: the network's record added the member, so the runtime's finds it there.
_, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, true), store.State{},
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
// Say the network's record found the runtime stopped and disabled: giving that back would stop
// every container on the machine.
for i := range state.Resources {
if state.Resources[i].ID == "networking.registry-trust-reload" {
state.Resources[i].Found = &store.FoundUnit{Unit: "docker.service", State: "stopped", Boot: "disabled"}
}
}
commands = nil
report, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, false), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(readObject(t, path)["insecure-registries"]); got != "[192.0.2.7:5000 "+theRegistry+"]" {
t.Fatalf("the list after the move: %s", got)
}
rec, _ := state.Find("docker.daemon")
if added := rec.Into.Added["insecure-registries"]; len(added) != 1 || canonical(added[0]) != `"`+theRegistry+`"` {
t.Errorf("the member is not recorded as the runtime's module's: %s", added)
}
reloads := 0
for _, c := range commands {
if strings.Contains(c, "docker.service") && (strings.Contains(c, " stop ") || strings.Contains(c, " restart ") ||
strings.Contains(c, " disable ")) {
t.Errorf("the runtime was given back as the network's record found it: %q", c)
}
if strings.Contains(c, "reload docker.service") {
reloads++
}
}
if reloads != 1 {
t.Errorf("the runtime was reloaded %d times; commands were %v", reloads, commands)
}
for _, o := range report.Outcomes {
if o.ID == "networking.registry-trust-reload" && o.Action != "forgotten" {
t.Errorf("the network's record of the unit was %q: %s", o.Action, o.Detail)
}
}
// Steady from here on, and undeclaring the runtime's module takes only what it added.
report, state, err = Apply(context.Background(), archHost(t), runtimeDecl(t, path, false), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, o := range report.Outcomes {
if o.ID == "docker.daemon" && o.Action != "unchanged" {
t.Errorf("a second apply was %q", o.Action)
}
}
if _, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(readObject(t, path)["insecure-registries"]); got != "[192.0.2.7:5000]" {
t.Errorf("undeclaring the runtime's module left %s", got)
}
}
// And the preview says the same before it happens.
func TestThePlanForgetsARecordOfAUnitStillHeld(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.json")
var commands []string
_, state, err := Apply(context.Background(), archHost(t), runtimeDecl(t, path, true), store.State{},
store.OriginDeclared, recordingServices(&commands), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, step := range Plan(runtimeDecl(t, path, false), state, store.OriginDeclared) {
if step.ID == "networking.registry-trust-reload" && step.Verb != "forget" {
t.Errorf("the plan would %s the network's record of a unit docker.runtime holds: %s", step.Verb, step.Why)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//go:build linux
package apply
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The read-only judgement of a node's directories (novox/hq issue 358): one line each — placed, would be refused
// naming the link and who could replace it, or not there yet (placed: an apply makes it) — and nothing made or
// changed.
func TestJudgeDirectoriesSaysWhatTheWalkWouldRefuseAndChangesNothing(t *testing.T) {
base := t.TempDir()
plain := filepath.Join(base, "plain")
if err := os.Mkdir(plain, 0o755); err != nil {
t.Fatal(err)
}
elsewhere := filepath.Join(base, "elsewhere", "state")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "linked")); err != nil {
t.Fatal(err)
}
missing := filepath.Join(base, "missing", "deeper")
d := declare(t, `{"id":"a.plain","type":"directory","path":"`+plain+`","mode":"0700"},`+
`{"id":"a.linked","type":"directory","path":"`+filepath.Join(base, "linked", "state")+`","mode":"0700"},`+
`{"id":"a.missing","type":"directory","path":"`+missing+`","mode":"0700"}`)
said := JudgeDirectories(d)
lines := strings.Split(said, "\n")
if len(lines) != 4 || !strings.HasPrefix(lines[0], "3 directories declared: 2 placed, 1 would be refused") {
t.Fatalf("the judgement: %q", said)
}
if lines[1] != plain+": placed" || lines[3] != missing+": placed" {
t.Errorf("a directory the walk reaches is not said placed: %q", said)
}
if !strings.Contains(lines[2], "would be refused") || !strings.Contains(lines[2], filepath.Join(base, "linked")) ||
!strings.Contains(lines[2], "every account") {
t.Errorf("the linked directory is not said refused naming the link and who: %q", lines[2])
}
if _, err := os.Stat(filepath.Join(base, "missing")); !os.IsNotExist(err) {
t.Error("the judgement made a directory")
}
for _, p := range []string{plain, elsewhere} {
if info, _ := os.Stat(p); info.Mode().Perm() != 0o755 {
t.Errorf("the judgement changed %s to %o", p, info.Mode().Perm())
}
}
}
// A link only root could have made is followed by the judgement as by the apply.
func TestJudgeDirectoriesFollowsARootOnlyLink(t *testing.T) {
wasDirs, wasLinks := couldReplace, linkReplacer
couldReplace = func(string, uint32, uint32, uint32) string { return "" }
linkReplacer = func(string, uint32) string { return "" }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "real", "state"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(base, "run")); err != nil {
t.Fatal(err)
}
path := filepath.Join(base, "run", "state")
if said := JudgeDirectories(declare(t, `{"id":"a.s","type":"directory","path":"`+path+`"}`)); !strings.Contains(said, path+": placed") {
t.Fatalf("a directory behind a root-only link is not said placed: %q", said)
}
}
+953
View File
@@ -0,0 +1,953 @@
package apply
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// A shared folder is kept usable through access entries (novox/hq ADR 0325, issue 567).
//
// Several modules use one folder of the operator's — a library one imports into, one renames in, one plays from —
// each as its own identity. The engine keeps, for every placed folder, one ACL entry per identity that uses it,
// on the folder and as its default for everything made under it, and changes nothing else of the path (§5).
//
// **When it may keep a folder.** Only one that is the mesh's to keep: taken on the operator's warrant (§8), or
// holding no data yet — empty when first seen, or made by the engine (§9). A folder that holds data and was never
// taken is stated waiting, with a wait of kind access, until the operator lets the mesh keep it (§7); one whose disk
// is not there waits for the disk (§9); root's data on its placed filesystem is never kept, and is unhealthy (§12).
//
// **What every apply does.** It judges the folder (assessFolder), makes a missing one where its data lives, and
// sets the users' entries on the folder itself. What lies under a taken folder is the walks' (ReconcileKept): a
// user added has its entries added to everything under it, a user dropped has exactly its own removed, outside
// the apply and bounded. The watch and the sweep keep what is made later usable (§6, §10).
// keptRecords is where the engine keeps what it knows of each kept or seen folder: the file beside the state in
// the engine, memory in a test.
type keptRecords interface {
All() (map[string]store.KeptFolder, error)
Update(path string, change func(f *store.KeptFolder, had bool) bool) error
}
// KeptFolders is the engine's record of the folders it keeps. The engine sets the file beside its state.
var KeptFolders keptRecords = NewKeptMemory()
// keptMemory is a keptRecords held in memory.
type keptMemory struct {
mu sync.Mutex
m map[string]store.KeptFolder
}
// NewKeptMemory is an empty record held in memory: a test's, or a one-shot apply's.
func NewKeptMemory() keptRecords { return &keptMemory{m: map[string]store.KeptFolder{}} }
func (k *keptMemory) All() (map[string]store.KeptFolder, error) {
k.mu.Lock()
defer k.mu.Unlock()
out := map[string]store.KeptFolder{}
for p, f := range k.m {
out[p] = f
}
return out, nil
}
func (k *keptMemory) Update(path string, change func(f *store.KeptFolder, had bool) bool) error {
k.mu.Lock()
defer k.mu.Unlock()
f, had := k.m[path]
f.Path = path
if change(&f, had) {
k.m[path] = f
} else {
delete(k.m, path)
}
return nil
}
// keptRecord is the record of path, and whether there is one. A record that cannot be read is none: the folder
// is judged afresh, which waits rather than keeps.
func keptRecord(path string) (store.KeptFolder, bool) {
all, err := KeptFolders.All()
if err != nil {
return store.KeptFolder{}, false
}
f, had := all[path]
return f, had
}
// folderWant is what every access naming one folder asks of it: the accesses, each uid at the widest use any of
// them gives it (ADR 0325 §4: the tool runner's account stands for every module whose tools use it), and whether
// the controller says it was taken.
type folderWant struct {
path string
ids []string
users map[uint32]store.KeptUser
taken bool
}
// foldersOf is every declared access's folder, by its clean path. A folder the guard refuses is not read (issue
// 339).
func foldersOf(resources []declaration.Resource, refused map[string]error) map[string]*folderWant {
out := map[string]*folderWant{}
for _, r := range resources {
a, ok := r.(*declaration.Access)
if !ok {
continue
}
path := filepath.Clean(a.Path)
if refused[path] != nil {
continue
}
f := out[path]
if f == nil {
f = &folderWant{path: path, users: map[uint32]store.KeptUser{}}
out[path] = f
}
f.ids = append(f.ids, a.ID)
f.taken = f.taken || a.Taken
for _, u := range a.Users {
had, seen := f.users[uint32(u.UID)]
if seen && (had.Use == declaration.AccessReadWrite || u.Use != declaration.AccessReadWrite) {
continue
}
f.users[uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID), Use: u.Use, From: u.From}
}
}
return out
}
// list is the users, by uid.
func (f *folderWant) list() []store.KeptUser {
if f == nil {
return nil
}
out := make([]store.KeptUser, 0, len(f.users))
for _, u := range f.users {
out = append(out, u)
}
sort.Slice(out, func(i, j int) bool { return out[i].UID < out[j].UID })
return out
}
// usersOf is a list of users as each uid and whether it writes.
func usersOf(us []store.KeptUser) map[uint32]bool {
out := map[uint32]bool{}
for _, u := range us {
out[u.UID] = out[u.UID] || u.Use == declaration.AccessReadWrite
}
return out
}
// sameUsers is whether two lists give the same uids the same use.
func sameUsers(a, b []store.KeptUser) bool {
x, y := usersOf(a), usersOf(b)
if len(x) != len(y) {
return false
}
for uid, rw := range x {
if w, ok := y[uid]; !ok || w != rw {
return false
}
}
return true
}
// accessName is the module and its own id for an access, from the resource id `<module>.<id>`.
func accessName(id string) (module, name string) {
if m, ok := moduleOf(id); ok {
return m, id[len(m)+1:]
}
return "", id
}
// KeepWords and DiskWords are what an access wait says the operator gives (ADR 0325 §7): never a path.
func KeepWords(name string) string { return "let the mesh keep the shared folder " + name }
func DiskWords(name string) string { return "mount the disk that holds " + name }
// The judgements of a folder.
const (
// folderKeep is a folder the mesh keeps: taken, or holding no data yet.
folderKeep = "keep"
// folderMake is a folder absent under a parent on its placed filesystem, not root's: made, then kept.
folderMake = "make"
// folderWaitKeep is a folder that holds data and was never taken: it waits for the operator's word.
folderWaitKeep = "wait-keep"
// folderWaitDisk is a folder whose disk is not there.
folderWaitDisk = "wait-disk"
// folderRoot is root's data on its placed filesystem: never kept.
folderRoot = "root"
// folderUnknown is a folder whose filesystem could not be named: left as it is until it is.
folderUnknown = "unknown"
)
// folderVerdict is the judgement of one folder, with what it read.
type folderVerdict struct {
kind string
// how is why a folder kept is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptMade.
how string
// mount is the mount point that holds what is there.
mount string
// why is a root verdict's or a broken folder's reason; err a folder that is not one (a file, a link).
why string
err error
// moved is that the folder's recorded mount is mounted and another holds it: it is judged afresh.
moved bool
// unnamed is a folder whose filesystem cannot be named: never kept, nor taken.
unnamed bool
}
// assessFolder judges a folder as an apply, a look and every walk do (ADR 0325 §7, §9, §12): its disk first, then
// whose it is, then whether the mesh may keep it. Its words carry no path: they reach the operator's channel.
func assessFolder(ctx context.Context, path string, rec store.KeptFolder, taken bool) folderVerdict {
others := recordedMounts(path)
info, err := lstatWithin(path)
if errors.Is(err, os.ErrNotExist) {
m, err := NearestAncestor(path)
if err != nil {
return folderVerdict{err: err}
}
if !m.Info.IsDir() {
return folderVerdict{err: errors.New("the folder is not there, and its nearest existing parent is not " +
"a folder (a file or a link); nothing is made under it")}
}
pl := placedOn(ctx, path, m.Ancestor, rec.Mount, others)
if pl.Away {
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
}
uid, gid, ok := statOwner(m.Info)
if !ok {
return folderVerdict{err: errors.New("the owner of the folder's nearest existing parent cannot be read " +
"on this machine")}
}
if why := rootOwned(uint32(uid), uint32(gid)); why != "" {
return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is not there, and its nearest " +
"existing parent is " + why}
}
return folderVerdict{kind: folderMake, mount: pl.Mount, how: store.KeptMade, moved: pl.Moved}
}
if err != nil {
return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
}
if !info.IsDir() {
return folderVerdict{err: errors.New("the folder is not a directory, and an access is a shared directory")}
}
pl := placedOn(ctx, path, path, rec.Mount, others)
if pl.Away {
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
}
// Another filesystem at the same mount point (another pool, another disk) is a move too. A record from an earlier
// build names its filesystem another way, and is not compared: the next keep names it this way.
here := PlacedID(pl.Mount)
// **A filesystem that cannot be named now is unknown, never a move** (a name read that did not answer, a
// filesystem with no UUID, id or source): the folder holds what it is — nothing walked, nothing newly kept,
// nothing reset — and is judged again when it is named. One never kept is so never kept: it fails closed.
if here == "" {
return folderVerdict{kind: folderUnknown, mount: pl.Mount, unnamed: true,
why: "its filesystem could not be named this time (no UUID, filesystem id or source answered)"}
}
moved := pl.Moved || (rec.Fs != "" && rec.Mount == pl.Mount && rec.Placed != here)
facts, err := readFolder(path)
if err != nil {
return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
}
if why := rootOwned(facts.UID, facts.GID); why != "" {
return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is " + why}
}
// The controller's word that the folder was taken holds for the filesystem the warrant was carried out on: a
// folder moved to another is not taken, and asks again (§8).
takenHere := taken && (rec.TakenOn == here || (rec.TakenOn == "" && !moved))
switch {
case rec.Kept && !moved:
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: rec.How}
case takenHere:
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptByWarrant, moved: moved}
case facts.Empty:
return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptEmpty, moved: moved}
}
// What is there now holds data the mesh never took — a disk mounted over a folder kept while it was empty
// below it included (a move): it waits for the operator's word.
return folderVerdict{kind: folderWaitKeep, mount: pl.Mount, moved: moved}
}
// movedRecord is f judged afresh on the filesystem now at mount: the warrant's filesystem is kept so the
// controller's word is not read for the new one, and nothing else of what was kept there holds. What the mesh
// added on the old filesystem is left to be taken out once it holds the folder again (Left) only where it was
// walked onto data under a warrant: a folder kept because it was empty, or made, carries entries on that empty
// folder alone, hidden under the mount point and granting nothing on data, and they are forgotten (said once, by
// the apply that saw the move).
func movedRecord(f store.KeptFolder, mount string) store.KeptFolder {
left := f.Left
if f.How == store.KeptByWarrant && len(f.Users) > 0 && f.Mount != "" {
left = addLeft(left, f.Mount, f.Placed, f.Fs, f.Users)
}
placed, fs := placeOf(mount)
return store.KeptFolder{Path: f.Path, Mount: mount, Placed: placed, Fs: fs, Want: f.Want, IDs: f.IDs,
Act: f.Act, TakenOn: f.TakenOn, Left: left}
}
// placeOf is the name of the filesystem at mount, and what it was made from.
func placeOf(mount string) (placed, fs string) {
fs = filesystemKey(mount)
return placedFrom(mount, fs), fs
}
// addLeft adds users' entries left on mount, once per mount and uid.
func addLeft(left []store.KeptLeft, mount, placed, fs string, users []store.KeptUser) []store.KeptLeft {
for i := range left {
if left[i].Mount == mount && left[i].Placed == placed {
have := usersOf(left[i].Users)
for _, u := range users {
if _, had := have[u.UID]; !had {
left[i].Users = append(left[i].Users, u)
}
}
return left
}
}
return append(left, store.KeptLeft{Mount: mount, Placed: placed, Fs: fs, Since: time.Now().UTC(),
Users: append([]store.KeptUser(nil), users...)})
}
// PlacedID names the filesystem at mount for a wait, with no path in it: the controller asks again when the
// folder's filesystem is not the one a warrant was given for. Empty when the filesystem cannot be named, and a folder
// on it is then never kept (it fails closed, and waits).
func PlacedID(mount string) string { return placedFrom(mount, filesystemKey(mount)) }
// placedFrom is PlacedID from a filesystem key already read.
func placedFrom(mount, key string) string {
if mount == "" || key == "" {
return ""
}
sum := sha256.Sum256([]byte(mount + "\x00" + key))
return "fs-" + hex.EncodeToString(sum[:6])
}
// filesystemKey is what names the filesystem at mount across reboots, in this order:
//
// - a filesystem on a block device: its UUID, found by matching the mount's device against the devices in
// /dev/disk/by-uuid (never blkid). xfs's statfs id follows the device number, so the UUID comes first;
// - else the kernel's filesystem id (statfs f_fsid) when it is not zero: ZFS's from its dataset, btrfs's from its
// UUID;
// - else mountinfo's source with the filesystem's type: an NFS export's server:/export, a FUSE filesystem's own
// source string (both report f_fsid 0).
//
// Never the mount point and type alone, and never a device's name, which a reboot may renumber. Empty when none of
// these answers within statBound: then nothing is kept there. Read once per mount at a time.
func filesystemKey(mount string) string {
if mount == "" {
return ""
}
v, ok := boundedOnce("fs "+mount, func() any { return readFilesystemKey(mount) })
if !ok {
return ""
}
return v.(string)
}
func readFilesystemKey(mount string) string {
if u := MountUUID(mount); u != "" {
return "uuid:" + u
}
if id := MountFsid(mount); strings.Trim(id, "0") != "" {
return "fsid:" + id
}
if source, fstype := mountSourceOf(mount); source != "" && source != "none" {
return "source:" + fstype + ":" + source
}
return ""
}
// statBound is how long a read of a folder may take before it is said not to answer: a hard network mount whose
// server is down blocks a stat for ever, and one access must never hold the apply.
var statBound = 5 * time.Second
// errNoAnswer is a folder whose read did not return within statBound.
var errNoAnswer = errors.New("it does not answer: a read of it did not return within its bound (a mount that is down)")
// lstatWithin is os.Lstat within statBound, and statWithin os.Stat. A read that hangs is left to return on its own.
func lstatWithin(path string) (os.FileInfo, error) { return within("lstat", os.Lstat, path) }
func statWithin(path string) (os.FileInfo, error) { return within("stat", os.Stat, path) }
// flight is one read in flight, shared by everyone who asks while it is: a read that hangs on a dead mount holds
// one goroutine per path, never one per apply or look.
type flight struct {
done chan struct{}
v any
}
var inFlight = struct {
sync.Mutex
reads map[string]*flight
}{reads: map[string]*flight{}}
// boundedOnce is read's answer within statBound, sharing a read of key already in flight; false when it did not
// answer in time.
func boundedOnce(key string, read func() any) (any, bool) {
inFlight.Lock()
f, running := inFlight.reads[key]
if !running {
f = &flight{done: make(chan struct{})}
inFlight.reads[key] = f
go func() {
f.v = read()
inFlight.Lock()
delete(inFlight.reads, key)
inFlight.Unlock()
close(f.done)
}()
}
inFlight.Unlock()
select {
case <-f.done:
return f.v, true
case <-time.After(statBound):
return nil, false
}
}
func within(op string, stat func(string) (os.FileInfo, error), path string) (os.FileInfo, error) {
type read struct {
info os.FileInfo
err error
}
v, ok := boundedOnce(op+" "+path, func() any {
info, err := stat(path)
return read{info, err}
})
if !ok {
return nil, errNoAnswer
}
r := v.(read)
return r.info, r.err
}
// absPath is an absolute path in words: what pathless takes out.
var absPath = regexp.MustCompile(`(^|[\s"'(=])/[^\s"',;:)]*`)
// Pathless is an error in words with no path (ADR 0234 §6): a path error's operation and cause, and any other
// path said as "a path".
func Pathless(err error) string { return pathless(err) }
func pathless(err error) string {
var pe *os.PathError
if errors.As(err, &pe) {
return pe.Op + ": " + pe.Err.Error()
}
return absPath.ReplaceAllString(err.Error(), "${1}a path")
}
// recordedMounts is every mount point recorded for another kept folder: where a disk was once seen.
func recordedMounts(except string) []string {
all, err := KeptFolders.All()
if err != nil {
return nil
}
var out []string
for p, f := range all {
if p != except && f.Mount != "" && f.Mount != "/" {
out = append(out, f.Mount)
}
}
return out
}
// RootWords is the reason root's data is stated unhealthy with (ADR 0325 §12): never a path.
func RootWords(why string) string {
return why + ", and root's data is never kept: the operator places the data elsewhere or gives it an owner " +
"(novox/hq ADR 0325 §12)"
}
// applyAccess is one access, applied (ADR 0325): its folder judged, made when it is missing where its data lives,
// and its users' entries kept on it when the mesh may keep it; waiting otherwise, and failing for root's data.
// Nothing is changed on the folder but the entries the mesh adds and the mask that shows them.
func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
in inputs) (Outcome, error) {
// What ADR 0312's grant put an account in is taken back first: no account is in a group for data (§5).
gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed)
out, err := applyAccessFolder(ctx, r, in)
if len(left) > 0 {
out.user, out.groups = previous.User, left
}
if said != "" {
out.Detail = joinDetail(out.Detail, said)
if gave && out.Action == "unchanged" {
// Updated, so what names the access under restart-on — the tool runner — holds the groups it has now.
out.Action = "updated"
}
}
return out, err
}
// applyAccessFolder is applyAccess's folder.
func applyAccessFolder(ctx context.Context, r *declaration.Access, in inputs) (Outcome, error) {
out := begin(r)
path := filepath.Clean(r.Path)
want := in.folders[path]
if !keeps(want, in.identities) {
return legacyAccess(r)
}
users := want.list()
_, name := accessName(r.ID)
rec, _ := keptRecord(path)
v := assessFolder(ctx, path, rec, want != nil && want.taken)
if v.err != nil {
return out, v.err
}
if v.moved {
// What is at the path now is not what was kept (a disk mounted over a folder kept while it was empty
// below it): judged afresh, the record of what was kept there no longer holds.
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
*f = movedRecord(*f, v.mount)
return had
})
}
note := ""
if v.moved {
note = "; what is at it now is on another filesystem than what the mesh kept there, so it was judged afresh"
if rec.How != store.KeptByWarrant {
note += "; the entries on the empty folder kept below are forgotten: hidden under the mount, they grant " +
"nothing on data"
}
}
if len(r.Grant) > 0 {
note += "; its grant is no longer acted on: the mesh puts no account in a group for data (novox/hq ADR 0325 §5)"
}
switch v.kind {
case folderUnknown:
out.Action = "waiting"
out.Detail = v.why + ": the folder is left exactly as it is, and judged again on the next apply" + note
return out, nil
case folderWaitDisk:
out.Action = "waiting"
out.Detail = "the disk that holds it is not there: nothing is made, and nothing mounts it until it is " +
"(novox/hq ADR 0325 §9); waits for the operator to " + DiskWords(name) + note
return out, nil
case folderRoot:
if len(users) == 0 {
out.Action, out.Detail = "unchanged", "present or not, nothing is kept: no identity uses it"+note
return out, nil
}
return out, errors.New(RootWords(v.why) + "; nothing was made or changed")
case folderMake:
made, err := CreatePath(path)
if err != nil {
return out, err
}
out.Action = "created"
out.Detail = fmt.Sprintf("made %d level(s) as its nearest existing parent is: owner, group and mode "+
"(novox/hq ADR 0325 §9)", made)
if len(users) == 0 {
out.Detail += note
return out, nil
}
case folderWaitKeep:
if len(users) == 0 {
out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it"+note
return out, nil
}
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
f.Mount, f.Want, f.IDs = v.mount, users, want.ids
f.Placed, f.Fs = placeOf(v.mount)
return true
})
out.Action = "waiting"
out.Detail = "it holds data the mesh has not taken: nothing is changed on it until the operator's warrant " +
"(novox/hq ADR 0325 §7, §8); waits for the operator to " + KeepWords(name) + note
return out, nil
}
if len(users) == 0 {
// Nobody uses it any more: what the mesh added goes on the next walk.
dropping := false
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
f.Want = nil
dropping = len(f.Users) > 0
return had
})
if out.Action == "" {
out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it"
}
if dropping {
out.Detail += "; the entries the mesh added are taken out by the next walk"
}
out.Detail += note
return out, nil
}
changed, err := keepTop(path, usersOf(users))
if errors.Is(err, ErrNoACL) {
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
f.Mount, f.Want = v.mount, users
f.Placed, f.Fs = placeOf(v.mount)
return true
})
out.Action = "waiting"
out.Detail = "its filesystem allows no access entries here: the take act lets it, on the operator's word " +
"(novox/hq ADR 0325 §8); waits for the operator to " + KeepWords(name) + note
return out, nil
}
if err != nil {
return out, err
}
var pending string
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
first := !f.Kept
f.Kept, f.Mount, f.Want, f.IDs = true, v.mount, users, want.ids
f.Placed, f.Fs = placeOf(v.mount)
if v.how == store.KeptByWarrant && f.TakenOn == "" {
// The controller says it was taken, and this engine has no record of where: here, now.
f.TakenOn = PlacedID(v.mount)
}
if first {
f.How, f.At = v.how, time.Now().UTC()
// A folder that held nothing has nothing under it to walk; one the controller says was taken
// without this engine's record is walked whole.
if v.how == store.KeptEmpty || v.how == store.KeptMade {
f.Users = users
}
}
if !sameUsers(f.Users, f.Want) {
pending = "; the entries under it follow on the next walk"
}
return true
})
if out.Action == "" {
out.Action = "unchanged"
if changed {
out.Action = "updated"
}
}
out.Detail = joinDetail(out.Detail, "kept for "+usersWords(users)+": its own entries and default, nothing "+
"else of it changed (novox/hq ADR 0325 §5)"+pending+note)
return out, nil
}
// takeBackGranted takes an account out of every group ADR 0312's grant put it in for an access, as removeUser takes
// back a user's (ADR 0252 §3): unless another declared resource still asks for it, which is then handed the group.
// Whether any was taken back, the groups still to take back — one whose leaving failed stays recorded, so the next
// apply tries again — and what was said; nothing for an access that granted nothing.
func takeBackGranted(ctx context.Context, sys system.System, previous store.Applied, run Runner, wanted Wanted,
handed Handed) (bool, []string, string) {
if previous.User == "" || len(previous.Groups) == 0 {
return false, nil, ""
}
account := previous.User
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, previous.Groups, fmt.Sprintf("the groups ADR 0312's grant put %s in (%s) were not taken "+
"back, and are tried again: %v", account, strings.Join(previous.Groups, ", "), err)
}
already := setOf(in)
gave := false
var left, said []string
for _, g := range previous.Groups {
switch {
case !already[g]:
continue
case len(wanted.othersAsk(account, g, previous.ID)) > 0:
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g,
strings.Join(wanted.othersAsk(account, g, previous.ID), ", ")))
handed.hand(account, g)
continue
}
gone, why := leaveGroup(ctx, sys, account, g, run)
if !gone {
left = append(left, g)
why += "; tried again on the next apply"
}
gave = gave || gone
said = append(said, why)
}
return gave, left, "the group grant ADR 0312 made is taken back: " + account + " " + strings.Join(said, "; ")
}
// usersWords names users for a person: "mesh-radarr (read-write), the tool runner's nox (read)".
func usersWords(users []store.KeptUser) string {
words := make([]string, 0, len(users))
for _, u := range users {
who := u.Identity
if u.From == declaration.FromToolRunner {
who = "the tool runner's " + who
}
words = append(words, who+" ("+u.Use+")")
}
return strings.Join(words, ", ")
}
// forgetUndeclared marks every recorded folder no declared access names any more: nobody wants its entries, and
// the next walk takes out exactly what the mesh added, then forgets it.
func forgetUndeclared(folders map[string]*folderWant) {
all, err := KeptFolders.All()
if err != nil {
return
}
for path := range all {
if folders[path] != nil {
continue
}
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
f.Want = nil
// A folder never kept, or kept with nobody's entries left anywhere, is forgotten now.
return (f.Kept && len(f.Users) > 0) || len(f.Left) > 0
})
}
}
// MissingPath is an accessed folder that is not there, and its nearest existing ancestor.
type MissingPath struct {
Ancestor string
// Levels are the names to make below Ancestor, outermost first.
Levels []string
// Info is the ancestor as lstat reads it: a link is not a directory.
Info os.FileInfo
}
// NearestAncestor reads, for a path that is not there, its nearest existing ancestor by lstat. An error for a
// path that is there, one not stated exactly, or an ancestor that cannot be read.
func NearestAncestor(path string) (MissingPath, error) {
if err := keptPath(path); err != nil {
return MissingPath{}, err
}
if _, err := lstatWithin(path); err == nil || !errors.Is(err, os.ErrNotExist) {
return MissingPath{}, errors.New("the folder is there, or cannot be read")
}
var levels []string
for p := path; p != "/"; p = filepath.Dir(p) {
levels = append([]string{filepath.Base(p)}, levels...)
parent := filepath.Dir(p)
info, err := lstatWithin(parent)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return MissingPath{}, errors.New("a parent of the folder cannot be read: " + pathless(err))
}
return MissingPath{Ancestor: parent, Levels: levels, Info: info}, nil
}
return MissingPath{}, errors.New("no part of the folder's path is there")
}
// accessPaths is every declared access's folder with its id: what no container mounts while it is not there.
//
// Only accesses the mesh keeps (Keeps): an older controller's access holds no container, as before ADR 0325.
func accessPaths(resources []declaration.Resource, refused map[string]error, identities bool) map[string]string {
folders := foldersOf(resources, refused)
out := map[string]string{}
for _, r := range resources {
a, ok := r.(*declaration.Access)
if !ok {
continue
}
if path := filepath.Clean(a.Path); refused[path] == nil && keeps(folders[path], identities) {
out[path] = a.ID
}
}
return out
}
// keeps is whether ADR 0325's keeping applies to a folder: some access naming it carries users, or the
// declaration carries identities — a controller that speaks link.IdentityContract. An older controller's access
// (no users, no identities) is applied exactly as before: mounted when present, refused when absent, nothing kept,
// nothing made, nothing judged (ADR 0051 §7).
func keeps(f *folderWant, identities bool) bool {
return identities || (f != nil && len(f.users) > 0)
}
// Keeps is keeps for a whole declaration's access at path, for the engine's looks.
func Keeps(d *declaration.Declaration, path string) bool {
if d == nil {
return false
}
return keeps(foldersOf(d.Resources, nil)[filepath.Clean(path)], len(d.Identities) > 0)
}
// mountsMissing is the access a container waits for: one whose folder it mounts, or mounts below, is not there
// now — its disk away, or not made yet. The runtime would make it as root (04-ISSUES/026).
func mountsMissing(ctx context.Context, c *declaration.Container, accesses map[string]string) string {
for _, v := range c.Volumes {
src := mountSource(v)
if !strings.HasPrefix(src, "/") {
continue
}
for path, id := range accesses {
if !atOrBelow(filepath.Clean(src), path) {
continue
}
if _, err := statWithin(path); errors.Is(err, os.ErrNotExist) || errors.Is(err, errNoAnswer) {
return id
}
// There, and its disk not: what is at the path is the empty mount point on the filesystem below, and
// a container's writes would land there (§9).
rec, _ := keptRecord(path)
if placedOn(ctx, path, path, rec.Mount, recordedMounts(path)).Away {
return id
}
}
}
return ""
}
// WaitingOnAccess is every container of the declaration that waits for an accessed folder not there, read now:
// not created by the apply, so not judged alive or dead — the access says why.
func WaitingOnAccess(d *declaration.Declaration) map[string]bool {
out := map[string]bool{}
if d == nil {
return out
}
accesses := accessPaths(d.Resources, refusedPlaces(d.Resources), len(d.Identities) > 0)
if len(accesses) == 0 {
return out
}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && mountsMissing(context.Background(), c, accesses) != "" {
out[c.ID] = true
}
}
return out
}
// The judgements of an access as a look says them.
const (
VerdictKeep = "keep"
VerdictMake = "make"
VerdictWaitKeep = "wait-keep"
VerdictWaitDisk = "wait-disk"
VerdictRoot = "root"
VerdictBroken = "broken"
// VerdictUnknown is a folder whose filesystem could not be named this look: neither kept nor faulted.
VerdictUnknown = "unknown"
// VerdictNobody is a folder no identity uses: nothing is kept, and only a missing disk is said.
VerdictNobody = "nobody"
)
// AccessJudgement is a look's judgement of an access's folder, and why, in words; Placed names the filesystem at
// it for a wait, with no path (PlacedID).
type AccessJudgement struct {
Kind, Why, Placed string
}
// AccessVerdict judges an access's folder for a look, read only (ADR 0325 §7, §9, §12): taken is whether the
// controller says it was taken, used whether any identity uses it. A folder the mesh could keep that no apply has
// kept — its filesystem allowing no ACLs — waits as one not taken does.
func AccessVerdict(ctx context.Context, path string, taken, used bool) AccessJudgement {
rec, _ := keptRecord(path)
v := assessFolder(ctx, path, rec, taken)
switch {
case v.err != nil:
return AccessJudgement{Kind: VerdictBroken, Why: pathless(v.err)}
case v.kind == folderWaitDisk:
return AccessJudgement{Kind: VerdictWaitDisk}
case v.kind == folderUnknown:
return AccessJudgement{Kind: VerdictUnknown, Why: v.why}
case !used:
return AccessJudgement{Kind: VerdictNobody}
case v.kind == folderRoot:
return AccessJudgement{Kind: VerdictRoot, Why: v.why}
case v.kind == folderMake:
return AccessJudgement{Kind: VerdictMake}
case v.kind == folderWaitKeep, v.kind == folderKeep && !rec.Kept && !taken:
return AccessJudgement{Kind: VerdictWaitKeep, Placed: PlacedID(v.mount)}
}
return AccessJudgement{Kind: VerdictKeep}
}
// Leftover is the mesh's entries that could not be taken out yet: under the accesses that last named the folder,
// why, in words with no path.
type Leftover struct {
Module, Access, Name, Path, Why string
}
// Leftovers is every folder the mesh still has entries on that nobody wants (ADR 0325 §8): a folder no access names
// any more whose disk is away, so its drop walk waits; and entries left on a filesystem no longer at a folder. Each
// is said until its drop walk has run.
func Leftovers() []Leftover {
all, err := KeptFolders.All()
if err != nil {
return nil
}
var out []Leftover
for path, f := range all {
module, name, access := recordName(path, f)
dropping := f.Kept && len(f.Want) == 0 && len(f.Users) > 0 && f.Act != nil && f.Act.State == "waiting"
switch {
case dropping:
// Why the walk could not run, as it recorded it: its disk not there, or its filesystem not named.
why := strings.TrimSuffix(strings.TrimPrefix(f.Act.Why, "not walked: "), "; walked once it is")
if why == "" {
why = "the walk could not run"
}
out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " +
name + " could not be removed: " + why + "; they are removed once it can (novox/hq ADR 0325 §8)"})
case len(f.Left) > 0:
out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " +
name + " on a filesystem no longer at its folder could not be removed: they are removed once that " +
"filesystem is back (novox/hq ADR 0325 §8)"})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Access < out[j].Access })
return out
}
// recordName is the module, the name and the access a kept folder's record is said under: the accesses that last
// named it, or for a record written before it kept them, a name with no path — the module of a container identity
// among its users, and a folder named by a digest of its path.
func recordName(path string, f store.KeptFolder) (module, name, access string) {
if len(f.IDs) > 0 {
module, name = accessName(f.IDs[0])
return module, name, f.IDs[0]
}
sum := sha256.Sum256([]byte(path))
name = "folder-" + hex.EncodeToString(sum[:4])
for _, u := range append(append([]store.KeptUser(nil), f.Users...), f.Want...) {
if m, ok := strings.CutPrefix(u.Identity, declaration.IdentityPrefix); ok && u.From == declaration.FromContainer {
return m, name, m + "." + name
}
}
return "", name, name
}
// legacyAccess is an access from a controller older than ADR 0325, applied exactly as before it (ADR 0051 §7).
//
// **Absent is refused, not created.** A bind mount whose source does not exist is made for you by the container
// runtime, as root, with whatever mode it picks — which is exactly the silent wrong-ownership 04-ISSUES/026
// records. So the host checks first and says plainly that the operator must provide the path, rather than
// conjuring a directory it does not own and cannot give the right owner. Nothing is written, so this never reports
// a change: the machine did not move, the host merely confirmed a fact about it.
func legacyAccess(r *declaration.Access) (Outcome, error) {
out := begin(r)
info, err := os.Stat(r.Path)
if errors.Is(err, os.ErrNotExist) {
return out, fmt.Errorf(
"%s is not there, and the mesh does not own it — the operator must provide it. It is "+
"shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+
"nothing, so a missing one is said here rather than made as root by the container "+
"runtime", r.Path)
}
if err != nil {
return out, err
}
if !info.IsDir() {
return out, fmt.Errorf(
"%s is not a directory, and an access is a shared directory the operator provides",
r.Path)
}
mode := declaration.AccessRead
if r.Mode != "" {
mode = r.Mode
}
out.Action = "unchanged"
out.Detail = "operator-owned; present, " + mode + ", nothing managed"
return out, nil
}
+446
View File
@@ -0,0 +1,446 @@
//go:build linux
package apply
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"time"
"golang.org/x/sys/unix"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The walks under a kept folder, beside the apply (novox/hq ADR 0325 §6, §8, §10).
//
// The take act adds the users' entries to everything under a folder on the operator's warrant; ReconcileKept adds a
// joining user's and removes exactly a dropped user's under a folder already taken; the sweep looks at a bounded
// part of each kept folder on every look, going on where the last stopped, and puts back what an app narrowed.
// One walk at a time per folder: a sweep finds a folder being walked and waits for the next look.
// folderLocks is one lock per kept folder, held by every walk of it.
var folderLocks sync.Map
func folderLock(path string) *sync.Mutex {
l, _ := folderLocks.LoadOrStore(path, &sync.Mutex{})
return l.(*sync.Mutex)
}
// actRecord is a walk's counts as the record keeps them.
func actRecord(act, warrant, by string, started time.Time, w *aclWalk, err error) *store.KeptAct {
c := w.counts
rec := &store.KeptAct{Act: act, Warrant: warrant, By: by, State: "done", Started: started, Ended: time.Now().UTC(),
Folders: c.Folders, Files: c.Files, Kept: c.Kept, Links: c.Links, Other: c.Other, Mounts: c.Mounts}
switch {
case errors.Is(err, ErrWalkBound):
rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on at the next apply"
case err != nil:
rec.State, rec.Why = "failed", err.Error()
case w.Failed != "":
rec.Why = fmt.Sprintf("%d entries could not be given their entries: %s", c.Unrepaired, w.Failed)
}
return rec
}
// ReconcileKept walks every kept folder whose entries under it are not what the last apply declared: a user that
// joined has its entries added to everything under it, a dropped user — or one whose use changed — has exactly its
// own taken out (ADR 0325 §8). Bounded by ActLimits; a walk stopped is gone on with at the next call. A folder
// nobody uses any more is forgotten once its entries are out. What it did, in words, for the engine to say.
func ReconcileKept(ctx context.Context) []string {
all, err := KeptFolders.All()
if err != nil {
return []string{"the kept folders could not be read: " + err.Error()}
}
var said []string
for path, f := range all {
if ctx.Err() != nil {
break
}
if len(f.Left) > 0 {
said = append(said, dropLeft(path, f)...)
if f, _ = keptRecord(path); f.Path == "" {
continue
}
}
if !f.Kept || sameUsers(f.Users, f.Want) {
continue
}
said = append(said, reconcileOne(ctx, path, f)...)
}
return said
}
// LeftFor is how long entries left on a filesystem are kept recorded, and said, while it is not back.
var LeftFor = 30 * 24 * time.Hour
// dropLeft takes out the entries the mesh left on a filesystem no longer at the folder, once that filesystem holds
// it again (§8): exactly those users' entries, nothing else. Left on the filesystem the folder is kept on now, they
// are that folder's own, and its walk takes out whichever are no longer wanted. Until then they stay recorded, and
// said (Leftovers).
func dropLeft(path string, f store.KeptFolder) []string {
lock := folderLock(path)
lock.Lock()
defer lock.Unlock()
holding := ""
if _, err := lstatWithin(path); err == nil {
holding = mountHolding(path, MountPoints())
}
holdingID := ""
if holding != "" {
holdingID = PlacedID(holding)
}
var said []string
var still []store.KeptLeft
var mine []store.KeptUser
for _, l := range f.Left {
// On the folder's own filesystem now: the same name, or a name of an earlier build's form at the same
// mount point (which cannot be compared, and is never walked away).
same := l.Mount == f.Mount && (l.Placed == "" || l.Placed == f.Placed || l.Fs == "")
back := l.Mount == holding && (l.Placed == "" || l.Placed == holdingID)
if l.Since.IsZero() {
l.Since = time.Now().UTC()
}
if !same && !back && time.Since(l.Since) > LeftFor {
// **A filesystem not back within LeftFor is gone** (a disk replaced, a pool destroyed): what was left
// on it is forgotten, said once, rather than said unhealthy for ever.
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem not back for %s "+
"are forgotten: it is taken for gone", path, LeftFor))
continue
}
switch {
case f.Kept && same:
// Left on the filesystem the folder is kept on now: the folder's own entries.
mine = append(mine, l.Users...)
case same:
// On the filesystem the folder is on now, not kept yet: never walked away; they are the folder's
// once it is kept there again.
still = append(still, l)
case !back:
still = append(still, l)
default:
drop := map[uint32]bool{}
for _, u := range l.Users {
drop[u.UID] = true
}
w, err := walkKept(path, ActLimits, machineStats, nil, dropVisit(drop))
if err != nil || w.Failed != "" {
still = append(still, l)
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+
"were not all taken out, tried again: %s", path, firstOf(pathlessOr(err), w.Failed)))
continue
}
said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+
"were taken out (%d folders and %d files)", path, w.counts.Folders, w.counts.Files))
}
}
_ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool {
k.Left = still
for _, u := range mine {
if _, has := usersOf(k.Users)[u.UID]; !has {
k.Users = append(k.Users, u)
}
}
return had && ((k.Kept && (len(k.Users) > 0 || len(k.Want) > 0)) || len(k.Left) > 0)
})
return said
}
func pathlessOr(err error) string {
if err == nil {
return ""
}
return pathless(err)
}
func firstOf(a, b string) string {
if a != "" {
return a
}
return b
}
func reconcileOne(ctx context.Context, path string, f store.KeptFolder) []string {
lock := folderLock(path)
lock.Lock()
defer lock.Unlock()
// **Judged as an apply judges it, before every walk** (§9, §12): a disk not there leaves its mount point —
// an empty folder of root's on the filesystem below — at the path, and a walk there would write onto root's
// folder, or "drop" entries from a folder that never had them and forget the ones on the data. Nothing is
// walked then, and the record stays for the walk that can reach the data.
if v := assessFolder(ctx, path, f, false); v.err != nil || v.kind != folderKeep || v.moved {
why := "the disk that holds it is not there"
switch {
case v.err != nil:
why = pathless(v.err)
case v.kind == folderRoot:
why = RootWords(v.why)
case v.kind == folderUnknown:
why = v.why
case v.moved || v.kind == folderWaitKeep:
why = "what is at it now is not what the mesh kept there"
}
_ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool {
k.Act = &store.KeptAct{Act: "walk", State: "waiting", Started: time.Now().UTC(),
Why: "not walked: " + why + "; walked once it is"}
return had
})
return []string{fmt.Sprintf("the kept folder %s: not walked, %s", path, why)}
}
have, want := usersOf(f.Users), usersOf(f.Want)
drop := map[uint32]bool{}
adds := false
for uid, rw := range have {
if w, ok := want[uid]; !ok || w != rw {
drop[uid] = true
}
}
for uid, rw := range want {
if h, ok := have[uid]; !ok || h != rw {
adds = true
}
}
act, visit := "add", keepVisit(want)
switch {
case len(drop) > 0 && adds:
visit = dropThenKeep(drop, want)
case len(drop) > 0:
act, visit = "drop", dropVisit(drop)
}
started := time.Now().UTC()
w, err := walkKept(path, ActLimits, machineStats, nil, visit)
rec := actRecord(act, "", "", started, w, err)
_ = KeptFolders.Update(path, func(k *store.KeptFolder, _ bool) bool {
k.Act = rec
// Done only when every entry was reached and changed: one that failed is walked again.
if err == nil && w.Failed == "" {
k.Users = f.Want
}
return k.Kept && (len(k.Want) > 0 || len(k.Users) > 0)
})
return []string{fmt.Sprintf("the kept folder %s: %s walk %s (%d folders and %d files changed, %d had "+
"them)%s", path, act, rec.State, rec.Folders, rec.Files, rec.Kept, orWhy(rec.Why))}
}
func orWhy(why string) string {
if why == "" {
return ""
}
return ": " + why
}
// TakeFolder is the take act (ADR 0325 §8): on the operator's warrant, the users' entries added to the folder of
// the access id and everything under it. It judges the ask against the declaration last applied and the folder as
// it is, sets the filesystem's ACL type where a ZFS dataset allows none yet, and starts the walk: what it answers
// at once, and a channel closed when the walk is through. Refused, nothing is changed.
func TakeFolder(ctx context.Context, d *declaration.Declaration, id, path, warrant, by string,
run Runner) (<-chan struct{}, string, error) {
if d == nil {
return nil, "", errors.New("this engine has applied no declaration, and takes no folder it was not told of")
}
var access *declaration.Access
for _, r := range d.Resources {
if a, ok := r.(*declaration.Access); ok && a.ID == id {
access = a
}
}
switch {
case access == nil:
return nil, "", fmt.Errorf("the access %s is not declared on this machine", id)
case filepath.Clean(access.Path) != path:
return nil, "", fmt.Errorf("the access %s is declared at another folder than the one asked", id)
}
folders := foldersOf(d.Resources, refusedPlaces(d.Resources))
want := folders[path]
users := want.list()
if len(users) == 0 {
return nil, "", errors.New("no identity uses the folder, so there is nothing to keep")
}
rec, _ := keptRecord(path)
v := assessFolder(ctx, path, rec, false)
switch {
case v.err != nil:
return nil, "", v.err
case v.kind == folderWaitDisk:
return nil, "", errors.New("the disk that holds the folder is not there")
case v.kind == folderRoot:
return nil, "", errors.New(RootWords(v.why))
case v.kind == folderMake:
return nil, "", errors.New("the folder is not there; the next apply makes it, and asks nobody")
case v.unnamed:
return nil, "", errors.New(v.why + ", so a warrant could name no filesystem it was given for")
}
lock := folderLock(path)
if !lock.TryLock() {
return nil, "", errors.New("a walk of the folder is running; asked again once it is through, the act goes on")
}
said := ""
_, err := keepTop(path, usersOf(users))
if errors.Is(err, ErrNoACL) {
allowed, aerr := allowACLs(ctx, v.mount, run)
if aerr != nil {
lock.Unlock()
return nil, "", aerr
}
said = allowed + "; "
_, err = keepTop(path, usersOf(users))
}
if err != nil {
lock.Unlock()
return nil, "", err
}
started := time.Now().UTC()
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
if v.moved {
*f = movedRecord(*f, v.mount)
}
if !f.Kept {
f.Kept, f.How, f.At, f.Users = true, store.KeptByWarrant, started, nil
}
f.Warrant, f.By, f.Mount, f.Want, f.IDs = warrant, by, v.mount, users, want.ids
// The warrant is for the filesystem it was carried out on (§8), named as a wait names it.
f.Placed, f.Fs = placeOf(v.mount)
f.TakenOn = f.Placed
f.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, State: "running", Started: started}
return true
})
done := make(chan struct{})
go func() {
defer close(done)
defer lock.Unlock()
w, err := walkKept(path, ActLimits, machineStats, nil, keepVisit(usersOf(users)))
rec := actRecord("take", warrant, by, started, w, err)
_ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool {
f.Act = rec
if err == nil {
f.Users = users
}
return true
})
}()
return done, said + "the take act started under the warrant " + warrant + ": the entries of " +
usersWords(users) + " are added to everything under the folder, following no link and entering no other " +
"filesystem", nil
}
// allowACLs lets the filesystem at mount carry ACLs, inside the take act only (ADR 0325 §8): a ZFS dataset's
// acltype set to posixacl, the dataset read from the machine's mounts. Any other filesystem is refused, naming why.
func allowACLs(ctx context.Context, mount string, run Runner) (string, error) {
source, fstype := mountSourceOf(mount)
if fstype != "zfs" || source == "" {
return "", fmt.Errorf("the filesystem that holds the folder (%s) allows no access entries, and only a ZFS "+
"dataset's can be let by the act; the operator mounts it with ACLs", orUnknown(fstype))
}
if _, err := run(ctx, "zfs", "set", "acltype=posixacl", source); err != nil {
return "", fmt.Errorf("the ZFS dataset that holds the folder could not be let carry access entries "+
"(zfs set acltype=posixacl): %w", err)
}
return "set acltype=posixacl on the ZFS dataset " + source, nil
}
func orUnknown(s string) string {
if s == "" {
return "of a type the machine does not say"
}
return s
}
// KeptLook is what one look found of a kept folder (ADR 0325 §10).
type KeptLook struct {
// Short is every user the folder itself gives less than its right, by entry and mask together, in words.
Short []string
// Busy is a walk of the folder running: the sweep waited.
Busy bool
// Sweep is the sweep's place after this look.
Sweep store.KeptSweep
// Repaired is how many entries this look found narrowed and put back; Unrepaired how many it could not.
Repaired, Unrepaired int
// Failed is why the sweep could not look, in words with no path.
Failed string
}
// LookKept looks at a kept folder once: the folder itself judged for each user, then the sweep's bounded part of
// what is under it, from where the last look stopped, putting back what an app narrowed. The sweep's place is
// recorded; a look that ran out of its bound says so.
func LookKept(path string, users []store.KeptUser, limits WalkLimits) KeptLook {
var out KeptLook
short, err := judgeTop(path, users)
if err != nil {
out.Failed = err.Error()
return out
}
out.Short = short
lock := folderLock(path)
if !lock.TryLock() {
out.Busy = true
rec, _ := keptRecord(path)
out.Sweep = rec.Sweep
return out
}
defer lock.Unlock()
rec, _ := keptRecord(path)
sw := rec.Sweep
w, err := walkKept(path, limits, machineStats, sw.Cursor, keepVisit(usersOf(users)))
out.Repaired, out.Unrepaired = w.counts.Narrowed-w.counts.Unrepaired, w.counts.Unrepaired
sw.Read += w.counts.Read
sw.Narrowed += w.counts.Narrowed
sw.Unrepaired += w.counts.Unrepaired
switch {
case errors.Is(err, ErrWalkBound):
sw.Cursor, sw.Bound = w.Cursor, true
case err != nil:
out.Failed = err.Error()
default:
// Covered whole: the next look starts a new pass, and what this one could not put back is kept until
// the next pass is through.
sw = store.KeptSweep{Covered: time.Now().UTC(), LastUnrepaired: sw.Unrepaired}
}
out.Sweep = sw
_ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool {
f.Sweep = sw
return had
})
return out
}
// judgeTop is every user the folder itself gives less than its right, read only.
func judgeTop(path string, users []store.KeptUser) ([]string, error) {
var short []string
_, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return nil, err
}
defer unix.Close(fd)
a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777)
if err != nil {
return nil, err
}
for _, u := range users {
need := needOn(u.Use == declaration.AccessReadWrite, true)
if got := effective(a, u.UID, st.Uid, uint32(st.Mode)&0o7777); got&need != need {
short = append(short, fmt.Sprintf("%s has %s of the %s it needs", u.Identity, permWords(got),
permWords(need)))
}
}
return short, nil
}
// JudgeKept reads, without changing anything, how many entries under a kept folder give a user less than its
// right, within limits: what the access says while an entry an app narrowed is not put back yet (ADR 0325 §6).
func JudgeKept(path string, users []store.KeptUser, limits WalkLimits) (WalkCounts, error) {
um := usersOf(users)
w, err := walkKept(path, limits, machineStats, nil, func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool,
error) {
a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777)
if err != nil {
return false, false, false, err
}
n, u := judgeACL(a, um, st, folder)
return false, n, u, nil
})
return w.counts, err
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -16,6 +16,7 @@ func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
var ran []string
connectFails := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -81,6 +82,7 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
var removed []string
gone := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", nil
}
@@ -139,6 +141,7 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "docker" {
return "", errors.New("not installed")
}
@@ -174,12 +177,13 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
}
}
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
// A package may be declared absent (novox/hq ADR 0180): removed when it is installed, read back,
// left alone when it is not.
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
installed := true
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
ran = append(ran, name+" "+strings.Join(args, " "))
if name != "pacman" {
return "", nil
+43
View File
@@ -0,0 +1,43 @@
package apply
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A container declared to log to the journal is run with the journal as its log driver, and the
// place it logs is part of its spec, so moving it recreates the container (novox/hq ADR 0179).
func TestAContainerLoggingToTheJournalIsRunThatWayAndRecreatedWhenMoved(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"`+pinned+`","logging":"journald"}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
sent := false
for i, a := range ran {
if a == "--log-driver" && i+1 < len(ran) && ran[i+1] == "journald" {
sent = true
}
}
if !sent {
t.Fatalf("the container's output was not sent to the journal: %v", ran)
}
with := d.Resources[0].(*declaration.Container)
without := *with
without.Logging = ""
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
t.Fatal("where a container logs is not part of its spec, so moving it would not recreate it")
}
}
+248
View File
@@ -0,0 +1,248 @@
package apply
// What a maintenance window is holding still, written where every apply on this machine can read it
// (novox/hq issue 224, ADR 0189).
//
// A scheduled step declared `while-stopped` stops its module's containers, runs, and starts them
// again. Until this, nothing told the **apply** that a window was open, and the apply's rule for a
// container it finds stopped is the right rule everywhere else: stopped is broken, so remove it and
// create it running. In the middle of a window that rule reopens the window — for the store's
// collector, a registry recreated mid-collection accepts an upload the sweep then deletes, and the
// build that made the image reported success.
//
// **The apply learns which containers a window holds; the window does not take the apply lock.**
// The issue weighed both. Holding the lock for a window makes the race impossible and makes a push
// that arrives mid-window wait for minutes, which is what issue 185's outages looked like from
// outside. Telling the apply blocks nothing: a held container is reported as held and left exactly
// as it is — even when its declaration changed — and the first apply after the window converges it
// by the ordinary rule. The cost, stated in the issue, is a second source for "is this container
// meant to be running"; it is kept honest by being short-lived and by expiring on its own.
//
// **A file under the node's state directory, not a variable in the scheduler.** The scheduler lives
// in the daemon, but the daemon is not the only thing that applies here: `mesh-host reconcile` and
// `mesh-host apply` run as their own processes beside it, and the installer applies the carried
// bundle — which is where the store's registry comes from — from a third. A map shared in memory
// would protect only the daemon's own applies, which is to say it would leave open exactly the case
// a person running `reconcile` by hand at 03:31 creates. The state directory is already where every
// one of them meets (the apply lock, the node's state, the kept originals), it is root's alone, and
// each window is one small file written atomically, so no reader ever sees half of one.
//
// **A window that is never closed must not hold for ever** — the same risk the field itself carries,
// one level up. Three things close it:
//
// - the scheduler removes the file in a defer that runs after the containers are started again,
// whatever the step did;
// - a window whose process is gone is closed: the file names the process that opened it, and a
// host that crashed or was replaced mid-window is no longer holding anything, so the apply is
// free to bring back what it left stopped — which is then the apply doing the job the dead
// process could not;
// - and every window carries an end, windowAtMost after it opened, past which it is read as
// closed whoever is still alive. A step that genuinely runs longer than that is pathological, and
// the apply then does what it did before this record existed, which is the lesser harm next to a
// service the mesh can never bring back.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/novox/mesh-host/internal/store"
)
// WindowsName is the directory beside the node's state that holds the open windows, one file each.
const WindowsName = "windows"
// windowAtMost is the longest a window is believed. The store's collector is minutes over a store of
// fifty-odd repositories; six hours is far past any collection this mesh will do and short enough
// that a window nothing closed is over by the next working day (novox/hq issue 224).
const windowAtMost = 6 * time.Hour
// Window is one scheduled step holding containers still: which step, which containers by runtime
// name, since when, and the latest moment it is believed.
type Window struct {
Step string `json:"step"`
Holds []string `json:"holds"`
Opened time.Time `json:"opened"`
Until time.Time `json:"until"`
// PID is the process that opened it. Not reported: it is how a window whose host died is read
// as closed.
PID int `json:"pid"`
}
// Windows is where this machine's open windows are recorded. A nil *Windows records nothing and
// holds nothing — a test, or a caller that has no state directory.
type Windows struct {
dir string
// alive says whether a process is still running. Replaced in tests; the real one asks the kernel.
alive func(pid int) bool
}
// WindowsIn records windows under stateDir/windows — the directory the node's state lives in, which
// every process that applies on this machine already shares.
func WindowsIn(stateDir string) *Windows {
return &Windows{dir: filepath.Join(stateDir, WindowsName), alive: processAlive}
}
// processAlive is whether pid names a running process. Signal 0 delivers nothing and only asks; a
// process this one may not signal is still a process (EPERM), which can only happen across users.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// fileFor is where one step's window is written. A step id is the declaration's, which may carry a
// dot or a slash; the file name keeps what is safe and replaces the rest.
func (w *Windows) fileFor(step string) string {
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
return r
}
return '_'
}, step)
return filepath.Join(w.dir, "window-"+safe+".json")
}
// open records that step holds these containers still from now, before the first of them is
// stopped — so no apply can find one stopped and not know why.
func (w *Windows) open(step string, holds []string, now time.Time) error {
if w == nil {
return nil
}
if err := os.MkdirAll(w.dir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(Window{Step: step, Holds: holds, Opened: now.UTC(),
Until: now.UTC().Add(windowAtMost), PID: os.Getpid()})
if err != nil {
return err
}
return writeAtomically(w.fileFor(step), raw, 0o600)
}
// How long a step about to open a window waits for an apply in flight on this machine to end, and how
// often it looks (novox/hq issue 291). Past the bound it opens the window anyway, said: the applies
// then wait for the window instead (store_away.go), which is the slower order, never a failed one.
var (
applyWaitAtMost = 15 * time.Minute
applyPoll = time.Second
)
// openAfterApplies opens step's window only once no apply is in flight on this machine (novox/hq issue
// 291): an apply that started before the window would otherwise reach the store's archives with its
// server already held still. The apply lock is held only while the record is written — every apply
// that starts afterwards reads the window and waits for the store — so a push arriving mid-window
// still never queues behind the window itself (issue 224's reason for not taking the lock).
func (w *Windows) openAfterApplies(ctx context.Context, step string, holds []string, log func(string)) error {
if w == nil {
return nil
}
stateDir := filepath.Dir(w.dir)
deadline := time.Now().Add(applyWaitAtMost)
waited := false
for {
release, took, err := store.TryLockIn(stateDir)
if err != nil {
return err
}
if took {
defer release()
if waited {
log(fmt.Sprintf("scheduled step %s: the apply in flight ended; its window opens now", step))
}
return w.open(step, holds, time.Now())
}
if !waited {
waited = true
log(fmt.Sprintf("scheduled step %s: an apply is in flight on this machine — the window opens once "+
"it ends, so nothing it fetches finds %s held still (novox/hq issue 291)", step, strings.Join(holds, ", ")))
}
if time.Now().After(deadline) {
log(fmt.Sprintf("scheduled step %s: the apply in flight did not end within %s; the window opens "+
"beside it, and it waits for the window", step, applyWaitAtMost))
return w.open(step, holds, time.Now())
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(applyPoll):
}
}
}
// close removes the record of step's window. Called after its containers are started again; a file
// already gone is the state wanted.
func (w *Windows) close(step string) error {
if w == nil {
return nil
}
if err := os.Remove(w.fileFor(step)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// Open is the windows open at now, by step. One that has ended, or whose process is gone, is not
// open — and its file is removed on the way, since nothing else will (novox/hq issue 224). A file
// that cannot be read is not a window either: the alternative is a machine whose containers are
// never converged because of a byte nobody can see.
func (w *Windows) Open(now time.Time) []Window {
if w == nil {
return nil
}
entries, err := os.ReadDir(w.dir)
if err != nil {
return nil
}
var open []Window
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "window-") || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(w.dir, e.Name())
raw, err := os.ReadFile(path)
if err != nil {
continue
}
var win Window
if err := json.Unmarshal(raw, &win); err != nil {
_ = os.Remove(path)
continue
}
if !now.Before(win.Until) || !w.alive(win.PID) {
_ = os.Remove(path)
continue
}
open = append(open, win)
}
sort.Slice(open, func(i, j int) bool { return open[i].Step < open[j].Step })
return open
}
// holding is the open window that holds the container named name, if one does.
func (w *Windows) holding(name string, now time.Time) (Window, bool) {
for _, win := range w.Open(now) {
for _, held := range win.Holds {
if held == name {
return win, true
}
}
}
return Window{}, false
}
// String is the window as a person reads it in a log line.
func (win Window) String() string {
return fmt.Sprintf("%s holds %s still since %s", win.Step, strings.Join(win.Holds, ", "),
win.Opened.Format(time.RFC3339))
}
+542
View File
@@ -0,0 +1,542 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189,
// issue 108).
//
// What it exists for: the artifact store's collector walks the storage and requires every writer
// stopped. A run-once step runs beside containers and a scheduled one is the same container again,
// so the mesh had no way to say it — which is why the store it inherited has never collected
// anything. The risk the field brings is one shape only: a window that opens and never closes.
// Every test here is about that shape.
// windowRun records the order of stop / run / start, which is the whole of what is being asserted.
type windowRun struct {
mu sync.Mutex
order []string
failAt string // the arg[0] that should fail ("run" makes the step fail)
wontGo string // a container name that refuses to start again
}
func (w *windowRun) run(_ context.Context, _ string, args ...string) (string, error) {
w.mu.Lock()
defer w.mu.Unlock()
switch args[0] {
case "info":
return "27.0\n", nil
case "stop", "start":
w.order = append(w.order, args[0]+" "+args[1])
if args[0] == "start" && args[1] == w.wontGo {
return "", errors.New("the runtime refused")
}
case "run":
w.order = append(w.order, "run")
if w.failAt == "run" {
return "", errors.New("the step exited non-zero")
}
}
return "", nil
}
func (w *windowRun) seen() []string {
w.mu.Lock()
defer w.mu.Unlock()
return append([]string{}, w.order...)
}
// aStoreWithACollector is a module in the shape distribution has: a server that must not be
// writing, and a nightly step that walks its storage with the server held still.
func aStoreWithACollector(t *testing.T) *declaration.Declaration {
t.Helper()
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
}
func fireOnce(t *testing.T, d *declaration.Declaration, w *windowRun) {
t.Helper()
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(string) {})
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
}
func TestAScheduledStepHoldsItsModulesContainerStillAndStartsItAgain(t *testing.T) {
w := &windowRun{}
fireOnce(t, aStoreWithACollector(t), w)
got := w.seen()
want := []string{"stop mesh-registry", "run", "start mesh-registry"}
var kept []string
for _, line := range got {
if strings.HasPrefix(line, "stop mesh-registry-collect") {
// Clearing the step's own exited container by name; not part of the window.
continue
}
kept = append(kept, line)
}
if len(kept) != len(want) {
t.Fatalf("the window was not stop, run, start: %v", got)
}
for i := range want {
if kept[i] != want[i] {
t.Fatalf("the window was %v, want %v", kept, want)
}
}
}
// The one that matters: a step that fails must leave the service running.
func TestAFailedStepStillClosesTheWindow(t *testing.T) {
w := &windowRun{failAt: "run"}
fireOnce(t, aStoreWithACollector(t), w)
var started bool
for _, line := range w.seen() {
if line == "start mesh-registry" {
started = true
}
}
if !started {
t.Fatalf("the step failed and the container it held still was never started again: %v", w.seen())
}
}
// A container that will not come back is said loudly: it is down, and nothing else notices until
// the next apply compares it.
func TestAContainerThatWillNotStartAgainIsSaidLoudly(t *testing.T) {
w := &windowRun{wontGo: "mesh-registry"}
var said []string
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(line string) { said = append(said, line) })
s.Sync(aStoreWithACollector(t), nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
var loud bool
for _, line := range said {
if strings.Contains(line, "WILL NOT START AGAIN") && strings.Contains(line, "mesh-registry") {
loud = true
}
}
if !loud {
t.Fatalf("a service left stopped by a maintenance window was not said loudly: %v", said)
}
}
// Several containers come back in the reverse of the order they were stopped: a module names the
// dependant first, and starting it before what it depends on is not bringing it back.
func TestTheWindowClosesInTheReverseOfTheOrderItOpened(t *testing.T) {
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"web","type":"container","name":"web","image":"`+pinned+`"},
{"id":"db","type":"container","name":"db","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["web","db"]}
]}`)
w := &windowRun{}
fireOnce(t, d, w)
var stops, starts []string
for _, line := range w.seen() {
switch {
case line == "stop web" || line == "stop db":
stops = append(stops, line)
case strings.HasPrefix(line, "start "):
starts = append(starts, line)
}
}
if len(stops) != 2 || stops[0] != "stop web" || stops[1] != "stop db" {
t.Fatalf("stopped in %v, want the order the step named them", stops)
}
if len(starts) != 2 || starts[0] != "start db" || starts[1] != "start web" {
t.Fatalf("started in %v, want the reverse", starts)
}
}
// And the refusals, each for what it says rather than that it says something.
func TestAMaintenanceWindowIsRefusedWhereItCannotMean(t *testing.T) {
for _, c := range []struct{ name, body, says string }{
{
"a window with no schedule",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","while-stopped":["store"]}`,
"needs a schedule",
},
{
"a window naming itself",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["collect"]}`,
"this step itself",
},
{
"a window naming something that is not a container here",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["elsewhere"]}`,
"no container by that id",
},
} {
_, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[` + c.body + `]}`))
if err == nil {
t.Errorf("%s was accepted", c.name)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: the refusal does not say %q: %v", c.name, c.says, err)
}
}
}
// A changed window is a changed declaration, and the install says so.
//
// The cadence already works this way: "a changed schedule is a changed spec — the marker moves and
// the install is reported updated and re-established" (containerSpec). Which containers are held
// still for the run is the same kind of statement, and a declaration that changed it while the
// machine reported no change would be a machine quietly running the old window.
func TestAChangedWindowMovesTheSpec(t *testing.T) {
one := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
two := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store","other"]}
]}`)
stepOf := func(d *declaration.Declaration) *declaration.Container {
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.ID == "collect" {
return c
}
}
t.Fatal("no step in the fixture")
return nil
}
if containerSpec(stepOf(one), inputs{}) == containerSpec(stepOf(two), inputs{}) {
t.Fatal("the window changed and the spec did not; the machine would report no change " +
"and keep holding the containers it held yesterday")
}
// And a container with no window is untouched by the field existing at all.
plain := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"}
]}`)
spec := containerSpec(plain.Resources[0].(*declaration.Container), inputs{})
if strings.Contains(spec, "while-stopped") || strings.Contains(spec, "held") {
t.Errorf("an ordinary container's spec mentions a field it does not set:\n%s", spec)
}
}
// ---- An apply arriving during a window (novox/hq issue 224) ----
//
// A window holds a container stopped on purpose; the apply's rule for a stopped container is to
// replace it. Until the apply could see the window, a push landing at 03:30 recreated the registry
// in the middle of its collection — the collector, already past its mark phase, then swept a blob
// that an upload had just written. These drive the scheduler and the apply against one pretend
// runtime, so what is asserted is what one does to the other.
// aRegistryMachine is a runtime holding one long-running container, mesh-registry, and running the
// step in the foreground until the test lets it finish.
type aRegistryMachine struct {
mu sync.Mutex
exists bool
running bool
spec string
calls []string
wontGo bool // the registry refuses to start again after the window
failRun bool // the step exits non-zero
stepIn chan struct{} // the step has started: the window is open
stepGo chan struct{} // let the step finish
}
func newRegistryMachine(spec string) *aRegistryMachine {
return &aRegistryMachine{exists: true, running: true, spec: spec,
stepIn: make(chan struct{}, 4), stepGo: make(chan struct{})}
}
func (m *aRegistryMachine) run(_ context.Context, _ string, args ...string) (string, error) {
m.mu.Lock()
switch args[0] {
case "info", "image":
m.mu.Unlock()
return "27.0\n", nil
case "stop":
m.calls = append(m.calls, "stop "+args[1])
if args[1] == "mesh-registry" {
m.running = false
}
case "start":
m.calls = append(m.calls, "start "+args[1])
if args[1] == "mesh-registry" {
if m.wontGo {
m.mu.Unlock()
return "", errors.New("the runtime refused")
}
m.running = true
}
case "container": // inspect
name := args[len(args)-1]
if name != "mesh-registry" || !m.exists {
m.mu.Unlock()
return "", errors.New("no such container")
}
out := "false\t" + m.spec
if m.running {
out = "true\t" + m.spec
}
m.mu.Unlock()
return out, nil
case "rm":
name := args[len(args)-1]
m.calls = append(m.calls, "rm "+name)
if name == "mesh-registry" {
m.exists, m.running = false, false
}
case "run":
if !slices.Contains(args, "--detach") {
// The step, in the foreground, for as long as the test says.
m.calls = append(m.calls, "step")
fail := m.failRun
m.mu.Unlock()
m.stepIn <- struct{}{}
<-m.stepGo
if fail {
return "", errors.New("the step exited non-zero")
}
return "", nil
}
m.calls = append(m.calls, "create "+args[slices.Index(args, "--name")+1])
for i, a := range args {
if a == "--label" && strings.HasPrefix(args[i+1], specLabel+"=") {
m.spec = strings.TrimPrefix(args[i+1], specLabel+"=")
}
}
m.exists, m.running = true, true
}
m.mu.Unlock()
return "", nil
}
// touched is every call since mark that recreated or removed the registry.
func (m *aRegistryMachine) touched(mark int) []string {
m.mu.Lock()
defer m.mu.Unlock()
var out []string
for _, c := range m.calls[mark:] {
if c == "rm mesh-registry" || c == "create mesh-registry" {
out = append(out, c)
}
}
return out
}
func (m *aRegistryMachine) mark() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls)
}
// theStoreMoved is the store's module with the server's declaration changed — a push that landed
// at 03:30 with a new registry image, say.
func theStoreMoved(t *testing.T) *declaration.Declaration {
t.Helper()
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`","env":{"NEW":"1"}},
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
}
// openAWindow fires the collector and returns once it is running, the registry held still.
func openAWindow(t *testing.T, d *declaration.Declaration, m *aRegistryMachine, windows *Windows) *Scheduler {
t.Helper()
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, m.run, func(string) {})
s.RecordWindowsIn(windows)
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
select {
case <-m.stepIn:
case <-time.After(5 * time.Second):
t.Fatal("the step never started")
}
return s
}
func TestAnApplyDuringAWindowLeavesWhatItHoldsAlone(t *testing.T) {
d := aStoreWithACollector(t)
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
m := newRegistryMachine(want)
windows := WindowsIn(t.TempDir())
s := openAWindow(t, d, m, windows)
mark := m.mark()
// The push lands mid-window, and with it a registry whose declaration moved: the very shape
// that, before this, removed the server and created it running under the collector.
report, state, err := ApplyMindingWindows(context.Background(), archHost(t), theStoreMoved(t),
store.State{}, store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply failed: %v", err)
}
if got := m.touched(mark); len(got) > 0 {
t.Fatalf("an apply during the window touched the container it holds still: %v", got)
}
o := outcomeOf(report, "store")
if o.Action != heldStill {
t.Fatalf("a held container was reported %q, want %q: %+v", o.Action, heldStill, o)
}
for _, says := range []string{"collect", "maintenance window", "first apply after", "declaration moved"} {
if !strings.Contains(o.Detail, says) {
t.Errorf("the outcome does not say %q: %s", says, o.Detail)
}
}
// Held still is not a change: nothing about the machine moved (the step's own install, first
// time here, is).
if (Report{Outcomes: []Outcome{o}}).Changed() {
t.Errorf("a container left alone reads as a change: %+v", o)
}
// The report says a window is open, and which.
if len(report.Windows) != 1 || report.Windows[0].Step != "collect" ||
len(report.Windows[0].Holds) != 1 || report.Windows[0].Holds[0] != "mesh-registry" {
t.Errorf("the report does not say the collector's window is open: %+v", report.Windows)
}
// The window closes: the registry is running again and the record is gone.
close(m.stepGo)
s.Wait()
if open := windows.Open(time.Now()); len(open) != 0 {
t.Fatalf("the window closed and is still recorded open: %+v", open)
}
// And the next apply converges it by the ordinary rule — the changed declaration recreates it.
mark = m.mark()
report, _, err = ApplyMindingWindows(context.Background(), archHost(t), theStoreMoved(t),
state, store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply after the window failed: %v", err)
}
if got := m.touched(mark); len(got) != 2 || got[0] != "rm mesh-registry" || got[1] != "create mesh-registry" {
t.Fatalf("the apply after the window did not converge the container: %v", got)
}
if o := outcomeOf(report, "store"); o.Action != "updated" {
t.Errorf("the container was reported %q after the window, want updated: %+v", o.Action, o)
}
if len(report.Windows) != 0 {
t.Errorf("a closed window is still reported open: %+v", report.Windows)
}
}
// A step that fails releases its hold like one that succeeds: the record is erased in the same
// defer that starts the server again, and an apply afterwards does what it always did — here, to a
// registry that would not come back, the one thing that brings it back.
func TestAFailedStepReleasesItsHold(t *testing.T) {
d := aStoreWithACollector(t)
m := newRegistryMachine(containerSpec(d.Resources[0].(*declaration.Container), inputs{}))
m.failRun, m.wontGo = true, true
windows := WindowsIn(t.TempDir())
s := openAWindow(t, d, m, windows)
if open := windows.Open(time.Now()); len(open) != 1 {
t.Fatalf("the window is not recorded while the step runs: %+v", open)
}
close(m.stepGo)
s.Wait()
if open := windows.Open(time.Now()); len(open) != 0 {
t.Fatalf("a failed step left its window recorded open: %+v", open)
}
mark := m.mark()
report, _, err := ApplyMindingWindows(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, m.run, nil, nil, nil, windows)
if err != nil {
t.Fatalf("the apply after the failed step failed: %v", err)
}
if got := m.touched(mark); len(got) != 2 {
t.Fatalf("the registry left stopped by a failed window was not brought back: %v", got)
}
if o := outcomeOf(report, "store"); o.Action == heldStill {
t.Errorf("the hold outlived the step: %+v", o)
}
}
// A window whose host died mid-step — crashed, or stood aside for a successor — holds nothing:
// the process that would start the server again is gone, so the apply must be free to. And a window
// nothing closed ends on its own.
func TestAWindowNothingClosesDoesNotHoldForEver(t *testing.T) {
dir := t.TempDir()
alive := true
windows := &Windows{dir: dir, alive: func(int) bool { return alive }}
now := time.Now()
if err := windows.open("collect", []string{"mesh-registry"}, now); err != nil {
t.Fatal(err)
}
if _, held := windows.holding("mesh-registry", now); !held {
t.Fatal("an open window with a live host does not hold its container")
}
if _, held := windows.holding("mesh-registry", now.Add(windowAtMost)); held {
t.Error("a window past its end still holds")
}
if err := windows.open("collect", []string{"mesh-registry"}, now); err != nil {
t.Fatal(err)
}
alive = false
if _, held := windows.holding("mesh-registry", now); held {
t.Error("a window whose host is gone still holds")
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Errorf("a window read as closed was left on disk: %v", entries)
}
// The real liveness check: this process is alive, and a pid nothing can have is not.
if !processAlive(os.Getpid()) || processAlive(0) {
t.Error("processAlive does not tell a live process from none")
}
}
// A window that cannot be recorded is not opened: an apply would not know, and would recreate the
// server under the step. One night's collection is the cheaper loss.
func TestAWindowThatCannotBeRecordedIsNotOpened(t *testing.T) {
notADir := filepath.Join(t.TempDir(), "state")
if err := os.WriteFile(notADir, nil, 0o600); err != nil {
t.Fatal(err)
}
d := aStoreWithACollector(t)
m := newRegistryMachine(containerSpec(d.Resources[0].(*declaration.Container), inputs{}))
var said []string
var mu sync.Mutex
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, m.run, func(line string) { mu.Lock(); said = append(said, line); mu.Unlock() })
s.RecordWindowsIn(WindowsIn(notADir))
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
for _, c := range m.calls {
if c == "stop mesh-registry" || c == "step" {
t.Fatalf("a window that could not be recorded was opened anyway: %v", m.calls)
}
}
mu.Lock()
defer mu.Unlock()
if len(said) == 0 || !strings.Contains(strings.Join(said, "\n"), "not run") {
t.Errorf("the step not running was not said: %v", said)
}
}
+125
View File
@@ -0,0 +1,125 @@
package apply
// A mesh directory outside a home is given its mode and owner only through a descriptor (novox/hq issue 358).
//
// Below a home every component is opened without following a link (ADR 0266, home_links.go), and a directory the
// operator handed to the mesh is changed through one descriptor opened from / with no link followed (issue 356).
// Every other directory the mesh owns — a module's state under /var/lib, a drop-in directory under /etc — was
// given its mode and owner by path, with chmod and chown, which follow a link anywhere in the path: whoever could
// turn a parent into a link between two applies directed root's chmod and chown at the link's target.
//
// The danger is a link another account can make or replace, not a link as such: /var/run is a link to /run on
// every node, and a disk is sometimes reached through one. So (the decision recorded on issue 358, 2026-10-11):
//
// - **the directory is opened component by component from /**, each with O_DIRECTORY|O_NOFOLLOW, and its mode
// and owner are changed with fchmod and fchown on that descriptor; a directory the walk makes is made in its
// parent's descriptor. A link put in place after any check is refused by the kernel at the moment of use;
// - **a link in a parent is followed only when root alone could have put it there**: the link is root's, and
// every directory from / down to the one holding it is root's and writable by no other account (no write for
// others, no write for a group other than root's). Its target is then walked the same way, from /;
// - **any other link refuses that directory**, naming the link and the account that could replace it; nothing
// is changed there, and the rest of the declaration applies;
// - **the directory itself is never a link** (issue 339, applyDirectory).
//
// A directory below a home keeps the walk of ADR 0266, which follows no link below the home at all.
import (
"fmt"
osuser "os/user"
"path/filepath"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// LinkInPathError is a mesh directory refused because a link in its path is one another account could replace.
type LinkInPathError struct {
// Path is the directory declared, Link the link in its path, Who the account that could replace it.
Path, Link, Who string
}
func (e *LinkInPathError) Error() string { return e.Path + " " + e.why() }
// why is the refusal without the path it refuses, as a PlacementRefusedError says it.
func (e *LinkInPathError) why() string {
return fmt.Sprintf("is reached through the link %s, which %s could replace: the node-engine runs as root and "+
"follows a link to a directory it gives a mode and owner only when root alone could have made it (novox/hq "+
"issue 358). Make %s a directory, or make it and every directory above it root's and writable by no other "+
"account", e.Link, e.Who, e.Link)
}
// couldReplace says who besides root could replace an entry of a directory with this owner, group and mode: ""
// when nobody can. A variable so a test, which runs below a directory every account may write (/tmp), can name
// which of its own directories count as root's alone.
var couldReplace = func(path string, uid, gid, mode uint32) string {
if uid != 0 {
return "the account " + accountNamed(uid) + " (it owns " + path + ")"
}
if mode&0o002 != 0 {
return "every account (" + path + " is writable by all)"
}
if mode&0o020 != 0 && gid != 0 {
return "the group " + groupNamed(gid) + " (it may write " + path + ")"
}
return ""
}
// linkReplacer says who besides root owns a link: "" when root does. A variable for the same reason.
var linkReplacer = func(link string, uid uint32) string {
if uid != 0 {
return "the account " + accountNamed(uid) + " (it owns the link)"
}
return ""
}
func accountNamed(uid uint32) string {
if u, err := osuser.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil {
return u.Username
}
return "with uid " + strconv.FormatUint(uint64(uid), 10)
}
func groupNamed(gid uint32) string {
if g, err := osuser.LookupGroupId(strconv.FormatUint(uint64(gid), 10)); err == nil {
return g.Name
}
return "with gid " + strconv.FormatUint(uint64(gid), 10)
}
// JudgeDirectories is the walk's judgement over every directory a declaration names, read-only (novox/hq issue
// 358): nothing is made, chmodded or chowned. One line per directory, in the declaration's order, after one line
// that counts them: placed, would be refused and why, refused already by the placement guard (issue 339), or
// below a home, where ADR 0266's walk judges it as before.
func JudgeDirectories(d *declaration.Declaration) string {
var lines []string
var placed, refused, guarded, homed int
for _, r := range d.Resources {
dir, ok := r.(*declaration.Directory)
if !ok {
continue
}
path := filepath.Clean(dir.Path)
if err := refusePath(path, placingDirectory, moduleOfID(dir.ID), dir.Owner); err != nil {
guarded++
lines = append(lines, path+": refused already by the placement guard: "+err.Error())
continue
}
if home := homeAbove(path); home != "" {
homed++
lines = append(lines, path+": below the home "+home+", walked without following any link as before (ADR 0266)")
continue
}
if err := judgeMeshDir(path); err != nil {
refused++
lines = append(lines, path+": would be refused: "+err.Error())
continue
}
placed++
lines = append(lines, path+": placed")
}
head := fmt.Sprintf("%d directories declared: %d placed, %d would be refused, %d refused already by the "+
"placement guard, %d below a home (novox/hq issue 358; judged read-only, nothing changed)",
placed+refused+guarded+homed, placed, refused, guarded, homed)
return strings.Join(append([]string{head}, lines...), "\n")
}
+114
View File
@@ -0,0 +1,114 @@
//go:build linux
package apply
// The apply half of mesh_dirs.go (novox/hq issue 358): a mesh directory outside every home made and given its mode
// and owner through the walk's descriptor.
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
"github.com/novox/mesh-host/internal/declaration"
)
// meshDirsByDescriptor says this system can open a directory component by component without following a link.
const meshDirsByDescriptor = true
// openMeshDir opens the directory at path, from /, one component at a time with O_DIRECTORY|O_NOFOLLOW; a missing
// component is made in its parent's descriptor with mode, and made is called with each one made and its descriptor.
// A link in a parent is followed only when couldReplace and linkReplacer find that root alone could have made it,
// and its target is walked again from /; any other link is a LinkInPathError, and the last component being one is
// refused as not placed. Answers the directory's descriptor, which the caller closes, with the walk: what it made
// and the path it reached the directory at.
func openMeshDir(path string, mode os.FileMode, made func(dir string, fd int) error) (int, *meshWalk, error) {
w := &meshWalk{want: filepath.Clean(path), create: true, mode: mode, made: made}
fd, err := w.open()
return fd, w, err
}
// applyMeshDirectory gives a mesh directory outside every home its declared mode and owner through a descriptor
// (novox/hq issue 358): opened by openMeshDir, made there when missing, judged a directory by fstat on it, changed
// with fchmod and fchown on it, and read back from it. The parents it makes inside the owner's home are the owner's,
// as makeDirs gives them (novox/hq ADR 0182).
func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, existed bool) (Outcome, error) {
uid, gid, chown := 0, 0, r.Owner != ""
var err error
if chown {
if uid, gid, err = idsOf(r.Owner); err != nil {
return out, fmt.Errorf("%s should belong to %q: %w", r.Path, r.Owner, err)
}
}
clean := filepath.Clean(r.Path)
home := ""
if chown {
if h, herr := homeOf(r.Owner); herr == nil && h != "" {
home = filepath.Clean(h)
}
}
giveMade := func(dir string, fd int) error {
if dir == clean || home == "" || (dir != home && !strings.HasPrefix(dir, home+string(os.PathSeparator))) {
return nil
}
if err := unix.Fchown(fd, uid, gid); err != nil {
return fmt.Errorf("cannot give %s to %q: %w", dir, r.Owner, &os.PathError{Op: "chown", Path: dir, Err: err})
}
return nil
}
fd, walk, err := openMeshDir(clean, mode, giveMade)
if err != nil {
var link *LinkInPathError
if errors.As(err, &link) {
return out, &PlacementRefusedError{Path: r.Path, Why: "it " + link.why()}
}
return out, err
}
defer unix.Close(fd)
// Made by this walk when its last component is: at the path the walk reached it at, which root-only links in its
// path may have moved from the path declared.
created := len(walk.Made) > 0 && walk.Made[len(walk.Made)-1] == walk.At
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return out, &os.PathError{Op: "fstat", Path: r.Path, Err: err}
}
beforeMode, beforeUID, beforeGID := os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid)
// Set explicitly even when it existed: a mode given at creation is not a mode maintained.
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err}
}
ownedAlready := !chown || (beforeUID == uid && beforeGID == gid)
if !ownedAlready {
if err := unix.Fchown(fd, uid, gid); err != nil {
return out, fmt.Errorf("cannot give %s to %q: %w", r.Path, r.Owner, &os.PathError{Op: "chown", Path: r.Path, Err: err})
}
}
// Read back, from the descriptor that was changed.
if err := unix.Fstat(fd, &st); err != nil {
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
}
if st.Mode&unix.S_IFMT != unix.S_IFDIR {
return out, fmt.Errorf("%s is not a directory after applying", r.Path)
}
if got := os.FileMode(st.Mode & 0o777); got != mode.Perm() {
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, got, mode.Perm())
}
out.Action = "unchanged"
switch {
case created || !existed:
out.Action = "created"
case !ownedAlready:
out.Action = "updated"
case beforeMode != mode.Perm():
out.Action = "updated"
out.Detail = fmt.Sprintf("mode %o to %o", beforeMode, mode.Perm())
}
return out, nil
}
+95
View File
@@ -0,0 +1,95 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Review 47 of novox/hq issue 358: a directory the walk refuses gates its module, and nothing below it is written,
// whether the link was there when the apply began or appeared while it ran.
// aLinkedModule is a module's directory at <base>/parent/state, its parent a link to <base>/elsewhere, and a file of
// the module below the directory; and another module's file elsewhere.
func aLinkedModule(t *testing.T) (target, other, decl string) {
t.Helper()
base := t.TempDir()
target = filepath.Join(base, "elsewhere", "state")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "parent")); err != nil {
t.Fatal(err)
}
dir := filepath.Join(base, "parent", "state")
other = filepath.Join(base, "other.conf")
decl = `{"id":"mod.state","type":"directory","path":"` + dir + `","mode":"0700"},` +
`{"id":"mod.conf","type":"file","path":"` + dir + `/settings.conf","content":"a=1\n"},` +
`{"id":"other.conf","type":"file","path":"` + other + `","content":"kept apart\n"}`
return target, other, decl
}
func holdsTheGate(t *testing.T, target, other string, report Report, err error) {
t.Helper()
var refusal *PlacementRefusedError
if !errors.As(err, &refusal) {
t.Fatalf("the directory behind the link was not refused: %v", err)
}
if _, statErr := os.Stat(filepath.Join(target, "settings.conf")); statErr == nil {
t.Error("a file was written into the link's target, below the directory the engine refused")
}
if info, _ := os.Stat(target); info.Mode().Perm() != 0o755 {
t.Errorf("the link's target was given mode %o (was 755)", info.Mode().Perm())
}
if o := outcomeOf(report, "mod.conf"); o.Action != "skipped" {
t.Errorf("the file below the refused directory is not reported skipped: %+v", o)
}
if strings.Contains(err.Error(), "everything was attempted") || !strings.Contains(err.Error(), "the rest of mod was not attempted") {
t.Errorf("the report does not say the module was gated: %v", err)
}
if got, readErr := os.ReadFile(other); readErr != nil || string(got) != "kept apart\n" {
t.Errorf("another module's file was not applied: %q, %v", got, readErr)
}
}
// The link there when the apply begins: refused by the guard before anything is applied.
func TestADirectoryBehindALinkGatesItsModuleThroughApply(t *testing.T) {
target, other, decl := aLinkedModule(t)
report, _, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
holdsTheGate(t, target, other, report, err)
}
// The link found only at the moment of use: refused by the walk during the apply, and still a refusal that gates its
// module and what is below it. Simulated by trusting / for the guard's walk (two walks of /: one to the link, one
// from its target) and not for the apply's.
func TestADirectoryRefusedDuringTheApplyGatesItsModule(t *testing.T) {
target, other, decl := aLinkedModule(t)
wasDirs, wasLinks := couldReplace, linkReplacer
walksOfRoot := 0
couldReplace = func(path string, _, _, _ uint32) string {
if path == "/" {
walksOfRoot++
if walksOfRoot > 2 {
return "every account (a test's /)"
}
}
return ""
}
linkReplacer = func(string, uint32) string { return "" }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
report, _, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if walksOfRoot < 3 {
t.Fatalf("the apply did not walk again at the moment of use (%d walks)", walksOfRoot)
}
holdsTheGate(t, target, other, report, err)
}
+186
View File
@@ -0,0 +1,186 @@
//go:build linux
package apply
// The descriptor half of mesh_dirs.go (novox/hq issue 358).
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// maxLinksFollowed bounds the root-only links one walk follows, as the kernel bounds a lookup's (MAXSYMLINKS).
const maxLinksFollowed = 40
// errNotThereYet is a read-only walk meeting a component that does not exist: an apply would make it there.
var errNotThereYet = errors.New("not there yet")
// meshWalk is one walk of a mesh directory's path from /.
type meshWalk struct {
// want is the directory as declared.
want string
// create makes a missing component, with mode, in its parent's descriptor; without it the walk stops there
// with errNotThereYet and makes nothing.
create bool
mode os.FileMode
// made is called with each directory the walk made and its descriptor.
made func(dir string, fd int) error
// Made is every directory the walk made, outermost first, and At the path the directory was reached at:
// want, or where the root-only links in its path led.
Made []string
At string
}
// judgeMeshDir is openMeshDir's judgement and nothing else (novox/hq issue 358): the same walk, making nothing
// and changing nothing; nil when an apply would give the directory its mode and owner, also when a part of its
// path is not there yet (an apply makes it, as root, in a directory it reached).
func judgeMeshDir(path string) error {
w := &meshWalk{want: filepath.Clean(path)}
fd, err := w.open()
if errors.Is(err, errNotThereYet) {
return nil
}
if err != nil {
return err
}
unix.Close(fd)
return nil
}
func (w *meshWalk) open() (int, error) {
if !filepath.IsAbs(w.want) {
return -1, fmt.Errorf("%s is not an absolute path", w.want)
}
p := w.want
for followed := 0; ; followed++ {
if followed > maxLinksFollowed {
return -1, &os.PathError{Op: "open", Path: w.want, Err: unix.ELOOP}
}
fd, next, err := w.fromRoot(p)
if err != nil {
return -1, err
}
if next == "" {
w.At = p
return fd, nil
}
p = next
}
}
// fromRoot is one walk down p: the directory's descriptor, or the path to walk next when a root-only link was
// met (with -1), or the refusal.
func (w *meshWalk) fromRoot(p string) (int, string, error) {
fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, "", &os.PathError{Op: "open", Path: "/", Err: err}
}
// Who could replace an entry of the directories walked so far: once one could, every link below it is
// theirs to make, whoever owns the link.
who, err := replacerOf(fd, "/")
if err != nil {
unix.Close(fd)
return -1, "", err
}
at := "/"
var parts []string
if p != "/" {
parts = strings.Split(strings.TrimPrefix(p, "/"), "/")
}
const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
for i, part := range parts {
here := filepath.Join(at, part)
last := i == len(parts)-1
next, err := unix.Openat(fd, part, dirFlags, 0)
if errors.Is(err, unix.ENOENT) {
if !w.create {
unix.Close(fd)
return -1, "", errNotThereYet
}
mkErr := unix.Mkdirat(fd, part, uint32(w.mode.Perm()))
if mkErr != nil && !errors.Is(mkErr, unix.EEXIST) {
unix.Close(fd)
return -1, "", &os.PathError{Op: "mkdir", Path: here, Err: mkErr}
}
next, err = unix.Openat(fd, part, dirFlags, 0)
if mkErr == nil && err == nil {
w.Made = append(w.Made, here)
if w.made != nil {
if gErr := w.made(here, next); gErr != nil {
unix.Close(next)
unix.Close(fd)
return -1, "", gErr
}
}
}
}
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
var st unix.Stat_t
if lerr := unix.Fstatat(fd, part, &st, unix.AT_SYMLINK_NOFOLLOW); lerr == nil && st.Mode&unix.S_IFMT == unix.S_IFLNK {
to, rerr := w.followable(fd, part, here, st, who, last)
unix.Close(fd)
if rerr != nil {
return -1, "", rerr
}
if !filepath.IsAbs(to) {
to = filepath.Join(at, to)
}
return -1, filepath.Join("/", to, filepath.Join(parts[i+1:]...)), nil
}
}
unix.Close(fd)
if err != nil {
switch {
case errors.Is(err, unix.ENOTDIR) && last:
return -1, "", fmt.Errorf("%s exists and is not a directory", here)
case errors.Is(err, unix.ENOTDIR):
return -1, "", fmt.Errorf("%s is not a directory, and %s lies below it", here, w.want)
}
return -1, "", &os.PathError{Op: "open", Path: here, Err: err}
}
fd = next
at = here
if who == "" {
if who, err = replacerOf(fd, here); err != nil {
unix.Close(fd)
return -1, "", err
}
}
}
return fd, "", nil
}
// replacerOf is couldReplace for the directory open on fd.
func replacerOf(fd int, path string) (string, error) {
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return "", &os.PathError{Op: "fstat", Path: path, Err: err}
}
return couldReplace(path, st.Uid, st.Gid, st.Mode&0o7777), nil
}
// followable judges the link at here, held by the directory open on fd: its target when root alone could have
// made it, else the refusal. who is who could replace an entry of a directory above it ("" when nobody).
func (w *meshWalk) followable(fd int, name, here string, st unix.Stat_t, who string, last bool) (string, error) {
if last {
return "", &PlacementRefusedError{Path: w.want, Why: "it is a symbolic link, and the node-engine follows no " +
"link to a directory it owns"}
}
if who == "" {
who = linkReplacer(here, st.Uid)
}
if who != "" {
return "", &LinkInPathError{Path: w.want, Link: here, Who: who}
}
buf := make([]byte, unix.PathMax)
n, err := unix.Readlinkat(fd, name, buf)
if err != nil {
return "", &os.PathError{Op: "readlink", Path: here, Err: err}
}
return string(buf[:n]), nil
}
+91
View File
@@ -0,0 +1,91 @@
//go:build linux
package apply
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 358: a mesh directory outside every home is given its mode and owner only through a
// descriptor opened from /, so a parent swapped for a link between two applies cannot carry root's chmod and
// chown to the link's target — unless root alone could have made that link (/var/run is one on every node).
// A link only root could have made — itself root's, in directories no other account may write — is followed, as
// /var/run to /run is on every node; relative or absolute.
func TestALinkOnlyRootCouldMakeIsFollowed(t *testing.T) {
for name, to := range map[string]func(base string) string{
"absolute": func(base string) string { return filepath.Join(base, "elsewhere") },
"relative": func(string) string { return "elsewhere" },
} {
t.Run(name, func(t *testing.T) {
rootAlone(t, "")
base, r, previous := aMeshDirectory(t)
target := swapParentForLink(t, base, to(base))
out, err := applyDirectory(r, previous, true)
if err != nil {
t.Fatalf("a directory behind a root-only link was refused: %v", err)
}
if got := permOf(t, target); got != 0o700 {
t.Errorf("the directory behind a root-only link is mode %o, want 700", got)
}
if out.Action != "updated" {
t.Errorf("the apply said %q, want updated", out.Action)
}
})
}
}
// A link another account owns refuses the directory even in directories root alone may write, naming the account.
func TestALinkAnotherAccountOwnsIsRefused(t *testing.T) {
rootAlone(t, "the account mallory (it owns the link)")
base, r, previous := aMeshDirectory(t)
target := swapParentForLink(t, base, filepath.Join(base, "elsewhere"))
_, err := applyDirectory(r, previous, true)
if err == nil || !strings.Contains(err.Error(), "mallory") {
t.Fatalf("a link another account owns was not refused naming it: %v", err)
}
if got := permOf(t, target); got != 0o755 {
t.Errorf("the link's target was given mode %o (was 755)", got)
}
}
// A directory that is missing is made through the walk, below a link root alone could make.
func TestAMissingDirectoryIsMadeBehindARootOnlyLink(t *testing.T) {
rootAlone(t, "")
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "real"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(base, "link")); err != nil {
t.Fatal(err)
}
r := &declaration.Directory{ID: "mod.state", Path: filepath.Join(base, "link", "a", "b"), Mode: "0750"}
out, err := applyDirectory(r, store.Applied{}, false)
if err != nil {
t.Fatal(err)
}
if out.Action != "created" {
t.Errorf("the apply said %q, want created", out.Action)
}
if got := permOf(t, filepath.Join(base, "real", "a", "b")); got != 0o750 {
t.Errorf("the made directory is mode %o, want 750", got)
}
}
// rootAlone has every directory count as writable by root alone, as /, /var and /run are on a node, and a link's
// owner be linkOwner ("" for root): a test runs below /tmp, which every account may write.
func rootAlone(t *testing.T, linkOwner string) {
t.Helper()
wasDirs, wasLinks := couldReplace, linkReplacer
couldReplace = func(string, uint32, uint32, uint32) string { return "" }
linkReplacer = func(string, uint32) string { return linkOwner }
t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
}
+136
View File
@@ -0,0 +1,136 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/unix"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The engine delivers before the controller half (novox/hq ADR 0325): a controller that does not speak
// link.IdentityContract sends no identities, no users and no taken, and every access of its declarations is applied
// exactly as before this engine — mounted when present, refused when absent (ADR 0051 §7), nothing kept, nothing
// made, no container held, nothing judged. These are origin/main's outcomes, word for word.
func TestAnOlderControllersAccessesAreAppliedExactlyAsBefore(t *testing.T) {
keeping(t, nil, nil)
dir := userDir(t)
unwritable := filepath.Join(dir, "library") // its group may not write it
empty := filepath.Join(dir, "spool")
absent := filepath.Join(dir, "media", "movies")
for _, d := range []string{unwritable, empty} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(d, 0o755); err != nil {
t.Fatal(err)
}
}
// A root-owned empty folder, read only.
rootEmpty := ""
for _, c := range []string{"/var/empty", "/srv/http", "/srv/ftp"} {
if entries, err := os.ReadDir(c); err == nil && len(entries) == 0 {
if uid, _, _ := statOwner(mustLstat(t, c)); uid == 0 {
rootEmpty = c
break
}
}
}
resources := []string{
`{"id":"radarr.library","type":"access","path":"` + unwritable + `","mode":"read-write"}`,
`{"id":"sab.spool","type":"access","path":"` + empty + `","mode":"read-write"}`,
`{"id":"plex.movies","type":"access","path":"` + absent + `"}`,
`{"id":"plex.app","type":"container","name":"plex","image":"` + pinned + `","volumes":["` + absent + `:/movies"]}`,
}
if rootEmpty != "" {
resources = append(resources, `{"id":"web.root","type":"access","path":"`+rootEmpty+`","mode":"read-write"}`)
}
var started bool
run := func(_ context.Context, name string, args ...string) (string, error) {
if len(args) > 0 && args[0] == "run" {
started = true
}
if len(args) > 0 && args[0] == "container" {
if started {
return "true\t" + containerSpec(declare(t, resources[3]).Resources[0].(*declaration.Container), inputs{}), nil
}
return "", errors.New("no such container")
}
return "", nil
}
toUser(t)
report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{},
store.OriginDeclared, run, nil, nil)
if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+
"must provide it") {
t.Fatalf("an absent access is not refused as before: %v", err)
}
for id, mode := range map[string]string{"radarr.library": "read-write", "sab.spool": "read-write"} {
o := outcomeOf(report, id)
if o.Action != "unchanged" || o.Detail != "operator-owned; present, "+mode+", nothing managed" {
t.Errorf("%s: %+v, and before it was unchanged, %q", id, o, "operator-owned; present, "+mode+", nothing managed")
}
}
if rootEmpty != "" {
if o := outcomeOf(report, "web.root"); o.Action != "unchanged" {
t.Errorf("a root-owned folder was judged: %+v", o)
}
}
if !started {
t.Error("a container mounting an absent access was held, and before it was created")
}
if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) {
t.Error("an absent access was made")
}
for _, d := range []string{unwritable, empty} {
if n, _ := unix.Getxattr(d, aclAccessXattr, nil); n > 0 {
t.Errorf("an ACL was set on %s", d)
}
if info := mustLstat(t, d); info.Mode().Perm() != 0o755 {
t.Errorf("%s changed mode: %o", d, info.Mode().Perm())
}
}
if all, _ := KeptFolders.All(); len(all) != 0 {
t.Errorf("folders were recorded as kept: %v", all)
}
if len(Leftovers()) != 0 || len(WaitingOnAccess(declare(t, strings.Join(resources, ",")))) != 0 {
t.Error("something was said or held for an older controller's access")
}
}
func mustLstat(t *testing.T, path string) os.FileInfo {
t.Helper()
info, err := os.Lstat(path)
if err != nil {
t.Fatal(err)
}
return info
}
// Review (low): a folder no access names, whose filesystem could not be named this look, is not said as its disk
// being away.
func TestALeftoverSaysWhyItsWalkCouldNotRun(t *testing.T) {
uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1"
_, lib, access := poolNamed(t, &uuid, &fsid, &source)
takeIt(t, lib, access)
other := `{"id":"x.dir","type":"directory","path":"` + filepath.Join(filepath.Dir(filepath.Dir(lib)), "x") + `"}`
if _, _, err := applyAccesses(t, store.State{}, other); err != nil {
t.Fatal(err)
}
uuid, fsid, source = "", "0000000000000000", "" // nothing names it now
toUser(t)
ReconcileKept(context.Background())
left := Leftovers()
if len(left) != 1 || strings.Contains(left[0].Why, "disk") || !strings.Contains(left[0].Why, "could not be named") {
t.Fatalf("the leftover does not say why its walk could not run: %+v", left)
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
return "", nil
}
if !firewall.Installed(ctx, run) {
// Uninstalled (novox/hq ADR 0175): retired for good, by the module that replaced it. Said
// Uninstalled (novox/hq ADR 0180): retired for good, by the module that replaced it. Said
// once, and nothing is asked of a command that is not there.
if rec.RetiredBy != firewall.RetiredRemoved {
rec.RetiredBy = firewall.RetiredRemoved
+5 -1
View File
@@ -42,6 +42,8 @@ func (u *ufwMachine) iptables(args []string) (string, error) {
}
func (u *ufwMachine) run(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
u.asked = append(u.asked, name+" "+strings.Join(args, " "))
switch name {
case "nft":
@@ -374,6 +376,7 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
}
guardUpAtStop := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "systemctl" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
@@ -471,6 +474,7 @@ func TestAGuardThatFailsLeavesTheDerivedFilterInForce(t *testing.T) {
`","content":"table inet mesh_guard {}\n"}`
stopped := false
run := func(_ context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name != "systemctl" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
@@ -525,7 +529,7 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
}
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
// (novox/hq ADR 0175).
// (novox/hq ADR 0180).
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
dir := t.TempDir()
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
+261
View File
@@ -0,0 +1,261 @@
package apply
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Whether a shared folder's disk is there (novox/hq ADR 0325 §9).
//
// **The rule the engine keeps.** A folder is placed on the filesystem it is first seen on: the mount point that
// holds it — the longest mount point in /proc/self/mountinfo that is the folder or above it — is recorded beside the
// kept folder. On every later apply the folder (or, when it is absent, its nearest existing parent) is judged
// against that record: held by another mount point than the one recorded while the recorded one is not mounted is
// the disk not there. A recorded mount point that is mounted while another holds the folder is a **move**: what is
// at the path now is not what was kept (a disk mounted over a folder first seen empty below it, a mount made
// deeper), so the folder is judged afresh — kept again only if it holds nothing, else it waits for the operator's
// word as any folder holding data does (ADR 0325 §8).
//
// **On first sight, with no record,** the engine knows a disk is missing only from where disks are meant to be:
// every mount target in /etc/fstab, every `Where=` of a systemd .mount unit, every mount point a ZFS dataset names
// (`zfs list`, when the machine has zfs), every mount point this engine has seen mounted since it started, and every
// mount point recorded for another kept folder. A target that is the folder or above it, lies below the mount point
// that holds what is there now, and is not mounted, is the disk not there. Otherwise the filesystem that holds what
// is there is the placed one. A mount nothing names in those places (a pool not imported, whose datasets `zfs list`
// does not show; a hand `mount` never seen) is not known to be missing on first sight: then a folder first seen
// empty below it is kept as empty, and the move above takes it back the moment the disk is there.
// MountPoints is every mount point this machine has mounted now, from /proc/self/mountinfo. A variable so a test
// can stand in for mounts it cannot make.
var MountPoints = func() []string {
return readMountPoints()
}
// mountHistory is every mount point this engine has seen mounted since it started (§9's "mountinfo history").
var mountHistory = struct {
sync.Mutex
seen map[string]bool
}{seen: map[string]bool{}}
// sawMounted adds points to the history, and is the history: every mount point seen, never "/".
func sawMounted(points []string) []string {
mountHistory.Lock()
defer mountHistory.Unlock()
for _, p := range points {
if p != "/" {
mountHistory.seen[p] = true
}
}
out := make([]string, 0, len(mountHistory.seen))
for p := range mountHistory.seen {
out = append(out, p)
}
return out
}
func readMountPoints() []string {
raw, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return []string{"/"}
}
return mountinfoPoints(string(raw))
}
// MountTargets is every place a disk is meant to be mounted: /etc/fstab's targets and ZFS datasets' mount points,
// read at most once a minute — every look judges every access by them. A variable so a test can stand in.
var MountTargets = func(ctx context.Context) []string {
targets.Lock()
defer targets.Unlock()
if targets.read.IsZero() || time.Since(targets.read) > time.Minute {
targets.list, targets.read = readMountTargets(ctx), time.Now()
}
return append([]string(nil), targets.list...)
}
var targets struct {
sync.Mutex
list []string
read time.Time
}
func readMountTargets(ctx context.Context) []string {
var out []string
if raw, err := os.ReadFile("/etc/fstab"); err == nil {
out = append(out, fstabTargets(string(raw))...)
}
out = append(out, mountUnitTargets(systemdUnitDirs)...)
if _, err := exec.LookPath("zfs"); err == nil {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if raw, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-o", "mountpoint").Output(); err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if p := strings.TrimSpace(line); strings.HasPrefix(p, "/") {
out = append(out, filepath.Clean(p))
}
}
}
}
return out
}
// systemdUnitDirs are where systemd keeps the units a machine mounts by.
var systemdUnitDirs = []string{"/etc/systemd/system", "/run/systemd/system", "/run/systemd/generator",
"/usr/lib/systemd/system", "/lib/systemd/system"}
// mountUnitTargets reads the `Where=` of every .mount unit in dirs.
func mountUnitTargets(dirs []string) []string {
var out []string
for _, dir := range dirs {
names, err := filepath.Glob(filepath.Join(dir, "*.mount"))
if err != nil {
continue
}
for _, name := range names {
raw, err := os.ReadFile(name)
if err != nil {
continue
}
for _, line := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Where="); ok && strings.HasPrefix(v, "/") {
out = append(out, filepath.Clean(strings.TrimSpace(v)))
}
}
}
}
return out
}
// mountinfoPoints reads the mount point of each line of mountinfo, its fifth field, with the kernel's octal escapes
// undone.
func mountinfoPoints(raw string) []string {
var out []string
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
out = append(out, filepath.Clean(unescapeMount(fields[4])))
}
return out
}
// mountSourceOf is the source and filesystem type of the mount at point, from mountinfo: for a ZFS dataset, its
// name. Empty when the machine says nothing of it.
func mountSourceOf(point string) (source, fstype string) { return MountSource(point) }
// MountSource is mountSourceOf as the machine answers it. A variable so a test can stand in for another filesystem
// mounted at the same point.
var MountSource = func(point string) (source, fstype string) {
raw, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return "", ""
}
for _, line := range strings.Split(string(raw), "\n") {
before, after, ok := strings.Cut(line, " - ")
fields, rest := strings.Fields(before), strings.Fields(after)
if !ok || len(fields) < 5 || len(rest) < 2 {
continue
}
if filepath.Clean(unescapeMount(fields[4])) == point {
source, fstype = unescapeMount(rest[1]), rest[0]
}
}
return source, fstype
}
// fstabTargets reads the second field of each fstab line that mounts somewhere: not swap, not a comment.
func fstabTargets(raw string) []string {
var out []string
sc := bufio.NewScanner(strings.NewReader(raw))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") || !strings.HasPrefix(fields[1], "/") {
continue
}
out = append(out, filepath.Clean(unescapeMount(fields[1])))
}
return out
}
// unescapeMount undoes the kernel's and fstab's octal escapes: \040 is a space.
func unescapeMount(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+4 <= len(s) {
if v, ok := octal3(s[i+1 : i+4]); ok {
b.WriteByte(v)
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
func octal3(s string) (byte, bool) {
if len(s) != 3 {
return 0, false
}
v := 0
for _, c := range s {
if c < '0' || c > '7' {
return 0, false
}
v = v*8 + int(c-'0')
}
return byte(v), v < 256
}
// mountHolding is the longest of points that is path or above it: the mount point that holds it.
func mountHolding(path string, points []string) string {
best := "/"
for _, p := range points {
if atOrBelow(path, p) && len(p) > len(best) {
best = p
}
}
return best
}
// placement is the judgement of where a folder's data lives.
type placement struct {
// Mount is the mount point that holds what is there: the folder, or its nearest existing parent.
Mount string
// Away is that the disk the folder was placed on is not there.
Away bool
// Moved is that the recorded mount point is mounted and another holds the folder: what is there is not what
// was kept.
Moved bool
}
// placedOn judges path, of which existing is what is there (the folder itself, or its nearest existing parent),
// against what was recorded and where disks are meant to be.
func placedOn(ctx context.Context, path, existing, recorded string, others []string) placement {
points := MountPoints()
mounted := map[string]bool{}
for _, p := range points {
mounted[p] = true
}
have := mountHolding(existing, points)
seen := sawMounted(points)
if recorded != "" {
return placement{Mount: have, Away: have != recorded && !mounted[recorded],
Moved: have != recorded && mounted[recorded]}
}
for _, t := range append(append(MountTargets(ctx), others...), seen...) {
if atOrBelow(path, t) && t != have && atOrBelow(t, have) && !mounted[t] {
return placement{Mount: have, Away: true}
}
}
return placement{Mount: have}
}
+626
View File
@@ -0,0 +1,626 @@
package apply
// Where the node-engine places nothing and mounts nothing, whoever asks (novox/hq issue 339).
//
// A directory names its path, and the controller resolves part of that path from what was set for the
// module: its `places` setting moves a directory anywhere, with an owner it names, and its `accesses` setting
// says which of the machine's paths are mounted into its container. The engine runs as root, so a path it
// accepts blindly is a path anyone who could change those settings hands to any account: a directory at /etc
// owned by a caller's account gives it /etc, and an access at / mounts the machine's root into a container.
// The controller refuses both where a setting is made; the engine refuses them again where it applies,
// because a guard in one place is a guard one change away from gone. Whatever the declaration says:
//
// 1. **No directory, access or mount source is one of the machine's own roots, or holds one**: /, /etc,
// /usr, /var, /var/lib, /home, /run and the rest of protectedRoots. Modules place directories BELOW /etc
// or /var/lib, never the root itself; owning one is owning everything in it.
// 2. **Nothing is placed in /proc, /sys, /dev, /boot, /root, /var/spool, /opt or the container runtimes' data
// (/var/lib/docker, /var/lib/containers, /var/lib/containerd, and where the runtimes' configuration moves
// them: runtimeDataRoots), nor in the node-engine's
// own trees** (its state, its identity, its installed builds) but by its own module; nothing is mounted from
// those but /proc, /sys and /dev. A mount of a kernel file, a device or the clock is the plumbing
// systemPath names.
// 2a. **An account's .ssh is never an access or a mount**, and is a directory only below its account's home,
// as that account's (rule 4).
// 3. **A directory below /etc, /usr or /run is root's.** The machine's configuration and programs are read
// as root's word; a directory there owned by another account is that account writing root's word. An
// access is never there at all: the operator's data is not the machine's configuration.
// 4. **Below a person's or an agent's home, a directory is that account's.** Root's or another account's
// directory there is one the account does not control in a tree whose every parent it does. A home
// itself may be a module's directory (a backup repository kept as an account's home is one), and as
// every directory the mesh did not make, it is used as found: never chowned or chmodded (applyDirectory).
// 5. **A mount source that is a refused directory or a refused access is refused with it**: the container
// would otherwise bind the very path the engine would not place, and the runtime creates a missing one
// as root.
// 6. **A file is judged as a directory is** (novox/hq issue 495): never one of the machine's own roots, nor
// in a tree nothing is placed in (rule 2), and never below a directory or an access refused here. A file
// or an archive below a refused path is refused with it, whichever module declares it and in whatever
// order. Rules 3 and 4 are a directory's owner and do not reach a file: a file below /etc is the mesh's
// ordinary configuration, and one below a home is judged by home_links.go and agent_home_guard.go.
// A refused directory also gates the rest of its module, as a step that did not complete does
// (apply.go's `gated`): what a module declares after its directory was written for that directory.
//
// Each is a failed resource with its reason in the node's report; nothing is touched. Paths are judged as
// declared and again with every link in them resolved, so a link at /srv/x pointing at /etc places nothing.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// protectedRoots are paths no directory, access or mount source may be, nor hold.
var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
"/var/spool"}
// forbiddenBelow are trees nothing is placed in or mounted from: the kernel's, the boot loader's and root's
// home. engineTrees are the node-engine's own, which only its own module places in.
var (
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
"/var/lib/containers", "/var/lib/containerd"}
// forbiddenBelowMount are the trees no container mounts from: a mount of the kernel's files and devices is
// the plumbing a container may need (systemPath); root's home and the boot loader's are no plumbing.
forbiddenBelowMount = []string{"/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", "/var/lib/containers",
"/var/lib/containerd"}
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
// rootsOnly are trees a directory below is root's, and an access is never in.
rootsOnly = []string{"/etc", "/usr", "/run", "/var/run"}
)
// runtimeFiles are where the container runtimes say where they keep their data: dockerd's daemon.json, its
// unit and the unit's drop-ins (an ExecStart with --data-root, or the older -g/--graph, continued over lines,
// quoted, through Environment= or EnvironmentFile=, or a --config-file naming another daemon.json), and podman's
// storage.conf (graphroot, a basic or a literal string). The running runtimes are asked first. containerd keeps its own under /var/lib/containerd, which forbiddenBelow names; a
// containerd configured elsewhere, and podman's rootless stores under each account's home, are not read: the
// first is no runtime this mesh runs, and the second is below a home, which rule 4 already keeps for its
// account. A variable so a test names its own.
type runtimeFiles struct {
daemonJSON string
units []string
dropInDirs []string
storageConf string
}
var runtimeConfigs = runtimeFiles{
daemonJSON: "/etc/docker/daemon.json",
units: []string{"/etc/systemd/system/docker.service", "/usr/lib/systemd/system/docker.service", "/lib/systemd/system/docker.service"},
dropInDirs: []string{"/etc/systemd/system/docker.service.d", "/run/systemd/system/docker.service.d", "/usr/lib/systemd/system/docker.service.d"},
storageConf: "/etc/containers/storage.conf",
}
var (
dataRootFlag = regexp.MustCompile(`(?:--data-root|--graph|-g)(?:=|\s+)(\S+)`)
configFlag = regexp.MustCompile(`--config-file(?:=|\s+)(\S+)`)
graphRoot = regexp.MustCompile(`(?m)^\s*graphroot\s*=\s*(?:"([^"]+)"|'([^']+)')`)
envVar = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// runtimeRoots is where the container runtimes keep their data, as the last apply read it (readRuntimeRoots);
// nil until an apply has read it, when the guard reads the files itself.
var (
runtimeRootsMu sync.Mutex
runtimeRoots []string
)
// AskRuntimes is how the engine asks the running container runtimes where they keep their data: set by the engine
// to run the commands, nil in a test, which then reads the files alone. Its own, never the apply's runner, whose
// commands a test reads back as what the apply did.
var AskRuntimes Runner
// refreshRuntimeRoots reads where the runtimes keep their data once, at the start of an apply.
func refreshRuntimeRoots(ctx context.Context) {
roots := readRuntimeRoots(ctx, AskRuntimes)
runtimeRootsMu.Lock()
runtimeRoots = roots
runtimeRootsMu.Unlock()
}
// runtimeDataRoots is every place the container runtimes keep their data, beyond the default trees forbiddenBelow
// names: every container's filesystem is there.
func runtimeDataRoots() []string {
runtimeRootsMu.Lock()
roots := runtimeRoots
runtimeRootsMu.Unlock()
if roots != nil {
return roots
}
return readRuntimeRoots(context.Background(), nil)
}
// readRuntimeRoots asks the running runtimes where they keep their data, when run is given (`docker info`,
// `podman info`), and reads their configuration besides: an answer from a runtime that is running is what it
// really does, and the files say what it will do when it starts again. Both count. A runtime that is not running
// or not installed answers nothing, which is no error.
func readRuntimeRoots(ctx context.Context, run Runner) []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
p = strings.Trim(strings.TrimSpace(p), `"'`)
if !filepath.IsAbs(p) {
return
}
p = filepath.Clean(p)
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
if run != nil {
// Each runtime has its own ten seconds: one that hangs costs the other nothing.
for _, q := range [][]string{{"docker", "info", "--format", "{{.DockerRootDir}}"},
{"podman", "info", "--format", "{{.Store.GraphRoot}}"}} {
ask, cancel := context.WithTimeout(ctx, 10*time.Second)
if root, err := run(ask, q[0], q[1:]...); err == nil {
add(root)
}
cancel()
}
}
daemonJSON := func(path string) {
raw, err := os.ReadFile(path)
if err != nil {
return
}
var c struct {
DataRoot string `json:"data-root"`
Graph string `json:"graph"`
}
if json.Unmarshal(raw, &c) == nil {
add(c.DataRoot)
add(c.Graph)
}
}
daemonJSON(runtimeConfigs.daemonJSON)
units := append([]string(nil), runtimeConfigs.units...)
for _, dir := range runtimeConfigs.dropInDirs {
matches, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
units = append(units, matches...)
}
// The unit and its drop-ins are one unit to systemd: a variable set in one is seen by an ExecStart in another.
env := map[string]string{}
var execs []string
for _, u := range units {
raw, err := os.ReadFile(u)
if err != nil {
continue
}
e, x := unitLines(string(raw))
for k, v := range e {
env[k] = v
}
execs = append(execs, x...)
}
for _, line := range execs {
line = envVar.ReplaceAllStringFunc(line, func(ref string) string {
m := envVar.FindStringSubmatch(ref)
if v, ok := env[m[1]+m[2]]; ok {
return v
}
return ref
})
for _, m := range dataRootFlag.FindAllStringSubmatch(line, -1) {
add(m[1])
}
for _, m := range configFlag.FindAllStringSubmatch(line, -1) {
daemonJSON(strings.Trim(m[1], `"'`))
}
}
if raw, err := os.ReadFile(runtimeConfigs.storageConf); err == nil {
for _, m := range graphRoot.FindAllStringSubmatch(string(raw), -1) {
add(m[1] + m[2])
}
}
return out
}
// unitLines reads a unit file as systemd does for what matters here: a line ending in a backslash continues on the
// next, Environment= sets variables (quoted or not, several to a line), EnvironmentFile= (a leading - says it may
// be missing) reads KEY=value lines, and every ExecStart line is returned whole.
func unitLines(text string) (map[string]string, []string) {
var joined []string
var cur strings.Builder
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
cur.WriteString(strings.TrimSuffix(trimmed, "\\") + " ")
continue
}
cur.WriteString(line)
joined = append(joined, cur.String())
cur.Reset()
}
if cur.Len() > 0 {
joined = append(joined, cur.String())
}
env := map[string]string{}
setPairs := func(s string) {
for _, f := range splitQuoted(s) {
if k, v, ok := strings.Cut(f, "="); ok {
env[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`)
}
}
}
var execs []string
for _, line := range joined {
l := strings.TrimSpace(line)
switch {
case strings.HasPrefix(l, "Environment="):
setPairs(strings.TrimPrefix(l, "Environment="))
case strings.HasPrefix(l, "EnvironmentFile="):
path := strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(l, "EnvironmentFile=")), "-")
if raw, err := os.ReadFile(path); err == nil {
for _, kv := range strings.Split(string(raw), "\n") {
kv = strings.TrimSpace(kv)
if kv == "" || strings.HasPrefix(kv, "#") {
continue
}
setPairs(kv)
}
}
case strings.HasPrefix(l, "ExecStart"):
execs = append(execs, l)
}
}
return env, execs
}
// splitQuoted splits on blanks outside double or single quotes, keeping the quotes' contents whole.
func splitQuoted(s string) []string {
var out []string
var cur strings.Builder
var quote rune
for _, r := range s {
switch {
case quote != 0 && r == quote:
quote = 0
case quote == 0 && (r == '"' || r == '\''):
quote = r
case quote == 0 && (r == ' ' || r == '\t'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// engineModule is the module whose resources may place in the engine's own trees.
const engineModule = "mesh-host"
// passwdFile is the user database homes are read from. A variable so a test names its own.
var passwdFile = "/etc/passwd"
// PlacementRefusedError is a resource the engine will not place, or mount, where it says.
type PlacementRefusedError struct {
Path, Why string
}
func (e *PlacementRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s (novox/hq issue 339); nothing was touched", e.Path, e.Why)
}
// homeAccount is a person's or an agent's account and its home.
type homeAccount struct {
Name string
UID int
}
// accountsOfHomes is each person's or agent's home and the account it belongs to, from the user database: an
// account with a uid of 1000 or more, or a home under /home. A variable so a test names its own.
var accountsOfHomes = func() map[string]homeAccount {
f, err := os.Open(passwdFile)
if err != nil {
return nil
}
defer f.Close()
out := map[string]homeAccount{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Split(sc.Text(), ":")
if len(fields) < 6 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
home := filepath.Clean(fields[5])
if home == "/" || home == "." || home == "" || home == "/nonexistent" {
continue
}
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
out[home] = homeAccount{Name: fields[0], UID: uid}
}
}
return out
}
// below says whether path is strictly below dir.
func below(path, dir string) bool {
if dir == "/" {
return path != "/"
}
return strings.HasPrefix(path, dir+"/")
}
// atOrBelow says whether path is dir or below it.
func atOrBelow(path, dir string) bool { return path == dir || below(path, dir) }
// resolved is a path with every link in it followed, as far as the path exists, and the rest as declared.
func resolved(path string) string {
rest := ""
for p := path; ; p = filepath.Dir(p) {
if real, err := filepath.EvalSymlinks(p); err == nil {
return filepath.Clean(filepath.Join(real, rest))
}
if filepath.Dir(p) == p {
return path
}
rest = filepath.Join(filepath.Base(p), rest)
}
}
// ownedByAccount says whether a declared owner is that account: by name, or by its uid ("1001", "1001:1001").
func ownedByAccount(owner string, a homeAccount) bool {
if owner == a.Name {
return true
}
user, _, _ := strings.Cut(owner, ":")
if uid, err := strconv.Atoi(user); err == nil {
return uid == a.UID
}
return false
}
// rootOwner says whether a declared owner is root: none, "root", or uid 0.
func rootOwner(owner string) bool {
if owner == "" || owner == "root" {
return true
}
user, _, _ := strings.Cut(owner, ":")
return user == "0"
}
// what a guarded path is, for the words of a refusal.
type placing int
const (
placingDirectory placing = iota
placingAccess
placingMount
placingFile
)
// refusePath says why a path is not placed or mounted; nil when it may be. module is the resource's module,
// owner a directory's declared owner.
func refusePath(path string, kind placing, module, owner string) error {
clean := filepath.Clean(path)
if !filepath.IsAbs(clean) {
return nil // the declaration refuses a relative path already; a named volume is not a path
}
for _, p := range []string{clean, resolved(clean)} {
if err := refuseOne(p, kind, module, owner); err != nil {
if p != clean {
err.Why = fmt.Sprintf("through a link, it is %s, and %s", p, err.Why)
err.Path = clean
}
return err
}
}
return nil
}
func refuseOne(path string, kind placing, module, owner string) *PlacementRefusedError {
for _, root := range protectedRoots {
if path == root || below(root, path) {
return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " +
"and owning or mounting it would be owning or mounting everything in it"}
}
}
trees := append([]string(nil), forbiddenBelow...)
if kind == placingMount {
// A mount is the machine's plumbing as often as a module's data — the clock, a kernel file, /dev/null
// (systemPath) — and what a setting can mount at all is a directory or an access, refused above it.
trees = append([]string(nil), forbiddenBelowMount...)
}
// The container runtimes' data, wherever the machine keeps it: every container's filesystem is there.
trees = append(trees, runtimeDataRoots()...)
for _, tree := range trees {
if atOrBelow(path, tree) {
return &PlacementRefusedError{Path: path, Why: "nothing is placed in or mounted from " + tree}
}
}
if module != engineModule {
for _, tree := range engineTrees {
if atOrBelow(path, tree) {
return &PlacementRefusedError{Path: path, Why: tree + " is the node-engine's own, placed in by " +
"its own module alone"}
}
}
}
for _, tree := range rootsOnly {
if !below(path, tree) {
continue
}
switch {
case kind == placingAccess:
return &PlacementRefusedError{Path: path, Why: "an access is the operator's data, and " + tree +
" is the machine's own"}
case kind == placingDirectory && !rootOwner(owner):
return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("a directory below %s is root's, and this "+
"one is declared %s's", tree, owner)}
}
}
ssh := false
for _, part := range strings.Split(path, "/") {
ssh = ssh || part == ".ssh"
}
if ssh && (kind == placingAccess || kind == placingMount) {
return &PlacementRefusedError{Path: path, Why: "it is an account's .ssh, which holds its keys and who may " +
"log in as it, and is never an access or a mount"}
}
if kind != placingDirectory {
return nil
}
homes := accountsOfHomes()
var deepest string
for home := range homes {
if below(path, home) && len(home) > len(deepest) {
deepest = home
}
}
if ssh && deepest == "" {
return &PlacementRefusedError{Path: path, Why: "a .ssh directory is placed only below its account's home, " +
"as that account's"}
}
if deepest != "" {
if a := homes[deepest]; !ownedByAccount(owner, a) {
if owner == "" {
owner = "root"
}
return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("it is below %s's home and declared %s's; "+
"below a home only that account's directories are placed", a.Name, owner)}
}
}
return nil
}
// moduleOfID is the module a resource id names, or "".
func moduleOfID(id string) string {
module, _ := moduleOf(id)
return module
}
// refusedPlaces judges every directory, access, file and archive of a declaration before anything is applied,
// and answers each refusal by path: what is refused is refused again as a container's mount source.
//
// Directories and accesses first, so a file or an archive below one of them is refused with it whatever order
// they are declared in (novox/hq issue 495): the engine refused Docker's data root as a directory and then wrote
// a file into it, because nothing judged the file's path.
func refusedPlaces(resources []declaration.Resource) map[string]error {
out := map[string]error{}
for _, r := range resources {
var err error
switch res := r.(type) {
case *declaration.Directory:
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
// link another account could make in its path refuses it, and with it what is below it, whatever
// order they are declared in. The apply walks again at the moment of use. A directory handed over
// keeps its own, stricter walk (issue 356), which refuses any link at all.
if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" &&
!HandedOver()[handOverKey(res.ID, res.Path)] {
if judged := judgeMeshDir(res.Path); judged != nil {
err = judged
var link *LinkInPathError
if errors.As(judged, &link) {
err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()}
}
}
}
case *declaration.Access:
err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "")
default:
continue
}
if err != nil {
out[filepath.Clean(r.Target())] = err
}
}
trees := make(map[string]error, len(out))
for path, err := range out {
trees[path] = err
}
for _, r := range resources {
var err error
switch res := r.(type) {
case *declaration.File:
if err = belowRefused(res.Path, trees); err == nil {
err = refusePath(res.Path, placingFile, moduleOfID(res.ID), "")
}
case *declaration.Archive:
// Only below a refused path: where an archive may unpack is otherwise the archive's own judgement
// (archive.go), and the engine's own module unpacks its versions into its own tree.
err = belowRefused(res.Path, trees)
default:
continue
}
if err != nil {
out[filepath.Clean(r.Target())] = err
}
}
return out
}
// belowRefused says why a path below a refused directory or access is refused with it; nil when it is below
// none. Judged as declared and with its links resolved, as refusePath judges.
func belowRefused(path string, refused map[string]error) error {
clean := filepath.Clean(path)
if !filepath.IsAbs(clean) {
return nil
}
for _, p := range []string{clean, resolved(clean)} {
// The deepest refused tree it is below, so the same declaration always gives the same reason.
var tree string
for t := range refused {
if below(p, t) && len(t) > len(tree) {
tree = t
}
}
if tree == "" {
continue
}
why := refused[tree]
reason := why.Error()
var refusal *PlacementRefusedError
if errors.As(why, &refusal) {
reason = refusal.Why
}
return &PlacementRefusedError{Path: clean, Why: "it is below " + tree + ", which is refused: " + reason}
}
return nil
}
// refuseMounts says why a container's mounts are not made; nil when they may be.
func refuseMounts(c *declaration.Container, refused map[string]error) error {
for _, v := range c.Volumes {
src := mountSource(v)
if !strings.HasPrefix(src, "/") {
continue // a named volume, which the runtime keeps in its own tree
}
src = filepath.Clean(src)
for path, why := range refused {
if atOrBelow(src, path) {
var refusal *PlacementRefusedError
if errors.As(why, &refusal) {
return &PlacementRefusedError{Path: src, Why: "it is mounted from " + path +
", which is refused: " + refusal.Why}
}
return why
}
}
if err := refusePath(src, placingMount, moduleOfID(c.ID), ""); err != nil {
return err
}
}
return nil
}
+196
View File
@@ -0,0 +1,196 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Where the node-engine places nothing and mounts nothing, whoever asks (novox/hq issue 339). A module's
// `places` setting moved a directory anywhere, with an owner it named, and the engine — root — made it so: a
// directory at /etc owned by a caller's account handed it /etc. Each test here is a declaration the controller
// might send; the engine refuses it whatever the controller decided.
func applyOnce(t *testing.T, resources string, run Runner, state store.State) (Report, store.State, error) {
t.Helper()
if run == nil {
run = noServices
}
return Apply(context.Background(), archHost(t), declare(t, resources), state, store.OriginDeclared, run, nil, nil)
}
func refusedFor339(t *testing.T, what string, err error) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "nothing was touched") {
t.Fatalf("%s was not refused as the machine's own: %v", what, err)
}
}
func TestNoDirectoryIsPlacedAtTheMachinesOwn(t *testing.T) {
for _, path := range []string{"/", "/etc", "/etc/", "/usr", "/var", "/var/lib", "/home", "/run", "/boot", "/root",
"/var/lib/mesh-host", "/var/lib/mesh-host/identity", "/proc/1/task", "/sys/kernel", "/dev/shm", "/root/.ssh",
"/srv/../etc"} {
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+path+`","mode":"0755"}`, nil, store.State{})
refusedFor339(t, path, err)
}
// Below /etc, /usr or /run, a directory is root's: the machine's configuration is read as root's word.
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"/etc/sudoers.d","owner":"1001:1001"}`,
nil, store.State{})
refusedFor339(t, "/etc/sudoers.d owned by another account", err)
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"/etc/systemd/system/getty@.service.d",`+
`"owner":"1001:1001"}`, nil, store.State{})
refusedFor339(t, "a drop-in directory owned by another account", err)
}
func TestNoAccessIsAtTheMachinesOwn(t *testing.T) {
for _, path := range []string{"/", "/etc", "/etc/ssl", "/usr", "/var/lib", "/home", "/root", "/proc",
"/var/lib/mesh-host", "/run/docker"} {
_, _, err := applyOnce(t, `{"id":"notes.media","type":"access","path":"`+path+`"}`, nil, store.State{})
refusedFor339(t, "an access at "+path, err)
}
}
// A container is never handed the machine's own as a mount, nor a directory or access the engine refused.
func TestNoContainerMountsTheMachinesOwn(t *testing.T) {
ran := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch {
case args[0] == "info":
return "27.0\n", nil
case args[0] == "container":
return "", os.ErrNotExist
case args[0] == "run":
ran = true
return "deadbeef\n", nil
}
return "", nil
}
for _, mount := range []string{"/:/host", "/etc:/host-etc", "/var/lib:/data", "/home:/homes:ro",
"/var/lib/mesh-host:/state", "/proc:/p", "/dev:/d", "/root/.ssh:/keys", "/boot/efi:/efi"} {
ran = false
_, _, err := Apply(context.Background(), archHost(t), parseTrusted(t, `{"declaration":1,"resources":[
{"id":"notes.server","type":"container","name":"notes","image":"`+pinned+`","volumes":["`+mount+`"]}
]}`), store.State{}, store.OriginCarried, run, nil, nil)
refusedFor339(t, "a container mounting "+mount, err)
if ran {
t.Fatalf("a container mounting %s was run", mount)
}
}
}
// The path is not followed: a link where a directory is declared, or a link on the way to it, is judged where
// it leads.
func TestALinkIsNotFollowedToTheMachinesOwn(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+link+`","mode":"0755"}`, nil, store.State{})
refusedFor339(t, "a directory that is a link", err)
if info, _ := os.Stat(target); info.Mode().Perm() != 0o700 {
t.Fatalf("the link's target was changed to %o", info.Mode().Perm())
}
// A link on the way: <dir>/via → /usr, so <dir>/via/share is /usr/share.
via := filepath.Join(dir, "via")
if err := os.Symlink("/usr", via); err != nil {
t.Fatal(err)
}
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+via+`/share"}`, nil, store.State{})
refusedFor339(t, "a directory reached through a link to /usr", err)
_, _, err = applyOnce(t, `{"id":"notes.media","type":"access","path":"`+via+`"}`, nil, store.State{})
refusedFor339(t, "an access reached through a link to /usr", err)
}
// A directory found here, that the mesh did not make, keeps its owner and mode; one the mesh made keeps the
// declared mode, as before.
func TestAFoundDirectoryIsUsedAsFound(t *testing.T) {
dir := t.TempDir()
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
report, state, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, store.State{})
if err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a directory the mesh did not make was chmodded to %o", info.Mode().Perm())
}
if len(report.Outcomes) != 1 || !strings.Contains(report.Outcomes[0].Detail, "used as found") {
t.Fatalf("the report does not say it was used as found: %+v", report.Outcomes)
}
if a, _ := state.Find("notes.data"); !a.AsFound {
t.Fatal("the record does not say the directory was found")
}
// Still found at the next apply, and never removed: undeclared, it is forgotten and left.
if _, state, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a found directory was chmodded at the next apply to %o", info.Mode().Perm())
}
if _, _, err = applyOnce(t, `{"id":"notes.other","type":"directory","path":"`+filepath.Join(dir, "other")+`"}`,
nil, state); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(found); err != nil {
t.Fatalf("a found directory was removed: %v", err)
}
// The mesh's own: made by it, its mode is maintained.
made := filepath.Join(dir, "made")
_, state, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+made+`","mode":"0755"}`, nil, store.State{})
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(made, 0o700); err != nil {
t.Fatal(err)
}
if _, state, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+made+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(made); info.Mode().Perm() != 0o755 {
t.Fatalf("the mesh's own directory was not given its mode back: %o", info.Mode().Perm())
}
// Moved — a placement changed — onto a directory that was already there: that one is found, not the mesh's.
if _, _, err = applyOnce(t, `{"id":"notes.made","type":"directory","path":"`+found+`","mode":"0755"}`,
nil, state); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o700 {
t.Fatalf("a placement moved onto a found directory chmodded it to %o", info.Mode().Perm())
}
}
// The module's own directories keep being placed: below /var/lib, below /etc as root's, a container's mount of
// the machine's docker socket.
func TestAModulesOwnPlacesAreStillPlaced(t *testing.T) {
for _, path := range []string{"/var/lib/notes/data", "/etc/systemd/logind.conf.d", "/var/run/fail2ban",
"/srv/notes", "/mnt/plex/config", "/storage/media", "/var/lib/mesh/registry"} {
if err := refusePath(path, placingDirectory, "notes", ""); err != nil {
t.Errorf("%s: %v", path, err)
}
}
for _, path := range []string{"/var/lib/minio-store", "/var/run/docker.sock", "/services/ollama", "/storage/media",
"/etc/localtime", "/proc/cpuinfo", "/dev/null", "/dev/dri"} {
if err := refusePath(path, placingMount, "notes", ""); err != nil {
t.Errorf("a mount of %s: %v", path, err)
}
}
if err := refusePath("/var/lib/notes/data", placingDirectory, "notes", "1001:2000"); err != nil {
t.Errorf("an owner below /var/lib: %v", err)
}
}
+68
View File
@@ -0,0 +1,68 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Below a person's or an agent's home, a directory is that account's (novox/hq issue 339): root's, or another
// account's, there is one the account does not control in a tree whose every parent it does — and a container
// mounting one the engine refused is refused with it.
func TestBelowAHomeOnlyThatAccountsDirectoryIsPlaced(t *testing.T) {
dir := t.TempDir()
home := filepath.Join(dir, "home", "alice")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
// A uid of its own, never the test's: the build seat runs tests as root, and root's home is no person's.
const alice = 4242
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, fmt.Appendf(nil, "root:x:0:0::/root:/bin/sh\nalice:x:%d:%d::%s:/bin/sh\n",
alice, alice, home), 0o644); err != nil {
t.Fatal(err)
}
before := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = before })
_, _, err := applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+home+`/.config/autostart"}`, nil, store.State{})
refusedFor339(t, "root's directory below alice's home", err)
_, _, err = applyOnce(t, `{"id":"notes.data","type":"directory","path":"`+home+`/.config/autostart",`+
`"owner":"`+strconv.Itoa(alice+1)+`"}`, nil, store.State{})
refusedFor339(t, "another account's directory below alice's home", err)
if _, err := os.Stat(filepath.Join(home, ".config")); err == nil {
t.Fatal("a refused directory's parent was made")
}
// Alice's own is placed (judged, not applied: a chown to her needs root).
if err := refusePath(home+"/.config/notes", placingDirectory, "notes", strconv.Itoa(alice)+":"+strconv.Itoa(alice)); err != nil {
t.Fatalf("alice's own directory: %v", err)
}
// A container mounting a refused directory is refused with it, and not run.
ran := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
return "", os.ErrNotExist
case "run":
ran = true
}
return "", nil
}
_, _, err = Apply(context.Background(), archHost(t), parseTrusted(t, `{"declaration":1,"resources":[
{"id":"notes.data","type":"directory","path":"`+home+`/.ssh"},
{"id":"notes.server","type":"container","name":"notes","image":"`+pinned+`","volumes":["`+home+`/.ssh:/keys"]}
]}`), store.State{}, store.OriginCarried, run, nil, nil)
refusedFor339(t, "a container mounting a refused directory", err)
if ran {
t.Fatal("a container mounting a refused directory was run")
}
}
+53 -1
View File
@@ -84,12 +84,38 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
var protecting, orphans []Step
made := meshMadeUnits(known)
heldUnits := unitsHeld(d.Resources)
heldFiles := filesHeld(d.Resources)
for _, orphan := range known.Orphans(declared, origin) {
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
Why: "recorded here and no longer declared"}
held := heldUnits[unitKey(orphan.Scope, orphan.User, orphan.Target)]
by, handed := heldFiles[fileKey(orphan)]
switch {
case handed:
// In ApplyKeeping's words (novox/hq ADR 0223).
step.Verb, step.Why = "forget", "no longer declared; "+by+" declares the file now, so it is handed to it, not removed"
case orphan.Type == string(declaration.TypeService) && held != "":
// In removeOrphan's words (novox/hq issue 190).
step.Verb, step.Why = "forget", "no longer declared; "+held+" still holds the unit, so it is left as it is"
case orphan.Stateless:
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
case orphan.Type == string(declaration.TypeArchive) && store.IsFormer(orphan.ID):
// In removeArchive's words (novox/hq issues 162, 194).
step.Verb, step.Why = "forget", "a former target; left in place, since the version before is what a rollback starts"
case orphan.Type == string(declaration.TypeArchive) && orphan.Unpacked == nil:
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it unpacked, so it is left in place"
case orphan.Type == string(declaration.TypeArchive):
step.Why = "no longer declared; the files it unpacked go, and the directories the host made for them once empty"
case orphan.Type == string(declaration.TypeFile) && orphan.Into == nil && orphan.Kept != "":
// In removeWhole's words (novox/hq ADR 0118): written over, so given back, not deleted.
step.Verb, step.Why = "restore", "no longer declared; the original the mesh wrote over goes back "+
"from "+orphan.Kept+", unless the file was changed since the mesh last wrote it"
case orphan.Type == string(declaration.TypeAccess) && orphan.User != "" && len(orphan.Groups) > 0:
// In takeBackGranted's words (novox/hq ADR 0325 §5: no account is in a group for data).
step.Verb, step.Why = "restore", "no longer declared; the path is left as it is, and "+orphan.User+
" leaves "+strings.Join(orphan.Groups, ", ")+", which the mesh put it in, unless another declared "+
"resource still asks for it"
case orphan.Type == string(declaration.TypeService):
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
// words. "restore" only where it may stop or disable something — the record cannot say
@@ -98,12 +124,26 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
// stopped whatever was found.
f := orphan.Found
switch {
case made[orphan.Target]:
case made[unitKey(orphan.Scope, orphan.User, orphan.Target)]:
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
"stopped and disabled at boot before that file goes"
case f == nil:
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it "+
"found, so it is left as it is"
case orphan.DeclaredUnit != nil && ((f.State == "running" && orphan.DeclaredUnit.State == "stopped") ||
(f.Boot == "enabled" && orphan.DeclaredUnit.Boot == "disabled")):
// startUnitBack's words (novox/hq ADR 0269): what the mesh's declaration stopped goes back.
var back []string
if f.State == "running" && orphan.DeclaredUnit.State == "stopped" {
back = append(back, "started")
}
if f.Boot == "enabled" && orphan.DeclaredUnit.Boot == "disabled" {
back = append(back, "enabled at boot")
}
// A preview: whether it is still as the mesh held it is read when the removal runs, and a
// masked unit or one changed since is left as it is.
step.Verb, step.Why = "restore", "no longer declared; restore if still as the mesh held it: "+
strings.Join(back, " and ")+" again, as the host found it, unless it is masked or was changed since"
case f.State == "stopped" || f.Boot == "disabled":
var back []string
if f.State == "stopped" {
@@ -235,6 +275,12 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta
step.Verb, step.Why = "create", "no record of it on this node"
return step
}
// A run-once process whose last run failed has its record cleared of what it was run as (novox/hq issue
// 467), and the apply runs it again.
if was.Wrote == "" && isStep(r) {
step.Verb, step.Why = "update", "its last run did not complete, so it runs again"
return step
}
if want := wouldWrite(r); want != "" && was.Wrote != "" && want != was.Wrote {
step.Verb, step.Why = "update", "the declaration changed since this host applied it"
return step
@@ -335,3 +381,9 @@ func wouldWrite(r declaration.Resource) string {
}
return digestOf(f.Content)
}
// isStep says whether a resource is a run-once process, whose record a failed run clears.
func isStep(r declaration.Resource) bool {
p, ok := r.(*declaration.Process)
return ok && p.RunOnce
}
+2
View File
@@ -103,6 +103,8 @@ type both struct {
}
func (b *both) run(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
switch name {
case "nft", "ufw", "iptables", "ip6tables", "firewall-cmd":
return b.u.run(ctx, name, args...)
+141 -22
View File
@@ -8,9 +8,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/witness"
)
// Running the mesh's own code, without the module choosing how.
@@ -37,11 +39,11 @@ var daemonRoot = "/var/lib/mesh/daemons"
var unitDir = "/etc/systemd/system"
func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
changed map[string]bool, previous store.Applied) (Outcome, error) {
changed map[string]bool, previous store.Applied, away *storeAway) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
body, err := fetch(ctx, r.Source)
body, err := fetchMinding(ctx, r.Source, away)
if err != nil {
return out, err
}
@@ -77,11 +79,26 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
// Everything about it is as declared. Still asked whether it is RUNNING, because a
// declaration that is satisfied by a record rather than by the machine is how a stopped
// service reports success.
if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil {
// **The record is carried forward, not re-derived.** An unchanged outcome is recorded
// like any other, so one that said nothing about what was written erased the digest; the
// next apply then found no record, re-created the daemon, and the one after that found a
// record again — the node's runtime restarted every other cycle (novox/hq 04-ISSUES/210).
out.wrote = want
// **A step that ran is done, and a schedule is its timer** (novox/hq issue 213). Neither's
// service is meant to be up between runs, so asking whether it is — and starting it when it
// was not — ran a completed step again on every apply, and a scheduled run off its cadence.
if r.RunOnce {
return out, nil
}
unit := r.Name + ".service"
if r.Schedule != "" {
unit = r.Name + ".timer"
}
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
_ = active
return out, nil
}
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
if _, err := run(ctx, "systemctl", "start", unit); err != nil {
return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err)
}
out.Action = "updated"
@@ -90,32 +107,77 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
return out, nil
}
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
// previous version would be loaded by a runtime that walks a directory.
if err := os.RemoveAll(at); err != nil {
return out, err
// **A core process keeps the build before it until the new one is proved** (novox/hq to-be 45
// §8): the engine's witness judges the new build and restores the kept one when it is not healthy
// in bound. Placing decides what happens to the directory first — the running build moved aside,
// a build on trial discarded, a build rolled back here refused, the running build kept when it is
// the one declared — and keeps that, so a restoration later knows which build is where.
by := witnessOf(r)
placing := witness.Placing{Unpack: true}
if by == witness.ByNone {
// Nothing judged. Whatever was kept for a process that was judged before goes.
if err := witness.Forget(daemonRoot, r.Name); err != nil {
return out, err
}
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
// previous version would be loaded by a runtime that walks a directory.
if err := os.RemoveAll(at); err != nil {
return out, err
}
} else {
placing, err = witness.Place(daemonRoot, r.Name, by, got, digestWritten(previous.Wrote), r.NotReversible, time.Now())
if err != nil {
return out, err
}
}
if err := os.MkdirAll(at, 0o755); err != nil {
return out, err
written := 0
if placing.Unpack {
if err := os.MkdirAll(at, 0o755); err != nil {
return out, err
}
if written, err = unpack(body, at); err != nil {
return out, err
}
if err := ownAll(at, r.User); err != nil {
return out, err
}
}
written, err := unpack(body, at)
if err != nil {
return out, err
}
if err := ownAll(at, r.User); err != nil {
return out, err
if placing.Commit != nil {
// Kept whatever the start below does: a build that would not start is still the build placed,
// and it is the witness's to judge.
defer func() {
if err := placing.Commit(); err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: placed %s, and what the witness keeps about it could not be saved: %v\n",
r.Name, err)
}
}()
}
// **A step is run to completion, not installed.** What follows it is gated on it finishing,
// so the machine is not asked to start something that needed a migration that did not happen.
// Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why
// the identity above includes the command.
//
// **Run as the unit a daemon would be, once** (novox/hq design 38 WP4c). Run directly, the
// step started in the host's own working directory, without its environment, its environment
// files or its user — `node bootstrap/index.js` resolved from wherever the host ran and was told
// none of the words it was declared with. A oneshot unit carries all four exactly as a daemon's
// does, and starting one waits for it to finish and fails when it fails.
if r.RunOnce {
if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil {
return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err)
unit := filepath.Join(unitDir, r.Name+".service")
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
return out, err
}
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
return out, err
}
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
out.refused = true // started and did not complete: run again until it does (novox/hq issue 467)
return out, fmt.Errorf("the %s step did not complete (journalctl -u %s.service says why): %w", r.Name, r.Name, err)
}
// Updated whenever it has a record, whose Wrote a failed run cleared (novox/hq issue 467).
out.Action = "created"
if previous.Wrote != "" {
if previous.Wrote != "" || previous.ID != "" {
out.Action = "updated"
}
out.Detail = fmt.Sprintf("%d file(s), step completed", written)
@@ -173,9 +235,15 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
out.Action = "created"
}
out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name)
if !placing.Unpack {
out.Detail = fmt.Sprintf("its build already in place, running as %s.service", r.Name)
}
if because != "" {
out.Detail += ", restarted because " + because + " changed"
}
if placing.Detail != "" {
out.Detail += "; " + placing.Detail
}
out.wrote = want
return out, nil
}
@@ -209,9 +277,9 @@ func unitFor(r *declaration.Process) string {
if r.User != "" {
fmt.Fprintf(&b, "User=%s\n", r.User)
}
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " "))
if r.Schedule != "" {
// Started by its timer and expected to finish. Restarting it would have it run
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
if r.Schedule != "" || r.RunOnce {
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
// continuously between fires, which is the opposite of a schedule.
b.WriteString("Type=oneshot\n")
b.WriteString("\n")
@@ -317,6 +385,14 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
}
found = true
// The timer first, so a scheduled run cannot start the service between the two.
//
// `--now` waits for the stop. The unit the host writes sets no stop timeout and no kill mode, so the
// service manager's defaults hold: SIGTERM, then after its default stop timeout (90 s unless the
// machine changed DefaultTimeoutStopSec) SIGKILL to the whole group. What a process does on SIGTERM —
// nfs-server removing its export file when its configuration is gone (novox/hq ADR 0263) — happens
// only if it finishes within that time; a killed process cleans nothing up, and an unassigned module
// never starts again to do it later. So a process that cleans up on stop does that first and keeps it
// short, rather than after work that can block.
if _, err := run(ctx, "systemctl", "disable", "--now", unit); err != nil {
return "", "", fmt.Errorf("stopping %s: %w", unit, err)
}
@@ -330,6 +406,13 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
}
}
bundle := filepath.Join(daemonRoot, name)
// And the build kept before it, with what the witness knew (novox/hq to-be 45 §8).
if _, err := os.Stat(witness.Dir(daemonRoot, name)); err == nil {
found = true
if err := witness.Forget(daemonRoot, name); err != nil {
return "", "", err
}
}
if _, err := os.Stat(bundle); err == nil {
found = true
if err := os.RemoveAll(bundle); err != nil {
@@ -341,3 +424,39 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
}
return "removed", "stopped; its unit and its bundle removed — the mesh's own code", nil
}
// runFrom is the command as the unit runs it. **A command written `./name` is that file in the
// process's own unpacked bundle** (novox/hq ADR 0193): a bundle compiled to a binary runs itself,
// and only the host knows where it unpacked it, while the service manager takes an absolute path or
// a name it finds on its own search path — never one relative to the working directory.
func runFrom(r *declaration.Process) []string {
run := append([]string(nil), r.Run...)
if len(run) > 0 && strings.HasPrefix(run[0], "./") {
run[0] = filepath.Join(daemonRoot, r.Name, strings.TrimPrefix(run[0], "./"))
}
return run
}
// witnessOf is how a process's new builds are judged: as it declares, or by its name's default — the
// mesh's two core processes (novox/hq to-be 45 §8). A step or a scheduled run is never judged.
func witnessOf(r *declaration.Process) string {
if r.RunOnce || r.Schedule != "" {
return witness.ByNone
}
if r.Witness != "" {
return r.Witness
}
return witness.Default(r.Name)
}
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
// wrote, "sha256:<hex> <unit>".
func digestWritten(wrote string) string {
if fields := strings.Fields(wrote); len(fields) > 0 && strings.HasPrefix(fields[0], "sha256:") {
return fields[0]
}
return ""
}
// DaemonRoot is where unpacked daemons live — and what a witness keeps beside them.
func DaemonRoot() string { return daemonRoot }
+82
View File
@@ -0,0 +1,82 @@
package apply
import (
"context"
"errors"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A run-once process is a step run where, how and as whom it was declared (novox/hq design 38
// WP4c): its bundle's directory, its environment and environment files, its user. Run directly,
// it started in the host's own directory with none of them.
func TestARunOnceProcessRunsAsItsOneshotUnit(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
me, err := user.Current()
if err != nil {
t.Fatal(err)
}
body, digest := anArchive(t, map[string]string{"bootstrap/index.js": "console.log(1)\n"})
var commands []string
fail := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
commands = append(commands, name+" "+strings.Join(args, " "))
if fail && name == "systemctl" && len(args) > 0 && args[0] == "start" {
return "", errors.New("exit status 1")
}
return "", nil
}
d := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"user":"`+me.Username+`",`+
`"env":{"MESH_ADMIN":"mesh-admin"},"env-file":["/var/lib/mesh/mosquitto/bootstrap.env"]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
unit, err := os.ReadFile(filepath.Join(units, "mosquitto-bootstrap.service"))
if err != nil {
t.Fatalf("no unit was written for the step: %v", err)
}
for _, want := range []string{
"WorkingDirectory=" + filepath.Join(bundles, "mosquitto-bootstrap"),
"EnvironmentFile=/var/lib/mesh/mosquitto/bootstrap.env",
`Environment="MESH_ADMIN=mesh-admin"`,
"User=" + me.Username,
"Type=oneshot",
"ExecStart=node bootstrap/index.js",
} {
if !strings.Contains(string(unit), want) {
t.Errorf("the step's unit lacks %q:\n%s", want, unit)
}
}
for _, never := range []string{"Restart=always", "[Install]", "Type=simple"} {
if strings.Contains(string(unit), never) {
t.Errorf("a step's unit says %q:\n%s", never, unit)
}
}
joined := strings.Join(commands, "; ")
if !strings.Contains(joined, "systemctl start mosquitto-bootstrap.service") {
t.Errorf("the step was not started as its unit: %s", joined)
}
if strings.Contains(joined, "node bootstrap/index.js") || strings.Contains(joined, "enable mosquitto-bootstrap") {
t.Errorf("the step was run directly or enabled: %s", joined)
}
// A step that fails fails the apply, and is not recorded as done.
fail = true
d2 := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"env":{"MESH_ADMIN":"changed"}}`)
if _, _, err := Apply(context.Background(), archHost(t), d2, store.State{}, store.OriginDeclared, run, nil, nil); err == nil {
t.Error("a step that failed did not fail the apply")
}
}
+72
View File
@@ -187,6 +187,7 @@ func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(t *testing.T) {
}
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
commands = append(commands, strings.Join(args, " "))
return "", nil
}
@@ -256,3 +257,74 @@ func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) {
}
}
}
// novox/hq 04-ISSUES/210: the node's runtime was re-created — and restarted — on every reconcile,
// because the host did not find what it wrote for a process the cycle before. Applying the same
// process declaration twice must do no work the second time.
func TestAProcessAppliedAgainIsUnchangedAndNotRestarted(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"})
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
commands = append(commands, name+" "+strings.Join(args, " "))
return "", nil
}
d := declare(t, `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","main.js"],"env":{"MESH_TOOL_MODULES":"a=/x/index.js"}}`)
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(first, "node-tools.runtime"); o.Action != "created" {
t.Fatalf("first apply: %+v, want created", o)
}
rec, ok := state.Find("node-tools.runtime")
if !ok || rec.Wrote == "" {
t.Fatalf("the host did not record what it wrote for the process: %+v", rec)
}
commands = nil
again, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(again, "node-tools.runtime"); o.Action != "unchanged" {
t.Errorf("second apply: %+v, want unchanged", o)
}
for _, c := range commands {
if strings.Contains(c, "restart") {
t.Errorf("the second apply restarted the process: %v", commands)
}
}
// And the record survives an unchanged apply: the third cycle is unchanged too. This is the
// cycle the live mesh showed — created, unchanged, created — before the record was carried.
if rec, _ := state.Find("node-tools.runtime"); rec.Wrote == "" {
t.Fatalf("an unchanged apply dropped the digest from the record: %+v", rec)
}
commands = nil
third, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(third, "node-tools.runtime"); o.Action != "unchanged" {
t.Errorf("third apply: %+v, want unchanged", o)
}
}
// novox/hq ADR 0193: a bundle compiled to a binary runs itself — `./name` is that file in the
// process's own unpacked bundle, made absolute because the service manager takes nothing relative.
func TestAProcessRunsItsOwnBundlesBinary(t *testing.T) {
unit := unitFor(&declaration.Process{Name: "node-tools", Run: []string{"./node-tools", "serve"}})
if !strings.Contains(unit, "ExecStart="+filepath.Join(daemonRoot, "node-tools", "node-tools")+" serve\n") {
t.Errorf("the binary is not run from its bundle:\n%s", unit)
}
other := unitFor(&declaration.Process{Name: "x", Run: []string{"node", "src/main.js"}})
if !strings.Contains(other, "ExecStart=node src/main.js\n") {
t.Errorf("a command found on the path was changed:\n%s", other)
}
}

Some files were not shown because too many files have changed in this diff Show More