Compare commits

...
Author SHA1 Message Date
jochen 2fde82993d docker: read the resolver file a container really uses (hq issue 348)
On 2026-10-09 the control node's route-proxy failed every lookup against 10.10.0.1 alone for twenty
minutes while the machine's file listed both mesh resolvers; its copy lives under the runtime's
root-only directory and no tool read it. docker_resolv_conf reads /etc/resolv.conf as the container
sees it through the runtime's archive (docker cp, no exec), parses it, and says whether it lists the
same resolvers as the machine.
2026-10-09 13:33:23 +02:00
jochen c0fa282548 netcheck: ask one named resolver, and list what listens here (hq issue 348)
During the 2026-10-09 outage the control node's resolver stayed active while it no longer listened on
its private address, and nothing on the mesh could ask that address a question or show which sockets
were bound. netcheck_dns takes server (and tcp) to ask one resolver alone and say its response code;
netcheck_listening reads the kernel's socket tables, as ss -lunt does, with the owning program where
the tool's account may see it and a sentence where it may not.
2026-10-09 13:27:39 +02:00
jochen d79a4eeab9 docker: say where the runtime's event history begins, so a short history never reads as a quiet one (hq issue 348)
docker_events asked for three hours across the 2026-10-09 resolver outage on the control node answered
zero events: the runtime keeps only its last ~256 events in memory, and health-check execs filled them
with the last ten minutes. The answer now carries held, oldest_held and complete, and a history line
when the window reaches further back than what the runtime still holds. The type filter moves into the
tool, since the history's start is only known from all of it.
2026-10-09 13:24:58 +02:00
mesh-admin e9f5727cad Merge pull request 'xorg: list the server's windows, and cut a window's screenshot from the screen (hq issue 345)' (#154) from fix/xorg-windows-and-window-screenshot into main 2026-10-09 10:51:01 +00:00
mesh-admin 1833045c33 Merge pull request 'picom: the backend and damage tracking are each machine's settings (hq issue 345)' (#157) from feat/picom-backend-and-damage-settings into main 2026-10-09 10:50:58 +00:00
mesh-admin 65308a7bce Merge pull request 'betterbird: a module for the mail and calendar client (hq issue 345)' (#155) from feat/betterbird-module into main 2026-10-09 10:50:54 +00:00
mesh-admin a783ac9478 Merge pull request 'xdg: keep the folders file trusted, so only the terminal sets the folders (hq issue 346)' (#158) from fix/xdg-user-dirs-trusted-again into main 2026-10-09 10:50:51 +00:00
jschoubben a588eb5983 xdg: keep the folders file trusted, so only the terminal sets the folders (hq issue 346)
mesh/merge-gate pass: builds xdg → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
user-dirs.dirs is shell assignments meant to be sourced by a shell, and a setting is pasted into it as
it is, so a folder of $HOME/x"; <command>; X=" set through the verb would add a command of the
caller's. With the file trusted again, the three folders are set at the controller's terminal alone
until settings are typed and quoted at render.
2026-10-09 12:40:04 +02:00
jschoubben 2d2c694306 betterbird: know a composer by its instance alone, and bound a restart's worst case (review of #155)
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A composer window whose class is not Betterbird's own was not seen, so a restart could lose its message.
Each step of a restart now has a bound short enough that all of them together, taken in full, stay
under the runtime's 30 s: 3 s for the window tree, 8 s to quit, 3 and 5 s to start, 4 s to see it.
2026-10-09 12:34:06 +02:00
jschoubben ca1bd18de6 picom: keep the configuration trusted, so only the terminal sets the backend (review of #157)
mesh/merge-gate pass: builds picom → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
A setting is pasted into picom.conf as it is, so a backend of xrender"; write-pid-path = "… adds an
option of the caller's: picom would then overwrite any file the operator can write. With the file
trusted, both settings are set at the controller's terminal alone until settings are typed and quoted
(hq issue 346).
2026-10-09 12:33:41 +02:00
jschoubben 73f2962bab picom: the backend and damage tracking are each machine's settings (hq issue 345)
mesh/merge-gate pass: builds picom → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A window mapped but left unpainted, or stale after a workspace switch, is a known failure of picom's glx
backend with damage tracking on some drivers, and the first suspect for the empty calendar reminder on
the workstation. Research 037 already found the backend to depend on the machine's driver. Both become
preferences whose defaults are today's (glx, use-damage true), so nothing changes until a machine is
given xrender or use-damage false to test it.
2026-10-09 12:25:08 +02:00
jschoubben 4fe8958dda xorg: refuse a window that is not on the screen, and keep an inline screenshot within the bus (review of #154)
mesh/merge-gate pass: builds xorg → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A window that is unmapped or on a hidden workspace was cut from the screen anyway, which shows what is
there instead of it. An inline image of up to 4 MiB could not cross the bus, whose largest message is
1 MiB; it is now held to 700 KiB and halved in size until it fits, saying so.
2026-10-09 12:23:51 +02:00
jschoubben 3629089ced betterbird: never lose an unsent message, never answer an address, and keep the test apart (review of #155)
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A restart ended Betterbird with whatever a composer held; now it refuses while a Msgcompose window is
open unless discard_unsent says the operator agreed, and answers within the runtime's 30 s. A
preference's value could carry an address, a user%40domain@host URI, a host or the home; those are
masked, recent-folder, account and cloud-file preferences and every add-on preference but the theme's
id are withheld, and a request for everything is refused. A log kept the token after Bearer or Basic.
The reminder test's Betterbird counted as the operator's and left its profile behind when it ended on
its own; it is now left out everywhere and its unit removes the profile when it stops.
2026-10-09 12:22:47 +02:00
jschoubben 52a4827983 xorg: list the server's windows, and cut a window's screenshot from the screen (hq issue 345)
mesh/merge-gate pass: builds xorg → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A reminder window that showed as transparent and empty could not be looked at: the window manager's
list leaves dialogs out and says nothing a compositor goes by, and xwd -id refused a window of a
32-bit visual with BadColor (X_QueryColors). xorg_windows answers every window with its class, and
on request its depth, type, state, role and own opacity; a window's screenshot is now cut from the
whole screen as the compositor shows it, and inline answers the image for a session that cannot
read the machine's files. Declares xorg-xwininfo, which both now use.
2026-10-09 12:07:29 +02:00
jschoubben a7953b969d betterbird: a module for the mail and calendar client, so its windows and preferences can be seen and declared (hq issue 345)
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A calendar reminder showed as a transparent, empty window on shanks and no tool could say which
window it was or which preferences Betterbird ran with. The module owns one file, the user.js its
setting prefs fills, which its tools place whole into the profile Betterbird uses and never over an
operator's own; it reads named preferences without credentials or accounts, restarts Betterbird under
the user manager so its output reaches the journal, checks what it promises, and shows a reminder on
demand from a throw-away profile so a fix can be proved without the operator's calendar. No package:
betterbird-bin is the AUR's.
2026-10-09 12:07:29 +02:00
mesh-admin 7339ce8556 Merge pull request 'claude-code: refuse registering what a session runs (hq issue 340)' (#149) from fix/340-what-a-session-runs-is-set-at-the-terminal into main 2026-10-09 08:00:24 +00:00
mesh-admin 73eef94101 Merge pull request 'mesh-delivery: end a merge that moves nothing or that a later merge delivered, and lift a group hold its check answered (hq issues 319, 316)' (#150) from fix/319-316-covered-and-lifted into main 2026-10-09 07:30:15 +00:00
mesh-admin 895e4a65b8 Merge pull request 'The catalogue's check type-checks and tests every touched TypeScript module' (#151) from check/typescript-modules-tested into main 2026-10-09 07:29:35 +00:00
jochen 7e510998d9 Merge branch 'check/typescript-modules-tested' into fix/319-316-covered-and-lifted
mesh/merge-gate pass: builds mesh-delivery → novox; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-09 03:29:00 +02:00
jochen 0d5227ee77 Test a TypeScript module whose tests alone changed
mesh/merge-gate pass: the change touches no module of the mesh's graph
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
A change to a module's tests moves no build (hq ADR 0267), so the planner's modules leave it out; the
TypeScript part also reads the modules the changed files are in.
2026-10-09 03:28:58 +02:00
jochen 0bd50fce0a The forge module's tool list test names the statuses and contains tools
mesh/merge-gate pass: builds mesh-delivery → novox; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
It had not been run before a merge: its expected list lacked gitea_commit_statuses since that tool came,
and now gitea_contains. The catalogue's TypeScript check, merged in from #151, runs it.
2026-10-09 03:23:19 +02:00
jochen 61ec4162d0 Merge branch 'check/typescript-modules-tested' into fix/319-316-covered-and-lifted 2026-10-09 03:23:05 +02:00
jochen f72903d05d A TypeScript module with tests and no sources of its own is tested, not skipped
mesh/merge-gate pass: the change touches no module of the mesh's graph
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 superseded: a newer head of the same pull request
powerlevel10k carries only data and a test that reads it; the part skipped it silently.
2026-10-09 03:23:01 +02:00
jochen f072ca7297 Type-check and test every touched TypeScript module before it merges
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
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…
The catalogue's check ran only Go. A TypeScript module's change, the forge's among them, ran nothing of
its own before it merged, and a type error would first have shown in its walk. A second part, declared
with mesh-check-also and run in the TypeScript toolchain, type-checks each touched TypeScript module with
the compiler and flags a build uses, against the SDK and Node types the toolchain holds, and runs its
tests as its own npm test does. A module that needs a package the toolchain lacks, or has no tests, is
said by name, never passed. The media catalogue's twin is hq issue 311.
2026-10-09 03:22:24 +02:00
mesh-admin 63de373b9d Merge pull request 'zfs: the safety review's fixes, and the module owns the pool's scrub schedule' (#147) from fix/zfs-safety-review into main 2026-10-09 01:02:48 +00:00
jochen 6327f297ea Cover a held merge only when the forge says a later delivered merge contains it (review of #150)
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; 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
Merge order on a protected trunk was the whole proof; the review asked for ancestry. The gitea module
gains gitea_contains, a read-only compare of two commits (total_commits 0 is contained), and mesh-delivery
asks it once per pair, off the lock like its plan ask, keeping the answer on the delivery. A forge that
cannot say leaves the merge held, not covered. Merge order stays the cheap first filter.

A later delivery now carries a module only when its own check passed (so a merge held without a passing
check is closed only by one that passed), its walk is of its own merge commit on the same trunk, and its
steps say the module reached every machine running it, not one. The words say what is known: a later
merge that contains it was delivered.
2026-10-09 02:57:18 +02:00
mesh-admin 6aabe8ee1f Merge pull request 'Issue 339: dunst's and xdg's files say nothing trusts their settings ("trusted": false)' (#148) from fix/339-files-nothing-trusts into main 2026-10-09 00:32:51 +00:00
jochen 2d1d4bf988 End a merge that moves nothing or that a later merge delivered, and lift a group hold its check answered (hq issues 319, 316)
mesh/merge-gate pass: builds mesh-delivery → novox; 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 held delivery asked a person a question with no right answer: four hq merges that build nothing,
merges adding a module the controller sends nowhere, controller merges whose change a later walk of
the same trunk already delivered, and a group member held because its composed check had not yet
answered. Only stop could end them, which marks a delivered change as an error on its pull request.

- A merge whose plan moves no module (nothing, or only a new module sent nowhere) is delivered at once.
- A new final state, covered: every module a merge moves was sent by a finished walk of a later merge
  into the same trunk, which contains it. Shown as success on the pull request; a group counts it as
  delivered.
- A group member waits published while its composed check is asked and unanswered, and a member held
  for it is lifted back to published once it passes for the heads that merged.

Read again each tick, so the deliveries already held end by themselves after the rollout.
2026-10-09 02:24:26 +02:00
jochen a9acd3dd28 Keep the scheduled run's served slot in a folder of root's alone, written never through a link
mesh/merge-gate pass: builds zfs, sent nowhere; 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
Third review of #147. The run writes as root into /var/lib/zfs-scrub (0700, root's), apart from the
operator account's error counts in /var/lib/zfs-tools, and refuses a folder that is a link, open to
others or someone else's, and a file that is a link: a new file opened O_NOFOLLOW|O_EXCL, renamed over.
2026-10-09 01:55:54 +02:00
jochen bdd2d65404 Say that nothing trusts dunst's and xdg's settings, so a verb may still change them
mesh/merge-gate pass: builds dunst, xdg → g14, 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
With mesh-controller #170 a file that asks for a setting counts as trusted
unless it says otherwise, and its settings are set at the controller's
terminal alone (hq issue 339). These two are a person's own notifier look and
folder names in their home, which nothing else trusts.
2026-10-09 01:42:23 +02:00
jochen bd0182785c Run the scrub schedule as a scheduled process, never pause a scrub, and keep the pool's name the only setting in a unit
mesh/merge-gate pass: builds zfs, sent nowhere; 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 second review of #147 found three blockers before the home server.

1. ${setting:} was not filled in a unit's name, so the distribution timers would have reached the
   machine as "zfs-scrub-weekly@${setting:scrub-pool}.timer". mesh-controller #171 fills it and refuses
   any value that is not a unit-name part. The module's own schedule needs no setting in a name now: the
   node-engine's timer for the scheduled process zfs-mesh-scrub fires `zfs-tools scrub-run` every quarter
   of an hour, and the run reads the pool, cadence, day and time from the configuration file.
2. The run does what upstream's zfs-scrub@.service does: if a scrub is running it waits for it with
   zpool wait -t scrub, otherwise it starts one and waits. It never pauses or stops a scrub, and the unit
   has no ExecStop. A resilver defers the scrub. The served slot is kept in /var/lib/zfs-tools.
3. Unassigning left the pool with no schedule. mesh-host #66 (hq ADR 0269, proposed) gives the
   distribution timer back running and enabled. The README says not to unassign before it is merged.

Also: the run comes before the distribution timers in the declaration; the day and time are checked
against fixed sets and reach no unit file (an invalid value runs nothing); a due scrub not started an
hour after its time is a warning; the error counts outlive a restart in a small state file.
2026-10-09 01:40:02 +02:00
jochen e69506a26f Make the zfs module safe to put on the home server, and give it the pool's scrub schedule
mesh/merge-gate pass: builds zfs, sent nowhere; 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 safety review of the first version found it safe for the pool's data and asked for these fixes before
it goes on the home server:

- zfs_snapshot: no recursive. Every answer gives the dataset's free space and the pool's capacity, and
  says that the snapshot holds every block deleted or overwritten until it is destroyed by hand.
- A command that runs out of time is ended with its whole process group. An act that timed out is
  answered from the list, or as "outcome unknown", never as "NOT DONE".
- The watch runs as the operator account.
- The event follower reads ZFS's queue from its start, with a cursor per boot, so the pool's import and
  any downtime reach the journal once. It writes "dropped N events", kills zpool before waiting for it,
  and exits non-zero after five failed follows in a row.
- Past error counts are a warning, and counts that rise are unhealthy.
- The guard test catches .Run(, exec.Command(, exec.CommandContext( and process starts.
- The fixtures and the parser follow OpenZFS 2.4.4's own printers: human scan sizes under -p, no iostat
  section lines, error scrubs, per-vdev rebuild resilvers, paused scrubs.

The operator also asked the module to own the scrub. A timer and a service of the module's own run
zpool scrub -w -- <pool> from the scrub-pool, scrub-cadence, scrub-day and scrub-time settings. In the
same declaration, the distribution's weekly and monthly timers for that pool are held stopped and
disabled, so exactly one schedule scrubs it. zfs_scrub shows the schedule and the next run, with any
resilver's progress. zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop act with why. The health check
warns of a late or failed scrub and of a running resilver, and is unhealthy for a resilver that finished
with errors.
2026-10-09 01:17:34 +02:00
88 changed files with 7993 additions and 501 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in
# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media
# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it
# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in
# its walk.
#
# For every touched module that has a package.json, in a copy of it outside the clone:
#
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
# registry. A module that depends on a package the toolchain does not hold is said NOT CHECKED, by name;
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
# compiles with — the same check the build makes, before the merge rather than in the walk;
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
# the compiled module, or a source that imports its sibling by its .js name, finds it.
#
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it.
#
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
set -eu
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
exit 1
fi
# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in —
# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run.
touched=$({
printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d'
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed=""
for m in $touched; do
d="modules/$m"
[ -f "$d/package.json" ] || continue
copy="$work/$m"
mkdir -p "$copy"
(cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -)
# What it depends on, against what the toolchain holds.
missing=$(node -e '
const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]);
const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n;
const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) });
const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) &&
require("fs").existsSync(process.argv[2] + "/" + n));
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
if [ -n "$missing" ]; then
echo "NOT CHECKED: $d depends on $missing, which the toolchain does not hold and the check cannot fetch"
continue
fi
mkdir -p "$copy/node_modules"
for p in "$deps"/*; do
case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac
cp -r "$p" "$copy/node_modules/"
done
sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort)
if [ -n "$sources" ]; then
echo "type-checking $d (TypeScript)"
# shellcheck disable=SC2086 # the module's sources, none with a space in its name
if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \
--types node $sources); then
failed="$failed $m"
continue
fi
else
echo "$d has no TypeScript sources of its own to type-check: its tests read its files"
fi
if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then
echo "NOT TESTED: $d is TypeScript and has no tests of its own"
continue
fi
if [ -n "$sources" ]; then
# shellcheck disable=SC2086
(cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \
--rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \
--target ES2022 --types node --rootDir . --outDir . $sources)
fi
echo "testing $d (TypeScript)"
if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \
--no-warnings=ExperimentalWarning test/*.test.ts); then
failed="$failed $m"
fi
done
if [ -n "$failed" ]; then
echo "TypeScript modules that failed:$failed"
exit 1
fi
+5 -3
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# mesh-check-toolchain: go
# mesh-check-also: typescript merge-check-typescript.sh
#
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
@@ -13,8 +14,9 @@
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
# 2. the Go tests of every module the change touches that has them, under the race detector when the
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
# written in TypeScript, is said as not tested, never passed silently;
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here is said as not
# tested, never passed silently. A module written in TypeScript is type-checked and tested by
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
@@ -73,7 +75,7 @@ fi
for m in $touched; do
[ -d "modules/$m" ] || continue
if [ ! -f "modules/$m/go.mod" ]; then
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
continue
fi
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
+122
View File
@@ -0,0 +1,122 @@
# betterbird
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq
issue 345.
## Owns
| what | where |
|---|---|
| the module's directory | `~/.config/betterbird/` |
| the module's `user.js`, filled from the setting `prefs` | `~/.config/betterbird/user.js` |
Nothing else. It holds no seat and adds no start.
- **No package.** g14 and shanks both run `betterbird-bin` 153.1.0esr-1, installed explicitly from the
AUR: `pacman` counts it as foreign. The node-engine installs packages from the official
repositories only, so the module does not declare it, and **installing and upgrading Betterbird stay
the operator's** (an AUR helper). `betterbird_status` says the version and that it is from the AUR;
`betterbird_check` says when it is missing.
- **No start.** Betterbird is started by the operator from the launcher (on the workstation measured,
it runs in the login session's scope, started by hand, with no autostart entry and no line in i3's
configuration). `betterbird_restart` starts it as the transient user unit `mesh-betterbird`, so its
output then goes to the journal.
- **The profile is Betterbird's** (ADR 0182): `~/.thunderbird/` holds the mail, the address books,
the calendars' caches, the logins and the keys. The module declares none of it.
## The user.js: how a preference is declared
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the
one it saved in `prefs.js`. The profile's directory has a random name per machine
(`<random>.default-default`), which a manifest cannot name. So:
1. the node-engine writes `~/.config/betterbird/user.js` from the module's setting `prefs`, one line of
`user_pref("name", value);` statements (default: a comment, no preference);
2. the module's tools copy that file whole into the profile: `betterbird_user_js` with `apply`, or
`betterbird_restart`, which places it before it starts Betterbird again;
3. Betterbird reads it at that start.
**The profile is found** as the one the running Betterbird holds open; with none running, as the
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
On the workstation measured these differ: `Profile1` is marked `Default=1`, and the installation's
default, the one in use, is the other.
**A user.js the module did not write is never overwritten.** The module's begins with the line
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with
what to do. The tools refuse a declared file that holds anything but comments and `user_pref`
statements, or that sets a preference whose name can hold a credential.
The file is **trusted** (it carries no `"trusted": false`): a preference can weaken how the mail client
checks a server, so the setting is changed only at the controller's terminal, never through a verb
(novox/hq issue 339).
## Tools
They are served by the node's runtime as the operator account (ADR 0175). **No answer carries mail, a
calendar entry, an account, an address or a credential.**
| tool | does |
|---|---|
| `betterbird_status` (r) | whether it runs (pid, since, scope, children), the package and version and that it is from the AUR, where its output goes, the profile and how it was found, the user.js (declared, placed, in effect), the add-ons the operator installed (ids), and the preferences that decide how its windows and notifications look |
| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked |
| `betterbird_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it |
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where |
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it |
| `betterbird_check` (r) | installed; one running; profile found; the user.js on the machine, valid, placed, in effect; its output read |
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
### A reminder on demand
`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile
under `/run/user/<uid>/mesh-betterbird-reminder-test/`, as the user unit
`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's
user.js and one calendar: a local `.ics` file with one event, "Mesh reminder test", whose reminder is
due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. Then:
- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`,
its depth (32: a visual with alpha, which the compositor blends), its map state;
- `xorg_screenshot` with that window shows it as the screen shows it.
The unit removes its profile when it ends, however it ends (`ExecStopPost`): at its 10 minutes, on
`stop`, or when the test window is closed. The test Betterbird is never counted as the operator's: its
command line names its own profile, and `status`, `check`, `restart` and the user.js leave it out.
The running Betterbird, its profile and its calendars are not touched: `--no-remote` keeps the two
apart. **`profiles.ini` and `installs.ini` are not rewritten either**, measured on the workstation
(2026-10-09): their content and modification time (June 2023) were the same before a `--profile` start,
while it ran, and after it ended.
## The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx)
| | |
|---|---|
| X class / instance | `eu.betterbird.Betterbird` / `Calendar` |
| role, type | `AlarmWindow`, `_NET_WM_WINDOW_TYPE_DIALOG` (i3 floats it) |
| depth | 32 (ARGB visual), like every Betterbird window there |
| size | 600×300, kept in the profile's `xulstore.json` |
A window of depth 32 that its program has not drawn is transparent under a compositor, not black:
that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's
question.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| files | `~/.config/betterbird/` and its `user.js` (no preference) | the same |
| the profile | untouched until `betterbird_user_js` with `apply` or `betterbird_restart` | the same; the profile has no user.js today, so `apply` may place one |
| Betterbird | keeps running as started | keeps running as started (since 2026-10-04, its output on the session's pipe nobody reads: `betterbird_check` names it until `betterbird_restart`) |
## Relies on
- A display server on the same machine (`x11-display`, ADR 0208 §3).
- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module
carries no window code of its own.
- `systemd-run` and `journalctl` of the account's user manager.
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
@@ -0,0 +1,790 @@
package main
// Betterbird as the tools see it. Betterbird is the mail client's one process tree: the main process
// (comm betterbird-bin, no -contentproc) owns every window — the mail window, the calendar's reminder
// dialog, the composer — and its children (-contentproc) render add-ons and media. The tools read only:
// - the account's processes, and of the main process where its output goes (fd 1 and 2) and which
// profile it holds open;
// - profiles.ini, to find the profile, and the profile's prefs.js and user.js, of which only the
// preferences asked for are answered and never one that can hold a credential or names an account
// (passwordSecret, accountPrefix);
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
// the module's setting `prefs`.
//
// Never read: the mail store, the address books, logins.json, key4.db, cert9.db, cookies, the
// calendar's caches. Nothing of the operator's mail, calendar or accounts is answered.
//
// The one file a tool writes in the profile is user.js, and only the module's own: the declared file,
// copied whole (betterbird_user_js with apply, betterbird_restart). A user.js that is not the module's
// is never overwritten.
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
mainComm = "betterbird-bin"
startBin = "/usr/bin/betterbird"
packageFor = "betterbird-bin"
appIni = "/opt/betterbird/application.ini"
restartAs = "mesh-betterbird"
testUnit = "mesh-betterbird-reminder-test"
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
profilesDir = ".thunderbird"
// declared is the module's user.js, written by the node-engine from the setting prefs.
declared = ".config/betterbird/user.js"
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
mark = "// Betterbird's user.js (module betterbird"
)
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
var readLink = os.Readlink
// mainProcs are Betterbird's main processes: comm betterbird-bin without -contentproc.
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(mainComm) {
// The reminder test's Betterbird is never the operator's: its command line names its own profile.
if !strings.Contains(p.Command, "-contentproc") && !strings.Contains(p.Command, testUnit) {
out = append(out, p)
}
}
return out
}
// Stream is where a process's standard output or error goes.
type Stream struct {
FD int `json:"fd"`
Goes string `json:"goes"`
// Dead is a pipe no process of the account reads: a write there fails.
Dead bool `json:"dead,omitempty"`
}
// streams says where fd 1 and 2 of a process go.
func (m *Machine) streams(pid int) []Stream {
var out []Stream
for _, fd := range []int{1, 2} {
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
s := Stream{FD: fd}
switch {
case err != nil:
s.Goes = "unreadable"
case target == "/dev/null":
s.Goes = "discarded (/dev/null)"
case strings.HasPrefix(target, "socket:"):
s.Goes = "a socket (the journal, when started by the module)"
case strings.HasPrefix(target, "pipe:"):
if r := m.pipeReaders(target, pid); len(r) > 0 {
s.Goes = "a pipe read by " + strings.Join(r, ", ")
} else {
s.Goes, s.Dead = "a pipe nobody reads", true
}
default:
s.Goes = m.tilde(target)
}
out = append(out, s)
}
return out
}
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
func (m *Machine) pipeReaders(pipe string, except int) []string {
entries, _ := os.ReadDir(m.path("/proc"))
seen := map[string]bool{}
var out []string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == except {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
for _, fd := range fds {
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
continue
}
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
}
sort.Strings(out)
return out
}
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
func readOnly(fdinfo string) bool {
for _, l := range strings.Split(fdinfo, "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
n, err := strconv.ParseInt(f[1], 8, 64)
return err == nil && n&3 == 0
}
}
return false
}
// iniSections reads an INI file into its sections, in order.
func iniSections(raw []byte) (names []string, sections map[string]map[string]string) {
sections = map[string]map[string]string{}
cur := ""
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#"):
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
cur = l[1 : len(l)-1]
if _, ok := sections[cur]; !ok {
names = append(names, cur)
sections[cur] = map[string]string{}
}
default:
if i := strings.IndexByte(l, '='); i > 0 && cur != "" {
sections[cur][strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return names, sections
}
// Profile is the profile the tools act on, and how it was found.
type Profile struct {
Dir string `json:"dir"`
FoundBy string `json:"found_by"`
path string
}
// profile finds the profile: the one the running Betterbird holds open, else the installation's
// default in profiles.ini ([Install…] Default), else the profile marked Default=1.
func (m *Machine) profile() (Profile, error) {
base := m.home(profilesDir)
if ps := m.mainProcs(); len(ps) > 0 {
counts := map[string]int{}
fds, _ := os.ReadDir(m.path(fmt.Sprintf("/proc/%d/fd", ps[0].PID)))
for _, fd := range fds {
t, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%s", ps[0].PID, fd.Name())))
if err != nil {
continue
}
rel, err := filepath.Rel(filepath.Join(m.Home, profilesDir), t)
if err != nil || strings.HasPrefix(rel, "..") || !strings.Contains(rel, "/") {
continue
}
counts[strings.SplitN(rel, "/", 2)[0]]++
}
best, n := "", 0
for d, c := range counts {
if c > n || (c == n && d < best) {
best, n = d, c
}
}
if best != "" {
return Profile{Dir: m.tilde(filepath.Join(m.Home, profilesDir, best)), path: filepath.Join(base, best),
FoundBy: fmt.Sprintf("held open by the running Betterbird (pid %d, %d files)", ps[0].PID, n)}, nil
}
}
raw, err := readBounded(filepath.Join(base, "profiles.ini"))
if err != nil {
return Profile{}, fmt.Errorf("no profile: Betterbird is not running and ~/%s/profiles.ini is unreadable (%v)", profilesDir, err)
}
names, sections := iniSections(raw)
pick := func(rel string, by string) (Profile, error) {
dir := filepath.Join(base, filepath.FromSlash(rel))
if filepath.IsAbs(rel) {
dir = m.path(rel)
}
if !strings.HasPrefix(filepath.Clean(dir), filepath.Clean(base)+string(filepath.Separator)) {
return Profile{}, fmt.Errorf("profiles.ini names a profile outside ~/%s: %s", profilesDir, rel)
}
return Profile{Dir: m.tilde(strings.TrimPrefix(dir, m.Root)), path: dir, FoundBy: by}, nil
}
var installs []string
for _, n := range names {
if strings.HasPrefix(n, "Install") && sections[n]["Default"] != "" {
installs = append(installs, n)
}
}
if len(installs) == 1 {
return pick(sections[installs[0]]["Default"], "profiles.ini: the installation's default ["+installs[0]+"]")
}
for _, n := range names {
if strings.HasPrefix(n, "Profile") && sections[n]["Default"] == "1" && sections[n]["Path"] != "" {
return pick(sections[n]["Path"], "profiles.ini: the profile marked Default=1 ["+n+"]")
}
}
return Profile{}, fmt.Errorf("profiles.ini names %d installation defaults and no profile marked default: start Betterbird once, then ask again", len(installs))
}
// prefLine is one user_pref(…) or pref(…) line of prefs.js or user.js.
var prefLine = regexp.MustCompile(`^\s*(?:user_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;\s*$`)
// readPrefs reads the preferences of a prefs.js or user.js: name to its value, as written.
func readPrefs(raw []byte) map[string]string {
out := map[string]string{}
s := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
if mm := prefLine.FindStringSubmatch(s.Text()); mm != nil {
out[mm[1]] = mm[2]
}
}
return out
}
// passwordSecret and accountPrefix say which preferences are never answered with their value: one
// whose name can hold a credential, and one that names an account, a server or a person.
var (
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
// hold anything, and a theme's id is what a window's look depends on.
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
)
// withheld says why a preference's value is never answered, or "" when it may be.
func withheld(name string) string {
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
return "an add-on's own preference: only the theme's id (extensions.activeThemeID) is answered"
}
if passwordSecret.MatchString(name) {
return "its name says it can hold a credential"
}
for _, p := range accountPrefix {
if strings.HasPrefix(name, p) {
return "it names an account, a server, a calendar or a person"
}
}
return ""
}
// Pref is one preference as the profile holds it.
type Pref struct {
Name string `json:"name"`
// Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default.
Value string `json:"value,omitempty"`
From string `json:"from,omitempty"`
Withheld string `json:"withheld,omitempty"`
}
// mostPrefs bounds how many preferences one answer carries.
const mostPrefs = 200
// Prefs answers the named preferences (a name, or a prefix ending in . or *) from the profile.
func (m *Machine) Prefs(names []string) (map[string]any, error) {
if len(names) == 0 {
return nil, errors.New("names is required: preferences by name, or a prefix ending in . or *, e.g. calendar.alarms.")
}
for _, n := range names {
if strings.Trim(strings.TrimSpace(n), ".*") == "" {
return nil, fmt.Errorf("%q asks for every preference: name one, or a prefix such as calendar.alarms.", n)
}
}
p, err := m.profile()
if err != nil {
return nil, err
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
all := map[string]bool{}
for n := range fromPrefs {
all[n] = true
}
for n := range fromUser {
all[n] = true
}
var out []Pref
cut := false
seen := map[string]bool{}
for _, want := range names {
prefix := strings.HasSuffix(want, ".") || strings.HasSuffix(want, "*")
want = strings.TrimSuffix(want, "*")
var matched []string
if prefix {
for n := range all {
if strings.HasPrefix(n, want) {
matched = append(matched, n)
}
}
sort.Strings(matched)
} else {
matched = []string{want}
}
for _, n := range matched {
if seen[n] {
continue
}
seen[n] = true
if len(out) == mostPrefs {
cut = true
break
}
pr := Pref{Name: n}
v, from := fromPrefs[n], "prefs.js"
if u, ok := fromUser[n]; ok {
v, from = u, "user.js (wins at the start)"
} else if _, ok := fromPrefs[n]; !ok {
from = "not set: Betterbird's default"
v = ""
}
pr.From = from
if why := withheld(n); why != "" {
pr.Withheld = why
} else if allowedExtensions[n] {
pr.Value = v
} else {
pr.Value = m.maskValue(v)
}
out = append(out, pr)
}
}
answer := map[string]any{"profile": p, "prefs": out}
if cut {
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
}
return answer, nil
}
// statement is the one thing a line of the module's user.js may be, beside a comment.
var statement = regexp.MustCompile(`^user_pref\("[A-Za-z0-9_.\-\[\]]+",\s*(true|false|-?[0-9]+|"(?:[^"\\]|\\.)*")\);$`)
// validUserJS says what is wrong with a user.js the module would place, or nil. It holds comments and
// user_pref(…) statements only — the setting is one line, so several statements may share one — and
// sets no preference whose name could hold a credential.
func validUserJS(text string) error {
if !strings.HasPrefix(text, mark) {
return fmt.Errorf("it does not begin with the module's mark (%q)", mark)
}
for i, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
for _, st := range splitStatements(line) {
if st == "" || strings.HasPrefix(st, "//") {
continue
}
if !statement.MatchString(st) {
return fmt.Errorf("line %d holds %q, which is not user_pref(\"name\", value);", i+1, st)
}
name := st[len(`user_pref("`):strings.Index(st, `",`)]
if passwordSecret.MatchString(name) {
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
}
}
}
return nil
}
// splitStatements cuts a line after each `);`, outside a string.
func splitStatements(line string) []string {
var out []string
in, esc, start := false, false, 0
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case esc:
esc = false
case c == '\\' && in:
esc = true
case c == '"':
in = !in
case c == ';' && !in:
out = append(out, strings.TrimSpace(line[start:i+1]))
start = i + 1
}
}
if rest := strings.TrimSpace(line[start:]); rest != "" {
out = append(out, rest)
}
return out
}
// UserJS is the module's user.js against the profile's.
type UserJS struct {
Declared string `json:"declared"`
Present bool `json:"declared_present"`
Valid string `json:"declared_problem,omitempty"`
Profile string `json:"profile_user_js"`
Placed string `json:"placed"`
Statements int `json:"statements"`
// Effective says whether the running Betterbird started after the file was placed.
Effective string `json:"effective,omitempty"`
Content string `json:"content,omitempty"`
Applied bool `json:"applied,omitempty"`
}
// userJS compares the declared user.js with the profile's; apply places the declared one when the
// profile has none or the module's.
func (m *Machine) userJS(apply bool) (UserJS, error) {
a := UserJS{Declared: "~/" + declared}
want, err := readBounded(m.home(declared))
a.Present = err == nil
p, perr := m.profile()
if perr != nil {
return a, perr
}
target := filepath.Join(p.path, "user.js")
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
if a.Present {
if verr := validUserJS(string(want)); verr != nil {
a.Valid = verr.Error()
}
a.Content = string(want)
for _, line := range strings.Split(string(want), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if statement.MatchString(st) {
a.Statements++
}
}
}
}
have, herr := readBounded(target)
switch {
case herr != nil:
a.Placed = "none: the profile has no user.js"
case a.Present && bytes.Equal(have, want):
a.Placed = "the module's, as declared"
case bytes.HasPrefix(have, []byte(mark)):
a.Placed = "the module's, from an earlier declaration"
default:
a.Placed = "not the module's: a user.js of the operator's, which the module never overwrites"
}
if apply {
switch {
case !a.Present:
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
case a.Valid != "":
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
}
if herr != nil || !bytes.Equal(have, want) {
tmp := target + ".mesh-new"
if err := os.WriteFile(tmp, want, 0o644); err != nil {
return a, err
}
if err := os.Rename(tmp, target); err != nil {
os.Remove(tmp)
return a, err
}
}
a.Applied, a.Placed = true, "the module's, as declared"
}
if ps := m.mainProcs(); len(ps) > 0 {
if st, err := os.Stat(target); err == nil {
if t, err := time.Parse(time.RFC3339, ps[0].Since); err == nil && st.ModTime().After(t) {
a.Effective = "not yet: Betterbird reads user.js when it starts, and it started before the file was placed (betterbird_restart)"
} else if err == nil {
a.Effective = "yes: the running Betterbird started after the file was placed"
}
}
}
return a, nil
}
// Status is what betterbird_status answers.
type Status struct {
Running []Proc `json:"running"`
Children int `json:"children"`
Installed string `json:"installed,omitempty"`
Foreign bool `json:"from_the_aur,omitempty"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
Profile *Profile `json:"profile,omitempty"`
Problem string `json:"profile_problem,omitempty"`
Output []Stream `json:"output,omitempty"`
UserJS *UserJS `json:"user_js,omitempty"`
AddOns []AddOn `json:"add_ons,omitempty"`
// Display are the preferences that decide how its windows and notifications look.
Display []Pref `json:"display_prefs,omitempty"`
}
// AddOn is one add-on the operator installed: its id, and whether it is on.
type AddOn struct {
ID string `json:"id"`
Active bool `json:"active"`
}
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
// renderer, and whether notifications go to the desktop's notifier.
var displayPrefs = []string{"calendar.alarms.playsound", "calendar.alarms.show", "calendar.alarms.showmissed",
"calendar.alarms.indicator.show", "alerts.useSystemBackend", "mail.biff.use_system_alert",
"extensions.activeThemeID", "browser.display.use_system_colors", "ui.systemUsesDarkTheme",
"gfx.webrender.software", "gfx.webrender.all", "layers.acceleration.disabled", "widget.gtk.overlay-scrollbars.enabled",
"mozilla.widget.use-argb-visuals"}
func (m *Machine) foreign(pkg string) bool {
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
}
// Status answers whether Betterbird runs, what is installed, its profile and the module's user.js.
func (m *Machine) Status() (Status, error) {
s := Status{Running: m.mainProcs()}
if s.Running == nil {
s.Running = []Proc{}
}
s.Children = len(m.procs(mainComm)) - len(s.Running)
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if raw, err := readBounded(m.path(appIni)); err == nil {
_, sec := iniSections(raw)
s.Version, s.Build = sec["App"]["Version"], sec["App"]["BuildID"]
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
}
p, err := m.profile()
if err != nil {
s.Problem = err.Error()
return s, nil
}
s.Profile = &p
if u, err := m.userJS(false); err == nil {
u.Content = ""
s.UserJS = &u
}
if raw, err := readBounded(filepath.Join(p.path, "extensions.json")); err == nil {
var ext struct {
Addons []struct {
ID string `json:"id"`
Active bool `json:"active"`
Location string `json:"location"`
} `json:"addons"`
}
if json.Unmarshal(raw, &ext) == nil {
for _, a := range ext.Addons {
if a.Location == "app-profile" {
s.AddOns = append(s.AddOns, AddOn{a.ID, a.Active})
}
}
}
}
if got, err := m.Prefs(displayPrefs); err == nil {
s.Display = got["prefs"].([]Pref)
}
return s, nil
}
// emailAddress and credentialWord are masked in what Betterbird printed.
var (
emailAddress = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
credentialWord = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|authorization|cookie)\S*\s*[=:]\s*)\S+`)
)
// userAtHost and hostName are what a preference's value may name a server by.
var (
userAtHost = regexp.MustCompile(`[A-Za-z0-9._%+\-]+%40[A-Za-z0-9.\-]+@[A-Za-z0-9.\-]+`)
// A host is a name of three labels or more, or of two whose last is a common top-level domain: a
// file's name (a.pdf) is not one.
hostName = regexp.MustCompile(`(?i)\b(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.){2,}[a-z]{2,}|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|org|net|be|nl|de|fr|eu|io|uk|us|info|biz|dev|app|cloud|local|lan|internal|home|corp))\b`)
bearerOrBasic = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]+`)
)
func mask(s string) string {
s = userAtHost.ReplaceAllString(s, "<hidden>")
s = emailAddress.ReplaceAllString(s, "<hidden>")
s = bearerOrBasic.ReplaceAllString(s, "$1 <hidden>")
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
}
// maskValue is a preference's value as answered: the home as ~, and no address, user or host.
func (m *Machine) maskValue(v string) string {
if m.Home != "" && m.Home != "/" {
v = strings.ReplaceAll(v, strings.TrimSuffix(m.Home, "/")+"/", "~/")
}
v = mask(v)
return hostName.ReplaceAllString(v, "<host>")
}
// mostAnswer bounds a log answer.
const mostAnswer = 128 << 10
// Log answers the last n lines Betterbird printed, from the journal of the unit the module starts it
// as; problems keeps the lines that say error, warning, crash or assertion. Addresses and credentials
// are masked.
func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error) {
if unit == "" {
unit = restartAs
}
if unit != restartAs && unit != testUnit {
return nil, fmt.Errorf("unit is %s or %s", restartAs, testUnit)
}
o := m.cmd(0, m.bus(), "journalctl", "--user", "--unit", unit+".service", "--no-pager", "--output", "short-iso", "-n", strconv.Itoa(n))
if err := failed(o, "journalctl"); err != nil {
return nil, err
}
var lines []string
size := 0
keep := regexp.MustCompile(`(?i)(error|warn|crash|assert|fail|exception)`)
for _, l := range strings.Split(strings.TrimRight(o.Stdout, "\n"), "\n") {
if l == "" || (problems && !keep.MatchString(l)) {
continue
}
l = mask(l)
if size+len(l) > mostAnswer {
break
}
size += len(l) + 1
lines = append(lines, l)
}
if lines == nil {
lines = []string{}
}
a := map[string]any{"unit": unit + ".service", "lines": lines}
if ps := m.mainProcs(); len(ps) > 0 && !strings.HasPrefix(ps[0].StartedIn, restartAs) && unit == restartAs {
a["note"] = fmt.Sprintf("the running Betterbird (pid %d) was not started by the module: it runs in %s, "+
"and its output goes where status says (output), not to this journal; betterbird_restart starts it as %s",
ps[0].PID, ps[0].StartedIn, restartAs)
}
return a, nil
}
// RestartAnswer is what betterbird_restart answers.
type RestartAnswer struct {
// Composers are the message windows that were open: what was unsent and is gone.
Composers int `json:"composers_discarded,omitempty"`
Note string `json:"note,omitempty"`
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
UserJS string `json:"user_js"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// composerClass is the X instance of a message being written, whatever the class beside it.
var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`)
// composers counts the open message windows, from the X window tree.
func (m *Machine) composers(s Session) (int, error) {
o := m.cmd(3*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
return 0, err
}
return len(composerClass.FindAllString(o.Stdout, -1)), nil
}
// Restart ends Betterbird (asked to quit, forced after 10 s), places the module's user.js when it can,
// and starts Betterbird again in the operator's session under the account's service manager, its output
// in the journal. While a message is being written it refuses, unless discard says the unsent may go.
// It answers within the runtime's 30 s: a start that has not shown after 4 s is said, and
// betterbird_status sees it come.
func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
n, err := m.composers(s)
switch {
case err != nil && !discard:
return a, fmt.Errorf("whether a message is being written cannot be told (%v): restart with discard_unsent only if none is", err)
case n > 0 && !discard:
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
}
a.Composers = n
a.Ended, a.Killed = m.stopProcs(8*time.Second, m.mainProcs())
if u, err := m.userJS(true); err != nil {
a.UserJS = "not placed: " + err.Error()
} else {
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
}
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
_ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin)
if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
m.Sleep(250 * time.Millisecond)
}
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
a.Note = "started as " + a.Unit + "; no main process yet after 4 s: betterbird_status shows it once it runs, betterbird_log says why if it does not"
}
return a, nil
}
// CheckAnswer is what betterbird_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
}
// Check verifies what the module promises: Betterbird installed, one running in the session, its
// profile found, the module's user.js declared, valid, placed and in effect, and its output read.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("Betterbird ("+packageFor+") is not installed", "install it from the AUR: the node-engine installs packages from the official repositories only, so the module does not")
}
ps := m.mainProcs()
switch {
case len(ps) == 0:
add("Betterbird is not running", "start it from the launcher, or betterbird_restart")
case len(ps) > 1:
add(fmt.Sprintf("%d Betterbird main processes run", len(ps)), "one profile is held by one process: betterbird_restart")
}
u, err := m.userJS(false)
switch {
case err != nil:
add("no profile: "+err.Error(), "start Betterbird once")
case !u.Present:
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
case u.Valid != "":
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
case strings.HasPrefix(u.Effective, "not yet"):
add("the module's user.js is placed but not in effect: "+u.Effective, "betterbird_restart")
}
if len(ps) > 0 {
for _, s := range m.streams(ps[0].PID) {
if s.Dead {
add(fmt.Sprintf("Betterbird's fd %d is a pipe nobody reads (a session started before its output went to the journal): what it prints is lost", s.FD),
"betterbird_restart, which starts it with its output in the journal")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,256 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const profileDir = testHome + "/.thunderbird/60016jsp.default-default"
// links stands in for the kernel's fd links: path → target.
func links(t *testing.T, m map[string]string) {
t.Helper()
old := readLink
readLink = func(p string) (string, error) {
for k, v := range m {
if strings.HasSuffix(p, k) {
return v, nil
}
}
return "", os.ErrNotExist
}
t.Cleanup(func() { readLink = old })
}
// betterbird is a running Betterbird with one child, its profile and its prefs.
func (f *fake) betterbird() {
f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1")
f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope")
f.write("/proc/4000/fd/40", "")
f.write("/proc/4000/fd/41", "")
f.write("/proc/4000/fd/1", "")
f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+
"[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n")
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
`user_pref("browser.display.use_system_colors", true);`+"\n"+
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
`user_pref("calendar.alarms.showmissed", true);`+"\n"+
`user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+
`user_pref("extensions.owl.token", "abc");`+"\n"+
`user_pref("signon.rememberSignons", true);`+"\n")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
}
func (f *fake) declare(prefs string) {
f.write(testHome+"/.config/betterbird/user.js", mark+", novox/hq issue 345). Owned by the mesh.\n//\n"+prefs+"\n")
}
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"})
p, err := f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") {
t.Fatalf("%+v %v", p, err)
}
links(t, map[string]string{})
p, err = f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") {
t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err)
}
}
func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n")
got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"})
if err != nil {
t.Fatal(err)
}
ps := got["prefs"].([]Pref)
by := map[string]Pref{}
for _, p := range ps {
by[p.Name] = p
}
if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") {
t.Fatalf("user.js wins: %+v", p)
}
for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} {
if p := by[n]; p.Value != "" || p.Withheld == "" {
t.Errorf("%s: %+v", n, p)
}
}
if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") {
t.Fatalf("%+v", p)
}
if _, err := f.Prefs(nil); err == nil {
t.Fatal("names are required")
}
}
func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") {
t.Fatalf("nothing declared: %v", err)
}
f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`)
u, err := f.userJS(true)
if err != nil || !u.Applied || u.Statements != 2 {
t.Fatalf("%+v %v", u, err)
}
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
declared, _ := os.ReadFile(filepath.Join(f.Root, testHome, ".config/betterbird/user.js"))
if string(placed) != string(declared) {
t.Fatal("placed whole, as declared")
}
if !strings.HasPrefix(u.Effective, "yes") {
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
}
later := time.Unix(1_900_000_000, 0)
os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later)
if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") {
t.Fatalf("placed after Betterbird started: %q", u.Effective)
}
f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n")
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") {
t.Fatalf("an operator's user.js is never overwritten: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" {
t.Fatal("and it is left as it was")
}
}
func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) {
for text, ok := range map[string]bool{
mark + ")\n// none\n": true,
mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true,
mark + ")\n" + `pref("a.b", true);`: false,
mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false,
mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false,
`user_pref("a.b", true);`: false,
} {
if err := validUserJS(text); (err == nil) != ok {
t.Errorf("%q: %v", text, err)
}
}
}
func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"})
f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n")
f.answer = func(name string, args []string) Output {
if name == "pacman" && args[0] == "-Q" {
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
}
if name == "pacman" && args[0] == "-Qmq" {
return Output{Stdout: "betterbird-bin\n"}
}
return Output{}
}
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
s.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
t.Fatalf("%+v", s)
}
if !s.Output[0].Dead {
t.Fatalf("the session's dead pipe: %+v", s.Output)
}
found := false
for _, p := range s.Display {
if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` {
found = true
}
}
if !found {
t.Fatalf("%+v", s.Display)
}
}
func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"})
f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} }
a, err := f.Check()
if err != nil || a.OK {
t.Fatalf("%+v %v", a, err)
}
all := ""
for _, x := range a.Findings {
all += x.What + " | " + x.Do + "\n"
}
if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") {
t.Fatal(all)
}
}
func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.onStart = func(argv []string) {
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
}
a, err := f.Restart(false)
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") {
t.Fatalf("%v", f.calls)
}
}
func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare(`user_pref("calendar.alarms.show", true);`)
before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false)
if err != nil {
t.Fatal(err)
}
if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 {
t.Fatalf("%+v", a)
}
dir := filepath.Join(f.Root, "/run/user/1000", testUnit)
user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js"))
cal, _ := os.ReadFile(filepath.Join(dir, "test.ics"))
if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) ||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
t.Fatalf("%s", user)
}
// The event starts 10 minutes after the reminder, which is due 40 s from now.
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
a.Due != "2027-01-15T08:00:40Z" {
t.Fatalf("%s %s", cal, a.Due)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") {
t.Fatalf("%v", f.calls)
}
after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) {
t.Fatal("the operator's profile is untouched")
}
if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) {
t.Fatalf("stop removes the test profile: %v", err)
}
}
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,157 @@
// The betterbird module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): Betterbird, the
// mail and calendar client, in the operator's session, served by the node's runtime as the operator
// account. The module holds no seat, so every tool is its own.
//
// The operator's mail, calendars, accounts and credentials are never read: a tool reads processes,
// profiles.ini, and of the profile only prefs.js, user.js and extensions.json, and answers no
// preference that can hold a credential or names an account.
package main
import (
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "betterbird_status",
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many child processes), the installed package and version and whether it is from the AUR, where its " +
"output goes, the profile it uses and how that was found, the module's user.js (declared, placed, in " +
"effect), the add-ons the operator installed, and the preferences that decide how its windows and " +
"notifications look (reminder dialog, theme, renderer, system notifications). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "betterbird_prefs",
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (user.js, " +
"which wins at the start; prefs.js; or Betterbird's default). A name ending in . or * is a prefix " +
"(at most 200 answered). A preference whose name can hold a credential, or that names an account, a " +
"server, a calendar or a person, is answered without its value. (r)",
Input: map[string]any{
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
},
Run: func(args map[string]any) (any, error) {
names, err := texts(args, "names")
if err != nil {
return nil, err
}
return machine.Prefs(names)
},
},
{
Name: "betterbird_user_js",
Description: "The module's user.js — the preferences the module declares, from its setting prefs, written by " +
"the mesh to ~/.config/betterbird/user.js — against the profile's user.js: whether it is placed, and " +
"whether the running Betterbird started after it was. With apply, place it in the profile (only where " +
"the profile has no user.js or the module's own; an operator's file is never overwritten). Betterbird " +
"reads it at its next start: betterbird_restart. (r; a with apply)",
Input: map[string]any{
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
apply, err := flag(args, "apply", false)
if err != nil {
return nil, err
}
return machine.userJS(apply)
},
},
{
Name: "betterbird_log",
Description: "The last lines Betterbird printed, from the journal of the unit the module starts it as " +
"(mesh-betterbird; or mesh-betterbird-reminder-test). With problems, only lines that say error, " +
"warning, assertion, crash or failure. Mail addresses and credentials are replaced by <hidden>. A " +
"Betterbird the module did not start prints elsewhere, and the answer says so. (r)",
Input: map[string]any{
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
"problems": map[string]any{"type": "boolean", "description": "only problem lines (default false)"},
"unit": map[string]any{"type": "string", "enum": []string{restartAs, testUnit}, "description": "which start's journal (default " + restartAs + ")"},
},
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "lines", 100, 1, 2000)
if err != nil {
return nil, err
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
unit, err := text(args, "unit", false)
if err != nil {
return nil, err
}
return machine.Log(n, problems, unit)
},
},
{
Name: "betterbird_restart",
Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " +
"allows it, and start Betterbird again in the operator's desktop session under the account's service " +
"manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " +
"(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " +
"ask the operator first. Answers the pids ended and the new main process. (a)",
Input: map[string]any{
"discard_unsent": map[string]any{"type": "boolean", "description": "restart even with a composer open, losing what it holds (default false)"},
},
Run: func(args map[string]any) (any, error) {
discard, err := flag(args, "discard_unsent", false)
if err != nil {
return nil, err
}
return machine.Restart(discard)
},
},
{
Name: "betterbird_check",
Description: "Check what the module promises: Betterbird is installed; one runs; its profile is found; the " +
"module's user.js is on the machine, valid, placed in the profile and in effect; its output is read " +
"(a pipe nobody reads loses what it prints). Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
{
Name: "betterbird_reminder_test",
Description: "Show a calendar reminder without touching the operator's calendar: a second Betterbird in a " +
"throw-away profile under the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most " +
"10 minutes), with the module's user.js and one local calendar file holding one event whose reminder is " +
"due after `after` seconds. carry copies named preferences of the operator's profile (a theme, a " +
"colour switch) into it, never a credential or an account. Then xorg_windows (class Calendar) and " +
"xorg_screenshot show the reminder window. stop ends it and removes the profile. (a)",
Input: map[string]any{
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names to copy from the operator's profile, e.g. [\"extensions.activeThemeID\"]"},
"stop": map[string]any{"type": "boolean", "description": "end the test Betterbird and remove its profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
after, err := whole(args, "after", 40, 15, 300)
if err != nil {
return nil, err
}
carry, err := texts(args, "carry")
if err != nil {
return nil, err
}
stop, err := flag(args, "stop", false)
if err != nil {
return nil, err
}
return machine.ReminderTest(time.Duration(after)*time.Second, carry, stop)
},
},
}
}
@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// betterbird's shape (novox/hq issue 345, ADR 0208): no package (Betterbird comes from the AUR, and the
// node-engine installs from the official repositories only), no seat, no start of its own (the operator
// starts it from the launcher); the X display on its own machine; one directory and one file of its own,
// the user.js the setting prefs fills; and the Go bundle serving exactly the listed betterbird_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Requires []string `json:"requires"`
Settings map[string]any `json:"settings"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestItOwnsTheUserJSAndNothingOfTheProfile(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
var ids []string
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
if r["type"] == "package" {
t.Errorf("betterbird-bin is the AUR's: a package resource asks the node-engine for what it cannot install")
}
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${machine:account-home}/.config/betterbird") {
t.Errorf("a path outside the module's directory: %s", p)
}
}
if strings.Join(ids, ",") != "configuration-dir,user-js" {
t.Fatalf("%v", ids)
}
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q", never)
}
}
file := m.Resources[1]
content := file["content"].(string)
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != nil {
t.Fatalf("the user.js begins with the mark, holds the setting, and stays trusted (only the terminal sets it): %v", file)
}
// The default is a comment, so a machine without the setting places a valid, empty user.js.
def := m.Settings["prefs"].(map[string]any)["default"].(string)
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
t.Fatal(err)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed betterbird_ and described", tool.Name)
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,152 @@
package main
// A reminder on demand, without the operator's calendar: a second Betterbird, in a profile of its own
// under the account's runtime directory, with one local calendar file holding one event whose reminder
// is due a few seconds after the start. The running Betterbird, its profile, its accounts and its
// calendars are not touched: the second one starts with --no-remote, so it neither hands over to the
// first nor is handed to.
//
// The test profile gets the module's user.js too, so the reminder is drawn with the preferences the
// module declares. A theme or another preference of the operator's profile can be carried over by
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
//
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
// looked at with xorg_screenshot.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// testDir is the test's profile and calendar, under the account's runtime directory.
func (m *Machine) testDir() string {
return m.path(fmt.Sprintf("/run/user/%d/%s", m.UID, testUnit))
}
// carryName is a preference name the test may carry over from the operator's profile.
var carryName = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
// ReminderAnswer is what betterbird_reminder_test answers.
type ReminderAnswer struct {
Unit string `json:"unit"`
Profile string `json:"profile"`
Event string `json:"event"`
Due string `json:"reminder_due"`
Carried []string `json:"carried,omitempty"`
Withheld []string `json:"withheld,omitempty"`
Look string `json:"look"`
Stopped bool `json:"stopped,omitempty"`
}
// ics is the test calendar: one event, its reminder `after` from now.
func ics(now time.Time, after time.Duration, stamp string) string {
f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
start := now.Add(after + 10*time.Minute)
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:Mesh reminder test", "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:Mesh reminder test", "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
}
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
func testPrefs(calendar string) string {
return strings.Join([]string{
`user_pref("calendar.registry.meshtest.type", "ics");`,
`user_pref("calendar.registry.meshtest.uri", "file://` + calendar + `");`,
`user_pref("calendar.registry.meshtest.name", "Mesh reminder test");`,
`user_pref("calendar.registry.meshtest.calendar-main-in-composite", true);`,
`user_pref("calendar.registry.meshtest.suppressAlarms", false);`,
`user_pref("calendar.list.sortOrder", "meshtest");`,
`user_pref("mail.provider.suppress_dialog_on_startup", true);`,
`user_pref("mail.shell.checkDefaultClient", false);`,
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
`user_pref("app.update.auto", false);`,
`user_pref("mail.rights.version", 1);`,
"",
}, "\n")
}
// ReminderTest starts the test Betterbird, or with stop ends it and removes its profile.
func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (ReminderAnswer, error) {
dir := m.testDir()
a := ReminderAnswer{Unit: testUnit + ".service", Profile: strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root),
Look: "xorg_windows with class Calendar answers the reminder window (role AlarmWindow, its depth and map state); " +
"xorg_screenshot with that window shows it as the screen does"}
if stop {
_ = m.cmd(5*time.Second, m.bus(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
a.Stopped, a.Look = true, ""
return a, nil
}
s, err := m.session()
if err != nil {
return a, err
}
var carried []string
if len(carry) > 0 {
p, err := m.profile()
if err != nil {
return a, err
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
have := readPrefs(prefsJS)
for _, n := range carry {
switch {
case !carryName.MatchString(n):
return a, fmt.Errorf("carry holds preference names, not %q", n)
case withheld(n) != "":
a.Withheld = append(a.Withheld, n+": "+withheld(n))
case have[n] == "":
a.Withheld = append(a.Withheld, n+": not set in the operator's profile")
default:
carried = append(carried, fmt.Sprintf("user_pref(%s, %s);", strconv.Quote(n), have[n]))
a.Carried = append(a.Carried, n)
}
}
}
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
if err := os.MkdirAll(filepath.Join(dir, "profile"), 0o700); err != nil {
return a, err
}
now := m.Now()
calendar := filepath.Join(dir, "test.ics")
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
return a, err
}
user := testPrefs(strings.TrimPrefix(calendar, m.Root))
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
user = string(mine) + "\n" + user
}
user += strings.Join(carried, "\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
return a, err
}
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
// test Betterbird is closed.
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
"-p", "ExecStopPost=/usr/bin/rm -rf " + strings.TrimPrefix(dir, m.Root), "--setenv=MOZ_NO_REMOTE=1"}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin, "--no-remote", "--profile", strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root))
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
a.Event = "Mesh reminder test, in a calendar file of the test profile only"
a.Due = now.Add(after).UTC().Format(time.RFC3339)
return a, nil
}
@@ -0,0 +1,182 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// The review of pull request 155 (hq issue 345): a restart never loses an unsent message unasked, a
// preference's value never carries an address, a log never a credential, the reminder test never
// counts as the operator's Betterbird and leaves nothing behind, and a restart answers within the
// runtime's call limit.
const composerTree = `
0x2a00003 "Inbox - Betterbird": ("Mail" "eu.betterbird.Betterbird") 2560x1388+0+0 +0+26
0x2a01234 "Write: hello - Betterbird": ("Msgcompose" "eu.betterbird.Betterbird") 900x700+10+10 +10+10
`
func TestARestartRefusesWhileAComposerIsOpenUnlessTheUnsentMayGo(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.answer = func(name string, args []string) Output {
if name == "xwininfo" {
return Output{Stdout: composerTree}
}
return Output{}
}
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
t.Fatalf("refused while a message is being written: %v", err)
}
if len(f.signals) != 0 {
t.Fatalf("nothing was ended: %v", f.signals)
}
f.onStart = func([]string) {
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
}
a, err := f.Restart(true)
if err != nil || len(a.Ended) != 1 || a.Composers != 1 {
t.Fatalf("with discard_unsent it goes ahead and says what was open: %+v %v", a, err)
}
}
func TestARestartAnswersWithinTheCallLimitEvenWhenNothingCooperates(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.stubborn[4000] = true
slept := time.Duration(0)
f.Sleep = func(d time.Duration) { slept += d }
a, err := f.Restart(false)
if err != nil {
t.Fatalf("a start that has not shown yet is said, not failed: %v", err)
}
if slept > 18*time.Second || len(a.Killed) != 1 || a.Note == "" {
t.Fatalf("waited %v of the runtime's 30 s: %+v", slept, a)
}
}
func TestAPreferencesValueCarriesNoAddressHostOrHome(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
f.write(profileDir+"/prefs.js", strings.Join([]string{
`user_pref("mail.last_msg_movecopy_target_uri", "imap://someone%40example.org@mail.example.org/INBOX");`,
`user_pref("messenger.account.a1.server", "x");`,
`user_pref("mail.cloud_files.accounts.a", "y");`,
`user_pref("editor.lastFileLocation", "/home/operator/Documents/a.pdf");`,
`user_pref("network.proxy.http", "proxy.corp.example.com");`,
`user_pref("mail.compose.default_to", "imap://someone%40example.org@mail.example.org/x and bob@example.org");`,
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`,
`user_pref("extensions.ui.lastCategory", "addons://list/theme");`,
`user_pref("font.size.variable.x-western", 12);`,
}, "\n")+"\n")
got, err := f.Prefs([]string{"mail.last_msg*", "messenger.account.", "mail.cloud_files.", "editor.", "network.proxy.",
"mail.compose.default_to", "extensions.", "font.size."})
if err != nil {
t.Fatal(err)
}
by := map[string]Pref{}
for _, p := range got["prefs"].([]Pref) {
by[p.Name] = p
}
for _, n := range []string{"mail.last_msg_movecopy_target_uri", "messenger.account.a1.server", "mail.cloud_files.accounts.a", "extensions.ui.lastCategory"} {
if p := by[n]; p.Value != "" || p.Withheld == "" {
t.Errorf("withheld: %s %+v", n, p)
}
}
if v := by["editor.lastFileLocation"].Value; v != `"~/Documents/a.pdf"` {
t.Errorf("home as ~: %s", v)
}
if v := by["network.proxy.http"].Value; strings.Contains(v, "example") {
t.Errorf("a host: %s", v)
}
if v := by["mail.compose.default_to"].Value; strings.Contains(v, "example") || strings.Contains(v, "someone") {
t.Errorf("an address: %s", v)
}
if v := by["extensions.activeThemeID"].Value; v != `"thunderbird-compact-dark@mozilla.org"` {
t.Errorf("a theme's id is answered as it is: %s", v)
}
if v := by["font.size.variable.x-western"].Value; v != "12" {
t.Errorf("%s", v)
}
for _, bad := range [][]string{{""}, {"*"}, {"."}, {" "}} {
if _, err := f.Prefs(bad); err == nil {
t.Errorf("%q is refused: it asks for everything", bad)
}
}
}
func TestALogLineKeepsNoBearerOrBasicCredential(t *testing.T) {
for _, l := range []string{"Authorization: Bearer eyJhbGciOi.abc.def", "auth header basic dXNlcjpwYXNz sent", "token=abc123"} {
got := mask(l)
for _, secret := range []string{"eyJhbGciOi", "dXNlcjpwYXNz", "abc123"} {
if strings.Contains(got, secret) {
t.Errorf("%q → %q", l, got)
}
}
}
}
func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.proc(6000, 1000, "betterbird-bin", []string{"/usr/bin/betterbird", "--no-remote", "--profile", "/run/user/1000/" + testUnit + "/profile"}, testUnit+".service")
if ps := f.mainProcs(); len(ps) != 1 || ps[0].PID != 4000 {
t.Fatalf("only the operator's: %+v", ps)
}
f.declare("// none")
if _, err := f.ReminderTest(40*time.Second, nil, false); err != nil {
t.Fatal(err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit) {
t.Fatalf("the unit removes its profile when it ends: %v", f.calls)
}
}
func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.answer = func(name string, args []string) Output {
if name == "xwininfo" {
return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"}
}
return Output{}
}
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
t.Fatalf("a composer of another class is still a composer: %v", err)
}
}
func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.stubborn[4000] = true
total := time.Duration(0)
run := f.Run
f.Run = func(ctx context.Context, env []string, name string, args ...string) Output {
// Every command takes its whole timeout: the worst case.
if d, ok := ctx.Deadline(); ok {
total += time.Until(d).Round(time.Second)
}
return run(ctx, env, name, args...)
}
f.Sleep = func(d time.Duration) { total += d }
if _, err := f.Restart(true); err != nil {
t.Fatal(err)
}
if total > 26*time.Second {
t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total)
}
}
+5
View File
@@ -0,0 +1,5 @@
module betterbird
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+55
View File
@@ -0,0 +1,55 @@
{
"module": "betterbird",
"version": "1",
"requires": [
"x11-display"
],
"settings": {
"prefs": {
"kind": "preference",
"default": "// none",
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)"
}
},
"tools": [
"betterbird_status",
"betterbird_prefs",
"betterbird_user_js",
"betterbird_log",
"betterbird_restart",
"betterbird_check",
"betterbird_reminder_test"
],
"resources": [
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/betterbird",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "user-js",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/user.js",
"owner": "${machine:account}",
"mode": "0644",
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/betterbird-tools",
"binary": "betterbird-tools",
"loads": [
"betterbird-tools"
]
}
]
}
}
+47 -3
View File
@@ -991,16 +991,27 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
}
// Events is what the runtime did in a window ending now, the latest last.
//
// **The runtime keeps only its last events in memory** (about 256), and the images' own health checks
// make three exec events each per look: on a machine with a few checked containers that history is ten
// minutes long or less, all of it exec events (hq research 032). A window asked for further back than
// the history reaches is answered with what is still held AND says where the history begins, so an
// empty list never reads as "nothing happened" (hq issue 348: three hours asked across an outage,
// answered zero). The type filter is applied here rather than by the runtime, for the same reason: the
// history's start is only known from all of it.
func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int, execs bool) (map[string]any, error) {
args := []string{"events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s", "--format", "{{json .}}"}
return c.eventsAt(ctx, time.Now(), minutes, kind, limit, execs)
}
func (c *Client) eventsAt(ctx context.Context, now time.Time, minutes int, kind string, limit int, execs bool) (map[string]any, error) {
if kind != "" {
switch kind {
case "container", "image", "network", "volume", "daemon", "plugin", "builder":
default:
return nil, fmt.Errorf("type %q: container, image, network, volume, daemon, plugin or builder", kind)
}
args = append(args, "--filter", "type="+kind)
}
args := []string{"events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s", "--format", "{{json .}}"}
out, err := c.docker(ctx, args...)
if err != nil {
return nil, err
@@ -1015,6 +1026,9 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
redacted := map[string]bool{}
events := []map[string]any{}
for _, e := range raw {
if kind != "" && e.Type != kind {
continue
}
if !execs && strings.HasPrefix(e.Action, "exec_") {
continue
}
@@ -1042,14 +1056,44 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if len(events) > limit {
events = events[len(events)-limit:]
}
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events,
"held": len(raw)}
if len(redacted) > 0 {
answer["redacted"] = sortedSet(redacted)
answer["leak"] = "exec command lines carried secrets, which the runtime keeps in its events; docker_secrets_in_events names them (novox/hq issue 282)"
}
for k, v := range historyReach(raw, now, minutes) {
answer[k] = v
}
return answer, nil
}
// historyGap is how much later than the window's start the runtime's oldest held event may be and the
// window still read as covered: events are not made every second.
const historyGap = 2 * time.Minute
// historyReach says where the runtime's history begins when it begins after the window asked for: the
// oldest event it still holds, and that nothing before it can be answered from here.
func historyReach(raw []runtimeEvent, now time.Time, minutes int) map[string]any {
start := now.Add(-time.Duration(minutes) * time.Minute)
if len(raw) == 0 {
return map[string]any{"complete": false,
"history": "the runtime holds no events at all for this window: it keeps only its last events in memory, so this says nothing about what happened before now — ask the service manager's journal for the docker unit"}
}
oldest := time.Unix(0, raw[0].TimeNano)
for _, e := range raw {
if t := time.Unix(0, e.TimeNano); t.Before(oldest) {
oldest = t
}
}
if oldest.Sub(start) <= historyGap {
return map[string]any{"complete": true, "oldest_held": oldest.UTC().Format(time.RFC3339)}
}
return map[string]any{"complete": false, "oldest_held": oldest.UTC().Format(time.RFC3339),
"history": fmt.Sprintf("the runtime keeps only its last %d events in memory and its oldest is from %s; the window asked starts at %s, so what happened before %s is gone from here, not absent — the images' health checks fill the history with exec events (hq research 032)",
len(raw), oldest.UTC().Format(time.RFC3339), start.UTC().Format(time.RFC3339), oldest.UTC().Format("15:04:05Z"))}
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
+52 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"reflect"
@@ -307,7 +308,8 @@ func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
if len(evs) != 1 || evs[0]["action"] != "die" || evs[0]["mesh_held"] != true || evs[0]["exit_code"] != "137" {
t.Fatalf("%v", evs)
}
if !f.ran("docker events --since 30m --until 0s --format {{json .}} --filter type=container") {
// The type is filtered here, not by the runtime: the history's start is only known from all of it (issue 348).
if !f.ran("docker events --since 30m --until 0s --format {{json .}}") || f.ran("docker events --since 30m --until 0s --format {{json .}} --filter") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Events(context.Background(), 30, "secret", 10, false); err == nil {
@@ -362,3 +364,52 @@ func TestProblemsNameWhyAndUnlabelledIsTheCleanupList(t *testing.T) {
t.Fatalf("%v %v", u, err)
}
}
// eventLine is one line of `docker events --format '{{json .}}'`.
func eventLine(at time.Time, kind, action, name string) string {
return fmt.Sprintf(`{"Type":%q,"Action":%q,"Actor":{"ID":"abcdef0123456789","Attributes":{"name":%q}},"timeNano":%d}`+"\n",
kind, action, name, at.UnixNano())
}
// Issue 348: three hours asked across an outage answered zero, because the runtime's history held only the
// last ten minutes of health-check execs. The answer must say the history is short, not that nothing happened.
func TestEventsSayWhereTheRuntimesHistoryBeginsWhenTheWindowReachesFurther(t *testing.T) {
now := time.Date(2026, 10, 9, 11, 24, 0, 0, time.UTC)
var out strings.Builder
for i := 0; i < 250; i++ {
out.WriteString(eventLine(now.Add(-10*time.Minute+time.Duration(i)*2*time.Second), "container", "exec_die", "mailu-webmail"))
}
got, err := client((&fake{}).on("docker events", Ran{Stdout: out.String()}), 1000).eventsAt(context.Background(), now, 180, "", 200, false)
if err != nil {
t.Fatal(err)
}
if got["count"] != 0 || got["held"] != 250 {
t.Fatalf("count %v held %v, want 0 shown of 250 held", got["count"], got["held"])
}
if got["complete"] != false || !strings.Contains(fmt.Sprint(got["history"]), "gone from here, not absent") ||
got["oldest_held"] != "2026-10-09T11:14:00Z" {
t.Fatalf("an empty answer from a short history reads as quiet: %v", got)
}
}
func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *testing.T) {
now := time.Date(2026, 10, 9, 11, 24, 0, 0, time.UTC)
out := eventLine(now.Add(-59*time.Minute), "network", "destroy", "build-net") +
eventLine(now.Add(-30*time.Minute), "container", "die", "build-bus") +
eventLine(now.Add(-1*time.Minute), "network", "create", "build-net")
f := (&fake{}).on("docker events", Ran{Stdout: out})
got, err := client(f, 1000).eventsAt(context.Background(), now, 60, "network", 200, false)
if err != nil {
t.Fatal(err)
}
if got["complete"] != true || got["count"] != 2 || got["history"] != nil {
t.Fatalf("a covered window: %v", got)
}
if f.ran("docker events --since 60m --until 0s --format {{json .}} --filter") {
t.Fatal("the type was filtered by the runtime, so the history's start is unknown")
}
got, _ = client((&fake{}).on("docker events", Ran{}), 1000).eventsAt(context.Background(), now, 60, "", 200, false)
if got["complete"] != false || got["history"] == nil {
t.Fatalf("no events at all reads as quiet: %v", got)
}
}
+15 -1
View File
@@ -70,6 +70,19 @@ func tools(c *Client) []stdio.Tool {
return c.Inspect(ctx, ref)
},
},
{
Name: "docker_resolv_conf",
Description: "The resolver file one container really uses (/etc/resolv.conf as it sees it, read through the runtime's archive, no exec): " +
"its nameservers, search and options and the raw text, beside the machine's own nameservers and whether they are the same.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.ResolvConfOf(ctx, ref)
},
},
{
Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " +
@@ -262,7 +275,8 @@ func tools(c *Client) []stdio.Tool {
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
"an exec's command line is shown with any secret it carried as [redacted: <what it was>] — a value of the container's environment named like a secret, a password in a URI, the word after a password flag.",
"an exec's command line is shown with any secret it carried as [redacted: <what it was>] — a value of the container's environment named like a secret, a password in a URI, the word after a password flag. " +
"The runtime keeps only its last ~256 events in memory, which health-check execs fill in minutes: complete=false and history say where what it holds begins, and an empty list then means gone, not quiet.",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
+111
View File
@@ -0,0 +1,111 @@
package main
// What resolver a container really asks (hq issue 348). A container gets its own copy of the machine's
// resolver file, made by the runtime when it is created and kept under the runtime's root-only
// directory; a container on the host's network gets a copy too. During the 2026-10-09 outage the
// control node's route-proxy failed every lookup against 10.10.0.1 alone for twenty minutes while the
// machine's file listed both mesh resolvers — and nothing could show what its copy said. This reads the
// file as the container sees it, through the runtime's archive endpoint (`docker cp … -`, a tar stream):
// no exec into the container, nothing written, and it works where the program has no shell.
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"strings"
)
// ContainerResolvConf is the path read inside a container.
const ContainerResolvConf = "/etc/resolv.conf"
// HostResolvConf is the machine's own resolver file, read for comparison.
const HostResolvConf = "/etc/resolv.conf"
// ResolvConf is a resolver file, parsed.
type ResolvConf struct {
Nameservers []string `json:"nameservers"`
Search []string `json:"search,omitempty"`
Options []string `json:"options,omitempty"`
Raw string `json:"raw"`
}
// parseResolvConf reads nameserver, search (and domain) and options lines; comments are kept in Raw only.
func parseResolvConf(text string) ResolvConf {
out := ResolvConf{Nameservers: []string{}, Raw: text}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) == 0 || strings.HasPrefix(f[0], "#") || strings.HasPrefix(f[0], ";") {
continue
}
switch f[0] {
case "nameserver":
if len(f) > 1 {
out.Nameservers = append(out.Nameservers, f[1])
}
case "search", "domain":
out.Search = append(out.Search, f[1:]...)
case "options":
out.Options = append(out.Options, f[1:]...)
}
}
return out
}
// fileFromArchive is the one regular file a `docker cp <ref>:<path> -` stream carries.
func fileFromArchive(stream string) (string, error) {
tr := tar.NewReader(strings.NewReader(stream))
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
return "", errors.New("the runtime's archive held no file")
}
if err != nil {
return "", fmt.Errorf("the runtime's archive could not be read: %w", err)
}
switch h.Typeflag {
case tar.TypeReg:
body, err := io.ReadAll(io.LimitReader(tr, 64<<10))
if err != nil {
return "", fmt.Errorf("the runtime's archive could not be read: %w", err)
}
return string(body), nil
case tar.TypeSymlink:
return "", fmt.Errorf("inside the container %s is a link to %s, which the runtime's archive does not follow", h.Name, h.Linkname)
}
}
}
// ResolvConfOf is a container's resolver file as it sees it, beside the machine's own, and whether they list
// the same resolvers in the same order.
func (c *Client) ResolvConfOf(ctx context.Context, ref string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
answer := map[string]any{"container": ref, "path": ContainerResolvConf}
stream, err := c.docker(ctx, "cp", ref+":"+ContainerResolvConf, "-")
if err != nil {
return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err)
}
text, err := fileFromArchive(stream)
if err != nil {
return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err)
}
inside := parseResolvConf(text)
answer["resolv_conf"] = inside
if raw, err := c.ReadFile(HostResolvConf); err != nil {
answer["machine"] = "the machine's " + HostResolvConf + " could not be read for comparison: " + err.Error()
} else {
host := parseResolvConf(string(raw))
answer["machine_nameservers"] = host.Nameservers
same := strings.Join(host.Nameservers, " ") == strings.Join(inside.Nameservers, " ")
answer["same_as_machine"] = same
if !same {
answer["note"] = "the container asks other resolvers than the machine lists: its copy was made when it was created, " +
"or the runtime's dns setting chose them; a container is given the machine's current file only when it is created again"
}
}
return answer, nil
}
@@ -0,0 +1,83 @@
package main
import (
"archive/tar"
"bytes"
"context"
"errors"
"strings"
"testing"
)
func archiveOf(t *testing.T, h tar.Header, body string) string {
t.Helper()
var buf bytes.Buffer
w := tar.NewWriter(&buf)
h.Size = int64(len(body))
if h.Typeflag == tar.TypeSymlink {
h.Size = 0
}
if err := w.WriteHeader(&h); err != nil {
t.Fatal(err)
}
if h.Typeflag != tar.TypeSymlink {
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
w.Close()
return buf.String()
}
// Issue 348: route-proxy asked 10.10.0.1 alone while the machine listed both mesh resolvers; its copy
// of the file could not be read by anything on the mesh.
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
c := client(f, 1000)
c.ReadFile = func(p string) ([]byte, error) {
if p != HostResolvConf {
t.Fatalf("read %s", p)
}
return []byte("nameserver 10.10.0.1\nnameserver 10.10.0.2\noptions timeout:1 attempts:2 edns0\n"), nil
}
got, err := c.ResolvConfOf(context.Background(), "route-proxy")
if err != nil {
t.Fatal(err)
}
rc := got["resolv_conf"].(ResolvConf)
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
t.Fatalf("parsed: %+v", rc)
}
if got["same_as_machine"] != false || got["note"] == nil {
t.Fatalf("a copy listing fewer resolvers than the machine is not said: %v", got)
}
if f.ran("docker exec") {
t.Fatal("read by exec, not through the archive")
}
}
func TestAResolverFileThatCannotBeReadIsSaidPlainly(t *testing.T) {
c := client((&fake{}).on("docker cp", Ran{Status: 1, Stderr: "Error response from daemon: No such container: nope\n"}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "nope"); err == nil || !strings.Contains(err.Error(), "could not be read") {
t.Fatalf("a missing container: %v", err)
}
link := archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeSymlink, Linkname: "/run/systemd/resolve/stub-resolv.conf"}, "")
c = client((&fake{}).on("docker cp", Ran{Stdout: link}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "link to") {
t.Fatalf("a link inside: %v", err)
}
c = client((&fake{}).on("docker cp", Ran{Stdout: "not a tar"}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "x"); err == nil {
t.Fatal("garbage was read as a file")
}
ok := archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, "nameserver 10.10.0.1\n")
c = client((&fake{}).on("docker cp", Ran{Stdout: ok}), 1000)
c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("permission denied") }
got, err := c.ResolvConfOf(context.Background(), "x")
if err != nil || !strings.Contains(got["machine"].(string), "could not be read") || got["same_as_machine"] != nil {
t.Fatalf("an unreadable machine file: %v, %v", got, err)
}
}
+6
View File
@@ -18,6 +18,7 @@
"tools": [
"docker_list",
"docker_inspect",
"docker_resolv_conf",
"docker_logs",
"docker_secrets_in_logs",
"docker_secrets_in_events",
@@ -38,6 +39,11 @@
"docker_problems",
"docker_ports"
],
"replaces": {
"docker_resolv_conf": [
"docker exec cat /etc/resolv.conf"
]
},
"resources": [
{
"id": "package",
+1
View File
@@ -61,6 +61,7 @@
},
{
"id": "configuration",
"trusted": false,
"type": "file",
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
+17
View File
@@ -81,3 +81,20 @@ export function mergeableUnder(branch: string, rule: BranchProtection | null | u
blocking.map((b) => `${b.context} (${b.state})`).join(", ");
return { branch, protected: !!rule, required, blocking, mergeable: blocking.length === 0, says };
}
/** A commit, by its sha or the start of one: what `gitea_contains` takes, so a branch name or an expression
* never reaches the compare's path. */
export function isCommit(ref: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(ref);
}
/** Whether a commit is contained in another — an ancestor of it, or it (novox/hq ADR 0270) — from the forge's
* compare of `in...commit`: the commits on `commit` that `in` lacks. None is contained. An answer that does not
* say how many is no answer. */
export function containedFrom(compare: unknown): { contains: boolean; ahead: number } {
const total = (compare as Record<string, unknown> | null)?.total_commits;
if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
throw new Error("the forge's compare said no total_commits");
}
return { contains: total === 0, ahead: total };
}
+6
View File
@@ -355,6 +355,12 @@ export class GiteaClient {
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
}
/** The forge's compare of two commits, `base...head`: the commits on head that base lacks, and how many
* (novox/hq ADR 0270). Reads only. */
async compare(owner: string, repo: string, base: string, head: string): Promise<unknown> {
return this.request<unknown>(`/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
+17
View File
@@ -46,3 +46,20 @@ test("no rule requires nothing; a rule that cannot be read says nothing, never y
assert.equal(unread.mergeable, null);
assert.match(unread.says, /could not be read/);
});
// novox/hq ADR 0270: a held delivery is covered only when a later delivered merge contains it.
test("a commit is contained in another when the compare finds nothing on it the other lacks", async () => {
const { containedFrom, isCommit } = await import("../checks.ts");
for (const [compare, contains, ahead] of [
[{ total_commits: 0, commits: [] }, true, 0],
[{ total_commits: 3, commits: [{}, {}, {}] }, false, 3],
] as const) {
assert.deepEqual(containedFrom(compare), { contains, ahead });
}
for (const bad of [{}, { total_commits: "0" }, { total_commits: -1 }, null]) {
assert.throws(() => containedFrom(bad), /total_commits/);
}
assert.equal(isCommit("6aabe8e"), true);
assert.equal(isCommit("90516fc0a1b2c3d4e5f60718293a4b5c6d7e8f90"), true);
for (const bad of ["main", "6aabe8e...90516fc", "../x", "6aabe8", ""]) assert.equal(isCommit(bad), false, bad);
});
+1 -1
View File
@@ -411,7 +411,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_branches",
"gitea_delete_branch",
"gitea_branch_protection_get", "gitea_branch_protection_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_status",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_contains", "gitea_commit_status",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
+19 -1
View File
@@ -12,7 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
import { mergeableUnder, newestByContext } from "../checks.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -492,6 +492,24 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
merge: mergeableUnder(base, rule, statuses, unreadable) };
},
},
{
name: "gitea_contains",
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
commit: { type: "string", description: "the commit asked about" },
in: { type: "string", description: "the commit it may be contained in" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const commit = String(args.commit ?? "").trim(), within = String(args.in ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!isCommit(commit) || !isCommit(within)) throw new Error("commit and in are commits: a sha, or the start of one");
const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit));
return { commit, in: within, contains, ahead };
},
},
{
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them.",
@@ -0,0 +1,476 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
)
// novox/hq issues 319 and 316: a held delivery whose change already runs, or that builds nothing, is not a
// question for a person. A merge that moves nothing is delivered at once; a merge whose every module a later
// merge on the same trunk delivered is covered — never stopped, never an error on its pull request; and a
// group member held for a composed check that had not answered goes on once it passes.
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// mergedAt is the forge's `pull.merged` at a moment.
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
p.MergeCommit = merge
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
// verdictMoving is a passing verdict whose plan moves these modules — or builds nothing, given none.
func verdictMoving(repo string, number int, sha string, modules ...string) CheckedEvent {
c := verdict(repo, number, sha, "pass")
c.Plan = planMoving(repo, modules...)
return c
}
func planMoving(repo string, modules ...string) *DeliveryPlan {
if len(modules) == 0 {
return &DeliveryPlan{Repository: repo, Base: "main", Summary: "builds nothing: the change touches no module of the mesh's graph"}
}
return &DeliveryPlan{Repository: repo, Base: "main", Moved: modules, Summary: "builds " + strings.Join(modules, ", ") + " → anchor"}
}
// deliverLater is a later merge into a trunk, walked: its walk opened for its merge commit, let go, and left
// in the state given, every module of it sent.
func deliverLater(w *world, repo string, number int, base, headSHA, merge string, at time.Time, state string,
modules ...string) string {
w.t.Helper()
p := pr(repo, number, headSHA, fmt.Sprintf("feat/later-%d", number))
p.Base = base
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(repo, number, headSHA, modules...))
w.h.PullMerged(mergedAt(p, merge, at))
walk := aWalk("plan-"+merge[:6], repo, merge, true)
walk.Branch = base
walk.Modules = map[string]*WalkModule{}
for _, m := range modules {
walk.Modules[m] = &WalkModule{}
}
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
walk = w.ctl.walk(walk.ID)
sent := at.Add(5 * time.Minute)
for _, m := range modules {
walk.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
walk.State = state
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
return IDOf(repo, headSHA)
}
func statusOn(w *world, repo, commit string) string {
w.forge.mu.Lock()
defer w.forge.mu.Unlock()
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
}
// **A merge that moves nothing is delivered at once** (issue 319): there is nothing for a walk to move and
// nothing for a person to decide — whether its check passed, failed or never answered, and whether its plan
// builds nothing at all or only a new module the controller builds and sends nowhere (issue 300).
func TestAMergeThatMovesNothingIsDeliveredAtOnce(t *testing.T) {
for _, c := range []struct {
name string
verdict string // "" is a merge before any verdict
plan *DeliveryPlan
}{
{"builds nothing, merged before its check answered", "", planMoving("novox/hq")},
{"builds nothing, merged on a failed check", "fail", planMoving("novox/hq")},
{"builds nothing, merged on a passing check", "pass", planMoving("novox/hq")},
{"a new module, sent nowhere", "pass", &DeliveryPlan{Repository: "novox/hq", New: []string{"modules/zfs"},
Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
{"a new module, sent nowhere, merged before its check answered", "", &DeliveryPlan{Repository: "novox/hq",
New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
w.ctl.planned = c.plan
p := pr("novox/hq", 7, head, "docs/x", "02-DECISIONS/x.md")
w.h.PullUpdated(p)
if c.verdict != "" {
v := verdict("novox/hq", 7, head, c.verdict)
v.Plan = c.plan
w.h.Checked(v)
}
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
// Past the bound a merge waits for its walk: nothing holds it then either.
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
d := w.delivery(IDOf("novox/hq", head))
if d.State != Delivered {
t.Fatalf("a merge whose plan is %q is %s (%s), not delivered", c.plan.Summary, d.State, d.HeldWhy)
}
if s := statusOn(w, "novox/hq", head); !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
})
}
}
// **A held delivery whose every module a later merge on its trunk delivered is covered** (issue 319): the
// later merge commit contains it, its walk is done, and it sent each module this one moves. Anything short
// of that — a module not carried, an earlier merge, another trunk, a walk that did not finish — leaves it held
// for a person, as before.
func TestAMergeALaterDeliveredWalkCarriedIsCovered(t *testing.T) {
const ctl = "novox/mesh-controller"
for _, c := range []struct {
name string
// The later merge: its trunk, minutes after t0 it merged (this one merged at 30), its walk's end, and
// what it sent.
base string
at int
state string
modules []string
// Whether this one was held for a person before the later merge was delivered.
heldFirst bool
// What the forge says of the later merge containing this one: yes, no, or away until asked again.
forge string
want State
}{
{"carried every module, delivered after this one was held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "yes", Covered},
{"carried every module, delivered before this one was due to be held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller", "route-proxy"}, false, "yes", Covered},
{"carried one of its two modules", "main", 35, walkDone, []string{"mesh-controller"}, true, "yes", Held},
{"merged before this one", "main", 25, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"merged into another branch", "release", 35, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"its walk failed", "main", 35, walkFailed, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"the forge says the later merge does not contain it", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "no", Held},
{"the forge could not say at first, then said it does", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "away", Covered},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
pair := merge + " in nnnnnnnnnnnn8888"
switch c.forge {
case "yes", "away":
w.forge.contains[pair] = true
case "no":
w.forge.contains[pair] = false
}
w.forge.containsDown = c.forge == "away"
p := pr(ctl, 1, head, "feat/one", "cmd/a.go")
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(ctl, 1, head, "mesh-controller", "build-agent"))
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
if c.heldFirst {
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("a merge no walk opened for is %s before the later one", s)
}
}
later := deliverLater(w, ctl, 2, c.base, "bbbbbbbbbbbb2222", "nnnnnnnnnnnn8888", t0.Add(time.Duration(c.at)*time.Minute),
c.state, c.modules...)
if c.state == walkDone && w.state(later) != Delivered {
t.Fatalf("the later merge is %s", w.state(later))
}
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if c.forge == "away" {
// A forge that cannot say is "not yet covered": held, and asked again a minute on.
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("with the forge away it is %s", s)
}
w.forge.mu.Lock()
w.forge.containsDown = false
w.forge.mu.Unlock()
w.later(2 * time.Minute)
w.settleAll()
}
d := w.delivery(IDOf(ctl, head))
if d.State != c.want {
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
}
s := statusOn(w, ctl, head)
if c.want == Covered {
if !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") ||
!strings.Contains(s, "nnnnnnnn") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
last := d.Transitions[len(d.Transitions)-1]
if !strings.Contains(last.Why, IDOf(ctl, "bbbbbbbbbbbb2222")) {
t.Fatalf("its transition does not name what covered it: %q", last.Why)
}
}
if strings.HasPrefix(s, "error") {
t.Fatalf("its pull request's mesh/delivery is an error: %q", s)
}
// Asked once for the pair, however many ticks read it — once more only after the forge was away.
asked := 0
for _, a := range w.forge.containsAsks {
if a == pair {
asked++
}
}
candidate := c.base == "main" && c.at > 30 && c.state == walkDone && len(c.modules) >= 2
switch {
case !candidate && asked != 0:
t.Fatalf("the forge was asked whether %s for a merge order and walk that cannot cover it", pair)
case candidate && c.forge != "away" && asked != 1:
t.Fatalf("the forge was asked %d time(s) whether %s, want once", asked, pair)
case candidate && c.forge == "away" && asked < 2:
t.Fatalf("the forge, away, was asked %d time(s) whether %s: never again", asked, pair)
}
})
}
}
// A covered member of a group counts as delivered: the member after it is let go, not stopped.
func TestACoveredGroupMemberLetsTheNextOneGo(t *testing.T) {
w := newWorld(t)
const ctl, lab = "novox/mesh-controller", "novox/mesh-lab"
const ctlHead, labHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctlID, labID := IDOf(ctl, ctlHead), IDOf(lab, labHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctlID, labID}} }
pc, pl := pr(ctl, 1, ctlHead, "feat/g", "cmd/a.go"), pr(lab, 2, labHead, "feat/g", "replays/a.go")
w.h.PullUpdated(pc)
w.h.PullUpdated(pl)
w.h.Checked(verdictMoving(ctl, 1, ctlHead, "mesh-controller"))
w.h.Checked(verdictMoving(lab, 2, labHead, "mesh-lab"))
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/g", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(pc, "m1m1m1m1m1m1", t0.Add(30*time.Minute)))
w.h.PullMerged(mergedAt(pl, "m2m2m2m2m2m2", t0.Add(31*time.Minute)))
w.h.WalkMoved(w.ctl.put(aWalk("plan-lab", lab, "m2m2m2m2m2m2", true)))
w.settleAll()
w.forge.contains["m1m1m1m1m1m1 in m3m3m3m3m3m3"] = true
deliverLater(w, ctl, 3, "main", "e3e3e3e3e3e3e3e3", "m3m3m3m3m3m3", t0.Add(35*time.Minute), walkDone, "mesh-controller")
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if w.state(ctlID) != Covered {
t.Fatalf("the controller's member is %s", w.state(ctlID))
}
if w.state(labID) != Delivering || !containsString(w.ctl.delivers, "plan-lab") {
t.Fatalf("the member after it is %s, let go %v", w.state(labID), w.ctl.delivers)
}
lw := w.ctl.walk("plan-lab")
lw.State = walkDone
w.h.WalkMoved(w.ctl.put(lw))
w.settleAll()
if state, why := GroupState(w.h.groups["feat/g"], w.h.membersOf(w.h.groups["feat/g"])); state != "delivered" {
t.Fatalf("the group is %s: %s", state, why)
}
}
// **On rollout, the deliveries already held close by themselves** (issue 319's held ones), and a restart
// after that moves nothing again: what was read back is where it stays.
func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
coverer := func(repo, module string) *Delivery {
return &Delivery{ID: IDOf(repo, "c0c0c0c0c0c0c0c0"), Repository: repo, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(repo, module), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: []Step{{Machine: "anchor", Module: module, State: StepPassed}, {Machine: "the rest running it", Module: module, State: StepSent}}}
}
held := func(repo string, plan *DeliveryPlan, why string) *Delivery {
return &Delivery{ID: IDOf(repo, head), Repository: repo, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: plan, Transitions: []Transition{{At: merged1, From: Ready, To: Held, Event: EvHold, Why: why}}}
}
const unwalkedWhy = "merged, and the controller opened no walk for it within 10m0s — nothing it holds follows that branch, " +
"or the merge was not heard: a person decides"
withCoverer := func(d *Delivery, change func(c *Delivery)) []*Delivery {
c := coverer(d.Repository, d.Plan.Moved[0])
change(c)
return []*Delivery{d, c}
}
const uncheckedWhy = "merged without a passing check: only a person decides that it goes on"
for _, c := range []struct {
name string
kept []*Delivery
group *Group
want State
}{
{"merged without a passing check, builds nothing",
[]*Delivery{held("novox/hq", planMoving("novox/hq"), "merged without a passing check: only a person decides that it goes on")},
nil, Delivered},
{"a new module sent nowhere, no walk opened",
[]*Delivery{held(cat, &DeliveryPlan{New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere"}, unwalkedWhy)},
nil, Delivered},
{"no walk opened, a later delivered walk carried its module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "mesh-controller")}, nil, Covered},
{"merged without a passing check, a later delivered walk whose own check passed carried its module",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(*Delivery) {}), nil, Covered},
{"merged without a passing check, the later delivery's own check did not pass",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(c *Delivery) {
c.Check = &Verdict{Gate: "fail"}
}), nil, Held},
{"no walk opened, the later delivery's walk was of another commit",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Commit = "e0e0e0e0e0e0e0e0"
}), nil, Held},
{"no walk opened, the later walk sent its module to its first machine only",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Steps = c.Steps[:1]
}), nil, Held},
{"no walk opened, the later walk's branch is another",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Branch = "release"
}), nil, Held},
{"no walk opened, a later delivered walk carried another module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "route-proxy")}, nil, Held},
{"held for its group, its own walk superseded, a later delivered walk carried its module",
func() []*Delivery {
d := held(cat, planMoving(cat, "systemd"), "its group feat/j's composed check did not pass for the heads that "+
"merged; a person decides that it goes on")
d.Group = "feat/j"
d.Walk = &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"}
other := &Delivery{ID: IDOf("novox/mesh-host", "abababababababab"), Repository: "novox/mesh-host",
Commit: "abababababababab", Number: 3, Base: "main", Group: "feat/j", State: Delivered,
MergedAs: "acacacacacacacac", MergedAt: merged1}
return []*Delivery{d, other, coverer(cat, "systemd")}
}(),
&Group{ID: "feat/j", Members: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")}, Closed: true,
Order: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")},
Check: &GroupCheck{Asked: "check-1", Verdict: "pass", Commits: map[string]string{IDOf(cat, head): head,
IDOf("novox/mesh-host", "abababababababab"): "abababababababab"}}},
Covered},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
for _, d := range c.kept {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
if c.group != nil {
if err := store.PutGroup(c.group); err != nil {
t.Fatal(err)
}
}
now := merged2.Add(30 * time.Hour)
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, now)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
id := c.kept[0].ID
d := w.delivery(id)
if d.State != c.want {
t.Fatalf("read back held, it is %s, want %s", d.State, c.want)
}
if s := statusOn(w, d.Repository, d.Commit); c.want != Held && !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
// A restart over what it kept: nothing moves again.
again := newWorldOver(t, store, newFakeController(), forge, now.Add(time.Hour))
if err := again.h.Reconcile(); err != nil {
t.Fatal(err)
}
again.settleAll()
if d2 := again.delivery(id); d2.State != d.State || len(d2.Transitions) != len(d.Transitions) {
t.Fatalf("a restart moved it again: %s with %d transitions, was %s with %d", d2.State, len(d2.Transitions),
d.State, len(d.Transitions))
}
})
}
}
// **A group member is not held for a composed check that has not answered** (issue 316): it waits published,
// and goes on once the check passes for the heads that merged.
func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) {
w := newWorld(t)
const a, b = "novox/a", "novox/b"
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
aID := IDOf(a, aHead)
pa, pb := pr(a, 1, aHead, "feat/k", "x"), pr(b, 2, bHead, "feat/k", "y")
w.h.PullUpdated(pa)
w.h.PullUpdated(pb)
w.h.Checked(verdictMoving(a, 1, aHead, "a"))
w.h.Checked(verdictMoving(b, 2, bHead, "b"))
w.settleAll()
if len(w.ctl.checks) != 1 {
t.Fatalf("the composed check was asked %v", w.ctl.checks)
}
w.h.PullMerged(mergedAt(pa, "m1m1m1m1m1m1", t0))
w.h.WalkMoved(w.ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true)))
w.settleAll()
if s := w.state(aID); s != Published {
t.Fatalf("merged while its group's composed check had not answered, it is %s (%s)", s, w.delivery(aID).HeldWhy)
}
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") {
t.Fatalf("its composed check passed, and it is %s, let go %v", s, w.ctl.delivers)
}
}
// **A member already held for its group's composed check goes on when that check passes for the heads that
// merged** (issue 316); a check that failed, or passed for other heads, keeps it held for a person.
func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
const a, b = "novox/a", "novox/b"
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
aID, bID := IDOf(a, aHead), IDOf(b, bHead)
for _, c := range []struct {
name string
verdict string
commits map[string]string
want State
}{
{"passed for the heads that merged", "pass", map[string]string{aID: aHead, bID: bHead}, Delivering},
{"warned for the heads that merged", "warning", map[string]string{aID: aHead, bID: bHead}, Delivering},
{"failed", "fail", map[string]string{aID: aHead, bID: bHead}, Held},
{"passed for other heads", "pass", map[string]string{aID: "a9a9a9a9a9a9a9a9", bID: bHead}, Held},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
why := "its group feat/k's composed check did not pass for the heads that merged; a person decides that it goes on"
da := &Delivery{ID: aID, Repository: a, Commit: aHead, Number: 1, Base: "main", Branch: "feat/k", Group: "feat/k",
State: Held, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m1m1m1m1m1m1", MergedAt: t0, HeldWhy: why,
Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k",
State: Ready, Since: t0, Created: t0.Add(-time.Hour), Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"}}
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Order: []string{aID, bID}, Closed: true,
OrderOf: map[string]string{aID: aHead, bID: bHead},
Check: &GroupCheck{Asked: "check-1", At: t0, Commits: c.commits}}
for _, d := range []*Delivery{da, db} {
_ = store.PutDelivery(d)
}
_ = store.PutGroup(g)
ctl := newFakeController()
ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))
w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute))
w.settleAll()
if s := w.state(aID); s != Held {
t.Fatalf("before its composed verdict it is %s", s)
}
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: c.verdict, Summary: "composed: " + c.verdict})
w.settleAll()
if s := w.state(aID); s != c.want {
t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want)
}
if c.want == Delivering && !containsString(ctl.delivers, "plan-a") {
t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers)
}
})
}
}
@@ -107,6 +107,8 @@ func statusOf(d *Delivery) (string, string) {
return "error", clip("stopped: "+why, 140)
case Retired:
return "success", "history: merged before this owner delivered anything, never walked"
case Covered:
return "success", clip("covered: a later merge that contains it was delivered: "+d.CoveredBy, 140)
}
return "pending", string(d.State)
}
@@ -95,6 +95,8 @@ type fakeController struct {
down bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
planned *DeliveryPlan
}
func newFakeController() *fakeController {
@@ -108,6 +110,10 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove
if c.down {
return nil, errors.New("down")
}
if c.planned != nil {
p := *c.planned
return &p, nil
}
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
}
func (c *fakeController) Order(ms []Member) (Order, error) {
@@ -229,11 +235,15 @@ type fakeForge struct {
required []string
protectionDown bool
asked []string
// contains answers gitea_contains by "<commit> in <in>"; a pair it does not hold is a forge that cannot say.
contains map[string]bool
containsDown bool
containsAsks []string
}
func newFakeForge() *fakeForge {
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{},
pulls: map[string]ForgePull{}}
pulls: map[string]ForgePull{}, contains: map[string]bool{}}
}
func (f *fakeForge) Note(owner, repo, commit, line string) error {
@@ -341,6 +351,21 @@ func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string
return out, nil
}
// Contains answers what the fake was told of two commits.
func (f *fakeForge) Contains(owner, repo, commit, in string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.containsAsks = append(f.containsAsks, commit+" in "+in)
if f.containsDown || f.down {
return false, errors.New("the forge is away")
}
yes, said := f.contains[commit+" in "+in]
if !said {
return false, fmt.Errorf("the forge has no commit %s or %s", commit, in)
}
return yes, nil
}
func (f *fakeForge) notesOn(commit string) []string {
f.mu.Lock()
defer f.mu.Unlock()
@@ -160,7 +160,7 @@ func (d *Delivery) owe(e Effect) {
func afterTheTrunk(s State) bool {
return s == Published || s == Delivering || s == Held || s == Delivered || s == Failed || s == Stopped ||
s == Superseded || s == Retired
s == Superseded || s == Retired || s == Covered
}
func orNothing(s State) string {
@@ -755,10 +755,32 @@ func (h *Holder) plan() []func() {
}
}
// Held, or merged with no walk opened for it: ended by what is already true of it, never asked of a person
// (novox/hq issue 319). A plan that moves nothing is delivered; one whose every module a later merge into the
// same trunk delivered is covered. Read again each tick, so a delivery read back after a restart, or held
// before this was known, ends by itself.
for _, d := range h.deliveries {
if d.State != Held && !h.unwalkedPast(d, now) {
continue
}
if d.CoveredBy == "" {
var ask []string
d.CoveredBy, ask = h.coverOf(d)
for _, in := range ask {
if a := h.askContains(d, in, now); a != nil {
asks = append(asks, a)
}
}
}
if ts := settle(d, Facts{Now: now}); len(ts) > 0 {
h.moved(d, ts)
h.closeGroupOf(d)
}
}
// Merged, and no walk opened for it after the walks were read again: never silent — held, saying so.
for _, d := range h.deliveries {
if d.State == Ready && d.MergedAs != "" && d.Walk == nil && !d.MovesNothing() && d.HeldWhy == "" &&
now.Sub(d.MergedAt) > unwalked && h.reconciled.After(d.MergedAt.Add(unwalked)) {
if h.unwalkedPast(d, now) {
d.HeldWhy = "merged, and the controller opened no walk for it within " + unwalked.String() + " — nothing it " +
"holds follows that branch, or the merge was not heard: a person decides"
h.settleAndKeep(d)
@@ -796,6 +818,128 @@ func (h *Holder) plan() []func() {
return asks
}
// unwalkedPast is whether a merged delivery has gone without a walk past the bound one opens within, read
// again after it: what holds it for a person, unless something already ends it.
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
return d.State == Ready && d.MergedAs != "" && d.Walk == nil && !d.MovesNothing() && d.HeldWhy == "" &&
now.Sub(d.MergedAt) > unwalked && h.reconciled.After(d.MergedAt.Add(unwalked))
}
// coverOf says which later merges cover a merged delivery, or nothing, and which later merge commits the forge
// must still be asked about (novox/hq ADR 0270). A module is covered by a later delivery that carries it (see
// carries) and whose merge commit the forge said contains this one's merge. Merge order is the cheap filter;
// ancestry decides. The forge is asked only once every module has a candidate, once per pair, and its answer
// is kept on the delivery: an answer not known yet is "not yet covered".
func (h *Holder) coverOf(d *Delivery) (string, []string) {
if d.MergedAs == "" || d.MergedAt.IsZero() || d.Base == "" || d.Plan == nil || len(d.Plan.Moved) == 0 {
return "", nil
}
candidates := map[string][]*Delivery{}
for _, module := range d.Plan.Moved {
for _, c := range h.deliveries {
if carries(c, d, module) {
candidates[module] = append(candidates[module], c)
}
}
if len(candidates[module]) == 0 {
return "", nil // nothing later carried it: nothing to ask
}
}
carried := map[string][]string{}
var by []*Delivery
var ask []string
for _, module := range d.Plan.Moved {
var first *Delivery
for _, c := range candidates[module] {
yes, known := d.ContainedIn[c.MergedAs]
if !known && !containsString(ask, c.MergedAs) {
ask = append(ask, c.MergedAs)
}
if yes && (first == nil || c.MergedAt.Before(first.MergedAt) || c.MergedAt.Equal(first.MergedAt) && c.ID < first.ID) {
first = c
}
}
if first == nil {
return "", ask
}
if carried[first.ID] == nil {
by = append(by, first)
}
carried[first.ID] = append(carried[first.ID], module)
}
sort.Slice(by, func(i, j int) bool { return by[i].MergedAt.Before(by[j].MergedAt) })
var parts []string
for _, c := range by {
mods := carried[c.ID]
sort.Strings(mods)
parts = append(parts, fmt.Sprintf("%s (merged as %s into %s, walk %s): %s", c.ID, shortOf(c.MergedAs, 8), d.Base,
c.Walk.ID, strings.Join(mods, ", ")))
}
return strings.Join(parts, "; "), nil
}
// carries is whether c delivered a module after d: of the same repository, merged later into the same trunk,
// delivered on its own passing check — a merge held without one is covered only by a later merge that passed
// its own — by its own walk, of its merge commit on that trunk, which sent the module to every machine running
// it. Whether c's merge commit contains d's is the forge's to say (coverOf).
func carries(c, d *Delivery, module string) bool {
if c.ID == d.ID || c.State != Delivered || !c.Check.Passes() || !strings.EqualFold(c.Repository, d.Repository) ||
!strings.EqualFold(c.Base, d.Base) || c.MergedAs == "" || sameCommit(c.MergedAs, d.MergedAs) ||
!c.MergedAt.After(d.MergedAt) || c.Walk == nil || c.Walk.State != walkDone || !sameCommit(c.Walk.Commit, c.MergedAs) ||
c.Walk.Branch != "" && !strings.EqualFold(c.Walk.Branch, d.Base) {
return false
}
everywhere := false
for _, s := range c.Steps {
if s.Module != module {
continue
}
switch {
case s.State == StepFailed || s.State == StepRolledBack:
return false
case s.State == StepSent && (s.Machine == stepEveryMachine || s.Machine == stepTheRest):
everywhere = true
}
}
return everywhere
}
// askContains is the ask of the forge whether a later merge commit contains a delivery's merge: at most once a
// minute while the forge cannot say, and never again once it said — its answer is kept on the delivery.
func (h *Holder) askContains(d *Delivery, in string, now time.Time) func() {
key := d.ID + " in " + in
if tried, was := h.askTried[key]; was && now.Sub(tried) < time.Minute {
return nil
}
h.askTried[key] = now
id, owner, repo, merged := d.ID, d.Owner(), d.Repo(), d.MergedAs
return func() {
yes, err := h.Forge.Contains(owner, repo, merged, in)
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] %s: whether %s contains its merge cannot be read yet (%v): not yet covered", id,
shortOf(in, 12), err)
return
}
delete(h.askTried, key)
d := h.deliveries[id]
if d == nil {
return
}
if d.ContainedIn == nil {
d.ContainedIn = map[string]bool{}
}
d.ContainedIn[in] = yes
h.keep(d)
}
}
// heldForGroup is whether a member is held only because its group's composed check had not passed.
func heldForGroup(d *Delivery, g *Group) bool {
return d.State == Held && strings.HasPrefix(d.HeldWhy, fmt.Sprintf("its group %s's composed check", g.ID))
}
// askCheck is the ask of a proposed delivery's own check, when nothing else will bring its verdict: no verdict,
// no check this owner asked, and proposed past the grace the controller's own check of its announcement is
// given. Nil otherwise — once asked, a late verdict is the bound's and H2's to speak for.
@@ -1031,7 +1175,7 @@ func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
for i, m := range ordered {
d := byID[m.ID]
switch {
case d.State == Delivered || d.State == Retired:
case d.State.Reached():
continue
case d.State == Failed || d.State == Stopped || d.State == Superseded:
var asks []func()
@@ -1041,7 +1185,27 @@ func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
}
}
return asks
case d.State == Held:
// Held for a composed check that has since passed for the heads that merged: lifted, published again,
// and its turn taken in order (novox/hq issue 316). Any other hold is a person's.
if composed && heldForGroup(d, g) && walkCanGoOn(d) {
d.HeldWhy = ""
d.Lifted = fmt.Sprintf("its group %s's composed check said %s for the heads that merged: %s", g.ID,
g.Check.Verdict, g.Check.Summary)
h.settleAndKeep(d)
if d.State == Published {
if a := h.turn(d, fmt.Sprintf("its turn in group %s (%d of %d)", g.ID, i+1, len(ordered))); a != nil {
return []func(){a}
}
}
}
return nil
case d.State == Published:
// Asked and not answered is not "did not pass": it waits, published, for the answer (issue 316).
if !composed && d.HeldWhy == "" && g.Check != nil && g.Check.Verdict == "" &&
sameMembers(g.Check.Commits, commitsOf(members)) {
return nil
}
if !composed && d.HeldWhy == "" {
d.HeldWhy = fmt.Sprintf("its group %s's composed check did not pass for the heads that merged; a person "+
"decides that it goes on", g.ID)
@@ -1093,10 +1257,10 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
return "failed", "a member failed"
case count[Stopped] > 0 && g.Closed:
return "stopped", "a member was stopped"
case n > 0 && count[Delivered]+count[Retired] == n:
case n > 0 && count[Delivered]+count[Retired]+count[Covered] == n:
return "delivered", "every member is delivered"
case g.Closed:
return "delivering", fmt.Sprintf("%d of %d delivered", count[Delivered], n)
return "delivering", fmt.Sprintf("%d of %d delivered", count[Delivered]+count[Covered], n)
case len(g.Cycle) > 0:
return "rejected", cycleSaid(g)
case count[Rejected] > 0:
@@ -65,7 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses")
"gitea.gitea_commit_statuses", "gitea.gitea_contains")
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
}
@@ -61,6 +61,13 @@ type Delivery struct {
// History is why it is history, once it is (novox/hq issue 313).
FromForge bool `json:"from_forge,omitempty"`
History string `json:"history,omitempty"`
// CoveredBy says which later merges into the same trunk delivered every module it moves, once they did
// (novox/hq issue 319); Lifted why what held it for a person no longer holds (issue 316).
CoveredBy string `json:"covered_by,omitempty"`
Lifted string `json:"lifted,omitempty"`
// ContainedIn is the forge's answer, by a later merge commit, to whether that commit contains this one's
// merge (gitea_contains): asked once per pair, and kept (ADR 0270).
ContainedIn map[string]bool `json:"contained_in,omitempty"`
// Walk is the controller's walk of its trunk commit, as last read; Steps the machines it reached.
Walk *WalkSeen `json:"walk,omitempty"`
Steps []Step `json:"steps,omitempty"`
@@ -144,10 +151,11 @@ type MachinePlan struct {
Waits []string `json:"waits,omitempty"`
}
// MovesNothing is whether the delivery's plan moves no module and adds none: nothing for a walk to do. A
// delivery with no plan known is not said to move nothing.
// MovesNothing is whether the delivery's plan moves no module the mesh holds: nothing for a walk to do. A
// module it adds is not moved: the controller builds and registers it and sends it nowhere, and opens no walk
// for it (novox/hq issues 300 and 319). A delivery with no plan known is not said to move nothing.
func (d *Delivery) MovesNothing() bool {
return d.Plan != nil && len(d.Plan.Moved)+len(d.Plan.New) == 0
return d.Plan != nil && len(d.Plan.Moved) == 0
}
// Step is one machine's part of a delivering walk.
@@ -338,6 +346,8 @@ type WalkSeen struct {
Tier int `json:"tier"`
Tiers int `json:"tiers"`
Updated time.Time `json:"updated"`
// Branch is the branch the walk's commit was merged into; empty in a record kept before it was read.
Branch string `json:"branch,omitempty"`
}
// Seen is a walk as a delivery keeps it.
@@ -350,6 +360,7 @@ func (w Walk) Seen() *WalkSeen {
s.GoBy = d.By
s.StoppedBy = d.Stopped
}
s.Branch = w.Branch
return s
}
@@ -366,6 +377,13 @@ func (s *WalkSeen) LetGoByAPerson() bool { return s != nil && strings.HasPrefix(
// Open is whether the controller still works it.
func (w Walk) Open() bool { return w.State == walkBuilding || w.State == walkRolling }
// The steps that say a module reached every machine running it: sent to all at once, or to the rest after its
// first machines passed.
const (
stepEveryMachine = "every machine running it"
stepTheRest = "the rest running it"
)
// StepsOf are the machine steps a walk's record says, for one delivery: every module it moved, on its first
// machines from its gate, and the rest once sent.
func StepsOf(w Walk) []Step {
@@ -379,7 +397,7 @@ func StepsOf(w Walk) []Step {
m := w.Modules[name]
if m == nil || m.FirstAt == nil {
if m != nil && m.SentAt != nil && m.FirstAt == nil && m.Why == "" {
out = append(out, Step{Machine: "every machine running it", Module: name, Build: m.Build,
out = append(out, Step{Machine: stepEveryMachine, Module: name, Build: m.Build,
State: StepSent, Since: *m.SentAt})
}
continue
@@ -406,7 +424,7 @@ func StepsOf(w Walk) []Step {
out = append(out, Step{Machine: machine, Module: name, Build: m.Build, State: state, Since: since, Why: why})
}
if m.SentAt != nil && state == StepPassed {
out = append(out, Step{Machine: "the rest running it", Module: name, Build: m.Build, State: StepSent,
out = append(out, Step{Machine: stepTheRest, Module: name, Build: m.Build, State: StepSent,
Since: *m.SentAt})
}
}
@@ -76,6 +76,8 @@ type Forge interface {
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
// it merges into would let it merge. Reads only.
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
Contains(owner, repo, commit, in string) (bool, error)
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -337,6 +339,24 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil {
return false, err
}
answer, output, ok := answerOf(raw)
if !ok {
return false, fmt.Errorf("the forge refused gitea_contains: %s", lastLine(output))
}
var a struct {
Contains *bool `json:"contains"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Contains == nil {
return false, fmt.Errorf("the forge's gitea_contains is not readable: %s", clip(string(answer), 200))
}
return *a.Contains, nil
}
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
type kvStore struct{}
@@ -34,17 +34,27 @@ const (
// replayed from the bus when it first held the seat. It reached the machines before this owner existed,
// and nothing is delivered, released or put back for it (novox/hq issue 313).
Retired State = "retired"
// Covered is a merge a later one that contains it delivered: every module it moves was sent to every machine
// running it by the finished walk of a later merge into the same trunk, whose own check passed and which the
// forge says contains this one. It is not walked itself, and it is no failure: its pull request says covered,
// as a success (novox/hq ADR 0270). A revert merged in between is still possible: covered says what was
// delivered, not what runs.
Covered State = "covered"
)
// AllStates are every state a delivery can be in, for the table's own test and its verb.
var AllStates = []State{Proposed, Checked, Ready, Rejected, Published, Delivering, Held, Delivered, Failed,
Superseded, Stopped, Retired}
Superseded, Stopped, Retired, Covered}
// Final is whether nothing follows a state.
func (s State) Final() bool {
return s == Delivered || s == Failed || s == Superseded || s == Stopped || s == Retired
return s == Delivered || s == Failed || s == Superseded || s == Stopped || s == Retired || s == Covered
}
// Reached is whether a final state says the change reached the machines, or had nothing to reach them
// with: what a group counts as delivered.
func (s State) Reached() bool { return s == Delivered || s == Retired || s == Covered }
// Event is what moves a delivery.
type Event string
@@ -72,6 +82,8 @@ const (
EvStop Event = "stop" // a person stopped it, or its group did
EvHistory Event = "history" // made from the forge's word for a merge long past: history
EvRetire Event = "retire" // a person's word that a held delivery is history, never to be walked
EvCovered Event = "covered" // a later merge into the same trunk delivered every module it moves
EvLifted Event = "lifted" // what held it for a person is no longer so: its group's composed check passed
)
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
@@ -91,6 +103,8 @@ type Row struct {
Holds func(d *Delivery, f Facts) error
// Act says the row is taken only when asked — a person's or H2's — never by settle.
Act bool
// Says is the transition's why when nobody gave one: the fact that moved it, rather than the guard.
Says func(d *Delivery) string
}
func unless(cond bool, why string) error {
@@ -161,6 +175,29 @@ var Table = []Row{
{From: []State{Checked}, Event: EvRefused, To: Rejected,
Guard: "the gate failed or could not run, or the repository's own check failed",
Holds: func(d *Delivery, f Facts) error { return unless(!d.Check.Passes(), "the verdict passes") }},
{From: []State{Proposed, Checked, Rejected, Held}, Event: EvDone, To: Delivered,
Guard: "merged, and its plan builds nothing for a walk to move — nothing at all, or only a new module the " +
"controller builds and sends nowhere: nothing to deliver and nothing for a person to decide, whatever its check said",
Holds: func(d *Delivery, f Facts) error {
return unless(d.MergedAs != "" && d.MovesNothing() && d.Walk == nil, "not merged, or a walk moves it")
},
Says: func(d *Delivery) string { return "merged, and nothing for a walk to move: " + d.Plan.Summary }},
{From: []State{Ready, Held}, Event: EvCovered, To: Covered,
Guard: "merged, no walk of its own open, and every module it moves sent to every machine running it by the " +
"finished walk of a later merge into the same trunk, whose own check passed and which the forge says contains " +
"it: a later merge that contains it was delivered",
Holds: func(d *Delivery, f Facts) error {
switch {
case d.MergedAs == "" || d.CoveredBy == "":
return errors.New("not merged, or nothing later carried its modules")
case d.Walk != nil && (d.Walk.State == walkBuilding || d.Walk.State == walkRolling):
return fmt.Errorf("its own walk %s is open: the controller delivers it", d.Walk.ID)
}
return nil
},
Says: func(d *Delivery) string {
return "covered: a later merge that contains it was delivered: " + d.CoveredBy
}},
{From: []State{Proposed, Checked, Rejected}, Event: EvMergedUnchecked, To: Held,
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides that it goes on",
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
@@ -208,6 +245,13 @@ var Table = []Row{
return unless(d.Walk != nil && d.Walk.Started() && (!d.Walk.Waited() || d.Walk.LetGoByAPerson()),
"its walk was not started by a person or the controller's own path")
}},
{From: []State{Held}, Event: EvLifted, To: Published,
Guard: "held only for its group's composed check, which has since passed or warned for the heads that merged: " +
"it is published again and waits for its turn",
Holds: func(d *Delivery, f Facts) error {
return unless(d.Lifted != "" && d.HeldWhy == "" && d.MergedAs != "" && walkCanGoOn(d), "nothing lifted its hold")
},
Says: func(d *Delivery) string { return "lifted: " + d.Lifted }},
{From: []State{Held}, Event: EvRelease, To: Delivering, Act: true,
Guard: "a person's decision, with why",
Holds: func(d *Delivery, f Facts) error {
@@ -240,6 +284,15 @@ var Table = []Row{
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "" && f.By != "", "a stop says who and why") }},
}
// walkCanGoOn is whether a delivery published again would be delivered by what it has: its own walk still
// open, or nothing for a walk to move.
func walkCanGoOn(d *Delivery) bool {
if d.Walk == nil {
return d.MovesNothing()
}
return d.Walk.State == walkBuilding || d.Walk.State == walkRolling
}
// historyLate is how long after its merge a merge heard only from the forge's word is history: the forge
// announces a merge within seconds, so one heard this late was replayed from the bus's history.
const historyLate = 15 * time.Minute
@@ -309,6 +362,9 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev, Why: "its guard does not hold: " + err.Error()}
}
why := f.Why
if why == "" && r.Says != nil {
why = r.Says(d)
}
if why == "" {
why = r.Guard
}
@@ -55,11 +55,19 @@ func aDeliveryFor(r Row) (*Delivery, Facts) {
case EvHold:
d.HeldWhy, d.MergedAs = "its group's check did not pass", "aaaa1111"
case EvDone:
if r.From[0] == Published {
switch {
case r.From[0] == Published:
d.Plan = &DeliveryPlan{Summary: "moves nothing"}
} else {
case r.From[0] == Proposed:
d.MergedAs, d.Plan = "aaaa1111", &DeliveryPlan{Summary: "builds nothing"}
default:
d.Walk = &WalkSeen{ID: "plan-1", State: walkDone}
}
case EvCovered:
d.MergedAs, d.CoveredBy = "aaaa1111", "delivered with novox/app@bbbb (merged as bbbb2222, walk plan-2): app"
case EvLifted:
d.MergedAs, d.Lifted = "aaaa1111", "its group's composed check passed"
d.Walk = &WalkSeen{ID: "plan-1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}
case EvFailed:
d.Walk = &WalkSeen{ID: "plan-1", State: walkFailed}
case EvSuperseded:
@@ -104,7 +112,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
events := []Event{EvAnnounced, EvAppeared, EvAdopted, EvChecked, EvAccepted, EvRefused, EvRecheck, EvReask, EvNewHead,
EvClosed, EvMerged, EvMergedUnchecked, EvGo, EvHold, EvRelease, EvDone, EvFailed, EvSuperseded, EvStop,
EvHistory, EvRetire}
EvHistory, EvRetire, EvCovered, EvLifted}
for _, from := range append([]State{None}, AllStates...) {
for _, ev := range events {
for _, act := range []bool{false, true} {
@@ -140,10 +148,16 @@ func TestTheTableKeepsItsRules(t *testing.T) {
t.Errorf("%s goes back to %s by %s", from, r.To, r.Event)
}
}
// The trunk rule: only a ready delivery is published, by its merge; nothing off the trunk delivers.
if r.To == Published && (r.Event != EvMerged || len(r.From) != 1 || r.From[0] != Ready) {
// The trunk rule: only a ready delivery is published, by its merge — or a held one published again when
// what held it is gone (issue 316); nothing off the trunk delivers.
if r.To == Published && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) &&
!(r.Event == EvLifted && len(r.From) == 1 && r.From[0] == Held) {
t.Errorf("%v —%s→ published: only a ready delivery's merge publishes", r.From, r.Event)
}
// Nothing a person did not decide ends a delivery as stopped or failed by observation from held.
if contains(r.From, Held) && !r.Act && (r.To == Stopped || r.To == Failed) {
t.Errorf("held —%s→ %s by observation", r.Event, r.To)
}
if r.To == Delivering {
for _, from := range r.From {
if from == Proposed || from == Checked || from == Ready || from == Rejected {
+2 -1
View File
@@ -43,7 +43,8 @@
"gitea.gitea_note_append",
"gitea.gitea_delivery_view",
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses"
"gitea.gitea_commit_statuses",
"gitea.gitea_contains"
],
"state": [
"deliveries",
+252
View File
@@ -0,0 +1,252 @@
package main
// Asking one named resolver directly (hq issue 348). netcheck_dns without a server asks the machine's
// resolver the way its programs do — resolv.conf, hosts, search domains, several servers in turn — which
// answers "can this machine resolve?" and hides which resolver answered. During the 2026-10-09 outage the
// question was "does the resolver at 10.10.0.1 answer?", and nothing on the mesh could ask it. Asked with a
// server, the question goes to that address and port alone, over UDP or TCP, and the answer says its
// response code: a refusal, no such name, and no answer at all are three different findings.
//
// The wire format is written here rather than taken from a library: a question and the records of five
// types are all it needs, and the module stays without a dependency.
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
)
var dnsTypeCodes = map[string]uint16{"A": 1, "CNAME": 5, "MX": 15, "TXT": 16, "AAAA": 28}
var rcodeWords = map[int]string{0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}
// serverAddress is a server as asked — an address, with a port or without (53) — as host:port.
func serverAddress(server string) (string, error) {
server = strings.TrimSpace(server)
if host, port, err := net.SplitHostPort(server); err == nil {
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return "", fmt.Errorf("server %q: the port is not one (1-65535)", server)
}
if net.ParseIP(host) == nil {
return "", fmt.Errorf("server %q: an address, not a name — a resolver is asked by the address it listens on", server)
}
return net.JoinHostPort(host, port), nil
}
if net.ParseIP(server) == nil {
return "", fmt.Errorf("server %q: an address, not a name — a resolver is asked by the address it listens on", server)
}
return net.JoinHostPort(server, "53"), nil
}
// askServer asks one resolver one question and reads its answer.
func askServer(server, name, kind string, overTCP bool, timeout time.Duration) (rcode int, answers []string, err error) {
addr, err := serverAddress(server)
if err != nil {
return 0, nil, err
}
query, id, err := buildQuery(name, dnsTypeCodes[kind])
if err != nil {
return 0, nil, err
}
network := "udp"
if overTCP {
network = "tcp"
}
conn, err := net.DialTimeout(network, addr, timeout)
if err != nil {
return 0, nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
var reply []byte
if overTCP {
framed := binary.BigEndian.AppendUint16(nil, uint16(len(query)))
if _, err := conn.Write(append(framed, query...)); err != nil {
return 0, nil, err
}
var size [2]byte
if _, err := readFull(conn, size[:]); err != nil {
return 0, nil, err
}
reply = make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := readFull(conn, reply); err != nil {
return 0, nil, err
}
} else {
if _, err := conn.Write(query); err != nil {
return 0, nil, err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return 0, nil, err
}
reply = buf[:n]
}
return parseReply(reply, id, dnsTypeCodes[kind])
}
func readFull(conn net.Conn, b []byte) (int, error) {
got := 0
for got < len(b) {
n, err := conn.Read(b[got:])
got += n
if err != nil {
return got, err
}
}
return got, nil
}
// buildQuery is one question, recursion desired, under a random id.
func buildQuery(name string, qtype uint16) ([]byte, uint16, error) {
var idb [2]byte
_, _ = rand.Read(idb[:])
id := binary.BigEndian.Uint16(idb[:])
msg := binary.BigEndian.AppendUint16(nil, id)
msg = append(msg, 0x01, 0x00) // recursion desired
msg = append(msg, 0, 1, 0, 0, 0, 0, 0, 0) // one question
wire, err := encodeName(name)
if err != nil {
return nil, 0, err
}
msg = append(msg, wire...)
msg = binary.BigEndian.AppendUint16(msg, qtype)
msg = binary.BigEndian.AppendUint16(msg, 1) // IN
return msg, id, nil
}
func encodeName(name string) ([]byte, error) {
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
var out []byte
if name != "" {
for _, label := range strings.Split(name, ".") {
if label == "" || len(label) > 63 {
return nil, fmt.Errorf("name %q has an empty or too long label", name)
}
out = append(out, byte(len(label)))
out = append(out, label...)
}
}
return append(out, 0), nil
}
var errShort = errors.New("the answer is cut short")
// readName reads a possibly compressed name at off, and the offset after it.
func readName(msg []byte, off int) (string, int, error) {
var labels []string
next, jumped := -1, 0
for {
if off >= len(msg) {
return "", 0, errShort
}
l := int(msg[off])
switch {
case l == 0:
if next < 0 {
next = off + 1
}
return strings.Join(labels, "."), next, nil
case l&0xC0 == 0xC0:
if off+1 >= len(msg) {
return "", 0, errShort
}
if next < 0 {
next = off + 2
}
if jumped++; jumped > 32 {
return "", 0, errors.New("the answer's names point in a loop")
}
off = int(binary.BigEndian.Uint16(msg[off:]) & 0x3FFF)
default:
if off+1+l > len(msg) {
return "", 0, errShort
}
labels = append(labels, string(msg[off+1:off+1+l]))
off += 1 + l
}
}
}
// parseReply reads the response code and the answers of the asked type.
func parseReply(msg []byte, id, qtype uint16) (int, []string, error) {
if len(msg) < 12 {
return 0, nil, errShort
}
if binary.BigEndian.Uint16(msg) != id {
return 0, nil, errors.New("the answer is to another question (its id differs)")
}
if msg[2]&0x80 == 0 {
return 0, nil, errors.New("the answer is not marked as one")
}
rcode := int(msg[3] & 0x0F)
qd, an := int(binary.BigEndian.Uint16(msg[4:])), int(binary.BigEndian.Uint16(msg[6:]))
off := 12
for i := 0; i < qd; i++ {
_, n, err := readName(msg, off)
if err != nil {
return rcode, nil, err
}
off = n + 4
}
answers := []string{}
for i := 0; i < an; i++ {
_, n, err := readName(msg, off)
if err != nil {
return rcode, answers, err
}
off = n
if off+10 > len(msg) {
return rcode, answers, errShort
}
rtype := binary.BigEndian.Uint16(msg[off:])
size := int(binary.BigEndian.Uint16(msg[off+8:]))
data := off + 10
if data+size > len(msg) {
return rcode, answers, errShort
}
rdata := msg[data : data+size]
off = data + size
if rtype != qtype {
continue // a CNAME on the way to an address, say
}
switch rtype {
case 1, 28:
answers = append(answers, net.IP(rdata).String())
case 5:
s, _, err := readName(msg, data)
if err != nil {
return rcode, answers, err
}
answers = append(answers, s)
case 15:
if size < 3 {
return rcode, answers, errShort
}
s, _, err := readName(msg, data+2)
if err != nil {
return rcode, answers, err
}
answers = append(answers, fmt.Sprintf("%d %s", binary.BigEndian.Uint16(rdata), s))
case 16:
var parts []string
for p := 0; p < len(rdata); {
l := int(rdata[p])
if p+1+l > len(rdata) {
return rcode, answers, errShort
}
parts = append(parts, string(rdata[p+1:p+1+l]))
p += 1 + l
}
answers = append(answers, strings.Join(parts, ""))
}
}
return rcode, answers, nil
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"encoding/binary"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// respond answers one query: novox.internal A 10.10.0.1 via a compressed name, anything else rcode.
func respond(q []byte, rcode byte) []byte {
end := 12
for q[end] != 0 {
end += int(q[end]) + 1
}
question := q[12 : end+5]
name := strings.ToLower(string(q[13:end]))
r := append([]byte{}, q[:2]...)
r = append(r, 0x81, 0x80|rcode, 0, 1)
answer := rcode == 0 && strings.Contains(name, "novox") && binary.BigEndian.Uint16(q[end+1:]) == 1
if answer {
r = append(r, 0, 1, 0, 0, 0, 0)
} else {
r = append(r, 0, 0, 0, 0, 0, 0)
}
r = append(r, question...)
if answer {
r = append(r, 0xC0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 10, 10, 0, 1)
}
return r
}
func udpResolver(t *testing.T, rcode byte) string {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 512)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
_, _ = pc.WriteTo(respond(buf[:n], rcode), from)
}
}()
return pc.LocalAddr().String()
}
func tcpResolver(t *testing.T) string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
var size [2]byte
if _, err := readFull(c, size[:]); err == nil {
q := make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := readFull(c, q); err == nil {
r := respond(q, 0)
_, _ = c.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(r))), r...))
}
}
c.Close()
}
}()
return l.Addr().String()
}
// Issue 348: the question that could not be asked during the outage — does the resolver at one address answer?
func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T) {
got, err := AskDNS("novox.internal", "A", udpResolver(t, 0), false, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "udp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" || got.Error != "" {
t.Fatalf("udp: %+v, %v", got, err)
}
got, err = AskDNS("novox.internal", "A", tcpResolver(t), true, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "tcp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" {
t.Fatalf("tcp: %+v, %v", got, err)
}
got, _ = AskDNS("nope.internal", "A", udpResolver(t, 3), false, time.Second)
if got.Rcode != "NXDOMAIN" || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("no such name is a result with its code: %+v", got)
}
got, _ = AskDNS("novox.internal", "A", udpResolver(t, 5), false, time.Second)
if got.Rcode != "REFUSED" || got.Error == "" {
t.Fatalf("a refusal: %+v", got)
}
}
func TestDNSSaysARefusedPortAndRefusesANameAsServer(t *testing.T) {
l, _ := net.Listen("tcp", "127.0.0.1:0")
addr := l.Addr().String()
l.Close()
got, err := AskDNS("novox.internal", "A", addr, true, time.Second)
if err != nil || !strings.Contains(got.Error, "connection refused") {
t.Fatalf("nothing listening is a result naming it: %+v, %v", got, err)
}
if _, err := AskDNS("novox.internal", "A", "resolver.example", false, time.Second); err == nil {
t.Fatal("a server given by name was accepted")
}
if _, err := AskDNS("novox.internal", "A", "", true, time.Second); err == nil {
t.Fatal("tcp without a server was accepted")
}
if a, _ := serverAddress("10.10.0.1"); a != "10.10.0.1:53" {
t.Fatalf("default port: %s", a)
}
}
func TestListeningReadsTheKernelsTablesAndSaysWhatItCouldNotSee(t *testing.T) {
root := t.TempDir()
must := func(err error) {
if err != nil {
t.Fatal(err)
}
}
must(os.MkdirAll(filepath.Join(root, "net"), 0o755))
head := " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
// 127.0.0.1:53 and 10.10.0.1:53 listening, a connection on 22 (not listening), and an udp 53 bound on loopback only.
must(os.WriteFile(filepath.Join(root, "net", "tcp"), []byte(head+
" 0: 0100007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 111 1 0 100 0 0 10 0\n"+
" 1: 01000A0A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 112 1 0 100 0 0 10 0\n"+
" 2: 0100007F:0016 0200007F:9C40 01 00000000:00000000 00:00000000 00000000 0 0 113 1 0 100 0 0 10 0\n"), 0o644))
must(os.WriteFile(filepath.Join(root, "net", "udp"), []byte(head+
" 0: 0100007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 121 2 0 0\n"), 0o644))
must(os.MkdirAll(filepath.Join(root, "42", "fd"), 0o755))
must(os.WriteFile(filepath.Join(root, "42", "comm"), []byte("dnsmasq\n"), 0o644))
must(os.Symlink("socket:[111]", filepath.Join(root, "42", "fd", "5")))
got := Listening(root, 53)
if got.Count != 3 || len(got.Unread) != 0 {
t.Fatalf("%+v", got)
}
var where []string
for _, s := range got.Sockets {
where = append(where, s.Protocol+" "+s.Address)
}
if strings.Join(where, ",") != "tcp 10.10.0.1,tcp 127.0.0.1,udp 127.0.0.1" {
t.Fatalf("sockets: %v", where)
}
if got.Sockets[1].Process != "42/dnsmasq" || got.Sockets[0].Owner == "" || !strings.Contains(got.Owners, "2 socket(s)") {
t.Fatalf("owners: %+v", got)
}
if missing := Listening(t.TempDir(), 0); len(missing.Unread) != 2 {
t.Fatalf("an unreadable table is said, not left out: %+v", missing)
}
}
func TestListeningOnThisMachineFindsASocketItOpened(t *testing.T) {
if _, err := os.Stat("/proc/net/tcp"); err != nil {
t.Skip("no /proc here")
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
port := l.Addr().(*net.TCPAddr).Port
got := Listening("/proc", port)
if got.Count != 1 || got.Sockets[0].Address != "127.0.0.1" {
t.Fatalf("%+v", got)
}
if got.Sockets[0].Process == "" {
t.Fatalf("its own socket's program is not named: %+v", got)
}
}
+58 -2
View File
@@ -50,8 +50,13 @@ func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
Name string `json:"name"`
Type string `json:"type"`
// Server and Protocol say which resolver was asked when one was named; empty, the machine's.
Server string `json:"server,omitempty"`
Protocol string `json:"protocol,omitempty"`
// Rcode is the named resolver's response code: NOERROR, NXDOMAIN, REFUSED, SERVFAIL…
Rcode string `json:"rcode,omitempty"`
Answers []string `json:"answers"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
@@ -64,6 +69,13 @@ var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
// A name that does not resolve is a result with its error; an unknown type is refused.
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
return AskDNS(name, kind, "", false, timeout)
}
// AskDNS is CheckDNS, or — with a server — the same question put to that resolver alone, over UDP or
// TCP, its hosts file and search domains left out (hq issue 348). No answer, a refusal and a response
// code other than NOERROR are results with their error; a malformed question is refused.
func AskDNS(name, kind, server string, overTCP bool, timeout time.Duration) (DNSResult, error) {
kind = strings.ToUpper(strings.TrimSpace(kind))
if kind == "" {
kind = "A"
@@ -76,6 +88,37 @@ func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
}
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
if server != "" {
if _, err := serverAddress(server); err != nil {
return DNSResult{}, err
}
out.Server, out.Protocol = server, "udp"
if overTCP {
out.Protocol = "tcp"
}
start := time.Now()
rcode, answers, err := askServer(server, name, kind, overTCP, timeout)
out.ElapsedMS = time.Since(start).Milliseconds()
if err == nil {
out.Rcode = rcodeWords[rcode]
if out.Rcode == "" {
out.Rcode = "RCODE" + strconv.Itoa(rcode)
}
if rcode != 0 {
out.Error = "the resolver answered " + out.Rcode
}
}
out.Answers = append(out.Answers, answers...)
if err != nil {
out.Error = dnsWords(err, timeout)
}
if kind != "MX" {
sort.Strings(out.Answers)
}
return out, nil
} else if overTCP {
return DNSResult{}, errors.New("tcp asks a named server; give server too")
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := net.DefaultResolver
@@ -171,3 +214,16 @@ func timeoutOf(args map[string]any) (time.Duration, error) {
}
return d, nil
}
// dnsWords is a failed question in words: refused (nothing listens there), no answer in time, or what the
// system said.
func dnsWords(err error, timeout time.Duration) string {
var ne net.Error
switch {
case strings.Contains(err.Error(), "connection refused"):
return "connection refused: nothing listens on that address and port"
case errors.As(err, &ne) && ne.Timeout():
return fmt.Sprintf("no answer within %s", timeout)
}
return err.Error()
}
+2 -2
View File
@@ -57,12 +57,12 @@ func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
func TestTheToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
if !names["netcheck_tcp"] || !names["netcheck_dns"] || !names["netcheck_listening"] || len(names) != 3 {
t.Fatalf("tools: %v", names)
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
// What listens on this machine (hq issue 348): the kernel's own socket tables, read where `ss -lunt` reads
// them. During the 2026-10-09 outage the resolver's unit stayed active while it no longer listened on its
// private address, and nothing on the mesh could show that: whether a socket is bound is the kernel's to
// say, not the unit's.
import (
"bufio"
"encoding/hex"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Socket is one listening TCP socket or one bound UDP socket.
type Socket struct {
Protocol string `json:"protocol"`
Address string `json:"address"`
Port int `json:"port"`
// Process is the program holding it, "pid/name"; empty when Owner says why it is not known.
Process string `json:"process,omitempty"`
Owner string `json:"owner,omitempty"`
inode string
}
// ListeningResult is what netcheck_listening answers.
type ListeningResult struct {
Sockets []Socket `json:"sockets"`
Count int `json:"count"`
// Unread names a table that could not be read: a socket in it is missing from the list, not absent.
Unread []string `json:"unread,omitempty"`
// Owners says how many sockets' programs are unknown and why, when any is.
Owners string `json:"owners,omitempty"`
}
// Listening reads the socket tables under root (the machine's /proc), keeps TCP sockets in the listen
// state and every bound UDP socket, on port when port is not 0.
func Listening(root string, port int) ListeningResult {
out := ListeningResult{Sockets: []Socket{}}
for _, t := range []struct{ file, protocol string }{
{"tcp", "tcp"}, {"tcp6", "tcp6"}, {"udp", "udp"}, {"udp6", "udp6"},
} {
f, err := os.Open(filepath.Join(root, "net", t.file))
if err != nil {
if !os.IsNotExist(err) || !strings.HasSuffix(t.file, "6") {
out.Unread = append(out.Unread, t.file+": "+err.Error())
}
continue
}
sockets, err := parseSocketTable(f, t.protocol, port)
f.Close()
if err != nil {
out.Unread = append(out.Unread, t.file+": "+err.Error())
}
out.Sockets = append(out.Sockets, sockets...)
}
unknown := owners(root, out.Sockets)
if unknown > 0 {
out.Owners = fmt.Sprintf("%d socket(s) without their program: held by a process of another account, whose "+
"open files this tool may not read; the address and port are the kernel's and complete", unknown)
}
sort.Slice(out.Sockets, func(i, j int) bool {
a, b := out.Sockets[i], out.Sockets[j]
if a.Port != b.Port {
return a.Port < b.Port
}
if a.Protocol != b.Protocol {
return a.Protocol < b.Protocol
}
return a.Address < b.Address
})
out.Count = len(out.Sockets)
return out
}
// parseSocketTable reads one /proc/net table: "sl local_address rem_address st … inode".
func parseSocketTable(f *os.File, protocol string, port int) ([]Socket, error) {
var out []Socket
sc := bufio.NewScanner(f)
first := true
for sc.Scan() {
if first {
first = false
continue
}
fields := strings.Fields(sc.Text())
if len(fields) < 10 {
continue
}
state := fields[3]
tcp := strings.HasPrefix(protocol, "tcp")
// TCP 0A is LISTEN; UDP 07 is a bound socket (unconnected), 01 a connected one, which is not listening.
if (tcp && state != "0A") || (!tcp && state != "07") {
continue
}
addr, p, err := hexAddress(fields[1])
if err != nil {
return out, err
}
if port != 0 && p != port {
continue
}
out = append(out, Socket{Protocol: protocol, Address: addr, Port: p, inode: fields[9]})
}
return out, sc.Err()
}
// hexAddress reads the kernel's "0100007F:0035": the address in host order per 32-bit word, the port big-endian.
func hexAddress(s string) (string, int, error) {
a, p, ok := strings.Cut(s, ":")
if !ok {
return "", 0, fmt.Errorf("address %q", s)
}
port, err := strconv.ParseUint(p, 16, 16)
if err != nil {
return "", 0, fmt.Errorf("port %q", p)
}
raw, err := hex.DecodeString(a)
if err != nil || (len(raw) != 4 && len(raw) != 16) {
return "", 0, fmt.Errorf("address %q", a)
}
ip := make(net.IP, len(raw))
for w := 0; w < len(raw); w += 4 {
ip[w], ip[w+1], ip[w+2], ip[w+3] = raw[w+3], raw[w+2], raw[w+1], raw[w]
}
return ip.String(), int(port), nil
}
// owners names each socket's program from the processes' open files, as far as they can be read, and
// counts those it could not name.
func owners(root string, sockets []Socket) int {
want := map[string][]int{}
for i, s := range sockets {
want["socket:["+s.inode+"]"] = append(want["socket:["+s.inode+"]"], i)
}
if len(want) == 0 {
return 0
}
procs, _ := os.ReadDir(root)
for _, p := range procs {
pid, err := strconv.Atoi(p.Name())
if err != nil {
continue
}
fds, err := os.ReadDir(filepath.Join(root, p.Name(), "fd"))
if err != nil {
continue
}
for _, fd := range fds {
link, err := os.Readlink(filepath.Join(root, p.Name(), "fd", fd.Name()))
if err != nil {
continue
}
idx, ok := want[link]
if !ok {
continue
}
name, _ := os.ReadFile(filepath.Join(root, p.Name(), "comm"))
for _, i := range idx {
if sockets[i].Process == "" {
sockets[i].Process = fmt.Sprintf("%d/%s", pid, strings.TrimSpace(string(name)))
}
}
}
}
unknown := 0
for i := range sockets {
if sockets[i].Process == "" {
sockets[i].Owner = "not readable by this tool's account"
unknown++
}
}
return unknown
}
+33 -3
View File
@@ -1,12 +1,14 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// own sockets and resolver — a TCP connect, a DNS lookup (of the machine's resolver, or of one named),
// and what listens here — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -21,6 +23,29 @@ func main() {
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_listening",
Description: "What listens on this machine: every TCP socket in the listen state and every bound UDP socket, " +
"from the kernel's own tables (what ss -lunt reads), with the program holding it where this tool may see it. " +
"port narrows it to one port, e.g. 53 to see on which addresses the resolver answers.",
Input: map[string]any{
"port": map[string]any{"type": "integer", "description": "only this port (default every port)"},
},
Run: func(args map[string]any) (any, error) {
port := 0
if _, given := args["port"]; given {
p, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
if p < 1 || p > 65535 {
return nil, fmt.Errorf("port %d is not a port (1-65535)", p)
}
port = p
}
return Listening("/proc", port), nil
},
},
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
@@ -48,11 +73,14 @@ func tools() []stdio.Tool {
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts), or — with server — ask " +
"that one resolver alone over UDP (or TCP), answering its response code (NOERROR, NXDOMAIN, REFUSED…). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"server": map[string]any{"type": "string", "description": "ask this resolver alone: an address, with :port or without (53), e.g. 10.10.0.1"},
"tcp": map[string]any{"type": "boolean", "description": "ask the server over TCP rather than UDP (default false)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
@@ -65,7 +93,9 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
server, _ := args["server"].(string)
overTCP, _ := args["tcp"].(bool)
return AskDNS(name, kind, strings.TrimSpace(server), overTCP, timeout)
},
},
}
+13 -1
View File
@@ -4,8 +4,20 @@
"tools": [
"netcheck_tcp",
"netcheck_dns",
"netcheck_http"
"netcheck_http",
"netcheck_listening"
],
"replaces": {
"netcheck_listening": [
"ss -lunt",
"ss -ltn",
"netstat -lnt"
],
"netcheck_dns": [
"dig",
"nslookup"
]
},
"build": {
"artifacts": [
{
+18
View File
@@ -13,6 +13,24 @@ The X compositor as a module (novox/hq ADR 0208, research 026).
a process has one starter. Its package's autostart entry is that starter where there is one, and
the `xinitrc` slot where there is none.
## Settings
Two preferences, each a machine's own (the per-node layer of `settings`), because the right values follow
the machine's graphics driver (novox/hq research 037):
| setting | default | what it changes |
|---|---|---|
| `backend` | `glx` | how picom draws: `glx` on the graphics card, `xrender` on the CPU |
| `use-damage` | `true` | `true` repaints only what changed (picom's own default); `false` repaints the whole screen each frame |
The defaults are what every machine ran before, so the file filled with them changes nothing. They exist
for hq issue 345: a window that is mapped but shows nothing (a calendar reminder) or stays stale after a
workspace switch (a chat window) is a known failure of glx with damage tracking on some drivers, and
`xrender` or `use-damage` false is how a machine is set to test that. The file stays trusted, so both are set at the
controller's terminal alone (issue 339): a value is pasted into picom.conf as it is, and a backend of
`xrender"; write-pid-path = "…` would add an option of the caller's (hq issue 346). A changed file is read at
the next `picom_restart` or login: the module has no unit to reload.
## Tools
Served by the node's runtime as the operator account (ADR 0175). The tools find the operator's X
@@ -12,21 +12,29 @@ import (
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Settings map[string]setting `json:"settings"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
// setting is one key's default, as the definition gives it (novox/hq ADR 0262).
type setting struct {
Kind string `json:"kind"`
Default any `json:"default"`
Why string `json:"why"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
@@ -59,3 +59,36 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
// The backend and damage tracking are each machine's (novox/hq research 037: the backend depends on the
// machine's graphics driver; issue 345: a window left unpainted under glx is the first suspect). Both
// are settings whose defaults are what every machine ran before, so the file filled with them is today's.
func TestTheBackendAndDamageTrackingAreSettingsWithTodaysDefaults(t *testing.T) {
m := readManifest(t)
b, d := m.Settings["backend"], m.Settings["use-damage"]
if b.Kind != "preference" || b.Default != "glx" || b.Why == "" || d.Kind != "preference" || d.Default != true || d.Why == "" {
t.Fatalf("settings: %+v", m.Settings)
}
r := m.resource(t, "configuration")
c := r["content"].(string)
for _, want := range []string{`backend = "${setting:backend}";`, "use-damage = ${setting:use-damage};"} {
if !strings.Contains(c, want) {
t.Errorf("the file asks for %q", want)
}
}
// Trusted (unmarked): a value is pasted into the file as it is, so `xrender"; write-pid-path = "…`
// would add an option of the caller's. Until settings are typed and quoted (hq issue 346), only the
// controller's terminal sets them.
if _, marked := r["trusted"]; marked {
t.Fatal("the file stays trusted: its settings are set at the terminal alone (hq issue 346)")
}
got := parseRules("picom.conf", withDefaults(c), nil)
if got.Global["backend"] != "glx" || got.Global["use-damage"] != "true" || got.Global["vsync"] != "true" {
t.Fatalf("filled with the defaults: %v", got.Global)
}
}
// withDefaults fills the file's settings with their defaults, as a machine without its own values gets it.
func withDefaults(c string) string {
return strings.NewReplacer("${setting:backend}", "glx", "${setting:use-damage}", "true").Replace(c)
}
+1 -1
View File
@@ -16,7 +16,7 @@ func TestTheModulesOwnConfigurationReadsAsRulesInOrder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
r := parseRules("picom.conf", string(raw), nil)
r := parseRules("picom.conf", withDefaults(string(raw)), nil)
if r.Global["backend"] != "glx" || r.Global["vsync"] != "true" || r.Global["shadow"] != "false" {
t.Fatalf("global options: %v", r.Global)
}
+7 -1
View File
@@ -2,7 +2,13 @@
# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to
# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.
backend = "glx";
# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research
# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every
# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or
# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is
# left unpainted or stale.
backend = "${setting:backend}";
use-damage = ${setting:use-damage};
vsync = true;
fading = true;
+13 -1
View File
@@ -13,6 +13,18 @@
"scope": "node"
}
],
"settings": {
"backend": {
"kind": "preference",
"default": "glx",
"why": "glx draws with the graphics card and has served every machine so far; xrender draws on the CPU and is what to try where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345)"
},
"use-damage": {
"kind": "preference",
"default": true,
"why": "true repaints only what changed, picom's own default; false repaints the whole screen each frame, which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345)"
}
},
"tools": [
"picom_restart",
"picom_rules",
@@ -43,7 +55,7 @@
"path": "${machine:account-home}/.config/picom/picom.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\nbackend = \"glx\";\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n"
"content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\n# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research\n# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every\n# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or\n# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is\n# left unpainted or stale.\nbackend = \"${setting:backend}\";\nuse-damage = ${setting:use-damage};\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n"
}
],
"build": {
+5
View File
@@ -47,6 +47,11 @@ default, so the mesh's layer is set before the first assignment (as `power`'s ar
one file: the module has no merged file and no contribution, so issue 168 cannot carry them anywhere
else.
**They are set at the controller's terminal alone** (the file is trusted, unmarked; issue 339). A value is
pasted into the file as it is, and the format is shell assignments meant to be sourced by a shell, so a
value of `$HOME/x"; <command>; X="` would add a command of whoever set it (hq issue 346). Until settings
are typed and quoted when written, no verb may set them.
**Why `xdg-user-dirs-update` is disabled.** It runs at every login, from its XDG autostart entry, which
`dex` starts. Measured on xdg-user-dirs 0.20 against a copy of the folders:
@@ -194,3 +194,21 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
}
}
}
// The folders file stays trusted (unmarked), so its three settings are set at the controller's terminal
// alone. A value is pasted into it as it is, and the format is shell assignments defined to be sourced
// by a shell: `$HOME/x"; <command>; X="` would add a command of the caller's (hq issue 346). Until
// settings are typed and quoted at render, no verb may set them.
func TestTheFoldersFileStaysTrustedUntilSettingsAreQuoted(t *testing.T) {
m, _ := readManifest(t)
for _, r := range m.Resources {
if r["id"] != "user-dirs" {
continue
}
if _, marked := r["trusted"]; marked {
t.Fatal("user-dirs.dirs carries a trusted marking: its settings are the terminal's alone (hq issue 346)")
}
return
}
t.Fatal("no user-dirs resource")
}
+19 -3
View File
@@ -9,7 +9,7 @@ The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phas
- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device
and a connected display. See *Blockers* for why not `graphical-session`.
- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`,
`xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine,
`xorg-setxkbmap`, `xorg-xwd`, `xorg-xwininfo` and `autorandr`. The GPU's driver is not here. It follows the machine,
so it belongs to that machine model's hardware module.
## What it owns
@@ -71,7 +71,8 @@ which is a cpp directive error under plain `xrdb -merge` but is skipped here.
| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings |
| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name |
| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options |
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) |
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`); with `inline`, the PNG in the answer as base64 (at most 700 KiB; a larger one is halved in size until it fits, and `inline_scale` says so). A window that is not viewable is refused |
| `xorg_windows` | r | the windows as the X server knows them, managed or not: id, name, instance and class, size and place; with a class or name filter (or `detail`), each one's depth (`argb`), visual, map state, override-redirect, types, states, role and own opacity |
| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) |
All answers are structured. Each tool that changes the running server says how long the change lasts.
@@ -90,7 +91,22 @@ The bus it hands on is the user manager's (`unix:path=/run/user/<uid>/bus`). Wit
answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`.
The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump
into a PNG itself.
into a PNG itself. **A window is cut from the dump of the whole screen** at the place `xwininfo` gives,
never dumped by its id: `xwd -id` asks the server for the window's colormap entries, which a window of
a 32-bit (alpha) visual refused with `BadColor (X_QueryColors)` on a workstation (2026-10-09), and the
cut shows the window as the compositor shows it, which is what a question about a window that looks
wrong needs. A window under another one shows what covers it, and a window that is not viewable (unmapped, or on a
workspace not shown) is refused rather than photographed as whatever is at its place. `inline` answers
the image itself, for a session that cannot read the machine's files: a tool's answer crosses the bus,
whose largest message is NATS's default of 1 MiB (the `nats` module sets no other), so the PNG is held to
700 KiB (about 935 KiB as base64) and halved in size until it fits.
`xorg_windows` exists because a window manager lists only the windows it manages, and says nothing a
compositor goes by. A window of depth 32 has an alpha channel: the compositor blends it with what is
behind it, so one whose program drew nothing looks transparent rather than black. Its details take
two commands per window (`xwininfo -all`, and `xprop` for the role and the window's own opacity), so
they are read for at most 40 windows. `xprop` is the `picom` module's package; without it the role and
opacity are left out.
## What it improves
+21 -2
View File
@@ -143,16 +143,35 @@ func tools(d desktop.Desk) []stdio.Tool {
},
{
Name: "xorg_screenshot",
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id " +
"(cut from the screen as the compositor shows it, so a see-through window shows what is behind it). " +
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
"the path, size and bytes, never the image.",
"the path, size and bytes; with inline, also the PNG itself as base64 (at most 700 KiB, halved in size until it " +
"fits; inline_scale says so). A window that is not viewable (unmapped, or on a workspace not shown) is refused.",
Input: desktop.Schema(map[string]any{
"output": desktop.Str("only this output (optional)"),
"window": desktop.Str("only this window, by its X id, e.g. 0x3a00007 (optional)"),
"path": desktop.Str("where to write it, inside the home (optional)"),
"inline": desktop.Flag("also answer the PNG as base64, at most 700 KiB, for a session that cannot read the file (default false)"),
}),
Run: call(x.screenshot),
},
{
Name: "xorg_windows",
Description: "The windows on the X screen as the server knows them, managed or not (dialogs, tooltips, " +
"windows no window manager lists): id, name, instance and class, size and absolute place, level in " +
"the tree. With class or name (regular expressions), or detail, also each window's depth (argb: a " +
"32-bit visual a compositor blends with what is behind it), visual class, map state, override-redirect, " +
"window types and states, role and own opacity — for at most 40 windows. Without a filter, only " +
"windows with a class, unless all.",
Input: desktop.Schema(map[string]any{
"class": desktop.Str("only windows whose class or instance matches this regular expression"),
"name": desktop.Str("only windows whose name (title) matches this regular expression"),
"detail": desktop.Flag("read each window's details (default false; true when class or name is given)"),
"all": desktop.Flag("without a filter, also windows without a class (default false)"),
}),
Run: call(x.windows),
},
{
Name: "xorg_x_log",
Description: "The X server's log for the running session (or the newest one): its version, when it " +
+1 -1
View File
@@ -111,7 +111,7 @@ func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
}
}
sort.Strings(got)
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd,xorg-xwininfo"
if strings.Join(got, ",") != want {
t.Fatalf("packages: %v", got)
}
+79 -6
View File
@@ -3,8 +3,10 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"os"
@@ -494,7 +496,34 @@ func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
// mostInline is the largest PNG a screenshot carries in its answer. A tool's answer crosses the bus,
// whose largest message is 1 MiB; 700 KiB is about 935 KiB as base64, which leaves room for the rest.
const mostInline = 700 << 10
// shrink averages each by×by block of an image into one pixel.
func shrink(img image.Image, by int) *image.RGBA {
b := img.Bounds()
out := image.NewRGBA(image.Rect(0, 0, b.Dx()/by, b.Dy()/by))
for y := 0; y < out.Bounds().Dy(); y++ {
for x := 0; x < out.Bounds().Dx(); x++ {
var r, g, bl, n uint32
for dy := 0; dy < by; dy++ {
for dx := 0; dx < by; dx++ {
cr, cg, cb, _ := img.At(b.Min.X+x*by+dx, b.Min.Y+y*by+dy).RGBA()
r, g, bl, n = r+cr, g+cg, bl+cb, n+1
}
}
out.SetRGBA(x, y, color.RGBA{uint8(r / n >> 8), uint8(g / n >> 8), uint8(bl / n >> 8), 0xff})
}
}
return out
}
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
inline, _, err := a.Bool("inline")
if err != nil {
return nil, err
}
stamp := time.Now().Format("20060102-150405")
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
if err != nil {
@@ -514,15 +543,28 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
}
dump.Close()
defer os.Remove(dump.Name())
args := []string{"-silent", "-out", dump.Name()}
// A window is cut from the whole screen, not dumped by its id: what the screen shows is what the
// compositor made of it (a window with an alpha channel is blended with what is behind it), and xwd
// asks the server for a window's colormap entries, which a window of a 32-bit visual can refuse
// with BadColor (X_QueryColors). The cut is the window's frame-less area at its absolute place.
var cut *image4
if w := a.Opt("window", ""); w != "" {
if !windowID.MatchString(w) {
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
}
args = append(args, "-id", w)
} else {
args = append(args, "-root")
g, err := x.windowGeometry(ctx, env, w)
if err != nil {
return nil, err
}
// A window that is not viewable is not on the screen: its rectangle would show what is there
// instead, which is a picture of the wrong thing.
if g.Mapped != "" && g.Mapped != "IsViewable" {
return nil, fmt.Errorf("window %s is %s, not on the screen (IsUnMapped: not shown; IsUnviewable: "+
"its parent is not shown, e.g. on another workspace): bring it to the screen first", w, g.Mapped)
}
cut = &g
}
args := []string{"-silent", "-out", dump.Name(), "-root"}
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
if r.Code == 127 {
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
@@ -538,7 +580,15 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
}
var shot image.Image = img
if name := a.Opt("output", ""); name != "" {
if cut != nil {
r := image.Rect(cut.X, cut.Y, cut.X+cut.W, cut.Y+cut.H).Intersect(img.Bounds())
if r.Empty() {
return nil, fmt.Errorf("the window is not on the screen (%dx%d at %d,%d)", cut.W, cut.H, cut.X, cut.Y)
}
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
shot = crop
} else if name := a.Opt("output", ""); name != "" {
_, outs, _, err := x.query(ctx)
if err != nil {
return nil, err
@@ -568,7 +618,30 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
return nil, err
}
b := shot.Bounds()
return map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}, nil
answer := map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}
if inline {
// For a session that cannot read the machine's files: the image itself, within the bus's bound.
// An image larger than mostInline is halved until it fits, and the answer says by how much; the
// file keeps the full size.
small, scale := buf.Bytes(), 1
for len(small) > mostInline && shot.Bounds().Dx()/(scale*2) >= 64 {
scale *= 2
var b bytes.Buffer
if err := png.Encode(&b, shrink(shot, scale)); err != nil {
return nil, err
}
small = b.Bytes()
}
if len(small) > mostInline {
answer["inline"] = fmt.Sprintf("not included: %d bytes even at 1/%d; take one output or one window", len(small), scale)
} else {
answer["png_base64"] = base64.StdEncoding.EncodeToString(small)
if scale > 1 {
answer["inline_scale"] = fmt.Sprintf("1/%d of the file's size, to fit the bus", scale)
}
}
}
return answer, nil
}
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"xorg/internal/desktop"
)
// The windows on the X screen, as the server knows them (xwininfo): what a window manager's own list
// (node-display-session.windows) leaves out — a window it does not manage, a dialog, a tooltip, a
// window that is mapped but drawn by nobody — and what a compositor goes by: the window's depth (32
// bits is a visual with an alpha channel, which a compositor blends with what is behind it), its type,
// whether it is mapped, and its own opacity.
//
// The tree comes from one `xwininfo -root -tree`. A window's details take one `xwininfo -all` and one
// `xprop` each, so they are read only for the windows asked about, and at most mostDetailed of them.
const mostDetailed = 40
// Window is one window of the tree, and its details when they were read.
type Window struct {
ID string `json:"window"`
Name string `json:"name,omitempty"`
Instance string `json:"instance,omitempty"`
Class string `json:"class,omitempty"`
Width int `json:"width"`
Height int `json:"height"`
X int `json:"x"`
Y int `json:"y"`
// Level is the window's depth in the tree: 1 is a child of the root (a frame or an unmanaged window).
Level int `json:"level"`
// The details (detail true, or a class asked for).
Depth int `json:"depth,omitempty"`
ARGB *bool `json:"argb,omitempty"`
Visual string `json:"visual_class,omitempty"`
Mapped string `json:"map_state,omitempty"`
OverrideRedir *bool `json:"override_redirect,omitempty"`
Types []string `json:"types,omitempty"`
States []string `json:"states,omitempty"`
Role string `json:"role,omitempty"`
Opacity *float64 `json:"opacity,omitempty"`
DetailProblems string `json:"detail_problem,omitempty"`
}
// treeLine is one line of `xwininfo -root -tree`: the id, the name (or "(has no name)"), the class
// pair when the window has one, the size and position relative to the parent, and the absolute one.
var treeLine = regexp.MustCompile(`^(\s*)(0x[0-9a-fA-F]+) (?:\(has no name\)|"(.*)"): \((?:"([^"]*)" "([^"]*)")?\)\s+(\d+)x(\d+)[+-]-?\d+[+-]-?\d+\s+\+?(-?\d+)\+?(-?\d+)\s*$`)
// ParseTree reads the windows of `xwininfo -root -tree`, in the server's stacking order per parent.
func ParseTree(text string) []Window {
var out []Window
for _, line := range strings.Split(text, "\n") {
m := treeLine.FindStringSubmatch(line)
if m == nil {
continue
}
w := Window{ID: m[2], Name: m[3], Instance: m[4], Class: m[5]}
w.Width, _ = strconv.Atoi(m[6])
w.Height, _ = strconv.Atoi(m[7])
w.X, _ = strconv.Atoi(m[8])
w.Y, _ = strconv.Atoi(m[9])
// xwininfo indents a child of the root by 5 spaces and each further level by 3.
w.Level = 1 + (len(m[1])-5)/3
out = append(out, w)
}
return out
}
// ParseDetails adds what `xwininfo -id <w> -all` says about one window.
func ParseDetails(w *Window, text string) {
section := ""
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
indent := len(raw) - len(strings.TrimLeft(raw, " "))
switch {
case strings.HasPrefix(line, "Depth:"):
w.Depth, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Depth:")))
argb := w.Depth == 32
w.ARGB = &argb
section = ""
case strings.HasPrefix(line, "Visual Class:"):
w.Visual = strings.TrimSpace(strings.TrimPrefix(line, "Visual Class:"))
case strings.HasPrefix(line, "Map State:"):
w.Mapped = strings.TrimSpace(strings.TrimPrefix(line, "Map State:"))
case strings.HasPrefix(line, "Override Redirect State:"):
v := strings.TrimSpace(strings.TrimPrefix(line, "Override Redirect State:")) == "yes"
w.OverrideRedir = &v
case line == "Window type:":
section = "type"
case line == "Window state:":
section = "state"
case line == "":
section = ""
case section != "" && indent >= 10:
if section == "type" {
w.Types = append(w.Types, line)
} else {
w.States = append(w.States, line)
}
default:
if indent < 10 {
section = ""
}
}
}
}
// opacityLine and roleLine are the two properties read with xprop.
var (
opacityLine = regexp.MustCompile(`^_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`)
roleLine = regexp.MustCompile(`^WM_WINDOW_ROLE\(STRING\) = "(.*)"`)
)
// ParseWindowProps adds the window's role and its own opacity (0 to 1) from xprop.
func ParseWindowProps(w *Window, text string) {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if m := opacityLine.FindStringSubmatch(line); m != nil {
if n, err := strconv.ParseUint(m[1], 10, 32); err == nil {
v := float64(n) / float64(0xffffffff)
v = float64(int(v*1000+0.5)) / 1000
w.Opacity = &v
}
}
if m := roleLine.FindStringSubmatch(line); m != nil {
w.Role = m[1]
}
}
}
func (x xorg) windows(ctx context.Context, a desktop.Args) (any, error) {
classExpr := a.Opt("class", "")
nameExpr := a.Opt("name", "")
detail, _, err := a.Bool("detail")
if err != nil {
return nil, err
}
all, _, err := a.Bool("all")
if err != nil {
return nil, err
}
var classRe, nameRe *regexp.Regexp
if classExpr != "" {
if classRe, err = regexp.Compile(classExpr); err != nil {
return nil, fmt.Errorf("class is a regular expression: %w", err)
}
}
if nameExpr != "" {
if nameRe, err = regexp.Compile(nameExpr); err != nil {
return nil, fmt.Errorf("name is a regular expression: %w", err)
}
}
res, s, err := x.d.InSession(ctx, "xwininfo", "-root", "-tree")
if err != nil {
return nil, err
}
if !res.OK() {
if res.Code == 127 {
return nil, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
}
return nil, res.Err()
}
tree := ParseTree(res.Stdout)
var shown []Window
for _, w := range tree {
switch {
case classRe != nil && !(classRe.MatchString(w.Class) || classRe.MatchString(w.Instance)):
continue
case nameRe != nil && !nameRe.MatchString(w.Name):
continue
case classRe == nil && nameRe == nil && !all && w.Class == "":
// Without a filter, the windows a program owns: those with a class.
continue
}
shown = append(shown, w)
}
detailed := 0
if detail || classRe != nil || nameRe != nil {
env := s.Env(x.d.Base)
for i := range shown {
if detailed == mostDetailed {
break
}
detailed++
w := &shown[i]
info := x.d.Run(ctx, env, nil, "xwininfo", "-id", w.ID, "-all")
if !info.OK() {
// A window can go between the tree and its details.
w.DetailProblems = strings.TrimSpace(info.Stderr)
continue
}
ParseDetails(w, info.Stdout)
if props := x.d.Run(ctx, env, nil, "xprop", "-id", w.ID, "WM_WINDOW_ROLE", "_NET_WM_WINDOW_OPACITY"); props.OK() {
ParseWindowProps(w, props.Stdout)
}
}
}
answer := map[string]any{"session": s.Display, "windows": shown, "count": len(shown), "in_tree": len(tree)}
if detailed < len(shown) && (detail || classRe != nil || nameRe != nil) {
answer["detailed"] = fmt.Sprintf("the first %d of %d; narrow class or name for the rest", detailed, len(shown))
}
return answer, nil
}
// windowGeometry is a window's absolute position and size, from `xwininfo -id`.
func (x xorg) windowGeometry(ctx context.Context, env []string, id string) (image4, error) {
r := x.d.Run(ctx, env, nil, "xwininfo", "-id", id)
if !r.OK() {
if r.Code == 127 {
return image4{}, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
}
return image4{}, r.Err()
}
return ParseGeometry(r.Stdout)
}
// image4 is a rectangle on the screen, and the window's map state there.
type image4 struct {
X, Y, W, H int
Mapped string
}
// ParseGeometry reads the absolute corner and the size from `xwininfo -id`.
func ParseGeometry(text string) (image4, error) {
var g image4
found := 0
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
for prefix, into := range map[string]*int{
"Absolute upper-left X:": &g.X, "Absolute upper-left Y:": &g.Y, "Width:": &g.W, "Height:": &g.H,
} {
if strings.HasPrefix(line, "Map State:") {
g.Mapped = strings.TrimSpace(strings.TrimPrefix(line, "Map State:"))
}
if strings.HasPrefix(line, prefix) {
if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix))); err == nil {
*into = n
found++
}
}
}
}
if found < 4 || g.W <= 0 || g.H <= 0 {
return image4{}, fmt.Errorf("xwininfo gave no geometry for the window")
}
return g, nil
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"image/color"
"image/png"
"os"
"strings"
"testing"
"xorg/internal/desktop"
)
// The tree and the details as xwininfo printed them on a workstation (2026-10-09), with a calendar
// reminder of the mail client open: a dialog of a 32-bit visual.
const treeText = `
xwininfo: Window id: 0x3e6 (the root window) "i3"
Root window id: 0x3e6 (the root window) "i3"
Parent window id: 0x0 (none)
160 children:
0x1800002 "Dunst": ("Dunst" "Dunst") 259x39+2291+50 +2291+50
0x2a00e04 "Betterbird": ("eu.betterbird.Betterbird" "eu.betterbird.Betterbird") 1x1+0+0 +0+0
2 children:
0x2a00e08 (has no name): () 1x1+-1+-1 +-1+-1
0x600090 "[i3 con] floating_con around 0x5594": () 602x325+979+556 +979+556
1 child:
0x2a00221 "1 Reminder": ("Calendar" "eu.betterbird.Betterbird") 600x300+1+24 +980+581
0x2a00222 "say "hi": there": ("Mail" "eu.betterbird.Betterbird") 10x10+-100+-100 +-100+-100
`
const detailText = `
xwininfo: Window id: 0x2a00221 "1 Reminder"
Absolute upper-left X: 980
Absolute upper-left Y: 581
Width: 600
Height: 300
Depth: 32
Visual: 0x87
Visual Class: TrueColor
Map State: IsViewable
Override Redirect State: no
Someone wants these events:
KeyPress
Window manager hints:
Client accepts input or input focus: Yes
Window type:
Dialog
Window state:
Focused
Process id: 37147 on host h
Frame extents: 0, 0, 0, 0
Normal window size hints:
`
func TestTheTreeIsReadWithClassesPlacesAndLevels(t *testing.T) {
got := ParseTree(treeText)
if len(got) != 6 {
t.Fatalf("%d windows: %+v", len(got), got)
}
r := got[4]
if r.ID != "0x2a00221" || r.Name != "1 Reminder" || r.Instance != "Calendar" || r.Class != "eu.betterbird.Betterbird" ||
r.Width != 600 || r.Height != 300 || r.X != 980 || r.Y != 581 || r.Level != 2 {
t.Fatalf("%+v", r)
}
if got[2].ID != "0x2a00e08" || got[2].Class != "" || got[2].X != -1 || got[2].Level != 2 {
t.Fatalf("a nameless child at a negative place: %+v", got[2])
}
if got[5].Name != `say "hi": there` || got[5].X != -100 || got[5].Level != 1 {
t.Fatalf("a name with quotes and a colon: %+v", got[5])
}
}
func TestTheDetailsSayDepthTypeStateRoleAndOpacity(t *testing.T) {
w := Window{ID: "0x2a00221"}
ParseDetails(&w, detailText)
ParseWindowProps(&w, "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483647\n")
if w.Depth != 32 || w.ARGB == nil || !*w.ARGB || w.Visual != "TrueColor" || w.Mapped != "IsViewable" ||
w.OverrideRedir == nil || *w.OverrideRedir || strings.Join(w.Types, ",") != "Dialog" ||
strings.Join(w.States, ",") != "Focused" || w.Role != "AlarmWindow" || w.Opacity == nil || *w.Opacity != 0.5 {
t.Fatalf("%+v", w)
}
plain := Window{}
ParseWindowProps(&plain, "WM_WINDOW_ROLE: not found.\n_NET_WM_WINDOW_OPACITY: not found.\n")
if plain.Opacity != nil || plain.Role != "" {
t.Fatalf("no opacity of its own is none, not 0: %+v", plain)
}
}
func TestWindowsFiltersByClassAndReadsTheDetailsOfWhatItShows(t *testing.T) {
f := &fakeDesk{answers: map[string]desktop.Result{
"xwininfo -root -tree": {Stdout: treeText},
"xwininfo -id 0x2a00e04 -all": {Stdout: " Depth: 32\n Map State: IsUnMapped\n"},
"xwininfo -id 0x2a00221 -all": {Stdout: detailText},
"xwininfo -id 0x2a00222 -all": {Code: 1, Stderr: "X Error: BadWindow"},
"xprop -id 0x2a00221 WM_WINDOW_ROLE _NET_WM_WINDOW_OPACITY": {Stdout: "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n"},
}}
got, err := byName(t, f.desk(true), "xorg_windows")(map[string]any{"class": "(?i)betterbird"})
if err != nil {
t.Fatal(err)
}
ws := got.(map[string]any)["windows"].([]Window)
if len(ws) != 3 || ws[1].Role != "AlarmWindow" || ws[1].Depth != 32 || ws[2].DetailProblems == "" {
t.Fatalf("%+v", ws)
}
f2 := &fakeDesk{answers: map[string]desktop.Result{"xwininfo -root -tree": {Stdout: treeText}}}
got, err = byName(t, f2.desk(true), "xorg_windows")(map[string]any{})
if err != nil {
t.Fatal(err)
}
if n := got.(map[string]any)["count"]; n != 4 || len(f2.ran) != 1 {
t.Fatalf("without a filter: the windows with a class, and no details read: %v %v", n, f2.ran)
}
}
func TestAWindowsScreenshotIsCutFromTheScreenNotDumpedByItsID(t *testing.T) {
// The fake xwd writes a 4x3 screen whose pixel (x, y) is (x*10, y*10, 7).
home := t.TempDir()
t.Setenv("MESH_OPERATOR_HOME", home)
t.Setenv("HOME", home)
f := &fakeDesk{answers: map[string]desktop.Result{
"xwininfo -id 0x2a00221": {Stdout: " Absolute upper-left X: 1\n Absolute upper-left Y: 1\n Width: 2\n Height: 2\n"},
}}
d := f.desk(true)
run := d.Run
d.Run = func(ctx context.Context, env []string, stdin []byte, name string, args ...string) desktop.Result {
if name == "xwd" {
for i, a := range args {
if a == "-id" {
t.Fatal("xwd is never asked for one window")
}
if a == "-out" {
os.WriteFile(args[i+1], xwdOf(4, 3, true, func(x, y int) (uint8, uint8, uint8) { return uint8(x * 10), uint8(y * 10), 7 }), 0o644)
}
}
return desktop.Result{}
}
return run(ctx, env, stdin, name, args...)
}
got, err := byName(t, d, "xorg_screenshot")(map[string]any{"window": "0x2a00221", "path": "~/shot.png", "inline": true})
if err != nil {
t.Fatal(err)
}
a := got.(map[string]any)
if a["width"] != 2 || a["height"] != 2 {
t.Fatalf("%v", a)
}
raw, err := base64.StdEncoding.DecodeString(a["png_base64"].(string))
if err != nil {
t.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if c := color.RGBAModel.Convert(img.At(0, 0)).(color.RGBA); c != (color.RGBA{10, 10, 7, 0xff}) {
t.Fatalf("the cut starts at the window's corner: %v", c)
}
if _, err := ParseGeometry("Width: 0\n"); err == nil {
t.Fatal("no geometry is refused")
}
}
// screenFake is a desk whose xwd writes a w×h screen of noise and whose xwininfo
// answers one window with the given map state.
func screenFake(t *testing.T, w, h int, state string) desktop.Desk {
f := &fakeDesk{answers: map[string]desktop.Result{
"xwininfo -id 0x2a00221": {Stdout: " Absolute upper-left X: 1\n Absolute upper-left Y: 1\n Width: 2\n Height: 2\n Map State: " + state + "\n"},
}}
d := f.desk(true)
run := d.Run
d.Run = func(ctx context.Context, env []string, stdin []byte, name string, args ...string) desktop.Result {
if name == "xwd" {
for i, a := range args {
if a == "-out" {
// Noise, which a PNG cannot compress: the worst case for the bound.
os.WriteFile(args[i+1], xwdOf(w, h, true, func(x, y int) (uint8, uint8, uint8) {
n := uint32(x)*2654435761 ^ uint32(y)*2246822519
n ^= n >> 13
n *= 3266489917
return uint8(n), uint8(n >> 8), uint8(n >> 16)
}), 0o644)
}
}
return desktop.Result{}
}
return run(ctx, env, stdin, name, args...)
}
return d
}
func TestAWindowThatIsNotViewableIsNotPhotographed(t *testing.T) {
home := t.TempDir()
t.Setenv("MESH_OPERATOR_HOME", home)
for _, state := range []string{"IsUnMapped", "IsUnviewable"} {
_, err := byName(t, screenFake(t, 4, 3, state), "xorg_screenshot")(map[string]any{"window": "0x2a00221", "path": "~/s.png"})
if err == nil || !strings.Contains(err.Error(), state) {
t.Errorf("%s: %v", state, err)
}
}
}
func TestAnInlineImageFitsTheBusAndIsScaledDownWhenItWouldNot(t *testing.T) {
home := t.TempDir()
t.Setenv("MESH_OPERATOR_HOME", home)
got, err := byName(t, screenFake(t, 800, 600, "IsViewable"), "xorg_screenshot")(map[string]any{"path": "~/s.png", "inline": true})
if err != nil {
t.Fatal(err)
}
a := got.(map[string]any)
b64, _ := a["png_base64"].(string)
if b64 == "" || len(b64) > 1<<20-64<<10 {
t.Fatalf("an inline image within the bus's 1 MiB, with room for the answer: %d bytes; %v", len(b64), a["inline"])
}
if a["bytes"].(int) > mostInline && a["inline_scale"] == nil {
t.Fatalf("a file larger than the bound is answered scaled down, and says so: %v", a["inline_scale"])
}
}
+1 -1
View File
@@ -298,7 +298,7 @@ func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T
{"xorg_set_mode", map[string]any{"output": "eDP-1", "mode": "auto"}},
{"xorg_primary", nil}, {"xorg_dpi", nil}, {"xorg_input_devices", nil},
{"xorg_input_set", map[string]any{"device": "16", "tap": true}}, {"xorg_keyboard", nil},
{"xorg_screenshot", nil},
{"xorg_screenshot", nil}, {"xorg_windows", nil},
} {
_, err := byName(t, d, call.tool)(orEmpty(call.args))
if !desktop.IsNoSession(err) {
+6
View File
@@ -29,6 +29,7 @@
"xorg_input_set",
"xorg_keyboard",
"xorg_screenshot",
"xorg_windows",
"xorg_x_log"
],
"resources": [
@@ -72,6 +73,11 @@
"type": "package",
"package": "xorg-xwd"
},
{
"id": "package-xorg-xwininfo",
"type": "package",
"package": "xorg-xwininfo"
},
{
"id": "package-autorandr",
"type": "package",
+178 -61
View File
@@ -1,107 +1,224 @@
# zfs
The mesh's view of ZFS on a machine: every pool's health, layout, space, scrubs, I/O, cache and events,
every dataset with its space and snapshots, and one act, a named snapshot of one dataset. It is for the
home server, whose pool holds the operator's media library, which the mesh does not own (novox/hq ADR
0051). So the module reads, and the one thing it does only adds.
and every dataset with its space and snapshots. It owns the pool's scrub schedule, run by a scheduled process of its own. It has four acts: a named
snapshot of one dataset, and a scrub's start, pause and stop. It is for the home server, whose pool holds
the operator's media library, which the mesh does not own (novox/hq ADR 0051). So the module reads, and
what it does only adds a snapshot or reads the pool through.
## What it does not do
There is no destroy, rollback, set, send, receive, rename, clone or any other change. Each of those changes
or removes the operator's data and needs a decision of its own first. `zfs_snapshot` is the only tool that
changes anything, and it only adds a snapshot.
There is no destroy, rollback, set, send, receive, rename, clone, replace, attach, detach or clear. Each of
those changes or removes the operator's data, or the pool's layout, and needs a decision of its own first.
A resilver is ZFS's own: it starts one when a disk is replaced or comes back. The module watches and
reports resilvers, and starts none.
This is checked by `TestTheBundleRunsOnlyReadingVerbsAndSnapshot`. Every command the bundle runs is built
by one of its argument functions, and each function is held to its verb: `list`, `get`, `status`,
`iostat`, `events`, `history` or `snapshot`. A call site that builds its own argument list fails the test.
This is checked by `TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub`. Every command the bundle runs is
built by one of its argument functions, and each function is held to its program and verb:
- `zfs`: `list`, `get` and `snapshot`;
- `zpool`: `list`, `status`, `iostat`, `events`, `history`, `wait -t scrub` and `scrub` (with `-p` or `-s`
only, and those only from the person's acts, never from the scheduled run);
- `systemctl`: `show`.
The test fails on any call site that builds its own argument list, through `ask`, `AsRoot`, `Escalated`,
`.Run(`, `exec.Command(`, `exec.CommandContext(`, `os.StartProcess` or `syscall.Exec`.
It installs nothing. ZFS belongs to the machine, and its kernel module is built against that machine's
kernel. On a machine without ZFS, every tool answers `applicable: false` with the reason, and the health
check is unhealthy and says to unassign the module, because a module put where it does nothing must not
look healthy.
It holds no seat. A new `node-*` seat is the controller's to define (novox/hq ADR 0121, ADR 0246). This
is a module with tools, like `disk-load` and `sensors`. If the mesh later gives a machine's storage a
seat, these tools become its verbs.
It holds no seat. A new `node-*` seat is the controller's to define (novox/hq ADR 0121, ADR 0246).
## Tools
The tools run as the operator account. What ZFS lets any account read runs as that account. What ZFS
keeps for root (events, history and the snapshot itself) goes through `sudo -n` with a fixed argument
list, never a shell. Sizes are whole bytes, read under `-p`, and each answer also says its findings in
lines under `said`.
keeps for root goes through `sudo -n` with a fixed argument list, never a shell: events, history, the
snapshot and the scrub acts. Sizes are whole bytes, read under `-p`, except where status prints human sizes
even under `-p` (a scan's sizes). Each answer also says its findings in lines under `said`.
A command that runs out of time is ended with everything it started. It runs in a process group of its
own. At the deadline the group gets SIGTERM, which sudo passes on to the command it runs as root, and
after five seconds SIGKILL. An act that ran out of time is answered from what the machine shows afterwards,
or as "outcome unknown".
| tool | what | replaces |
|---|---|---|
| `zfs_datasets` | Every filesystem and volume: mountpoint, used, available, referenced, compression ratio, sharenfs, and the number of snapshots with the space they hold together (`usedbysnapshots`) | `zfs list` |
| `zfs_snapshots` | The snapshots of one dataset (`dataset`, optional), oldest first: creation time, used (the space only that snapshot holds) and referenced | `zfs list -t snapshot` |
| `zfs_datasets` | Every filesystem and volume: mountpoint, used, available, referenced, compression ratio, sharenfs, and the number of snapshots with the space they hold together | `zfs list` |
| `zfs_snapshots` | The snapshots of one `dataset` (optional), oldest first: creation time, used (the space only that snapshot holds) and referenced | `zfs list -t snapshot` |
| `zfs_snapshot` | Takes `dataset@name` (see below) | `zfs snapshot` |
| `zfs_pools` | Each pool's state with status's explanation and action, space, capacity and fragmentation %, device error count, data errors and last scan | `zpool list`, `zpool status -x` |
| `zfs_space` | Each pool's space, then every dataset, biggest first: usedbydataset, usedbysnapshots, usedbychildren and usedbyrefreservation, quotas and reservations where set, compression, logical size against physical size, and the savings | `zfs list -o space`, `zpool list -v` |
| `zfs_pool_layout` | Vdevs and disks by the names the pool knows them by, with state, read, write and checksum errors and status's note; log, cache, special and dedup devices; spares | `zpool status`, `zpool status -v` |
| `zfs_scrub` | The last scan (scrub or resilver): when, how long, what it repaired, the errors, or its progress while it runs; the days since the last finished scrub, with a warning past 35. With `history: true`, it also reads the pool's history (as root) for the scrub and resilver that status no longer shows | `zpool history -i` |
| `zfs_pools` | Each pool's state with status's reason and action, space, capacity and fragmentation %, device error count, data errors and scans | `zpool list`, `zpool status -x` |
| `zfs_space` | Each pool's space, then every dataset, biggest first, with what uses its space, quotas and reservations, compression and the savings | `zfs list -o space`, `zpool list -v` |
| `zfs_pool_layout` | Vdevs and disks, with state, error counts and status's note; log, cache, special and dedup devices; spares | `zpool status`, `zpool status -v` |
| `zfs_scrub` | Each pool's scans: its scrub or error scrub, and its resilver (pool-wide, or each vdev's rebuild), with progress and time to go while one runs; the days since the last finished scrub; and the module's schedule with its next run. `history: true` also reads the pool's history | `zpool history -i` |
| `zfs_scrub_start` | Starts a scrub of `pool`, or resumes a paused one, with `why` | `zpool scrub` |
| `zfs_scrub_pause` | Pauses one, keeping its place, with `why` | `zpool scrub -p` |
| `zfs_scrub_stop` | Stops one, losing its place, with `why` | `zpool scrub -s` |
| `zfs_io` | A sample of `seconds` (default 3, from 1 to 10): operations and bytes per second per pool, vdev and disk | `zpool iostat -v` |
| `zfs_arc` | ARC size, target, minimum and maximum, hit ratio since boot, demand hit ratio, and L2ARC where present | `arcstat`, `arc_summary` |
| `zfs_events` | Recent events, newest first, as root. By default it shows only errors and changes of state, and `all: true` shows every event (`limit`, default 50) | `zpool events -v` |
| `zfs_properties` | Every property of one `dataset` with its source, the usual ones in lines first. The value of `keylocation` is withheld, and ZFS never gives a key itself | `zfs get all` |
| `zfs_arc` | ARC size, target, minimum and maximum, hit ratio since boot, and L2ARC where present | `arcstat`, `arc_summary` |
| `zfs_events` | Recent events, newest first, as root. By default it shows only errors and changes of state, and `all: true` shows every event | `zpool events -v` |
| `zfs_properties` | Every property of one `dataset` with its source. The value of `keylocation` is withheld | `zfs get all` |
| `zfs_health` | The health check (below) | |
## The snapshot
`zfs_snapshot {dataset, name, why, recursive?}` refuses all of these before anything runs:
`zfs_snapshot {dataset, name, why}` snapshots one dataset, never its children. There is no `recursive`,
and passing one is refused. It refuses all of these before anything runs:
- a dataset that `zfs list` does not show on this machine;
- a name with anything but letters, digits, `.`, `_` and `-`;
- a whole name longer than ZFS's 255 characters;
- a snapshot that already exists (with `recursive`, on any child too);
- a name with anything but letters, digits, `.`, `_` and `-`, or a whole name longer than 255 characters;
- a snapshot that already exists;
- a missing `why`, or a `why` longer than one line.
It then runs `sudo -n zfs snapshot -- <dataset>@<name>`, with `-r` only when `recursive` is true. It
answers from `zfs list` afterwards, not from what it ran: the snapshot made and its creation time, and any
other snapshot that came or went during the call (another schedule's, for example). A snapshot the list
does not show is an error, whatever the command said.
It runs `sudo -n zfs snapshot -- <dataset>@<name>`. It answers from `zfs list` afterwards, not from what
it ran: the snapshot made, its creation time, and any other snapshot that came or went during the call. It
also gives the dataset's free space and the pool's capacity. **From then on, the snapshot holds every block
of the dataset that is deleted or overwritten, so that space is not freed until the snapshot is destroyed by
hand.** No tool of the mesh destroys a snapshot.
The act is recorded as the event `snapshot.taken` (the dataset, the name, why, whether it was done, and
when), and ZFS keeps the command in the pool's own history. An event the bus does not accept is said in
the answer as NOT RECORDED.
The act is recorded as the event `snapshot.taken`, and ZFS keeps the command in the pool's own history.
## The scrub schedule
The module owns the schedule of the pool its `scrub-pool` setting names.
| setting | default | what |
|---|---|---|
| `scrub-pool` | none: the operator's value | the pool to scrub |
| `scrub-cadence` | `weekly` | `weekly`, or `monthly` (the first given weekday of each month) |
| `scrub-day` | `Mon` | `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat` or `Sun`, nothing else |
| `scrub-time` | `00:00` | `HH:MM` from `00:00` to `23:59`, in the machine's time zone |
The defaults are what the distribution's weekly timer did on the home server before the module, so taking
it over does not move the schedule. The operator chose monthly for the home server.
**The scheduled run.** The node-engine runs the module's scheduled process `zfs-mesh-scrub` (`zfs-tools
scrub-run`, as root) from its own timer `zfs-mesh-scrub.timer`, every quarter of an hour. The run reads the
configuration the mesh writes from the settings (`/etc/zfs-tools/scrub.conf`). It finds the slot, which is
the last scheduled time not after now. If the slot is not yet served, the run acts as upstream's
`zfs-scrub@.service` does:
- if a scrub is running, it waits for it with `zpool wait -t scrub -- <pool>`;
- otherwise it starts one with `zpool scrub -- <pool>` and waits for it the same way;
- if the start is refused because a scrub has just begun, it waits for that one.
It never pauses or stops a scrub. The unit has no ExecStop, so stopping the unit ends only the wait. A
resilver that is running defers the scrub to the next run after it. The served slot is kept in
`/var/lib/zfs-scrub/scrub-served.json`, in a folder of root's alone (0700), apart from the account's state.
The run writes it only into a real folder of its own and never through a link: a new file opened with
`O_NOFOLLOW` and `O_EXCL`, then renamed over the old one. The tools run as the operator account and cannot
read it, so they judge a missed scrub from status. Without that file, a scrub that status shows started in the slot
serves it. So a scrub starts within 15 minutes of its time, and after the machine was off, within 15
minutes of its coming back.
**No setting value reaches a unit file.** The day, the time and the cadence go only into the
configuration. The run checks them against fixed sets (the seven weekday names, and `HH:MM`). It refuses
any other value and runs nothing; a value like `*:*` or `Mon..Fri` is impossible. The pool's name reaches
the names of the two distribution timers. The controller fills it there and refuses anything but letters,
digits, `:`, `_`, `.` and `-` (mesh-controller #171).
**Exactly one schedule per pool.** The distribution's `zfs-scrub-weekly@<pool>.timer` and
`zfs-scrub-monthly@<pool>.timer` are held stopped and disabled. They come after the scheduled run in the
declaration, so a run that cannot be installed stops the apply before the timer that scrubs the pool today
is touched. On the home server the weekly one was enabled by hand, outside the mesh. A scrub already
running is not stopped by this; only its timer is.
**Unassigning the module gives the distribution's timer back running and enabled**, as the node-engine
found it, once mesh-host #66 (hq ADR 0269, proposed) is merged. Until then the node-engine never starts a
unit on the way out, and unassigning would leave the pool with no scrub schedule. Do not unassign the
module before #66 is merged.
**One pool per assignment in this version.** A manifest's resources are fixed, and a setting cannot make
one timer per pool in a list. The home server has one pool.
### The scrub acts
`zfs_scrub_start`, `zfs_scrub_pause` and `zfs_scrub_stop` take `{pool, why}`. They refuse a pool that
`zpool list` does not show, and a missing `why`. They run `sudo -n zpool scrub [-p|-s] -- <pool>` and
answer what status says afterwards. Each is recorded as the event `scrub.requested`.
A scrub reads every block and rewrites, from the pool's redundancy, only a block whose checksum is wrong.
It changes no file's content. It adds read load for its whole length, which on the home server's pool was
about 46 hours for the weekly scrub that ended 2026-10-06. These three are **candidates for the approval
mark** (novox/hq research 039): a person's acts that never change data, and that cost hours of I/O.
## Resilvers
ZFS starts a resilver by itself when a disk is replaced or reattached, so there is no schedule to own. The
module watches and reports it:
- `zfs_scrub` shows a running resilver, pool-wide or a vdev's rebuild, with its progress and the time to go;
- the health check warns while one runs, and is unhealthy when one finished with errors;
- the event follower writes its start and finish to the journal.
## Health
The module runs two long-running processes of its own binary, and neither writes a file or changes
anything:
The module runs two long-running processes of its own binary, and the scheduled run:
- **The watch** (`zfs-tools watch`) carries the module's health check, `zfs_health` (novox/hq ADR 0240).
Each minute it reads the pools and writes a line in its journal whenever their health changes.
- **The event follower** (`zfs-tools events`) runs as root and follows `zpool events -f`. It writes each
error and change of state to its journal as ZFS emits it. ZFS keeps its events in memory only, so they
are lost at a reboot, and the journal keeps them past one. The node-engine judges it by its unit. That
is the check the module does not run itself, which a tool check needs beside it (ADR 0240 rule 2).
- **The watch** (`zfs-tools watch`) runs as the operator account and carries the health check,
`zfs_health` (novox/hq ADR 0240). Each minute it reads the pools and writes a line in its journal whenever
their health changes.
- **The event follower** (`zfs-tools events`) runs as root and follows `zpool events -f`. It is described
in the next section.
- **The scheduled run** (`zfs-tools scrub-run`) is not long-running and has no health check of its own;
`zfs_health` says when it failed or did not start a due scrub.
`zfs_health` is **unhealthy** when:
`zfs_health` is **unhealthy** for a fault now:
- a pool is not ONLINE (the answer gives status's reason and action);
- a device has read, write or checksum errors, or is not online;
- a pool reports data errors;
- a pool is more than 90 % full;
- no pool is imported;
- ZFS is not on the machine.
- a pool not ONLINE (the answer gives status's reason and action), or a device not online;
- a device's error counts that rose since the last look. A rise stays unhealthy for 15 minutes, so the
node-engine's two looks see it;
- data errors, or a resilver that finished with errors;
- a pool more than 90 % full;
- no pool imported, or no ZFS on the machine.
These are **warnings**, which leave the module healthy:
These are **warnings**, which leave it healthy:
- a pool is more than 80 % full;
- a pool's last scrub finished more than 35 days ago, or the pool was never scrubbed.
- error counts from before ("N past checksum errors; clear them by hand once understood"). The first look
after the bundle starts takes every count as past;
- a pool more than 80 % full;
- a resilver running;
- a last finished scrub older than the schedule allows (weekly 9 days, monthly 38 days), or 35 days for a
pool without a schedule;
- a scheduled scrub that has not started an hour after its time;
- a scheduled run that failed, with no scrub finished since;
- a schedule whose timer is not active and enabled, or whose settings are not valid.
The health check judges only the last scan that status shows. If that scan was a resilver, the last
scrub's age is in `zfs_scrub` with `history: true`.
The error counts last seen are kept in `/var/lib/zfs-tools/errors.json`, so a restart of the bundle does
not take every count as past again.
## The event journal
ZFS keeps its events in a queue in the kernel, in memory, from the moment its module loads, so a reboot
loses them. The follower reads the queue from its oldest event, then follows. So the pool's import, and
whatever came while the follower was down, reach the journal too. It writes each error and change of state
in one line, resilver and scrub starts and ends among them. It also writes ZFS's own "dropped N events" line
when the queue overflowed before it was read.
A cursor of the boot and the last event read is kept in `/run/zfs-events/cursor`, which a reboot empties as
it empties ZFS's queue. With it, a follower that restarts does not write an event twice. After five
follows in a row that end within a minute, the follower exits non-zero, so the service manager counts the
restarts and the unit's state shows the failure.
**What survives a reboot** is what reached the journal. That holds where journald keeps its journal on
disk. On the home server it does: its journal is in `/var/log/journal`, and it holds entries from before the
reboot of 2026-10-04 02:09, as read through the mesh on 2026-10-09. Events ZFS emits after the follower has
stopped during a shutdown are lost with the queue.
## Tests
`go test ./...` runs against OpenZFS 2.4 output in `cmd/zfs-tools/testdata`. Each file has the exact form
the command prints, with documentation names in place of the real pools and disks. The tests cover:
`go test ./...` runs against OpenZFS 2.4.4 output in `cmd/zfs-tools/testdata`. Each file has the form that
version's `zpool_main.c` and `libzfs` print, with documentation names in place of the real pools and disks.
The tests cover:
- parsing every command's output;
- the snapshot's argument list, every refusal (none runs a command), the read-back, a snapshot the list
does not show, a refused sudo, an event the bus does not accept, and another snapshot taken meanwhile;
- the health verdicts;
- parsing every command's output, including the scan lines (human sizes, rebuilds per vdev, error scrubs,
paused scrubs) and `iostat -H` without section lines;
- a timed-out command ended with its children;
- the snapshot's refusals, read-back, space statement and unknown outcome;
- the scrub acts, the schedule's slots, and the scheduled run (start, wait, never pause, defer to a
resilver, refuse an invalid setting);
- the follower's cursor, dropped events, kill before wait, and exit after repeated failures;
- the health verdicts, past against new errors;
- a machine without ZFS;
- the manifest against the tools the bundle serves.
+5 -1
View File
@@ -23,7 +23,8 @@ type Event struct {
var eventKeys = map[string]bool{
"pool": true, "pool_state": true, "vdev_path": true, "vdev_type": true, "vdev_state": true, "vdev_laststate": true,
"vdev_read_errors": true, "vdev_write_errors": true, "vdev_cksum_errors": true, "zio_err": true, "zio_objset": true,
"zio_object": true, "history_internal_name": true, "history_internal_str": true, "vdev_ashift": true,
"zio_object": true, "history_internal_name": true, "history_internal_str": true, "vdev_ashift": true, "eid": true,
"resilver_type": true,
}
// vdevStates are ZFS's vdev states as numbers, as events give them.
@@ -41,6 +42,9 @@ func ParseEvents(out string) []Event {
if strings.TrimSpace(line) == "" {
continue
}
if droppedLine.MatchString(strings.TrimSpace(line)) {
continue
}
if line[0] != ' ' && line[0] != '\t' {
f := strings.Fields(line)
if len(f) < 2 || (f[0] == "TIME" && f[len(f)-1] == "CLASS") {
+201 -36
View File
@@ -3,25 +3,105 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// The module's second long-running process (`zfs-tools events`), run by the node-engine as root. ZFS keeps
// its events in memory only, so a reboot or a reload of its kernel module loses them; the follower writes
// each error and change of state to its journal as ZFS emits it, where it outlives both. It writes no file
// and changes nothing. It is judged by its unit (novox/hq ADR 0240), the check beside zfs_health that the
// module does not run itself.
// The module's second long-running process (`zfs-tools events`), run by the node-engine as root.
//
// ZFS keeps its events in a queue in the kernel, in memory, from the moment its module loads: a reboot
// loses them. `zpool events -f` gives the queue from its oldest event kept, then follows. The follower
// writes each error and change of state (resilver and scrub starts and ends among them) to its journal, so
// they outlive the reboot where the journal is kept on disk. It reads the queue from its start, so the
// pool's import and whatever came while the follower was down reach the journal too; a cursor of the boot
// and the last event read, in CursorFile, keeps it from writing an event twice when it restarts. A line
// ZFS gives when its queue was full and it dropped events is written as it comes.
//
// It writes no other file and changes nothing. It is judged by its unit (novox/hq ADR 0240): after
// MaxFollowFailures follows in a row that end within FollowSettle, it exits non-zero, so the service
// manager's restart counting and the unit's state say so.
// FollowArgv follows ZFS's events as they come, with their details.
// FollowArgv follows ZFS's events from the oldest kept, with their details.
func FollowArgv() []string { return []string{"events", "-f", "-v"} }
// FollowRetry is how long the follower waits before following again after zpool exits.
const FollowRetry = 30 * time.Second
// CursorFile is where the follower keeps the boot and the last event it read. Under /run, so a reboot,
// which empties ZFS's queue, empties it too.
const CursorFile = "/run/zfs-events/cursor"
// BootID names this boot.
const BootID = "/proc/sys/kernel/random/boot_id"
const (
// FollowRetry is how long the follower waits before following again after zpool ended.
FollowRetry = 30 * time.Second
// FollowSettle is how long a follow must last to count as having worked.
FollowSettle = time.Minute
// MaxFollowFailures in a row end the follower with an error.
MaxFollowFailures = 5
)
// Cursor is the last event read in this boot: its id and its time. ZFS numbers events from its module's
// load, so after a reload a smaller id with a later time is a new event.
type Cursor struct {
Boot string `json:"boot"`
EID uint64 `json:"eid"`
Time time.Time `json:"time"`
path string
}
// LoadCursor is the cursor kept for this boot; an empty one for another boot or none kept.
func LoadCursor(path, boot string) *Cursor {
c := &Cursor{Boot: boot, path: path}
b, err := os.ReadFile(path)
if err != nil {
return c
}
var kept Cursor
if json.Unmarshal(b, &kept) != nil || kept.Boot != boot {
return c
}
kept.path = path
return &kept
}
// Save keeps the cursor, atomically.
func (c *Cursor) Save(path string) error {
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, _ := json.Marshal(c)
tmp := path + ".new"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// seen says whether an event was read already.
func (c *Cursor) seen(eid uint64, at time.Time, timed bool) bool {
if c.EID == 0 && c.Time.IsZero() {
return false
}
if eid > c.EID {
return false
}
// A smaller or equal id: read, unless it is later than the cursor (ZFS's counter began again).
return !timed || !at.After(c.Time)
}
// eventTime reads an event's time as `zpool events` gives it: "Oct 8 2026 21:01:55.889900112".
func eventTime(s string) (time.Time, bool) {
@@ -43,27 +123,42 @@ func EventLine(e Event) string {
return line
}
// Follow reads `zpool events -f -v` and writes each notable event at or after since in a line. `-f` first
// gives every event ZFS still keeps; those from before since were written by an earlier follower, or are
// in zfs_events, and are not written again.
func Follow(r io.Reader, out io.Writer, since time.Time) error {
var droppedLine = regexp.MustCompile(`^dropped (\d+) events$`)
// Follow reads `zpool events -f -v` and writes each notable event the cursor has not seen in a line, and
// each line saying ZFS dropped events; the cursor moves past every event read and is saved as it goes.
func Follow(r io.Reader, out io.Writer, cur *Cursor) error {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
var block []string
flush := func() {
if len(block) == 0 {
return
}
for _, e := range ParseEvents(strings.Join(block, "\n")) {
if !Notable(e.Class) {
at, timed := eventTime(e.Time)
eid, _ := strconv.ParseUint(strings.TrimPrefix(e.Details["eid"], "0x"), 16, 64)
if cur.seen(eid, at, timed) {
continue
}
if t, ok := eventTime(e.Time); ok && t.Before(since) {
continue
if Notable(e.Class) {
fmt.Fprintln(out, EventLine(e))
}
fmt.Fprintln(out, EventLine(e))
cur.EID = eid
if timed {
cur.Time = at
}
_ = cur.Save(cur.path)
}
block = block[:0]
}
for sc.Scan() {
l := sc.Text()
if m := droppedLine.FindStringSubmatch(strings.TrimSpace(l)); m != nil {
flush()
fmt.Fprintf(out, "ZFS dropped %s events before they were read: its queue was full\n", m[1])
continue
}
if strings.TrimSpace(l) == "" {
flush()
continue
@@ -74,31 +169,101 @@ func Follow(r io.Reader, out io.Writer, since time.Time) error {
return sc.Err()
}
// Follower runs zpool events -f until the context ends, again after FollowRetry whenever it exits.
func Follower(ctx context.Context, out io.Writer) {
since := time.Now()
// FollowProcess is a running `zpool events -f`.
type FollowProcess interface {
Stdout() io.Reader
Kill() error
Wait() error
}
// FollowOnce follows until zpool ends or its output cannot be read. On a failure to read, zpool is
// killed before it is waited for: it would otherwise follow on, and the wait never end.
func FollowOnce(p FollowProcess, out io.Writer, cur *Cursor) error {
ferr := Follow(p.Stdout(), out, cur)
if ferr != nil {
_ = p.Kill()
}
werr := p.Wait()
if ferr != nil {
return fmt.Errorf("reading zpool events: %w", ferr)
}
if werr != nil {
return fmt.Errorf("zpool events -f ended: %w", werr)
}
return errors.New("zpool events -f ended")
}
// RunFollower follows again after each end, and gives up with an error after MaxFollowFailures follows in
// a row that did not last FollowSettle.
func RunFollower(ctx context.Context, out io.Writer, start func(context.Context) (FollowProcess, error), cur *Cursor,
cursorPath string, wait func(time.Duration)) error {
if cursorPath != "" {
cur.path = cursorPath
}
failures := 0
for ctx.Err() == nil {
cmd := exec.CommandContext(ctx, "zpool", FollowArgv()...)
stdout, err := cmd.StdoutPipe()
if err == nil {
err = cmd.Start()
}
if err == nil {
err = Follow(stdout, out, since)
if werr := cmd.Wait(); err == nil {
err = werr
}
began := time.Now()
var err error
p, serr := start(ctx)
if serr != nil {
err = serr
} else {
err = FollowOnce(p, out, cur)
}
if ctx.Err() != nil {
return
return nil
}
fmt.Fprintf(out, "zpool events -f stopped (%v); following again in %s\n", err, FollowRetry)
// What comes while it waits is still in ZFS's own list, and the next follow writes it from here.
since = time.Now()
if time.Since(began) >= FollowSettle {
failures = 0
}
failures++
fmt.Fprintf(out, "following ZFS's events stopped (%v); %d of %d in a row\n", err, failures, MaxFollowFailures)
if failures >= MaxFollowFailures {
return fmt.Errorf("following ZFS's events failed %d times in a row, the last: %v", failures, err)
}
wait(FollowRetry)
}
return nil
}
// zpoolFollow is the real `zpool events -f -v`, in a process group of its own.
type zpoolFollow struct {
cmd *exec.Cmd
out io.ReadCloser
}
func (z *zpoolFollow) Stdout() io.Reader { return z.out }
func (z *zpoolFollow) Kill() error { return syscall.Kill(-z.cmd.Process.Pid, syscall.SIGKILL) }
func (z *zpoolFollow) Wait() error { return z.cmd.Wait() }
// StartZpoolFollow starts `zpool events -f -v`; the context's end kills it.
func StartZpoolFollow(ctx context.Context) (FollowProcess, error) {
cmd := exec.Command("zpool", FollowArgv()...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = KillGrace
out, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
z := &zpoolFollow{cmd: cmd, out: out}
go func() {
<-ctx.Done()
_ = z.Kill()
}()
return z, nil
}
// Follower is the events process: the cursor for this boot, then RunFollower.
func Follower(ctx context.Context, out io.Writer) error {
boot, _ := os.ReadFile(BootID)
cur := LoadCursor(CursorFile, strings.TrimSpace(string(boot)))
return RunFollower(ctx, out, StartZpoolFollow, cur, CursorFile, func(d time.Duration) {
select {
case <-ctx.Done():
return
case <-time.After(FollowRetry):
case <-time.After(d):
}
}
})
}
+114 -13
View File
@@ -23,14 +23,75 @@ type Health struct {
Warnings []string `json:"warnings,omitempty"`
}
// Judge judges the pools from what zpool list and zpool status said.
func Judge(pools []Pool, status []PoolStatus, now time.Time) Health {
// ErrorsFresh is how long a rise in a device's error counts keeps the health unhealthy: long enough for
// the node-engine's two looks to see it, and a condition to be raised.
const ErrorsFresh = 15 * time.Minute
// Look is what one health check read.
type Look struct {
Pools []Pool
Status []PoolStatus
Now time.Time
Schedule *Schedule
// ScheduleErr is a schedule whose settings are not valid.
ScheduleErr error
// Served is the last slot the scheduled run served, per pool.
Served map[string]int64
}
// errorFinding judges one line of status's error counts against the last look: a count that rose is a
// fault now; a count that was there before is past, a warning to clear by hand once understood.
func (mem *ErrorMemory) errorFinding(pool string, d *Device, now time.Time) (fault, past string) {
key := pool + "/" + d.Kind + "/" + d.Name
counts := [3]uint64{d.Read, d.Write, d.Checksum}
names := [3]string{"read", "write", "checksum"}
if mem == nil {
mem = NewErrorMemory()
}
mem.mu.Lock()
defer mem.mu.Unlock()
before, seen := mem.counts[key]
mem.counts[key] = counts
var rose []string
for i := range counts {
if seen && counts[i] > before[i] {
rose = append(rose, fmt.Sprintf("%s errors rose from %d to %d", names[i], before[i], counts[i]))
}
}
if len(rose) > 0 {
mem.rose[key] = now
return fmt.Sprintf("%s %s in pool %s: %s since the last look.", d.Kind, d.Name, pool, strings.Join(rose, ", ")), ""
}
if at, ok := mem.rose[key]; ok && now.Sub(at) < ErrorsFresh {
return fmt.Sprintf("%s %s in pool %s: its errors rose at %s (read %d, write %d, checksum %d).", d.Kind, d.Name, pool,
at.Local().Format("15:04"), d.Read, d.Write, d.Checksum), ""
}
if d.Errors() == 0 {
return "", ""
}
var parts []string
for i := range counts {
if counts[i] > 0 {
parts = append(parts, fmt.Sprintf("%d past %s errors", counts[i], names[i]))
}
}
return "", fmt.Sprintf("%s %s in pool %s has %s; clear them by hand once understood.", d.Kind, d.Name, pool, strings.Join(parts, " and "))
}
// Judge judges the pools from what one look read. Unhealthy is a fault now: a pool or device not online,
// error counts that rose, data errors, a pool past CapacityFail, a resilver that finished with errors.
// A warning is a past fault or a risk: error counts that were there before, a pool past CapacityWarn, a
// resilver running, a scrub later than its schedule allows, a schedule that failed or does not run.
func (mem *ErrorMemory) Judge(l Look) Health {
var found, warn []string
if len(pools) == 0 {
if len(l.Pools) == 0 {
found = append(found, "ZFS is on this machine and no pool is imported.")
}
for _, p := range pools {
s := statusOf(status, p.Name)
if l.ScheduleErr != nil {
warn = append(warn, sentence("the scrub schedule's settings are not valid: "+l.ScheduleErr.Error()))
}
for _, p := range l.Pools {
s := statusOf(l.Status, p.Name)
state := p.Health
if s != nil && s.State != "" {
state = s.State
@@ -59,8 +120,12 @@ func Judge(pools []Pool, status []PoolStatus, now time.Time) Health {
if d.Kind == "section" {
return
}
if d.Errors() > 0 {
found = append(found, fmt.Sprintf("%s %s in pool %s has errors: read %d, write %d, checksum %d.", d.Kind, d.Name, p.Name, d.Read, d.Write, d.Checksum))
fault, past := mem.errorFinding(p.Name, d, l.Now)
if fault != "" {
found = append(found, sentence(fault))
}
if past != "" {
warn = append(warn, sentence(past))
}
if d.Kind != "pool" && d.State != "" && d.State != "ONLINE" && d.State != "AVAIL" && d.State != "INUSE" {
// A device not online already makes its pool DEGRADED or worse; said here by name.
@@ -70,20 +135,54 @@ func Judge(pools []Pool, status []PoolStatus, now time.Time) Health {
if e := strings.TrimSpace(s.Errors); e != "" && e != "No known data errors" {
found = append(found, fmt.Sprintf("Pool %s: %s.", p.Name, strings.TrimSuffix(e, ".")))
}
if ended := LastScrubEnded(s.Scan); ended != nil && now.Sub(*ended) > ScrubWarnAfter {
warn = append(warn, fmt.Sprintf("Pool %s was last scrubbed %.0f days ago, more than %d.", p.Name, now.Sub(*ended).Hours()/24, int(ScrubWarnAfter.Hours()/24)))
} else if s.Scan.State == "none" {
for _, r := range s.Resilvers() {
what := "the resilver"
if r.Vdev != "" {
what += " of " + r.Vdev
}
switch {
case r.State == "running":
line := fmt.Sprintf("A resilver")
if r.Vdev != "" {
line += " of " + r.Vdev
}
line += fmt.Sprintf(" in pool %s is running", p.Name)
if r.Done != nil {
line += fmt.Sprintf(": %.2f %% done", *r.Done)
}
if r.ToGo != "" {
line += ", " + r.ToGo + " to go"
}
warn = append(warn, sentence(strings.ToLower(line[:1])+line[1:]))
case r.State == "finished" && deref(r.Errors) > 0:
found = append(found, sentence(fmt.Sprintf("%s in pool %s finished with %d errors, %s", what, p.Name, deref(r.Errors), stamp(r.Ended))))
}
}
late, by := ScrubLate(p.Name, l.Schedule)
ended := LastScrubEnded(*s)
switch {
case ended != nil && l.Now.Sub(*ended) > late:
warn = append(warn, fmt.Sprintf("Pool %s was last scrubbed %.0f days ago, more than %s (%d days).", p.Name,
l.Now.Sub(*ended).Hours()/24, by, int(late.Hours()/24)))
case len(s.Scans) == 0:
warn = append(warn, fmt.Sprintf("Pool %s was never scrubbed.", p.Name))
}
if l.Schedule != nil && l.Schedule.Pool == p.Name {
warn = append(warn, scheduleFindings(l.Schedule, s, l.Now, l.Served)...)
}
}
if l.Schedule != nil && statusOf(l.Status, l.Schedule.Pool) == nil {
warn = append(warn, fmt.Sprintf("The scrub schedule names pool %s, which is not imported here.", l.Schedule.Pool))
}
mem.save()
if len(found) > 0 {
return Health{Healthy: false, Why: strings.Join(found, " "), Found: found, Warnings: warn}
}
var parts []string
for _, p := range pools {
for _, p := range l.Pools {
parts = append(parts, fmt.Sprintf("%s ONLINE, %d %% full", p.Name, p.Capacity))
}
why := "Every pool is online with no errors: " + strings.Join(parts, "; ") + "."
why := "Every pool is online with no new errors: " + strings.Join(parts, "; ") + "."
if len(warn) > 0 {
why += " Warnings: " + strings.Join(warn, " ")
}
@@ -104,7 +203,9 @@ func (m *Machine) Health(ctx context.Context) Health {
if err != nil {
return Health{Healthy: false, Why: sentence("zpool status did not answer: " + err.Error()), Found: []string{err.Error()}}
}
return Judge(pools, status, m.Now())
sched, serr := m.ScheduleNow(ctx)
return m.Errors.Judge(Look{Pools: pools, Status: status, Now: m.Now(), Schedule: sched, ScheduleErr: serr,
Served: m.readServed(ServedFile)})
}
func sentence(s string) string {
+22 -25
View File
@@ -5,7 +5,6 @@ import (
"context"
"strings"
"testing"
"time"
)
func TestOnlinePoolsWithoutErrorsAreHealthyAndAnOldScrubIsOnlyAWarning(t *testing.T) {
@@ -13,7 +12,7 @@ func TestOnlinePoolsWithoutErrorsAreHealthyAndAnOldScrubIsOnlyAWarning(t *testin
if !h.Healthy || len(h.Found) != 0 {
t.Fatalf("health: %+v", h)
}
if len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "Pool rpool was last scrubbed 89 days ago, more than 35") {
if len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "Pool rpool was last scrubbed 89 days ago, more than the module's line for a pool without a schedule (35 days)") {
t.Errorf("rpool's July scrub is a warning; tank's September one is not: %v", h.Warnings)
}
if !strings.Contains(h.Why, "tank ONLINE, 60 % full") {
@@ -33,7 +32,6 @@ func TestADegradedFullPoolIsUnhealthyWithEveryReason(t *testing.T) {
"Pool tank is DEGRADED: One or more devices is currently being resilvered",
"Action: Wait for the resilver to complete",
"Pool tank is 91 % full, past 90 %",
"device ata-WDC_WD120EFBX-68B0EN0_D7JA0002 in pool tank has errors: read 12, write 107, checksum 3",
"device ata-WDC_WD120EFBX-68B0EN0_D7JA0002 in pool tank is FAULTED",
"Pool tank: 2 data errors",
} {
@@ -41,6 +39,9 @@ func TestADegradedFullPoolIsUnhealthyWithEveryReason(t *testing.T) {
t.Errorf("why lacks %q: %s", want, h.Why)
}
}
if !strings.Contains(strings.Join(h.Warnings, " "), "ata-WDC_WD120EFBX-68B0EN0_D7JA0002 in pool tank has 12 past read errors and 107 past write errors and 3 past checksum errors") {
t.Errorf("its counts on a first look are past: %v", h.Warnings)
}
if strings.Contains(h.Why, "D7JA0009 in pool tank is") {
t.Errorf("a spare in use is not a finding: %s", h.Why)
}
@@ -50,27 +51,27 @@ func TestCapacityPastEightyIsAWarningAndPastNinetyUnhealthy(t *testing.T) {
st, _ := ParseStatus(fixture(t, "zpool-status-scrubbing.txt"))
now := newFake(t).now
p := Pool{Name: "tank", Capacity: 85, Health: "ONLINE"}
if h := Judge([]Pool{p}, st, now); !h.Healthy || len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "85 % full, past 80 %") {
if h := NewErrorMemory().Judge(Look{Pools: []Pool{p}, Status: st, Now: now}); !h.Healthy || len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "85 % full, past 80 %") {
t.Errorf("85 %%: %+v", h)
}
p.Capacity = 80
if h := Judge([]Pool{p}, st, now); !h.Healthy || len(h.Warnings) != 0 {
if h := NewErrorMemory().Judge(Look{Pools: []Pool{p}, Status: st, Now: now}); !h.Healthy || len(h.Warnings) != 0 {
t.Errorf("80 %% is not past 80: %+v", h)
}
p.Capacity = 92
if h := Judge([]Pool{p}, st, now); h.Healthy {
if h := NewErrorMemory().Judge(Look{Pools: []Pool{p}, Status: st, Now: now}); h.Healthy {
t.Errorf("92 %%: %+v", h)
}
}
func TestFaultedAndNoPoolAreUnhealthy(t *testing.T) {
now := newFake(t).now
if h := Judge(nil, nil, now); h.Healthy || !strings.Contains(h.Why, "no pool is imported") {
if h := NewErrorMemory().Judge(Look{Now: now}); h.Healthy || !strings.Contains(h.Why, "no pool is imported") {
t.Errorf("no pool: %+v", h)
}
st := []PoolStatus{{Name: "tank", State: "FAULTED", Status: "One or more devices could not be opened.", Errors: "No known data errors",
Root: &Device{Name: "tank", Kind: "pool", State: "FAULTED"}}}
if h := Judge([]Pool{{Name: "tank", Health: "FAULTED"}}, st, now); h.Healthy || !strings.Contains(h.Why, "Pool tank is FAULTED: One or more devices could not be opened") {
if h := NewErrorMemory().Judge(Look{Pools: []Pool{{Name: "tank", Health: "FAULTED"}}, Status: st, Now: now}); h.Healthy || !strings.Contains(h.Why, "Pool tank is FAULTED: One or more devices could not be opened") {
t.Errorf("faulted: %+v", h)
}
}
@@ -81,7 +82,7 @@ func TestTheWatchSaysOnlyChanges(t *testing.T) {
w := &Watch{M: f.machine(), Out: &out}
w.Look(context.Background())
w.Look(context.Background())
if n := strings.Count(out.String(), "\n"); n != 1 || !strings.Contains(out.String(), "healthy: Every pool is online") {
if n := strings.Count(out.String(), "\n"); n != 1 || !strings.Contains(out.String(), "healthy: Every pool is online with no new errors") {
t.Errorf("one line for one state: %q", out.String())
}
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt"))
@@ -91,23 +92,19 @@ func TestTheWatchSaysOnlyChanges(t *testing.T) {
}
}
func TestTheFollowerWritesEachNotableEventFromItsStartOnce(t *testing.T) {
var out bytes.Buffer
since := time.Date(2026, 10, 8, 21, 0, 0, 0, time.Local)
if err := Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, since); err != nil {
t.Fatal(err)
}
got := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(got) != 3 || !strings.Contains(got[0], "ereport.fs.zfs.io") || !strings.Contains(got[1], "vdev_state=FAULTED") ||
!strings.Contains(got[2], "resilver_start") {
t.Errorf("lines: %q", got)
}
out.Reset()
Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, time.Date(2026, 10, 8, 21, 2, 10, 0, time.Local))
if got := strings.TrimSpace(out.String()); strings.Count(got, "\n") != 0 || !strings.Contains(got, "resilver_start") {
t.Errorf("only what came after since: %q", got)
}
func TestTheFollowerArgvAndAResilverStartAndFinishAreJournaled(t *testing.T) {
if strings.Join(FollowArgv(), " ") != "events -f -v" {
t.Errorf("argv: %v", FollowArgv())
}
for _, c := range []string{"sysevent.fs.zfs.resilver_start", "sysevent.fs.zfs.resilver_finish", "sysevent.fs.zfs.scrub_start",
"sysevent.fs.zfs.scrub_finish", "resource.fs.zfs.statechange", "ereport.fs.zfs.checksum"} {
if !Notable(c) {
t.Errorf("%s is not journaled", c)
}
}
var out bytes.Buffer
Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, &Cursor{})
if !strings.Contains(out.String(), "sysevent.fs.zfs.resilver_start eid=0x14 pool=tank resilver_type=healing") {
t.Errorf("the resilver's start: %q", out.String())
}
}
+7 -1
View File
@@ -34,11 +34,12 @@ type fake struct {
files map[string]string
now time.Time
uid int
errors *ErrorMemory
}
func newFake(t *testing.T) *fake {
return &fake{t: t, answers: map[string]Ran{}, after: map[string]func(*fake){}, files: map[string]string{},
now: time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local), uid: 1000}
now: time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local), uid: 1000, errors: NewErrorMemory()}
}
func (f *fake) on(line string, out string) { f.answers[line] = Ran{Out: out} }
@@ -65,10 +66,15 @@ func (f *fake) machine() *Machine {
ReadFile: func(p string) ([]byte, error) {
s, ok := f.files[p]
if !ok {
if strings.HasPrefix(p, os.TempDir()) {
// What the code under test wrote into a test's own directory.
return os.ReadFile(p)
}
return nil, os.ErrNotExist
}
return []byte(s), nil
},
Errors: f.errors,
Emit: func(eventType string, body any) error {
if f.emitErr != nil {
return f.emitErr
+25 -31
View File
@@ -15,12 +15,15 @@ func IOArgv(seconds int) []string {
return []string{"iostat", "-y", "-Hpv", strconv.Itoa(seconds), "1"}
}
// IORow is one line of the sample: a pool, a vdev, a device or a section.
// IORow is one line of the sample: a pool, a vdev or a device.
type IORow struct {
Name string `json:"name"`
Pool string `json:"pool"`
// Kind is pool, vdev, device or section, from the pool's layout.
Kind string `json:"kind"`
// Kind is pool, vdev or device, and Role data, log, cache, special or dedup, from the pool's layout:
// iostat -H prints no section lines and no indentation.
Kind string `json:"kind"`
Role string `json:"role,omitempty"`
// Allocated and Free are a top-level vdev's; a vdev without capacity of its own has neither.
Allocated *uint64 `json:"allocated_bytes,omitempty"`
Free *uint64 `json:"free_bytes,omitempty"`
ReadOps float64 `json:"read_ops_per_s"`
@@ -30,8 +33,9 @@ type IORow struct {
}
// ParseIO reads `zpool iostat -Hpv`: name, allocated, free, read and write operations, read and write
// bandwidth, tab-separated; "-" where a line has no such number. Lines of a pool follow the pool's own
// line; layout names each line's kind.
// bandwidth, tab-separated. Under -H there are no section lines (a blank line stands before each class)
// and under -p a vdev without capacity of its own prints 0 for both (zpool_main.c, print_iostat_default).
// Lines of a pool follow the pool's own line; its layout names each line's kind and role.
func ParseIO(out string, layout []PoolStatus) ([]IORow, error) {
var rows []IORow
pool := ""
@@ -43,25 +47,24 @@ func ParseIO(out string, layout []PoolStatus) ([]IORow, error) {
if len(f) != 7 {
return nil, fmt.Errorf("zpool iostat answered a line with %d columns, not 7: %q", len(f), line)
}
r := IORow{Name: strings.TrimSpace(f[0])}
r := IORow{Name: strings.TrimSpace(f[0]), Kind: "device"}
if statusOf(layout, r.Name) != nil {
pool = r.Name
r.Kind = "pool"
r.Kind = ""
}
r.Pool = pool
if r.Kind == "" {
r.Kind = "device"
if _, ok := sections[r.Name]; ok && allDash(f[1:]) {
r.Kind = "section"
} else if p := statusOf(layout, pool); p != nil {
walk(p.Root, func(d *Device, _ int) {
if d.Name == r.Name && d.Kind != "pool" {
r.Kind = d.Kind
}
}, 0)
}
r.Kind = "pool"
} else if p := statusOf(layout, pool); p != nil {
walk(p.Root, func(d *Device, _ int) {
if d.Name == r.Name && d.Kind != "pool" && d.Kind != "section" {
r.Kind, r.Role = d.Kind, d.Role
}
}, 0)
}
if !(f[1] == "0" && f[2] == "0") {
r.Allocated, r.Free = optionalNumber(f[1]), optionalNumber(f[2])
}
r.Allocated, r.Free = optionalNumber(f[1]), optionalNumber(f[2])
vals := []*float64{&r.ReadOps, &r.WriteOps, &r.ReadBytes, &r.WriteBytes}
for i, v := range vals {
s := f[3+i]
@@ -79,15 +82,6 @@ func ParseIO(out string, layout []PoolStatus) ([]IORow, error) {
return rows, nil
}
func allDash(f []string) bool {
for _, s := range f {
if strings.TrimSpace(s) != "-" {
return false
}
}
return true
}
func optionalNumber(s string) *uint64 {
if s == "-" {
return nil
@@ -125,11 +119,11 @@ func (m *Machine) IO(ctx context.Context, seconds int) (any, error) {
}
a := IOAnswer{Applicable: true, Seconds: seconds, Rows: rows, Said: []string{}}
for _, r := range rows {
if r.Kind == "section" {
a.Said = append(a.Said, r.Pool+" "+r.Name+":")
continue
kind := r.Kind
if r.Role != "" && r.Role != "data" {
kind += ", " + r.Role
}
a.Said = append(a.Said, fmt.Sprintf("%s %s (%s): read %.0f ops/s %s/s, write %.0f ops/s %s/s", r.Pool, r.Name, r.Kind,
a.Said = append(a.Said, fmt.Sprintf("%s %s (%s): read %.0f ops/s %s/s, write %.0f ops/s %s/s", r.Pool, r.Name, kind,
r.ReadOps, bytesSaid(uint64(r.ReadBytes)), r.WriteOps, bytesSaid(uint64(r.WriteBytes))))
}
return a, nil
+121 -9
View File
@@ -3,11 +3,14 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
@@ -23,11 +26,18 @@ import (
// CommandTimeout bounds every command but a sample.
const CommandTimeout = 30 * time.Second
// KillGrace is how long a command that ran out of time is given to end on SIGTERM before its whole
// process group is killed.
var KillGrace = 5 * time.Second
// Ran is what one command did.
type Ran struct {
Out string
Err string
Code int
// TimedOut is a command that did not answer within its time and was ended: what it did is not known
// from its answer, only from reading the machine again.
TimedOut bool
}
// Runner runs one command; injected, so every tool is tested over recorded answers.
@@ -35,20 +45,49 @@ type Runner func(ctx context.Context, name string, args ...string) Ran
// ExecRunner runs a command on the machine, bounded by the context (CommandTimeout unless the caller set
// one).
//
// **A command that runs out of time is ended with everything it started.** It runs in a process group of
// its own; at the deadline the group is sent SIGTERM, and after KillGrace SIGKILL. Under sudo the command
// itself runs as root, which this account cannot signal: SIGTERM reaches sudo, which passes it on to the
// command, and the group's SIGKILL ends sudo. The output pipes are closed after KillGrace whatever still
// holds them, so the call always returns.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
if _, has := ctx.Deadline(); !has {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, CommandTimeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = KillGrace
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())}
if err := cmd.Start(); err != nil {
return Ran{Code: -1, Err: err.Error()}
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
timedOut := false
select {
case err = <-done:
case <-ctx.Done():
timedOut = true
pgid := cmd.Process.Pid
_ = syscall.Kill(-pgid, syscall.SIGTERM)
select {
case err = <-done:
case <-time.After(KillGrace):
_ = syscall.Kill(-pgid, syscall.SIGKILL)
err = <-done
}
// Anything of the group still alive and signalable goes with it.
_ = syscall.Kill(-pgid, syscall.SIGKILL)
}
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String()), TimedOut: timedOut}
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
if errors.As(err, &exit) && !timedOut {
r.Code = exit.ExitCode()
} else {
r.Code = -1
@@ -56,10 +95,10 @@ func ExecRunner(ctx context.Context, name string, args ...string) Ran {
r.Err = err.Error()
}
}
if ctx.Err() != nil {
r.Code = -1
r.Err = fmt.Sprintf("did not answer within its time (%v)", ctx.Err())
}
}
if timedOut {
r.Code = -1
r.Err = strings.TrimSpace(fmt.Sprintf("did not answer within its time and was ended. %s", r.Err))
}
return r
}
@@ -74,11 +113,14 @@ type Machine struct {
ReadFile func(path string) ([]byte, error)
// Emit publishes an event as this module (the record of an act); nil where nothing can be published.
Emit func(eventType string, body any) error
// Errors is what the health check last saw of each device's error counts; nil judges every count
// as past.
Errors *ErrorMemory
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ReadFile: os.ReadFile}
return &Machine{Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ReadFile: os.ReadFile, Errors: LoadErrorMemory(ErrorsFile)}
}
// NotApplicable is the answer of a machine without ZFS: the module does not apply here.
@@ -105,6 +147,73 @@ type errAbsent struct{ why string }
func (e errAbsent) Error() string { return e.why }
// errTimedOut is an act that did not answer within its time and was ended: whether it took effect is
// known only by reading the machine again.
type errTimedOut struct{ what string }
func (e errTimedOut) Error() string {
return e.what + " did not answer within its time and was ended; whether it took effect is not known"
}
// ErrorMemory is what the health check last saw of each device's error counts, so a count that rises is
// told from one that was there before (a fault now, against a fault past).
type ErrorMemory struct {
mu sync.Mutex
counts map[string][3]uint64
rose map[string]time.Time
path string
}
// NewErrorMemory remembers nothing yet: every count on the first look is past.
func NewErrorMemory() *ErrorMemory {
return &ErrorMemory{counts: map[string][3]uint64{}, rose: map[string]time.Time{}}
}
// ErrorsFile is where the error counts last seen are kept, so a restart of the bundle does not take every
// count as past again.
const ErrorsFile = "/var/lib/zfs-tools/errors.json"
type keptErrors struct {
Counts map[string][3]uint64 `json:"counts"`
Rose map[string]time.Time `json:"rose"`
}
// LoadErrorMemory is the memory kept in path; an empty one when there is none, and either way it is kept
// there from now on.
func LoadErrorMemory(path string) *ErrorMemory {
mem := NewErrorMemory()
mem.path = path
if b, err := os.ReadFile(path); err == nil {
var k keptErrors
if json.Unmarshal(b, &k) == nil {
if k.Counts != nil {
mem.counts = k.Counts
}
if k.Rose != nil {
mem.rose = k.Rose
}
}
}
return mem
}
// save keeps the memory, atomically; a failure only costs the next restart its memory.
func (mem *ErrorMemory) save() {
if mem == nil || mem.path == "" {
return
}
mem.mu.Lock()
b, err := json.Marshal(keptErrors{Counts: mem.counts, Rose: mem.rose})
mem.mu.Unlock()
if err != nil {
return
}
tmp := mem.path + ".new"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, mem.path)
}
}
// ask runs a read-only command as this account and answers its output, or why it could not.
func (m *Machine) ask(ctx context.Context, name string, args ...string) (string, error) {
r := m.Run(ctx, name, args...)
@@ -130,6 +239,9 @@ func (m *Machine) Escalated(name string, args ...string) (string, []string) {
func (m *Machine) AsRoot(ctx context.Context, name string, args ...string) (Ran, error) {
program, argv := m.Escalated(name, args...)
r := m.Run(ctx, program, argv...)
if r.TimedOut {
return r, errTimedOut{name + " " + strings.Join(args, " ")}
}
if r.Code != 0 {
why := orOut(r)
if why2 := absent(r); why2 != "" && program != "sudo" {
+16 -5
View File
@@ -2,11 +2,12 @@
// scrubs, I/O, cache and events, every dataset with its space and snapshots — and one act, taking a named
// snapshot of one dataset.
//
// One binary, three ways to run. With no argument it is the bundle the node's tool runner launches and
// One binary, four ways to run. With no argument it is the bundle the node's tool runner launches and
// speaks MCP to over stdio (novox/hq ADR 0188, ADR 0193), as the operator account. With `watch` and
// `events` it is one of the module's two long-running processes, run by the node-engine: the watch, which
// the health check zfs_health stands on (watch.go), and the event follower, as root, judged by its unit
// (follow.go).
// (follow.go). With `scrub-run` it is the scheduled scrub, run as root by the node-engine's timer
// (schedule.go).
package main
import (
@@ -25,12 +26,22 @@ func main() {
defer stop()
switch os.Args[1] {
case "watch":
(&Watch{M: Here(), Out: os.Stdout}).Run(ctx)
(&Watch{M: Here(), Out: os.Stdout}).Loop(ctx)
case "scrub-run":
said, err := Here().ScrubRun(ctx, ServedFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(said)
case "events":
Follower(ctx, os.Stdout)
if err := Follower(ctx, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "zfs-tools: %q is not a way to run; with no argument it serves its tools, with watch it "+
"watches the pools, with events it follows ZFS's events\n", os.Args[1])
"watches the pools, with events it follows ZFS's events, with scrub-run it runs the scheduled scrub\n", os.Args[1])
os.Exit(2)
}
return
+160 -38
View File
@@ -8,6 +8,7 @@ import (
"sort"
"strings"
"testing"
"time"
)
type manifest struct {
@@ -40,7 +41,7 @@ func TestTheManifestNamesTheToolsTheBundleServesAndWhatEachReplaces(t *testing.T
for _, tl := range Tools(newFake(t).machine()) {
served = append(served, tl.Name)
want := "(r)"
if tl.Name == "zfs_snapshot" {
if tl.Name == "zfs_snapshot" || strings.HasPrefix(tl.Name, "zfs_scrub_") {
want = "(a)"
}
if !strings.HasSuffix(tl.Description, want) {
@@ -67,7 +68,7 @@ func TestTheManifestNamesTheToolsTheBundleServesAndWhatEachReplaces(t *testing.T
if len(m.Claims) != 0 {
t.Errorf("the module claims no seat: %v", m.Claims)
}
if strings.Join(m.Emits, ",") != SnapshotEvent {
if strings.Join(m.Emits, ",") != SnapshotEvent+","+ScrubEvent {
t.Errorf("emits %v", m.Emits)
}
if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen)\b|/home/`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" {
@@ -75,50 +76,170 @@ func TestTheManifestNamesTheToolsTheBundleServesAndWhatEachReplaces(t *testing.T
}
}
// ADR 0240: the health is zfs_health on the watch, beside the event follower's unit, which the module does
// not judge itself; the module installs nothing, so it never puts ZFS on a machine.
func TestTheHealthIsTheWatchsToolCheckBesideTheFollowersUnit(t *testing.T) {
// ADR 0240: zfs_health on the watch, which runs as the operator account; the follower judged by its
// unit. The scheduled run is not long-running and declares none. The module installs nothing, so it never
// puts ZFS on a machine.
func TestTheHealthChecksAndWhoRunsWhat(t *testing.T) {
m, raw := readManifest(t)
byID := map[string]map[string]any{}
for _, r := range m.Resources {
byID[r["id"].(string)] = r
}
if len(byID) != 2 {
t.Fatalf("resources: %v", m.Resources)
w := byID["watch"]
if h, _ := w["health"].(map[string]any); h["kind"] != "tool" || h["tool"] != "zfs_health" || w["user"] != "${machine:account}" {
t.Errorf("the watch runs as the operator account and carries zfs_health: %v", w)
}
for id, want := range map[string][2]string{"watch": {"tool", "watch"}, "events": {"unit", "events"}} {
r := byID[id]
h, _ := r["health"].(map[string]any)
run, _ := r["run"].([]any)
if r["type"] != "process" || h["kind"] != want[0] || len(run) != 2 || run[1] != want[1] {
t.Errorf("%s: %v", id, r)
}
e := byID["events"]
if h, _ := e["health"].(map[string]any); h["kind"] != "unit" || e["user"] != nil {
t.Errorf("the follower runs as root, judged by its unit: %v", e)
}
if h := byID["watch"]["health"].(map[string]any); h["tool"] != "zfs_health" {
t.Errorf("the watch is judged by zfs_health: %v", h)
if d := byID["state-dir"]; d["path"] != "/var/lib/zfs-tools" || d["owner"] != "${machine:account}" ||
filepath.Dir(ErrorsFile) != d["path"] {
t.Errorf("the state the account keeps: %v", d)
}
// The scheduled run's state is root's alone, apart from the account's (third review).
if d := byID["scrub-state-dir"]; d["path"] != filepath.Dir(ServedFile) || d["mode"] != "0700" || d["owner"] != nil {
t.Errorf("the state the run keeps: %v", d)
}
if strings.Contains(raw, `"package"`) {
t.Errorf("the module installs no package: ZFS is the machine's, and its kernel module is built against its kernel")
}
}
// No destroy, rollback, set, send or any other change but a snapshot. Every command the bundle runs goes
// through ask, AsRoot or Escalated with an argument list from one of the functions below, each of which is
// held to its verb here; a call site that builds its own list fails this test. A new verb needs a decision
// first, and then a line here.
func TestTheBundleRunsOnlyReadingVerbsAndSnapshot(t *testing.T) {
allowed := map[string]string{"DatasetsArgv": "list", "SnapshotsArgv": "list", "PoolsArgv": "list", "StatusArgv": "status",
"IOArgv": "iostat", "EventsArgv": "events", "HistoryArgv": "history", "PropertiesArgv": "get", "SnapshotArgv": "snapshot", "FollowArgv": "events"}
verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(),
"StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"),
"PropertiesArgv": PropertiesArgv("tank"), "SnapshotArgv": SnapshotArgv("tank", "x", true), "FollowArgv": FollowArgv()}
for fn, argv := range verbs {
if argv[0] != allowed[fn] {
t.Errorf("%s runs %q, not %q", fn, argv[0], allowed[fn])
// One schedule per pool, and no setting in any unit's name but the distribution's timers', which the
// controller fills (mesh-controller #171) and checks. The module's own schedule is the node-engine's timer
// for the scheduled run, a fixed name; the day and time reach only the configuration, which the run checks.
// The run comes before the distribution's timers are stopped, so a run that cannot be installed stops the
// apply before the timer that scrubs the pool today is touched.
func TestExactlyOneScrubScheduleAndNoSettingInAUnitFile(t *testing.T) {
m, raw := readManifest(t)
byID := map[string]map[string]any{}
order := map[string]int{}
for i, r := range m.Resources {
byID[r["id"].(string)] = r
order[r["id"].(string)] = i
}
run := byID["scrub-run"]
args, _ := run["run"].([]any)
if run["type"] != "process" || run["name"] != "zfs-mesh-scrub" || run["schedule"] != "*/15 * * * *" || run["user"] != nil ||
len(args) != 2 || args[1] != "scrub-run" {
t.Errorf("the scheduled run: %v", run)
}
if ScrubTimer != run["name"].(string)+".timer" || ScrubService != run["name"].(string)+".service" {
t.Errorf("the tools read %s and %s; the node-engine names them after %v", ScrubTimer, ScrubService, run["name"])
}
for _, c := range []string{"weekly", "monthly"} {
d := byID["distribution-"+c]
if d["unit"] != "zfs-scrub-"+c+"@${setting:scrub-pool}.timer" || d["state"] != "stopped" || d["boot"] != "disabled" {
t.Errorf("the distribution's %s timer: %v", c, d)
}
if order["scrub-run"] > order["distribution-"+c] {
t.Errorf("the distribution's %s timer is stopped before the run is installed", c)
}
}
call := regexp.MustCompile(`\.(ask|AsRoot|Escalated|Command|CommandContext)\(`)
site := regexp.MustCompile(`\.(?:ask|AsRoot|Escalated|CommandContext)\((?:\w+, )?"(zfs|zpool)", (\w+)\(`)
for _, r := range m.Resources {
p, _ := r["path"].(string)
if strings.HasPrefix(p, "/etc/systemd/") {
t.Errorf("the module writes a unit file of its own: %s", p)
}
}
if strings.Contains(raw, "scrub-day}") && !strings.Contains(byID["scrub-config"]["content"].(string), "${setting:scrub-day}") {
t.Errorf("the day reaches something other than the configuration")
}
if n := strings.Count(raw, "${setting:scrub-day}") + strings.Count(raw, "${setting:scrub-time}") + strings.Count(raw, "${setting:scrub-cadence}"); n != 3 {
t.Errorf("the day, time and cadence are written %d times; only into the configuration", n)
}
var settings struct {
Settings map[string]map[string]any `json:"settings"`
}
json.Unmarshal([]byte(raw), &settings)
if settings.Settings["scrub-cadence"]["default"] != "weekly" || settings.Settings["scrub-day"]["default"] != "Mon" ||
settings.Settings["scrub-time"]["default"] != "00:00" {
t.Errorf("defaults: %v", settings.Settings)
}
if _, has := settings.Settings["scrub-pool"]; has {
t.Errorf("the pool is the operator's value, with no default (ADR 0112, ADR 0262)")
}
}
// The home server, composed as the controller fills its settings (pool storage, cadence monthly, the
// operator's choice): the units the node-engine is sent and the configuration the run reads.
func TestTheHomeServersComposedUnitsAndConfiguration(t *testing.T) {
m, _ := readManifest(t)
fill := strings.NewReplacer("${setting:scrub-pool}", "storage", "${setting:scrub-cadence}", "monthly",
"${setting:scrub-day}", "Mon", "${setting:scrub-time}", "00:00")
var units []string
var conf string
for _, r := range m.Resources {
switch {
case r["type"] == "service":
units = append(units, fill.Replace(r["unit"].(string))+" "+r["state"].(string)+"/"+r["boot"].(string))
case r["type"] == "process" && r["schedule"] != nil:
units = append(units, r["name"].(string)+".timer running/enabled (the node-engine's, every quarter of an hour)")
case r["id"] == "scrub-config":
conf = fill.Replace(r["content"].(string))
}
}
want := []string{"zfs-mesh-scrub.timer running/enabled (the node-engine's, every quarter of an hour)",
"zfs-scrub-weekly@storage.timer stopped/disabled", "zfs-scrub-monthly@storage.timer stopped/disabled"}
if strings.Join(units, "\n") != strings.Join(want, "\n") {
t.Errorf("units:\n%s", strings.Join(units, "\n"))
}
for _, u := range units {
t.Log(u)
}
s, err := ParseScrubConfig(conf)
if err != nil || s.Pool != "storage" || s.Cadence != "monthly" {
t.Fatalf("configuration %q: %v", conf, err)
}
t.Logf("next scrub of storage after 2026-10-09 12:00: %s", s.NextSlot(time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local)).Format("2006-01-02 15:04"))
if next := s.NextSlot(time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local)); !next.Equal(time.Date(2026, 11, 2, 0, 0, 0, 0, time.Local)) {
t.Errorf("next monthly slot %s, want the first Monday of November", next)
}
}
// Nothing but reading, a snapshot and a scrub's start, pause or stop. Every command the bundle runs goes
// through ask, AsRoot, Escalated, Run, exec.Command or exec.CommandContext with an argument list from one
// of the functions below, each held to its program and verb; a call site that builds its own list fails
// this test. A new verb needs a decision first, and then a line here.
func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) {
allowed := map[string][2]string{
"DatasetsArgv": {"zfs", "list"}, "SnapshotsArgv": {"zfs", "list"}, "PropertiesArgv": {"zfs", "get"},
"SnapshotArgv": {"zfs", "snapshot"},
"PoolsArgv": {"zpool", "list"}, "StatusArgv": {"zpool", "status"}, "IOArgv": {"zpool", "iostat"},
"EventsArgv": {"zpool", "events"}, "HistoryArgv": {"zpool", "history"}, "FollowArgv": {"zpool", "events"},
"ScrubArgv": {"zpool", "scrub"}, "WaitArgv": {"zpool", "wait"},
"TimerShowArgv": {"systemctl", "show"}, "ServiceShowArgv": {"systemctl", "show"},
}
verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(),
"StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"),
"PropertiesArgv": PropertiesArgv("tank"), "SnapshotArgv": SnapshotArgv("tank", "x"), "FollowArgv": FollowArgv(),
"TimerShowArgv": TimerShowArgv("x.timer"), "ServiceShowArgv": ServiceShowArgv("x.service"), "WaitArgv": WaitArgv("tank")}
for fn, argv := range verbs {
if argv[0] != allowed[fn][1] {
t.Errorf("%s runs %q, not %q", fn, argv[0], allowed[fn][1])
}
}
for _, act := range []string{"start", "pause", "stop", "anything"} {
argv := ScrubArgv(act, "tank")
if argv[0] != "scrub" || argv[len(argv)-2] != "--" || argv[len(argv)-1] != "tank" || len(argv) > 4 {
t.Errorf("%s: %v", act, argv)
}
if len(argv) == 4 && argv[1] != "-p" && argv[1] != "-s" {
t.Errorf("%s: %v", act, argv)
}
}
call := regexp.MustCompile(`(\.(ask|AsRoot|Escalated|Run|Start|Output|CombinedOutput)\(|exec\.Command(Context)?\(|os\.StartProcess\(|syscall\.(Exec|ForkExec)\()`)
site := regexp.MustCompile(`(?:\.(?:ask|AsRoot|Escalated)|exec\.Command)\((?:\w+, )?"(zfs|zpool|systemctl)", (\w+)\(`)
// The bodies that run what they are given, each once.
bodies := map[string]bool{
"machine.go: r := m.Run(ctx, name, args...)": true,
"machine.go: r := m.Run(ctx, program, argv...)": true,
"machine.go: program, argv := m.Escalated(name, args...)": true,
"machine.go: cmd := exec.Command(name, args...)": true,
"machine.go: if err := cmd.Start(); err != nil {": true,
"follow.go: if err := cmd.Start(); err != nil {": true,
}
files, _ := filepath.Glob("*.go")
sites := 0
for _, file := range files {
@@ -130,24 +251,25 @@ func TestTheBundleRunsOnlyReadingVerbsAndSnapshot(t *testing.T) {
t.Fatal(err)
}
for i, l := range strings.Split(string(b), "\n") {
if !call.MatchString(l) || strings.HasPrefix(strings.TrimSpace(l), "func ") || strings.HasPrefix(strings.TrimSpace(l), "//") {
trimmed := strings.TrimSpace(l)
if !call.MatchString(l) || strings.HasPrefix(trimmed, "//") || bodies[file+":"+l] {
continue
}
if strings.Contains(l, "m.Escalated(name, args...)") || (file == "machine.go" && strings.Contains(l, "exec.CommandContext(ctx, name, args...)")) {
continue // AsRoot's and the runner's own bodies
}
m := site.FindStringSubmatch(l)
if m == nil {
t.Errorf("%s:%d runs a command not built by an argument function: %s", file, i+1, strings.TrimSpace(l))
t.Errorf("%s:%d runs a command not built by an argument function: %s", file, i+1, trimmed)
continue
}
if _, ok := allowed[m[2]]; !ok {
want, ok := allowed[m[2]]
if !ok {
t.Errorf("%s:%d runs %s's list, which is not held here", file, i+1, m[2])
} else if want[0] != m[1] {
t.Errorf("%s:%d runs %s with %s's list", file, i+1, m[1], m[2])
}
sites++
}
}
if sites < 10 {
if sites < 14 {
t.Errorf("found %d call sites; the test no longer sees them", sites)
}
}
+13 -13
View File
@@ -110,9 +110,9 @@ func TestStatusGivesEachPoolsLayoutWithSectionsAndTheLastScrub(t *testing.T) {
if kids[3].Role != "spare" || kids[3].Children[0].State != "AVAIL" || kids[3].Children[0].Note != "" {
t.Errorf("spares: %+v", kids[3].Children[0])
}
s := tank.Scan
s := tank.Scrub()
end := time.Date(2026, 9, 14, 2, 34, 6, 0, time.Local)
if s.Kind != "scrub" || s.State != "finished" || s.Took != "1 days 02:10:05" || s.Repaired != "0" || deref(s.Errors) != 0 ||
if s.Kind != "scrub" || s.State != "finished" || s.Took != "1 days 02:10:05" || s.Repaired != "0B" || deref(s.Errors) != 0 ||
s.Ended == nil || !s.Ended.Equal(end) || s.Started == nil || !s.Started.Equal(end.Add(-26*time.Hour-10*time.Minute-5*time.Second)) {
t.Errorf("tank's scrub: %+v", s)
}
@@ -141,7 +141,7 @@ func TestADegradedPoolSaysItsFaultedDeviceItsErrorsAndTheResilver(t *testing.T)
if in := p.Root.Children[1].Children[0]; in.State != "INUSE" || in.Note != "currently in use" {
t.Errorf("the spare in use: %+v", in)
}
s := p.Scan
s := p.Resilvers()[0]
if s.Kind != "resilver" || s.State != "running" || s.Done == nil || *s.Done != 22.72 || s.ToGo != "08:25:13" ||
s.Started == nil || !s.Started.Equal(time.Date(2026, 10, 8, 21, 2, 17, 0, time.Local)) {
t.Errorf("the resilver: %+v", s)
@@ -153,16 +153,16 @@ func TestARunningScrubSaysItsProgress(t *testing.T) {
if err != nil {
t.Fatal(err)
}
s := st[0].Scan
if s.Kind != "scrub" || s.State != "running" || *s.Done != 29.17 || s.ToGo != "06:49:34" || s.Repaired != "0" {
s := st[0].Scrub()
if s.Kind != "scrub" || s.State != "running" || *s.Done != 29.17 || s.ToGo != "06:49:34" || s.Repaired != "0B" {
t.Errorf("scrub: %+v", s)
}
for _, said := range []string{"none requested", "scrub canceled on Sun Oct 4 01:00:00 2026", "scrub paused since Sun Oct 4 01:00:00 2026"} {
if got := parseScan(said); got.State == "unknown" {
if got := parseScan("scan", said); got.State == "unknown" {
t.Errorf("%q not read: %+v", said, got)
}
}
if got := parseScan("resilvered 1088516226 in 03:22:11 with 0 errors on Fri Oct 9 06:00:00 2026"); got.Kind != "resilver" || got.State != "finished" || got.Repaired != "1088516226" {
if got := parseScan("scan", "resilvered 1.01G in 03:22:11 with 0 errors on Fri Oct 9 06:00:00 2026"); got.Kind != "resilver" || got.State != "finished" || got.Repaired != "1.01G" {
t.Errorf("a finished resilver: %+v", got)
}
}
@@ -183,8 +183,8 @@ func TestIOSampleNamesEachLinesPoolAndKindFromTheLayout(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := map[string]string{"tank": "pool", "raidz2-0": "vdev", "ata-WDC_WD120EFBX-68B0EN0_D7JA0001": "device", "logs": "section",
"mirror-1": "vdev", "cache": "section", "rpool": "pool", "nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNX0R200001-part2": "device"}
want := map[string]string{"tank": "pool", "raidz2-0": "vdev", "ata-WDC_WD120EFBX-68B0EN0_D7JA0001": "device",
"mirror-1": "vdev", "rpool": "pool", "nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNX0R200001-part2": "device"}
for _, r := range rows {
if k, ok := want[r.Name]; ok && r.Kind != k {
t.Errorf("%s is a %s, said %s", r.Name, k, r.Kind)
@@ -362,7 +362,7 @@ func TestIOAndScrubAnswerFromFakes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if a := got.(IOAnswer); len(a.Rows) != 12 || a.Seconds != 3 {
if a := got.(IOAnswer); len(a.Rows) != 10 || a.Seconds != 3 {
t.Errorf("io: %+v", a)
}
if argv := IOArgv(3); strings.Join(argv, " ") != "iostat -y -Hpv 3 1" {
@@ -375,9 +375,9 @@ func TestIOAndScrubAnswerFromFakes(t *testing.T) {
a := got.(ScrubAnswer)
rp, tank := a.Pools[0], a.Pools[1]
if tank.DaysSince == nil || *tank.DaysSince != 25.4 || tank.Warning != "" {
t.Errorf("tank was scrubbed 25 days ago: %+v", tank)
t.Errorf("tank was scrubbed 25 days ago, and has no schedule here: %+v", tank)
}
if rp.Warning == "" || !strings.Contains(rp.Said, "WARNING") {
if rp.Warning == "" || !strings.Contains(strings.Join(rp.Said, " "), "WARNING") {
t.Errorf("rpool was scrubbed in July, more than 35 days ago: %+v", rp)
}
if len(ran(f, "sudo")) != 0 {
@@ -397,7 +397,7 @@ func TestScrubWithHistoryReadsItAsRootAndFindsTheScrubStatusNoLongerShows(t *tes
if r.LastScrub == nil || !r.LastScrub.Ended.Equal(time.Date(2026, 7, 13, 2, 30, 0, 0, time.Local)) || r.Warning == "" {
t.Errorf("the last scrub, from the history, is 88 days old: %+v", r)
}
if r.LastResilver == nil || r.Last.State != "running" {
if r.LastResilver == nil || r.Scans[0].State != "running" {
t.Errorf("the resilver: %+v", r)
}
}
+120 -51
View File
@@ -121,20 +121,29 @@ type Device struct {
// counts what was found at the vdev, not the sum of its disks.
func (d *Device) Errors() uint64 { return d.Read + d.Write + d.Checksum }
// Scan is a pool's last scan, as status says it: ZFS keeps only the latest, a scrub's or a resilver's.
// Scan is one `scan:` or `scrub:` line of status with its continuation. Status gives a pool's scrub
// (or, when it was more recent, its error scrub), and then its resilver: the pool-wide one, or the
// rebuild of each top-level vdev that had one (a sequential resilver, named by its vdev). ZFS keeps one
// pool-wide scan, so a resilver after a scrub hides that scrub; the pool's history still has it.
type Scan struct {
// Kind is scrub or resilver; "" when none was ever asked.
Kind string `json:"kind,omitempty"`
// State is finished, running, paused, canceled or none.
State string `json:"state"`
Started *time.Time `json:"started,omitempty"`
Ended *time.Time `json:"ended,omitempty"`
Took string `json:"took,omitempty"`
Repaired string `json:"repaired,omitempty"`
Errors *int `json:"errors,omitempty"`
Done *float64 `json:"done_pct,omitempty"`
ToGo string `json:"to_go,omitempty"`
Said string `json:"said"`
// Kind is scrub, resilver or error scrub.
Kind string `json:"kind"`
// Vdev is the top-level vdev of a rebuild; "" for a pool-wide scan.
Vdev string `json:"vdev,omitempty"`
// State is finished, running, paused, canceled or unknown.
State string `json:"state"`
Started *time.Time `json:"started,omitempty"`
Paused *time.Time `json:"paused,omitempty"`
Ended *time.Time `json:"ended,omitempty"`
Took string `json:"took,omitempty"`
// Repaired is what a scrub repaired or a resilver rewrote, in status's own human size (status prints
// it so even under -p); for an error scrub, the error blocks it scrubbed.
Repaired string `json:"repaired,omitempty"`
Errors *int `json:"errors,omitempty"`
Done *float64 `json:"done_pct,omitempty"`
ToGo string `json:"to_go,omitempty"`
NoEstimate bool `json:"no_estimate,omitempty"`
Said string `json:"said"`
}
// PoolStatus is one pool as `zpool status` says it.
@@ -144,7 +153,8 @@ type PoolStatus struct {
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
See string `json:"see,omitempty"`
Scan Scan `json:"scan"`
// Scans are the scan lines status gives, in its order; none when no scan was ever asked.
Scans []Scan `json:"scans"`
// Other are the lines status gives for a removal, a checkpoint or an expansion.
Other map[string]string `json:"other,omitempty"`
Root *Device `json:"layout,omitempty"`
@@ -161,11 +171,16 @@ func ParseStatus(out string) ([]PoolStatus, error) {
inConfig := false
var stack []*Device
section := ""
var scanKeys, scanSaid []string
flush := func() {
if cur != nil {
cur.Scan = parseScan(cur.Scan.Said)
cur.Scans = []Scan{}
for i := range scanSaid {
cur.Scans = append(cur.Scans, parseScan(scanKeys[i], scanSaid[i]))
}
pools = append(pools, *cur)
}
scanKeys, scanSaid = nil, nil
}
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimRight(raw, "\r ")
@@ -193,8 +208,8 @@ func ParseStatus(out string) ([]PoolStatus, error) {
cur.Action = v
case "see":
cur.See = v
case "scan":
cur.Scan.Said = v
case "scan", "scrub":
scanKeys, scanSaid = append(scanKeys, k), append(scanSaid, v)
case "errors":
cur.Errors = v
case "pool", "config":
@@ -217,8 +232,8 @@ func ParseStatus(out string) ([]PoolStatus, error) {
add(&cur.Status)
case "action":
add(&cur.Action)
case "scan":
cur.Scan.Said += "\n" + trimmed
case "scan", "scrub":
scanSaid[len(scanSaid)-1] += "\n" + trimmed
case "errors":
add(&cur.Errors)
default:
@@ -345,51 +360,62 @@ func count(s string) uint64 {
}
var (
scanFinished = regexp.MustCompile(`^(scrub repaired|resilvered) (\S+) in (.+?) with (\d+) errors on (.+)$`)
scanRunning = regexp.MustCompile(`^(scrub|resilver) in progress since (.+)$`)
scanPaused = regexp.MustCompile(`^(scrub|resilver) paused since (.+)$`)
scanCanceled = regexp.MustCompile(`^(scrub|resilver) canceled on (.+)$`)
scanDone = regexp.MustCompile(`([\d.]+)% done`)
scanToGo = regexp.MustCompile(`(\d+ days \d+:\d+:\d+|\d+:\d+:\d+) to go`)
scanRepaired = regexp.MustCompile(`(\S+) repaired`)
scrubFinished = regexp.MustCompile(`^scrub repaired (\S+) in (.+?) with (\d+) errors on (.+)$`)
resilverFinished = regexp.MustCompile(`^resilvered (?:\((\S+)\) )?(\S+) in (.+?) with (\d+) errors on (.+)$`)
scanRunning = regexp.MustCompile(`^(scrub|resilver|error scrub)(?: \((\S+)\))? in progress since (.+)$`)
scanPaused = regexp.MustCompile(`^(scrub|error scrub) paused since (.+)$`)
scanCanceled = regexp.MustCompile(`^(scrub|resilver|error scrub)(?: \((\S+)\))? canceled on (.+)$`)
errScrubFinished = regexp.MustCompile(`^scrubbed (\d+) error blocks in (\d+ days \d+:\d+:\d+) on (.+)$`)
scanStartedOn = regexp.MustCompile(`(?m)^(?:scrub|error scrub) started on (.+)$`)
scanDone = regexp.MustCompile(`([\d.]+)% done`)
scanToGo = regexp.MustCompile(`(\d+ days \d+:\d+:\d+|\d+:\d+:\d+) to go`)
scanRepaired = regexp.MustCompile(`(?m)^(\S+) (?:repaired|resilvered),`)
)
// parseScan reads status's scan line and its continuation.
func parseScan(said string) Scan {
s := Scan{State: "none", Said: said}
// parseScan reads one scan line and its continuation, as zpool_main.c's print_scan_scrub_resilver_status,
// print_rebuild_status_impl and print_err_scrub_status write them (OpenZFS 2.4.4).
func parseScan(key, said string) Scan {
s := Scan{State: "unknown", Said: said}
first, more, _ := strings.Cut(said, "\n")
first = strings.TrimSpace(first)
switch {
case first == "" || first == "none requested":
case first == "none requested":
s.State = "none"
return s
case scanFinished.MatchString(first):
m := scanFinished.FindStringSubmatch(first)
s.Kind, s.State, s.Repaired, s.Took = "scrub", "finished", m[2], m[3]
if m[1] == "resilvered" {
s.Kind = "resilver"
}
n, _ := strconv.Atoi(m[4])
s.Errors = &n
s.Ended = when(m[5])
if s.Ended != nil {
if d, ok := took(m[3]); ok {
st := s.Ended.Add(-d)
s.Started = &st
}
}
case scrubFinished.MatchString(first):
m := scrubFinished.FindStringSubmatch(first)
s.Kind, s.State, s.Repaired, s.Took = "scrub", "finished", m[1], m[2]
s.Errors, s.Ended = atoiPtr(m[3]), when(m[4])
case resilverFinished.MatchString(first):
m := resilverFinished.FindStringSubmatch(first)
s.Kind, s.Vdev, s.State, s.Repaired, s.Took = "resilver", m[1], "finished", m[2], m[3]
s.Errors, s.Ended = atoiPtr(m[4]), when(m[5])
case errScrubFinished.MatchString(first):
m := errScrubFinished.FindStringSubmatch(first)
s.Kind, s.State, s.Repaired, s.Took, s.Ended = "error scrub", "finished", m[1]+" error blocks", m[2], when(m[3])
case scanRunning.MatchString(first):
m := scanRunning.FindStringSubmatch(first)
s.Kind, s.State, s.Started = m[1], "running", when(m[2])
s.Kind, s.Vdev, s.State, s.Started = m[1], m[2], "running", when(m[3])
case scanPaused.MatchString(first):
m := scanPaused.FindStringSubmatch(first)
s.Kind, s.State, s.Started = m[1], "paused", when(m[2])
s.Kind, s.State, s.Paused = m[1], "paused", when(m[2])
case scanCanceled.MatchString(first):
m := scanCanceled.FindStringSubmatch(first)
s.Kind, s.State, s.Ended = m[1], "canceled", when(m[2])
default:
s.State = "unknown"
s.Kind, s.Vdev, s.State, s.Ended = m[1], m[2], "canceled", when(m[3])
}
if s.Kind == "" && key == "scrub" {
s.Kind = "error scrub"
}
if s.State == "finished" && s.Ended != nil {
if d, ok := took(s.Took); ok {
st := s.Ended.Add(-d)
s.Started = &st
}
}
if more != "" {
if m := scanStartedOn.FindStringSubmatch(more); m != nil {
s.Started = when(m[1])
}
if m := scanDone.FindStringSubmatch(more); m != nil {
v, _ := strconv.ParseFloat(m[1], 64)
s.Done = &v
@@ -397,6 +423,7 @@ func parseScan(said string) Scan {
if m := scanToGo.FindStringSubmatch(more); m != nil {
s.ToGo = m[1]
}
s.NoEstimate = strings.Contains(more, "no estimated completion time")
if m := scanRepaired.FindStringSubmatch(more); m != nil && s.Repaired == "" {
s.Repaired = m[1]
}
@@ -404,6 +431,48 @@ func parseScan(said string) Scan {
return s
}
func atoiPtr(s string) *int {
n, err := strconv.Atoi(s)
if err != nil {
return nil
}
return &n
}
// Scrub is the pool's scrub as status shows it; nil when status shows none (never asked, or hidden by a
// later resilver).
func (p PoolStatus) Scrub() *Scan {
for i := range p.Scans {
if p.Scans[i].Kind == "scrub" {
return &p.Scans[i]
}
}
return nil
}
// Resilvers are the pool's resilvers as status shows them: the pool-wide one, or each vdev's rebuild.
func (p PoolStatus) Resilvers() []Scan {
var out []Scan
for _, s := range p.Scans {
if s.Kind == "resilver" {
out = append(out, s)
}
}
return out
}
// ScansSaid is every scan line's first line, joined.
func (p PoolStatus) ScansSaid() string {
var out []string
for _, s := range p.Scans {
out = append(out, firstLine(s.Said))
}
if len(out) == 0 {
return "no scan on record"
}
return strings.Join(out, "; ")
}
// when reads status's dates, as ctime writes them: "Sun Sep 13 05:36:34 2026", in local time.
func when(s string) *time.Time {
s = strings.TrimSpace(s)
@@ -482,7 +551,7 @@ func (m *Machine) PoolsNow(ctx context.Context) (any, error) {
for _, p := range ps {
h := PoolHealth{PoolRow: p.Row(), State: p.Health}
if s := statusOf(st, p.Name); s != nil {
h.State, h.Status, h.Action, h.Errors, h.Scan = s.State, s.Status, s.Action, s.Errors, firstLine(s.Scan.Said)
h.State, h.Status, h.Action, h.Errors, h.Scan = s.State, s.Status, s.Action, s.Errors, s.ScansSaid()
h.DeviceErrs = sumErrors(s.Root)
}
line := h.Said + fmt.Sprintf("; %d device errors; errors: %s", h.DeviceErrs, h.Errors)
+339
View File
@@ -0,0 +1,339 @@
package main
// The safety review of the first version (mesh-catalog 90516fc) and the operator's scrub requirement:
// each test here failed against that version, or did not compile against it.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// A command that does not answer in time is ended with every process it started: the runner puts it in a
// process group of its own and signals the group, so a child holding the output open (as zfs under sudo
// does) cannot keep the call waiting.
func TestATimedOutCommandIsEndedWithItsChildren(t *testing.T) {
old := KillGrace
KillGrace = 300 * time.Millisecond
defer func() { KillGrace = old }()
pidFile := filepath.Join(t.TempDir(), "child")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
r := ExecRunner(ctx, "sh", "-c", "sleep 30 & echo $! > "+pidFile+"; wait")
if took := time.Since(start); took > 5*time.Second {
t.Fatalf("the call waited %s for a child holding its output", took)
}
if !r.TimedOut {
t.Errorf("not said as timed out: %+v", r)
}
b, err := os.ReadFile(pidFile)
if err != nil {
t.Fatal(err)
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
time.Sleep(100 * time.Millisecond)
if err := syscall.Kill(pid, 0); err == nil {
syscall.Kill(pid, syscall.SIGKILL)
t.Errorf("the child %d outlived the timeout", pid)
}
}
func TestTheSnapshotHasNoRecursiveAndSaysWhatItWillHold(t *testing.T) {
f := withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t26938034880512")
m := f.machine()
var take func(map[string]any) (any, error)
for _, tl := range Tools(m) {
if tl.Name == "zfs_snapshot" {
take = tl.Run
if _, has := tl.Input["recursive"]; has {
t.Errorf("zfs_snapshot still offers recursive")
}
}
}
if _, err := take(map[string]any{"dataset": "tank", "name": "x", "why": "y", "recursive": true}); err == nil ||
!strings.Contains(err.Error(), "recursive") {
t.Errorf("a recursive argument is refused, not ignored: %v", err)
}
if n := len(ran(f, "sudo")); n != 0 {
t.Errorf("the refusal ran %v", f.calls)
}
got, err := take(map[string]any{"dataset": "tank/media", "name": "before-move", "why": "before moving the library"})
if err != nil {
t.Fatal(err)
}
a := got.(SnapshotAnswer)
if a.Available != 11203942195200 || a.PoolCapacity != 60 || a.Pool != "tank" {
t.Errorf("free space and capacity: %+v", a)
}
for _, want := range []string{"10.2 TiB free", "pool tank 60 % full",
"holds every block of tank/media deleted or overwritten from now on", "until it is destroyed by hand"} {
if !strings.Contains(a.Said, want) {
t.Errorf("said lacks %q: %s", want, a.Said)
}
}
}
func TestASnapshotThatTimedOutIsOutcomeUnknownUnlessTheListShowsIt(t *testing.T) {
f := withZFS(t)
f.answers[takeLine] = Ran{Code: -1, TimedOut: true, Err: "did not answer within its time"}
_, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err == nil || !strings.Contains(err.Error(), "outcome unknown; check zfs_snapshots") || strings.Contains(err.Error(), "NOT DONE") {
t.Errorf("timed out, not shown: %v", err)
}
f = withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1")
f.answers[takeLine] = Ran{Code: -1, TimedOut: true}
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err != nil || !a.Done {
t.Errorf("timed out and shown by the list: done (%v, %+v)", err, a)
}
}
// The follower starts at the start of the kernel's queue, writes each notable event once across its own
// restarts, says what ZFS dropped, and records where it got to.
func TestTheFollowerWritesTheQueueFromItsStartOnceAndWhatWasDropped(t *testing.T) {
cur := &Cursor{}
var out bytes.Buffer
input := "dropped 2 events\n" + fixture(t, "zpool-events.txt")
if err := Follow(strings.NewReader(input), &out, cur); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 4 || !strings.Contains(lines[0], "ZFS dropped 2 events") || !strings.Contains(lines[1], "ereport.fs.zfs.io") ||
!strings.Contains(lines[3], "resilver_start") {
t.Fatalf("lines: %q", lines)
}
if cur.EID != 0x14 {
t.Errorf("the cursor is the last event read: %+v", cur)
}
// Restarted within the same boot: the queue is read again from its start, and nothing is written twice.
out.Reset()
if err := Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, cur); err != nil {
t.Fatal(err)
}
if out.Len() != 0 {
t.Errorf("written twice: %q", out.String())
}
// ZFS's module reloaded: its counter starts again, and an event after the cursor's time is new.
reloaded := strings.ReplaceAll(fixture(t, "zpool-events.txt"), "Oct 8 2026", "Oct 9 2026")
out.Reset()
Follow(strings.NewReader(reloaded), &out, cur)
if strings.Count(out.String(), "\n") != 3 {
t.Errorf("after a reload: %q", out.String())
}
}
func TestTheCursorIsKeptPerBoot(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cursor")
c := &Cursor{Boot: "a", EID: 7, Time: time.Date(2026, 10, 8, 21, 0, 0, 0, time.Local)}
if err := c.Save(path); err != nil {
t.Fatal(err)
}
if got := LoadCursor(path, "a"); got.EID != 7 || !got.Time.Equal(c.Time) {
t.Errorf("same boot: %+v", got)
}
if got := LoadCursor(path, "b"); got.EID != 0 || got.Boot != "b" {
t.Errorf("another boot starts from the queue's start: %+v", got)
}
}
// fakeFollow is a zpool events -f whose output is given, and which records how it was ended.
type fakeFollow struct {
out io.Reader
killed bool
waited bool
order []string
}
func (p *fakeFollow) Stdout() io.Reader { return p.out }
func (p *fakeFollow) Kill() error {
p.killed = true
p.order = append(p.order, "kill")
return nil
}
func (p *fakeFollow) Wait() error {
p.waited = true
p.order = append(p.order, "wait")
return errors.New("signal: killed")
}
func TestAFollowThatFailsIsKilledBeforeItIsWaitedFor(t *testing.T) {
long := "Oct 8 2026 21:01:55.889900112 " + strings.Repeat("x", 2<<20) + "\n"
p := &fakeFollow{out: strings.NewReader(long)}
err := FollowOnce(p, io.Discard, &Cursor{})
if err == nil || strings.Join(p.order, ",") != "kill,wait" {
t.Errorf("err %v, order %v", err, p.order)
}
}
func TestTheFollowerGivesUpAfterRepeatedFailuresSoItsUnitShowsIt(t *testing.T) {
starts := 0
start := func(context.Context) (FollowProcess, error) {
starts++
return nil, fmt.Errorf("zpool: no such file")
}
var out bytes.Buffer
err := RunFollower(context.Background(), &out, start, &Cursor{}, "", func(time.Duration) {})
if err == nil || starts != MaxFollowFailures {
t.Errorf("err %v after %d starts", err, starts)
}
if !strings.Contains(out.String(), "zpool: no such file") {
t.Errorf("each failure is said: %q", out.String())
}
}
// Past error counts are a warning to clear by hand; a count that rises is a fault now.
func TestPastErrorsWarnAndNewErrorsAreUnhealthy(t *testing.T) {
f := newFake(t)
f.on(line("zpool", PoolsArgv()), "slow\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "slow"))
m := f.machine()
h := m.Health(context.Background())
if !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "sda in pool slow has 2 past checksum errors; clear them by hand once understood") {
t.Fatalf("first look: %+v", h)
}
f.now = f.now.Add(time.Minute)
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "slow"), "0 0 2", "0 0 5", 1))
h = m.Health(context.Background())
if h.Healthy || !strings.Contains(h.Why, "sda in pool slow: checksum errors rose from 2 to 5") {
t.Fatalf("a rise: %+v", h)
}
f.now = f.now.Add(time.Minute)
if h := m.Health(context.Background()); h.Healthy {
t.Errorf("a rise stays unhealthy for %s, so two looks see it: %+v", ErrorsFresh, h)
}
f.now = f.now.Add(ErrorsFresh)
if h := m.Health(context.Background()); !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "5 past checksum errors") {
t.Errorf("after %s without a rise, a warning: %+v", ErrorsFresh, h)
}
}
func TestAResilverRunningWarnsAndOneFinishedWithErrorsIsUnhealthy(t *testing.T) {
f := newFake(t)
f.on(line("zpool", PoolsArgv()), "fast\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "fast"))
h := f.machine().Health(context.Background())
if !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "A resilver of mirror-1 in pool fast is running: 16.84 % done, 02:40:11 to go") {
t.Errorf("running: %+v", h)
}
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "fast"),
"1.20T in 03:22:11 with 0 errors", "1.20T in 03:22:11 with 4 errors", 1))
if h := newMachineLike(f).Health(context.Background()); h.Healthy || !strings.Contains(h.Why, "The resilver of mirror-0 in pool fast finished with 4 errors") {
t.Errorf("finished with errors: %+v", h)
}
}
func newMachineLike(f *fake) *Machine { return f.machine() }
// fixtureSection is one pool's part of a status fixture.
func fixtureSection(t *testing.T, name, pool string) string {
t.Helper()
all := fixture(t, name)
start := strings.Index(all, " pool: "+pool+"\n")
if start < 0 {
t.Fatalf("no pool %s in %s", pool, name)
}
rest := all[start+1:]
if end := strings.Index(rest, "\n pool: "); end >= 0 {
return all[start : start+1+end+1]
}
return all[start:]
}
// The scan line's sizes are human even under -p (zfs_nicebytes in print_scan_scrub_resilver_status), and
// status prints a rebuild's resilver per vdev and an error scrub under `scrub:`.
func TestEveryScanLineOfTheSourceIsRead(t *testing.T) {
st, err := ParseStatus(fixture(t, "zpool-status-rebuild.txt"))
if err != nil {
t.Fatal(err)
}
fast, slow, paused := st[0], st[1], st[2]
if len(fast.Scans) != 3 {
t.Fatalf("fast: an error scrub and two rebuilds: %+v", fast.Scans)
}
es := fast.Scans[0]
if es.Kind != "error scrub" || es.State != "finished" || es.Took != "0 days 00:01:02" || es.Ended == nil {
t.Errorf("error scrub: %+v", es)
}
done, running := fast.Scans[1], fast.Scans[2]
if done.Kind != "resilver" || done.Vdev != "mirror-0" || done.State != "finished" || done.Repaired != "1.20T" || deref(done.Errors) != 0 {
t.Errorf("finished rebuild: %+v", done)
}
if running.Vdev != "mirror-1" || running.State != "running" || running.Done == nil || *running.Done != 16.84 || running.ToGo != "02:40:11" {
t.Errorf("running rebuild: %+v", running)
}
if fast.Scrub() != nil {
t.Errorf("fast has no scrub on record: %+v", fast.Scrub())
}
ep := slow.Scans[0]
if ep.Kind != "error scrub" || ep.State != "paused" || ep.Started == nil || ep.Paused == nil || *ep.Done != 42 {
t.Errorf("paused error scrub: %+v", ep)
}
sp := paused.Scrub()
if sp == nil || sp.State != "paused" || !sp.Started.Equal(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local)) ||
!sp.Paused.Equal(time.Date(2026, 10, 8, 12, 0, 0, 0, time.Local)) || *sp.Done != 43.95 || sp.ToGo != "" {
t.Errorf("paused scrub: %+v", sp)
}
st, _ = ParseStatus(fixture(t, "zpool-status.txt"))
if s := st[1].Scrub(); s == nil || s.Repaired != "0B" || s.State != "finished" {
t.Errorf("a finished scrub with a human size: %+v", s)
}
st, _ = ParseStatus(fixture(t, "zpool-status-scrubbing.txt"))
if s := st[0].Scrub(); s == nil || s.Repaired != "0B" || *s.Done != 29.17 {
t.Errorf("a running scrub: %+v", s)
}
if s := parseScan("scan", "scrub in progress since Sun Oct 4 00:24:01 2026\n12.3T / 26.2T scanned, 0B / 26.2T issued\n0B repaired, 0.00% done, no estimated completion time"); s.ToGo != "" || !s.NoEstimate {
t.Errorf("no estimate: %+v", s)
}
}
// zpool iostat -H prints no section lines (a blank line stands before each class), and under -p a vdev
// without capacity stats prints 0 for both.
func TestIOWithoutSectionLines(t *testing.T) {
st, _ := ParseStatus(fixture(t, "zpool-status.txt"))
rows, err := ParseIO(fixture(t, "zpool-iostat.txt"), st)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.Kind == "section" {
t.Errorf("a section line: %+v", r)
}
}
if len(rows) != 10 || rows[2].Allocated != nil || rows[2].Free != nil || rows[4].Kind != "vdev" || rows[4].Role != "log" ||
rows[7].Role != "cache" {
t.Errorf("rows: %+v", rows)
}
}
// The error counts outlive a restart of the bundle: what was seen before is kept in a small file, so a
// count that rose while the bundle was down is told from one that was there before.
func TestTheErrorMemoryIsKeptAcrossARestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "errors.json")
f := newFake(t)
f.errors = LoadErrorMemory(path)
f.on(line("zpool", PoolsArgv()), "slow\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "slow"))
if h := f.machine().Health(context.Background()); !h.Healthy {
t.Fatalf("first look: %+v", h)
}
// Restarted: a new memory from the file, and the count rose meanwhile.
f.errors = LoadErrorMemory(path)
f.now = f.now.Add(time.Hour)
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "slow"), "0 0 2", "0 0 9", 1))
if h := f.machine().Health(context.Background()); h.Healthy || !strings.Contains(h.Why, "checksum errors rose from 2 to 9") {
t.Errorf("after a restart: %+v", h)
}
}
+462
View File
@@ -0,0 +1,462 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
// The scrub schedule the module owns.
//
// The mesh writes ScrubConfig from the assignment's settings. The node-engine runs the module's
// scheduled process, `zfs-tools scrub-run`, from its own timer (ScrubTimer) every quarter of an hour, and
// the run decides from the configuration whether a scrub is due: the last scheduled time not after now
// (the slot) not yet served. Due, it starts the pool's scrub, or waits for one already running, as
// upstream's zfs-scrub@.service does; it never pauses or stops one, and nothing stops the unit to pause
// it (the node-engine writes no ExecStop). So no setting value ever reaches a unit file: the day and the
// time are read and checked here, and an invalid one is refused before anything runs.
//
// The mesh holds the distribution's own scrub timers for the pool stopped and disabled, so one schedule
// scrubs it; the node-engine gives them back running and enabled when the module goes (novox/hq ADR 0269).
// ScrubTimer and ScrubService are the node-engine's units for the scheduled run (the process's name).
const (
ScrubTimer = "zfs-mesh-scrub.timer"
ScrubService = "zfs-mesh-scrub.service"
)
// ServedFile is where the scheduled run keeps the last slot it served, per pool: in a folder of root's
// alone (0700), apart from the operator account's state, so nothing the account runs can steer the run's
// writes (third review of mesh-catalog #147). The tools, which run as the account, cannot read it, and
// judge a missed scrub from status instead.
const ServedFile = "/var/lib/zfs-scrub/scrub-served.json"
// MissedAfter is how long after its slot a scrub that has not started is said to be missed: the run
// fires every quarter of an hour.
const MissedAfter = time.Hour
// ScrubConfig is the schedule as the mesh wrote it from the settings.
const ScrubConfig = "/etc/zfs-tools/scrub.conf"
// Margins after a cadence before a last scrub is said to be late: a weekly run that waited for a busy
// pool, a monthly run on the first given weekday of a month (28 or 35 days apart).
var cadenceAllows = map[string]time.Duration{
"weekly": 9 * 24 * time.Hour,
"monthly": 38 * 24 * time.Hour,
}
// Schedule is the pool's scrub schedule: what the settings say and what the service manager says of it.
type Schedule struct {
Pool string `json:"pool"`
Cadence string `json:"cadence"`
Day string `json:"day"`
Time string `json:"time"`
Timer string `json:"timer"`
Service string `json:"service"`
// TimerState and Enabled are the timer's ActiveState and UnitFileState.
TimerState string `json:"timer_state,omitempty"`
Enabled string `json:"enabled,omitempty"`
// Next is the next scheduled scrub, from the settings; TimerNext when the timer next fires the run that
// decides (every quarter of an hour).
Next *time.Time `json:"next_run,omitempty"`
TimerNext *time.Time `json:"timer_next,omitempty"`
LastTrigger *time.Time `json:"last_triggered,omitempty"`
// LastResult is the service's Result of its last run (success, exit-code, …), LastExit when it ended.
LastResult string `json:"last_result,omitempty"`
ServiceState string `json:"service_state,omitempty"`
LastExit *time.Time `json:"last_run_ended,omitempty"`
Note string `json:"note,omitempty"`
}
var (
poolName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:-]*$`)
weekday = regexp.MustCompile(`^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$`)
clock = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
)
// ParseScrubConfig reads the schedule: pool, cadence (weekly or monthly), day (Mon … Sun) and time (HH:MM).
func ParseScrubConfig(text string) (*Schedule, error) {
v := map[string]string{}
for _, l := range strings.Split(text, "\n") {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
k, val, ok := strings.Cut(l, "=")
if !ok {
return nil, fmt.Errorf("%s: %q is not key=value", ScrubConfig, l)
}
v[strings.TrimSpace(k)] = strings.TrimSpace(val)
}
s := &Schedule{Pool: v["pool"], Cadence: v["cadence"], Day: v["day"], Time: v["time"]}
switch {
case !poolName.MatchString(s.Pool):
return nil, fmt.Errorf("the scrub-pool setting %q is not a pool's name", s.Pool)
case cadenceAllows[s.Cadence] == 0:
return nil, fmt.Errorf("the scrub-cadence setting is %q; it is weekly or monthly", s.Cadence)
case !weekday.MatchString(s.Day):
return nil, fmt.Errorf("the scrub-day setting is %q; it is Mon, Tue, Wed, Thu, Fri, Sat or Sun", s.Day)
case !clock.MatchString(s.Time):
return nil, fmt.Errorf("the scrub-time setting is %q; it is HH:MM, 00:00 to 23:59", s.Time)
}
s.Timer, s.Service = ScrubTimer, ScrubService
return s, nil
}
var weekdays = map[string]time.Weekday{"Sun": time.Sunday, "Mon": time.Monday, "Tue": time.Tuesday,
"Wed": time.Wednesday, "Thu": time.Thursday, "Fri": time.Friday, "Sat": time.Saturday}
// at is the scheduled time on one day.
func (s *Schedule) at(day time.Time) time.Time {
h, _ := strconv.Atoi(s.Time[:2])
mi, _ := strconv.Atoi(s.Time[3:])
return time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location())
}
// scheduledOn says whether a scrub is scheduled on a day: its weekday, and for a monthly one the first
// such day of the month.
func (s *Schedule) scheduledOn(day time.Time) bool {
return day.Weekday() == weekdays[s.Day] && (s.Cadence == "weekly" || day.Day() <= 7)
}
// Slot is the last scheduled time not after now.
func (s *Schedule) Slot(now time.Time) time.Time {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := 0; i < 45; i++ {
if s.scheduledOn(day) && !s.at(day).After(now) {
return s.at(day)
}
day = day.AddDate(0, 0, -1)
}
return time.Time{}
}
// NextSlot is the first scheduled time after now.
func (s *Schedule) NextSlot(now time.Time) time.Time {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := 0; i < 45; i++ {
if s.scheduledOn(day) && s.at(day).After(now) {
return s.at(day)
}
day = day.AddDate(0, 0, 1)
}
return time.Time{}
}
// WaitArgv waits for a pool's running scrub to end.
func WaitArgv(pool string) []string { return []string{"wait", "-t", "scrub", "--", pool} }
// WaitBound is how long the scheduled run waits for a scrub: past it the run ends and leaves the scrub
// running; a scrub never pauses for it.
const WaitBound = 7 * 24 * time.Hour
// readServed is the last slot served per pool; none when the file is not there or not readable.
func (m *Machine) readServed(path string) map[string]int64 {
out := map[string]int64{}
b, err := m.ReadFile(path)
if err != nil {
return out
}
_ = json.Unmarshal(b, &out)
return out
}
// writeServed keeps the served slots, never through a link: the folder must be a real folder of this
// process's account alone (0700, made so when missing), and the file is written new beside it with
// O_NOFOLLOW and O_EXCL, then renamed over the old one, which replaces a link rather than following it.
func writeServed(path string, served map[string]int64) error {
dir := filepath.Dir(path)
if _, err := os.Lstat(dir); os.IsNotExist(err) {
if err := os.Mkdir(dir, 0o700); err != nil {
return err
}
}
fi, err := os.Lstat(dir)
if err != nil {
return err
}
st, _ := fi.Sys().(*syscall.Stat_t)
switch {
case fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir():
return fmt.Errorf("%s is not a folder of its own (a link or a file): the served slot is not written", dir)
case fi.Mode().Perm() != 0o700:
return fmt.Errorf("%s is %v, not 0700: the served slot is not written", dir, fi.Mode().Perm())
case st != nil && int(st.Uid) != os.Geteuid():
return fmt.Errorf("%s belongs to uid %d, not this process's %d: the served slot is not written", dir, st.Uid, os.Geteuid())
}
if fi, err := os.Lstat(path); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s is not a plain file (a link or something else): the served slot is not written", path)
}
b, _ := json.Marshal(served)
tmp := path + ".new"
_ = os.Remove(tmp)
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
// startedSince says whether status shows the pool's scrub started at or after a time.
func startedSince(p *PoolStatus, slot time.Time) bool {
if p == nil {
return false
}
sc := p.Scrub()
return sc != nil && sc.Started != nil && !sc.Started.Before(slot)
}
// ScrubRun is the scheduled run: a due scrub started, or one running waited for. It answers what it did in
// a line, and fails on an invalid schedule, which it never acts on.
func (m *Machine) ScrubRun(ctx context.Context, servedPath string) (string, error) {
raw, err := m.ReadFile(ScrubConfig)
if err != nil {
return "", fmt.Errorf("%s could not be read: %v", ScrubConfig, err)
}
s, err := ParseScrubConfig(string(raw))
if err != nil {
return "", fmt.Errorf("refused, nothing run: %v", err)
}
now := m.Now()
slot := s.Slot(now)
served := m.readServed(servedPath)
if served[s.Pool] >= slot.Unix() {
return fmt.Sprintf("the scrub of %s scheduled %s was served", s.Pool, slot.Format("2006-01-02 15:04")), nil
}
st, err := m.Status(ctx)
if err != nil {
return "", err
}
p := statusOf(st, s.Pool)
if p == nil {
return "", fmt.Errorf("the scheduled pool %s is not imported here", s.Pool)
}
mark := func() error {
served[s.Pool] = slot.Unix()
return writeServed(servedPath, served)
}
if startedSince(p, slot) && p.Scrub().State != "running" {
return fmt.Sprintf("the scrub of %s scheduled %s ran already", s.Pool, slot.Format("2006-01-02 15:04")), mark()
}
for _, r := range p.Resilvers() {
if r.State == "running" {
return fmt.Sprintf("a resilver of %s runs; the scrub scheduled %s waits for the next run after it", s.Pool,
slot.Format("2006-01-02 15:04")), nil
}
}
started := false
if sc := p.Scrub(); sc == nil || sc.State != "running" {
_, err := m.AsRoot(ctx, "zpool", ScrubArgv("start", s.Pool)...)
switch {
case err == nil:
started = true
case strings.Contains(err.Error(), "currently scrubbing"):
// One began since status was read: it serves the slot, and is waited for like any other.
default:
return "", fmt.Errorf("the scheduled scrub of %s could not start: %v", s.Pool, err)
}
}
if err := mark(); err != nil {
return "", fmt.Errorf("the scrub of %s runs, and the served slot could not be kept: %v", s.Pool, err)
}
wctx, cancel := context.WithTimeout(ctx, WaitBound)
defer cancel()
_, werr := m.AsRoot(wctx, "zpool", WaitArgv(s.Pool)...)
what := "waited for the scrub of " + s.Pool + " already running"
if started {
what = "started the scrub of " + s.Pool + " scheduled " + slot.Format("2006-01-02 15:04") + ", and waited for it"
}
if werr != nil {
return what + "; the wait ended without its end (" + werr.Error() + "), and the scrub goes on", nil
}
return what + " to finish", nil
}
// TimerShowArgv asks the service manager what a timer says: whether it runs and is enabled, when it
// fires next and when it last did, as Unix times.
func TimerShowArgv(unit string) []string {
return []string{"show", "--timestamp=unix", "--property=ActiveState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec", "--", unit}
}
// ServiceShowArgv asks what the scrub service's last run was.
func ServiceShowArgv(unit string) []string {
return []string{"show", "--timestamp=unix", "--property=ActiveState,Result,ExecMainStartTimestamp,ExecMainExitTimestamp", "--", unit}
}
// parseShow reads `systemctl show`'s key=value lines.
func parseShow(out string) map[string]string {
v := map[string]string{}
for _, l := range lines(out) {
if k, val, ok := strings.Cut(l, "="); ok {
v[k] = strings.TrimSpace(val)
}
}
return v
}
// unixStamp reads `--timestamp=unix`: "@1791842400"; nil for "", "n/a" or 0.
func unixStamp(s string) *time.Time {
s = strings.TrimPrefix(strings.TrimSpace(s), "@")
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n == 0 {
return nil
}
t := time.Unix(n, 0)
return &t
}
// ScheduleNow is the schedule with what the service manager says of it; nil when the module has no
// schedule on this machine (no configuration written).
func (m *Machine) ScheduleNow(ctx context.Context) (*Schedule, error) {
raw, err := m.ReadFile(ScrubConfig)
if err != nil {
return nil, nil
}
s, err := ParseScrubConfig(string(raw))
if err != nil {
return nil, err
}
if out, err := m.ask(ctx, "systemctl", TimerShowArgv(s.Timer)...); err != nil {
s.Note = "the service manager did not say what the timer is: " + err.Error()
} else {
v := parseShow(out)
s.TimerState, s.Enabled = v["ActiveState"], v["UnitFileState"]
s.TimerNext, s.LastTrigger = unixStamp(v["NextElapseUSecRealtime"]), unixStamp(v["LastTriggerUSec"])
}
next := s.NextSlot(m.Now())
s.Next = &next
if out, err := m.ask(ctx, "systemctl", ServiceShowArgv(s.Service)...); err == nil {
v := parseShow(out)
s.ServiceState, s.LastResult, s.LastExit = v["ActiveState"], v["Result"], unixStamp(v["ExecMainExitTimestamp"])
}
return s, nil
}
// scheduleFindings are the schedule's warnings: a timer not waiting, a last run that failed with no scrub
// finished since, a slot past by MissedAfter that no scrub served.
func scheduleFindings(s *Schedule, p *PoolStatus, now time.Time, served map[string]int64) []string {
var warn []string
var scrubEnded *time.Time
if p != nil {
scrubEnded = LastScrubEnded(*p)
}
if slot := s.Slot(now); now.Sub(slot) > MissedAfter && served[s.Pool] < slot.Unix() && !startedSince(p, slot) {
warn = append(warn, fmt.Sprintf("The scheduled scrub of %s due %s has not started.", s.Pool, slot.Format("2006-01-02 15:04")))
}
if s.TimerState != "" && (s.TimerState != "active" || s.Enabled != "enabled") {
warn = append(warn, fmt.Sprintf("The scrub schedule's timer %s is %s and %s, so no scheduled scrub of %s will start.",
s.Timer, s.TimerState, s.Enabled, s.Pool))
}
if s.LastResult != "" && s.LastResult != "success" && s.ServiceState != "activating" &&
(scrubEnded == nil || s.LastExit == nil || scrubEnded.Before(*s.LastExit)) {
when := "at a time the service manager did not say"
if s.LastExit != nil {
when = s.LastExit.Local().Format("2006-01-02 15:04")
}
warn = append(warn, fmt.Sprintf("The last scheduled scrub of %s failed (%s), %s; its journal says why.", s.Pool, s.LastResult, when))
}
return warn
}
// ScrubEvent is the event each scrub act is published as (module.json's emits).
const ScrubEvent = "scrub.requested"
// ScrubArgv is each act's command: fixed words, the pool after `--`.
func ScrubArgv(act, pool string) []string {
switch act {
case "pause":
return []string{"scrub", "-p", "--", pool}
case "stop":
return []string{"scrub", "-s", "--", pool}
}
return []string{"scrub", "--", pool}
}
// ScrubActAnswer is what zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop answer.
type ScrubActAnswer struct {
Done bool `json:"done"`
Act string `json:"act"`
Pool string `json:"pool"`
Why string `json:"why"`
Ran []string `json:"ran"`
After string `json:"after"`
Recorded bool `json:"recorded"`
Record string `json:"record"`
Said string `json:"said"`
}
// ScrubAct starts, pauses or stops a pool's scrub, and answers what status says afterwards. A scrub reads
// every block and rewrites from redundancy only a block it finds damaged; it adds I/O load, and never
// changes what a file holds.
func (m *Machine) ScrubAct(ctx context.Context, act, pool, why string) (ScrubActAnswer, error) {
a := ScrubActAnswer{Act: act, Pool: pool, Why: why}
switch act {
case "start", "pause", "stop":
default:
return a, fmt.Errorf("%q is not an act of the scrub: start, pause or stop", act)
}
switch {
case why == "":
return a, fmt.Errorf("say why: a scrub act is a person's, and the why is kept with it")
case strings.ContainsAny(why, "\n\r") || len(why) > MaxWhy:
return a, fmt.Errorf("say why in one line of at most %d characters", MaxWhy)
}
ps, err := m.Pools(ctx)
if err != nil {
return a, err
}
known := false
for _, p := range ps {
known = known || p.Name == pool
}
if !known {
return a, fmt.Errorf("there is no pool %q on this machine; zfs_pools lists them", pool)
}
program, argv := m.Escalated("zpool", ScrubArgv(act, pool)...)
a.Ran = append([]string{program}, argv...)
_, runErr := m.AsRoot(ctx, "zpool", ScrubArgv(act, pool)...)
_, timedOut := runErr.(errTimedOut)
if st, err := m.Status(ctx); err == nil {
if p := statusOf(st, pool); p != nil {
a.After = p.ScansSaid()
}
}
a.Done = runErr == nil
switch {
case timedOut:
a.Said = fmt.Sprintf("zpool scrub did not answer within its time and was ended: outcome unknown; check zfs_scrub. Status now: %s.", a.After)
case runErr != nil:
a.Said = fmt.Sprintf("NOT DONE: %v. Status now: %s.", runErr, a.After)
default:
a.Said = fmt.Sprintf("Asked %s to %s its scrub. Status now: %s. Why: %s.", pool, act, a.After, strings.TrimSuffix(why, "."))
}
if m.Emit == nil {
a.Record = "this process publishes no events"
} else if err := m.Emit(ScrubEvent, map[string]any{"pool": pool, "act": act, "why": why, "done": a.Done,
"after": a.After, "at": m.Now().UTC().Format(time.RFC3339)}); err != nil {
a.Record = err.Error()
} else {
a.Recorded, a.Record = true, "published as the event "+ScrubEvent
}
if !a.Recorded {
a.Said += " NOT RECORDED on the bus: " + a.Record + "; the pool's own history (zpool history) has the command."
}
if runErr != nil {
return a, fmt.Errorf("%s", a.Said)
}
return a, nil
}
+109 -41
View File
@@ -89,35 +89,46 @@ func ParseHistory(out string) (scrub, resilver *Pass) {
// ScrubRow is one pool's scans.
type ScrubRow struct {
Pool string `json:"pool"`
// Last is the pool's last scan as status says it: ZFS keeps only the latest, a scrub's or a resilver's.
Last Scan `json:"last_scan"`
// LastScrub and LastResilver are the last finished of each, from the history when it was read.
// Scans are the pool's scan lines as status gives them: its scrub or error scrub, then its resilver.
Scans []Scan `json:"scans"`
// LastScrub and LastResilver are the last finished of each, from status, or from the history when it
// was read and status no longer shows it.
LastScrub *Pass `json:"last_scrub,omitempty"`
LastResilver *Pass `json:"last_resilver,omitempty"`
DaysSince *float64 `json:"days_since_last_scrub,omitempty"`
Warning string `json:"warning,omitempty"`
Note string `json:"note,omitempty"`
Said string `json:"said"`
Said []string `json:"said"`
}
// ScrubAnswer is what zfs_scrub answers.
type ScrubAnswer struct {
Applicable bool `json:"applicable"`
Pools []ScrubRow `json:"pools"`
Schedule *Schedule `json:"schedule,omitempty"`
Said []string `json:"said"`
}
// LastScrubEnded is when the pool's last scrub finished, as status shows it; nil when status's last scan
// is not a finished scrub.
func LastScrubEnded(s Scan) *time.Time {
if s.Kind == "scrub" && s.State == "finished" {
// LastScrubEnded is when the pool's last scrub finished, as status shows it; nil when status shows no
// finished scrub.
func LastScrubEnded(p PoolStatus) *time.Time {
if s := p.Scrub(); s != nil && s.State == "finished" {
return s.Ended
}
return nil
}
// Scrubs are every pool's last scrub and resilver. With history, each pool's history is read too (as
// root), for the scan status no longer shows.
// ScrubLate is how long after its last finished scrub a pool is late: its schedule's cadence and margin,
// or ScrubWarnAfter where the module has no schedule for it.
func ScrubLate(pool string, s *Schedule) (time.Duration, string) {
if s != nil && s.Pool == pool {
return cadenceAllows[s.Cadence], "its " + s.Cadence + " schedule allows"
}
return ScrubWarnAfter, "the module's line for a pool without a schedule"
}
// Scrubs are every pool's scans, its schedule and when it fires next. With history, each pool's history
// is read too (as root), for the scrub and the resilver status no longer shows.
func (m *Machine) Scrubs(ctx context.Context, history bool) (any, error) {
st, err := m.Status(ctx)
if err != nil {
@@ -125,14 +136,42 @@ func (m *Machine) Scrubs(ctx context.Context, history bool) (any, error) {
}
now := m.Now()
a := ScrubAnswer{Applicable: true, Pools: []ScrubRow{}, Said: []string{}}
for _, p := range st {
r := ScrubRow{Pool: p.Name, Last: p.Scan}
ended := LastScrubEnded(p.Scan)
if p.Scan.Kind == "resilver" && p.Scan.State == "finished" && p.Scan.Ended != nil {
r.LastResilver = &Pass{Kind: "resilver", Started: p.Scan.Started, Ended: p.Scan.Ended, Outcome: "finished", Errors: p.Scan.Errors}
sched, serr := m.ScheduleNow(ctx)
switch {
case serr != nil:
a.Said = append(a.Said, "The scrub schedule's settings are not valid: "+serr.Error()+".")
case sched == nil:
a.Said = append(a.Said, "The module has no scrub schedule on this machine (no "+ScrubConfig+").")
default:
a.Schedule = sched
line := fmt.Sprintf("%s scrub of %s, %s at %s (monthly: the first %s of the month), decided by the run %s fires "+
"every quarter of an hour (%s, %s)", sched.Cadence, sched.Pool, sched.Day, sched.Time, sched.Day,
sched.Timer, orUnknown(sched.TimerState), orUnknown(sched.Enabled))
if sched.Cadence == "weekly" {
line = fmt.Sprintf("weekly scrub of %s, %s at %s, decided by the run %s fires every quarter of an hour (%s, %s)",
sched.Pool, sched.Day, sched.Time, sched.Timer, orUnknown(sched.TimerState), orUnknown(sched.Enabled))
}
if ended != nil {
r.LastScrub = &Pass{Kind: "scrub", Started: p.Scan.Started, Ended: ended, Outcome: "finished", Errors: p.Scan.Errors}
if sched.Next != nil {
line += "; next scheduled scrub of " + sched.Pool + ": " + sched.Next.Local().Format("2006-01-02 15:04")
}
if sched.LastResult != "" {
line += "; last run: " + sched.LastResult
}
a.Said = append(a.Said, line)
if sched.Note != "" {
a.Said = append(a.Said, sched.Note)
}
}
for _, p := range st {
r := ScrubRow{Pool: p.Name, Scans: p.Scans, Said: []string{}}
if ended := LastScrubEnded(p); ended != nil {
sc := p.Scrub()
r.LastScrub = &Pass{Kind: "scrub", Started: sc.Started, Ended: ended, Outcome: "finished", Errors: sc.Errors}
}
for _, rs := range p.Resilvers() {
if rs.State == "finished" && rs.Ended != nil && (r.LastResilver == nil || rs.Ended.After(*r.LastResilver.Ended)) {
r.LastResilver = &Pass{Kind: "resilver", Started: rs.Started, Ended: rs.Ended, Outcome: "finished", Errors: rs.Errors}
}
}
if history {
hctx, cancel := context.WithTimeout(ctx, HistoryTimeout)
@@ -150,56 +189,85 @@ func (m *Machine) Scrubs(ctx context.Context, history bool) (any, error) {
}
}
} else if r.LastScrub == nil {
r.Note = "status shows only the last scan, which is not a finished scrub; ask with history=true for the last scrub the pool's history records"
r.Note = "status shows no finished scrub of this pool; ask with history=true for the last one the pool's history records"
}
late, by := ScrubLate(p.Name, sched)
if r.LastScrub != nil && r.LastScrub.Ended != nil {
d := round1(now.Sub(*r.LastScrub.Ended).Hours() / 24)
r.DaysSince = &d
if now.Sub(*r.LastScrub.Ended) > ScrubWarnAfter {
r.Warning = fmt.Sprintf("the last scrub of %s finished %.0f days ago, more than %d", p.Name, d, int(ScrubWarnAfter.Hours()/24))
if now.Sub(*r.LastScrub.Ended) > late {
r.Warning = fmt.Sprintf("the last scrub of %s finished %.0f days ago, more than %s (%d days)", p.Name, d, by, int(late.Hours()/24))
}
} else if history {
r.Warning = fmt.Sprintf("no finished scrub of %s is on record", p.Name)
}
r.Said = scrubSaid(r)
for _, sc := range p.Scans {
r.Said = append(r.Said, p.Name+": "+scanSaid(sc))
}
if len(p.Scans) == 0 {
r.Said = append(r.Said, p.Name+": no scan was ever asked")
}
if r.DaysSince != nil {
r.Said = append(r.Said, fmt.Sprintf("%s: last finished scrub %.1f days ago", p.Name, *r.DaysSince))
}
if r.Warning != "" {
r.Said = append(r.Said, p.Name+": WARNING: "+r.Warning)
}
if r.Note != "" {
r.Said = append(r.Said, p.Name+": "+r.Note)
}
a.Pools = append(a.Pools, r)
a.Said = append(a.Said, r.Said)
a.Said = append(a.Said, r.Said...)
}
return a, nil
}
func scrubSaid(r ScrubRow) string {
s := r.Last
var line string
// scanSaid says one scan in a line: a running one with its progress and the time to go.
func scanSaid(s Scan) string {
what := s.Kind
if s.Vdev != "" {
what += " of " + s.Vdev
}
switch s.State {
case "none":
line = fmt.Sprintf("%s: no scan was ever asked", r.Pool)
case "running", "paused":
line = fmt.Sprintf("%s: a %s is %s", r.Pool, s.Kind, s.State)
line := fmt.Sprintf("a %s is %s", what, s.State)
if s.Started != nil {
line += " since " + s.Started.Local().Format("2006-01-02 15:04")
line += " (started " + s.Started.Local().Format("2006-01-02 15:04") + ")"
}
if s.Done != nil {
line += fmt.Sprintf(", %.2f %% done", *s.Done)
}
if s.ToGo != "" {
switch {
case s.ToGo != "":
line += ", " + s.ToGo + " to go"
case s.NoEstimate:
line += ", no estimate of the time to go yet"
}
return line
case "finished":
line = fmt.Sprintf("%s: the last scan was a %s, finished %s after %s, %s repaired, %d errors", r.Pool, s.Kind,
stamp(s.Ended), s.Took, s.Repaired, deref(s.Errors))
line := fmt.Sprintf("the last %s finished %s after %s, %s", what, stamp(s.Ended), s.Took, s.Repaired)
if s.Kind == "scrub" {
line += " repaired"
} else if s.Kind == "resilver" {
line += " resilvered"
}
if s.Errors != nil {
line += fmt.Sprintf(", %d errors", *s.Errors)
}
return line
case "canceled":
line = fmt.Sprintf("%s: the last %s was canceled %s", r.Pool, s.Kind, stamp(s.Ended))
default:
line = fmt.Sprintf("%s: scan: %s", r.Pool, firstLine(s.Said))
return fmt.Sprintf("the last %s was canceled %s", what, stamp(s.Ended))
case "none":
return "no scan was ever asked"
}
if r.DaysSince != nil {
line += fmt.Sprintf("; last finished scrub %.1f days ago", *r.DaysSince)
return "scan: " + firstLine(s.Said)
}
func orUnknown(s string) string {
if s == "" {
return "not known"
}
if r.Warning != "" {
line += " — WARNING: " + r.Warning
}
return line
return s
}
func stamp(t *time.Time) string {
+206
View File
@@ -0,0 +1,206 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The scheduled run (`zfs-tools scrub-run`, second review of mesh-catalog #147): the node-engine's timer
// fires it every quarter of an hour, and it decides from the configuration whether a scrub is due. It
// starts one, or waits for one already running, as upstream's zfs-scrub@.service does, and never pauses or
// stops a scrub.
func monthlyConf(pool string) string {
return "pool=" + pool + "\ncadence=monthly\nday=Mon\ntime=00:00\n"
}
func TestTheSlotIsTheLastScheduledTimeNotAfterNow(t *testing.T) {
loc := time.Local
at := func(y int, m time.Month, d, h, min int) time.Time { return time.Date(y, m, d, h, min, 0, 0, loc) }
cases := []struct {
conf string
now time.Time
want time.Time
}{
{"pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n", at(2026, 10, 9, 12, 0), at(2026, 10, 5, 0, 0)},
{"pool=tank\ncadence=weekly\nday=Fri\ntime=13:30\n", at(2026, 10, 9, 12, 0), at(2026, 10, 2, 13, 30)},
{"pool=tank\ncadence=weekly\nday=Fri\ntime=11:30\n", at(2026, 10, 9, 12, 0), at(2026, 10, 9, 11, 30)},
{monthlyConf("tank"), at(2026, 10, 9, 12, 0), at(2026, 10, 5, 0, 0)},
{monthlyConf("tank"), at(2026, 10, 4, 23, 0), at(2026, 9, 7, 0, 0)},
{"pool=tank\ncadence=monthly\nday=Thu\ntime=06:00\n", at(2026, 10, 1, 6, 0), at(2026, 10, 1, 6, 0)},
}
for _, c := range cases {
s, err := ParseScrubConfig(c.conf)
if err != nil {
t.Fatal(err)
}
if got := s.Slot(c.now); !got.Equal(c.want) {
t.Errorf("%q at %s: slot %s, want %s", c.conf, c.now, got, c.want)
}
if next := s.NextSlot(c.now); !next.After(c.now) || !s.Slot(next).Equal(next) {
t.Errorf("%q at %s: next slot %s", c.conf, c.now, next)
}
}
}
// root runs the scheduled run: no sudo.
func rootFake(t *testing.T, conf string) (*fake, string) {
f := withZFS(t)
f.uid = 0
f.files[ScrubConfig] = conf
dir := filepath.Join(t.TempDir(), "zfs-scrub")
return f, filepath.Join(dir, "scrub-served.json")
}
func TestADueScrubIsStartedThenWaitedForAndMarkedServed(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on("zpool scrub -- tank", "")
f.on("zpool wait -t scrub -- tank", "")
said, err := f.machine().ScrubRun(context.Background(), served)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(ran(f, "zpool scrub"), "|") + "/" + strings.Join(ran(f, "zpool wait"), "|"); got != "zpool scrub -- tank/zpool wait -t scrub -- tank" {
t.Errorf("ran %q", got)
}
if !strings.Contains(said, "started") {
t.Errorf("said %q", said)
}
// Served: the next fire within the same slot runs nothing.
f.calls = nil
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if n := len(ran(f, "zpool scrub")) + len(ran(f, "zpool wait")); n != 0 {
t.Errorf("a served slot ran %v", f.calls)
}
}
func TestAScrubInProgressIsWaitedForNeverPausedOrStarted(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-scrubbing.txt"))
f.on(line("zpool", PoolsArgv()), "tank\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on("zpool wait -t scrub -- tank", "")
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if strings.Join(f.calls, "|") == "" || len(ran(f, "zpool scrub")) != 0 || len(ran(f, "zpool wait -t scrub -- tank")) != 1 {
t.Errorf("ran %v", f.calls)
}
}
func TestAStartRefusedBecauseAScrubJustBeganIsWaitedForNotPaused(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.answers["zpool scrub -- tank"] = Ran{Code: 1, Err: "cannot scrub tank: currently scrubbing; use 'zpool scrub -s' to cancel the current scrub"}
f.on("zpool wait -t scrub -- tank", "")
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
for _, c := range f.calls {
if strings.Contains(c, "scrub -p") || strings.Contains(c, "scrub -s") {
t.Errorf("the run paused or stopped a scrub: %s", c)
}
}
if len(ran(f, "zpool wait -t scrub -- tank")) != 1 {
t.Errorf("not waited for: %v", f.calls)
}
}
func TestAResilverDefersTheScrubWithoutServingTheSlot(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt"))
f.on(line("zpool", PoolsArgv()), "tank\t1000\t100\t900\t10\t1\tDEGRADED\t1.00\n")
said, err := f.machine().ScrubRun(context.Background(), served)
if err != nil {
t.Fatal(err)
}
if len(ran(f, "zpool scrub")) != 0 || !strings.Contains(said, "resilver") {
t.Errorf("ran %v, said %q", f.calls, said)
}
if _, err := os.Stat(served); err == nil {
t.Errorf("the slot was marked served while the scrub waits for the resilver")
}
}
func TestAScrubAlreadyStartedInTheSlotServesItWithoutAStateFile(t *testing.T) {
// tank's scrub started 2026-09-13 00:24 (finished 09-14); a weekly Sunday-00:00 slot of 09-13 is served
// by it, so a run on 09-15 starts nothing.
f, served := rootFake(t, "pool=tank\ncadence=weekly\nday=Sun\ntime=00:00\n")
f.now = time.Date(2026, 9, 15, 12, 0, 0, 0, time.Local)
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if n := len(ran(f, "zpool scrub")) + len(ran(f, "zpool wait")); n != 0 {
t.Errorf("ran %v", f.calls)
}
}
func TestAnInvalidScheduleRunsNothingAndFails(t *testing.T) {
for _, conf := range []string{
"pool=tank\ncadence=monthly\nday=Mon\ntime=*:*\n",
"pool=tank\ncadence=monthly\nday=Mon..Fri\ntime=00:00\n",
"pool=tank\ncadence=monthly\nday=*\ntime=00:00\n",
"pool=tank\ncadence=hourly\nday=Mon\ntime=00:00\n",
"pool=tank\ncadence=monthly\nday=Mon\ntime=0:00\n",
} {
f, served := rootFake(t, conf)
if _, err := f.machine().ScrubRun(context.Background(), served); err == nil {
t.Errorf("%q: not refused", conf)
}
if len(ran(f, "zpool scrub"))+len(ran(f, "zpool wait")) != 0 {
t.Errorf("%q ran %v", conf, f.calls)
}
}
}
func TestAScheduledScrubThatDidNotStartIsAWarning(t *testing.T) {
f := withZFS(t)
f.files[ScrubConfig] = "pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n"
f.on(line("systemctl", TimerShowArgv(ScrubTimer)), "ActiveState=active\nUnitFileState=enabled\nNextElapseUSecRealtime=@1791842400\nLastTriggerUSec=@1791237600\n")
h := f.machine().Health(context.Background())
if !strings.Contains(strings.Join(h.Warnings, " "), "The scheduled scrub of tank due 2026-10-05 00:00 has not started") {
t.Errorf("warnings: %v", h.Warnings)
}
}
// Third review: the scheduled run's state is root's alone — a folder of its own, 0700, never through a
// link — so the operator account, or anything it runs, cannot make the run write elsewhere.
func TestTheServedSlotIsWrittenOnlyIntoAPrivateFolderAndNeverThroughALink(t *testing.T) {
dir := filepath.Join(t.TempDir(), "zfs-scrub")
path := filepath.Join(dir, "scrub-served.json")
if err := writeServed(path, map[string]int64{"tank": 1}); err != nil {
t.Fatal(err)
}
if fi, err := os.Lstat(dir); err != nil || fi.Mode().Perm() != 0o700 || !fi.IsDir() {
t.Errorf("the folder: %v %v", fi.Mode(), err)
}
// The file replaced by a link to somewhere else: refused, and the other place untouched.
victim := filepath.Join(t.TempDir(), "victim")
os.WriteFile(victim, []byte("keep"), 0o644)
os.Remove(path)
os.Symlink(victim, path)
if err := writeServed(path, map[string]int64{"tank": 2}); err == nil {
t.Errorf("written through a link")
}
if b, _ := os.ReadFile(victim); string(b) != "keep" {
t.Errorf("the link's target was changed: %q", b)
}
// The folder itself a link, or open to others: refused.
linked := filepath.Join(t.TempDir(), "linked")
os.Symlink(t.TempDir(), linked)
if err := writeServed(filepath.Join(linked, "scrub-served.json"), map[string]int64{"tank": 3}); err == nil {
t.Errorf("written into a linked folder")
}
open := filepath.Join(t.TempDir(), "open")
os.Mkdir(open, 0o755)
if err := writeServed(filepath.Join(open, "scrub-served.json"), map[string]int64{"tank": 3}); err == nil {
t.Errorf("written into a folder others can read")
}
if filepath.Dir(ServedFile) == filepath.Dir(ErrorsFile) {
t.Errorf("the run's state shares the account's folder")
}
}
@@ -0,0 +1,119 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
const scrubConf = "pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n"
// scheduled is the healthy fixtures with the module's scrub schedule for tank, its timer waiting and its
// last run a success.
func scheduled(t *testing.T) *fake {
f := withZFS(t)
f.files[ScrubConfig] = scrubConf
f.on(line("systemctl", TimerShowArgv(ScrubTimer)),
"ActiveState=active\nUnitFileState=enabled\nNextElapseUSecRealtime=@1791842400\nLastTriggerUSec=@1791237600\n")
f.on(line("systemctl", ServiceShowArgv(ScrubService)),
"ActiveState=inactive\nResult=success\nExecMainStartTimestamp=@1791237600\nExecMainExitTimestamp=@1791331200\n")
return f
}
func TestTheScheduleIsTheConfigAndWhatTheTimerSays(t *testing.T) {
f := scheduled(t)
got, err := f.machine().Scrubs(context.Background(), false)
if err != nil {
t.Fatal(err)
}
a := got.(ScrubAnswer)
s := a.Schedule
if s == nil || s.Pool != "tank" || s.Cadence != "weekly" || s.Timer != "zfs-mesh-scrub.timer" || s.TimerState != "active" ||
s.Enabled != "enabled" || s.Next == nil || !s.Next.Equal(time.Date(2026, 10, 12, 0, 0, 0, 0, time.Local)) || s.LastResult != "success" {
t.Fatalf("schedule: %+v", s)
}
if want := s.NextSlot(f.now).Format("2006-01-02 15:04"); !strings.Contains(strings.Join(a.Said, "\n"), "next scheduled scrub of tank: "+want) || want != "2026-10-12 00:00" {
t.Errorf("said: %v", a.Said)
}
}
func TestAScheduleThatIsNotSetIsSaidAndAnInvalidOneRefused(t *testing.T) {
f := withZFS(t)
got, _ := f.machine().Scrubs(context.Background(), false)
if a := got.(ScrubAnswer); a.Schedule != nil || !strings.Contains(strings.Join(a.Said, " "), "no scrub schedule") {
t.Errorf("no config: %+v", a)
}
for _, conf := range []string{
"pool=tank\ncadence=daily\nday=Mon\ntime=00:00\n",
"pool=tank\ncadence=weekly\nday=Monday\ntime=00:00\n",
"pool=tank\ncadence=weekly\nday=Mon\ntime=25:00\n",
"pool=tank x\ncadence=weekly\nday=Mon\ntime=00:00\n",
} {
if _, err := ParseScrubConfig(conf); err == nil {
t.Errorf("accepted %q", conf)
}
}
}
func TestTheScheduleWarnsOfAnOldScrubAFailedRunAndAStoppedTimer(t *testing.T) {
f := scheduled(t)
h := f.machine().Health(context.Background())
if !h.Healthy {
t.Fatalf("%+v", h)
}
w := strings.Join(h.Warnings, " ")
// tank's last scrub finished 2026-09-14, 25 days before the fake clock: past weekly plus its margin.
if !strings.Contains(w, "Pool tank was last scrubbed 25 days ago, more than its weekly schedule allows (9 days)") {
t.Errorf("warnings: %v", h.Warnings)
}
f.on(line("systemctl", ServiceShowArgv(ScrubService)),
"ActiveState=failed\nResult=exit-code\nExecMainStartTimestamp=@1791237600\nExecMainExitTimestamp=@1791237601\n")
f.on(line("systemctl", TimerShowArgv(ScrubTimer)),
"ActiveState=inactive\nUnitFileState=disabled\nNextElapseUSecRealtime=\nLastTriggerUSec=@1791237600\n")
w = strings.Join(f.machine().Health(context.Background()).Warnings, " ")
for _, want := range []string{"The last scheduled scrub of tank failed (exit-code)", "The scrub schedule's timer zfs-mesh-scrub.timer is inactive and disabled"} {
if !strings.Contains(w, want) {
t.Errorf("lacks %q: %s", want, w)
}
}
}
func TestTheScrubActsAreFixedCommandsWithWhyReadBackAndRecorded(t *testing.T) {
for act, cmd := range map[string]string{
"start": "sudo -n zpool scrub -- tank",
"pause": "sudo -n zpool scrub -p -- tank",
"stop": "sudo -n zpool scrub -s -- tank",
} {
f := withZFS(t)
f.on(cmd, "")
a, err := f.machine().ScrubAct(context.Background(), act, "tank", "the operator asked")
if err != nil {
t.Fatalf("%s: %v", act, err)
}
if strings.Join(a.Ran, " ") != cmd || a.After == "" || !a.Done {
t.Errorf("%s: %+v", act, a)
}
if len(f.emitted) != 1 || f.emitted[0]["_type"] != ScrubEvent || f.emitted[0]["act"] != act || f.emitted[0]["why"] != "the operator asked" {
t.Errorf("%s recorded: %+v", act, f.emitted)
}
}
f := withZFS(t)
for _, c := range []struct{ act, pool, why, says string }{
{"start", "nope", "why", "no pool"},
{"start", "tank", "", "say why"},
{"start", "-s", "why", "no pool"},
{"destroy", "tank", "why", "not an act"},
} {
if _, err := f.machine().ScrubAct(context.Background(), c.act, c.pool, c.why); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v", c, err)
}
}
if n := len(ran(f, "sudo")); n != 0 {
t.Errorf("a refusal ran %v", f.calls)
}
f.answers["sudo -n zpool scrub -- tank"] = Ran{Code: -1, TimedOut: true}
if _, err := f.machine().ScrubAct(context.Background(), "start", "tank", "why"); err == nil || !strings.Contains(err.Error(), "outcome unknown; check zfs_scrub") {
t.Errorf("timed out: %v", err)
}
}
+75 -34
View File
@@ -15,7 +15,11 @@ const SnapshotEvent = "snapshot.taken"
// The one act of this module: a snapshot of one dataset, by a person, with why. It adds a snapshot and
// changes nothing else: no destroy, rollback, set or send exists in this module (novox/hq ADR 0051, the
// operator's data). ZFS takes a snapshot atomically, and with -r every child's at the same instant.
// operator's data). It snapshots one dataset only, never its children (no -r in this version).
//
// **A snapshot keeps space.** From the moment it is taken it holds every block of the dataset that is
// deleted or overwritten afterwards, so that space is not freed until the snapshot is destroyed, which
// no tool of the mesh does. Every answer says so, with the dataset's free space and the pool's capacity.
// snapName is what a snapshot's own name may hold: letters, digits, dot, underscore and hyphen.
var snapName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
@@ -27,12 +31,8 @@ const MaxName = 255
const MaxWhy = 500
// SnapshotArgv is the command for one snapshot: fixed words, the dataset@name after `--`.
func SnapshotArgv(dataset, name string, recursive bool) []string {
argv := []string{"snapshot"}
if recursive {
argv = append(argv, "-r")
}
return append(argv, "--", dataset+"@"+name)
func SnapshotArgv(dataset, name string) []string {
return []string{"snapshot", "--", dataset + "@" + name}
}
// Made is one snapshot as `zfs list` shows it after the act.
@@ -45,13 +45,21 @@ type Made struct {
// SnapshotAnswer is what zfs_snapshot answers: what the list shows after the act, not what was run.
type SnapshotAnswer struct {
Done bool `json:"done"`
Dataset string `json:"dataset"`
Name string `json:"snapshot"`
Recursive bool `json:"recursive"`
Why string `json:"why"`
Ran []string `json:"ran"`
Made []Made `json:"made"`
Done bool `json:"done"`
// Outcome is made, not made, or unknown (the command ran out of time and the list does not show it).
Outcome string `json:"outcome"`
Dataset string `json:"dataset"`
Name string `json:"snapshot"`
Why string `json:"why"`
Ran []string `json:"ran"`
Made []Made `json:"made"`
// Available is the dataset's free space and PoolCapacity its pool's capacity, as the list says them
// after the act.
Pool string `json:"pool"`
Available uint64 `json:"available_bytes"`
PoolCapacity int `json:"pool_capacity_pct"`
PoolFree uint64 `json:"pool_free_bytes"`
Holds string `json:"holds"`
// Others are the snapshots that came or went on the machine during the act and were not this act's —
// another schedule's, say. Empty when nothing else changed.
Others []string `json:"others_changed"`
@@ -61,10 +69,10 @@ type SnapshotAnswer struct {
}
// TakeSnapshot takes dataset@name. It refuses an unknown dataset, a name with anything but
// [A-Za-z0-9._-], an empty why, and a snapshot that already exists (for -r, on any child too). It
// answers what `zfs list` shows afterwards.
func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string, recursive bool) (SnapshotAnswer, error) {
a := SnapshotAnswer{Dataset: dataset, Name: name, Recursive: recursive, Why: why, Made: []Made{}, Others: []string{}}
// [A-Za-z0-9._-], an empty why, and a snapshot that already exists. It answers what `zfs list` shows
// afterwards.
func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string) (SnapshotAnswer, error) {
a := SnapshotAnswer{Dataset: dataset, Name: name, Why: why, Made: []Made{}, Others: []string{}, Pool: poolOf(dataset)}
switch {
case dataset == "":
return a, fmt.Errorf("name the dataset; zfs_datasets lists them")
@@ -95,13 +103,6 @@ func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string, r
return a, err
}
covered := []string{dataset}
if recursive {
for _, d := range ds {
if strings.HasPrefix(d.Name, dataset+"/") {
covered = append(covered, d.Name)
}
}
}
had := names(before)
for _, d := range covered {
if had[d+"@"+name] {
@@ -109,9 +110,10 @@ func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string, r
}
}
program, argv := m.Escalated("zfs", SnapshotArgv(dataset, name, recursive)...)
program, argv := m.Escalated("zfs", SnapshotArgv(dataset, name)...)
a.Ran = append([]string{program}, argv...)
_, runErr := m.AsRoot(ctx, "zfs", SnapshotArgv(dataset, name, recursive)...)
_, runErr := m.AsRoot(ctx, "zfs", SnapshotArgv(dataset, name)...)
_, timedOut := runErr.(errTimedOut)
// What is there now, whatever the command said.
after, listErr := m.Snapshots(ctx)
@@ -138,25 +140,40 @@ func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string, r
}
sort.Strings(a.Others)
}
a.Done = runErr == nil && listErr == nil && len(a.Made) == len(covered)
// A command that ran out of time may still have made it: the list is the answer.
a.Done = (runErr == nil || timedOut) && listErr == nil && len(a.Made) == len(covered)
a.Holds = fmt.Sprintf("%s@%s holds every block of %s deleted or overwritten from now on, so that space "+
"is not freed until it is destroyed by hand; no tool of the mesh destroys a snapshot", dataset, name, dataset)
space := m.spaceAfter(ctx, &a)
switch {
case runErr != nil && len(a.Made) == 0:
case timedOut && !a.Done:
a.Outcome = "unknown"
a.Said = fmt.Sprintf("zfs snapshot did not answer within its time and was ended, and the list does not show %s@%s: "+
"outcome unknown; check zfs_snapshots.", dataset, name)
case runErr != nil && !timedOut && len(a.Made) == 0:
a.Outcome = "not made"
a.Said = fmt.Sprintf("NOT DONE: %v. No snapshot %s@%s is on the machine.", runErr, dataset, name)
case runErr != nil:
a.Said = fmt.Sprintf("zfs snapshot failed (%v), and yet the list shows %s.", runErr, madeNames(a.Made))
case runErr != nil && !timedOut:
a.Outcome = "unknown"
a.Said = fmt.Sprintf("zfs snapshot failed (%v), and yet the list shows %s: outcome unknown; check zfs_snapshots.", runErr, madeNames(a.Made))
case listErr != nil:
a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the snapshots could not be listed again to see it: %v.", listErr)
a.Outcome = "unknown"
a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the snapshots could not be listed again to see it (%v): "+
"outcome unknown; check zfs_snapshots.", listErr)
case len(a.Made) != len(covered):
a.Outcome = "not made"
a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the list shows %d of the %d snapshots it should have made: %s.",
len(a.Made), len(covered), madeNames(a.Made))
default:
a.Outcome = "made"
a.Said = fmt.Sprintf("Made %s, created %s. Why: %s.", madeNames(a.Made), a.Made[0].Created, strings.TrimSuffix(why, "."))
if len(a.Others) == 0 {
a.Said += " No other snapshot came or went meanwhile."
} else {
a.Said += fmt.Sprintf(" Meanwhile, not by this act: %s.", strings.Join(a.Others, ", "))
}
a.Said += " " + space + " " + a.Holds + "."
}
a.Recorded, a.Record = m.record(a)
@@ -179,14 +196,38 @@ func (m *Machine) record(a SnapshotAnswer) (bool, string) {
if len(a.Made) > 0 {
created = a.Made[0].Created
}
err := m.Emit(SnapshotEvent, map[string]any{"dataset": a.Dataset, "snapshot": a.Name, "recursive": a.Recursive,
"why": a.Why, "done": a.Done, "made": madeNames(a.Made), "created": created, "at": m.Now().UTC().Format(time.RFC3339)})
err := m.Emit(SnapshotEvent, map[string]any{"dataset": a.Dataset, "snapshot": a.Name,
"why": a.Why, "done": a.Done, "outcome": a.Outcome, "made": madeNames(a.Made), "created": created,
"at": m.Now().UTC().Format(time.RFC3339)})
if err != nil {
return false, err.Error()
}
return true, "published as the event " + SnapshotEvent
}
// spaceAfter reads the dataset's free space and its pool's capacity after the act, and says them.
func (m *Machine) spaceAfter(ctx context.Context, a *SnapshotAnswer) string {
var parts []string
if ds, err := m.Datasets(ctx); err == nil {
if d := find(ds, a.Dataset); d != nil {
a.Available = d.Available
parts = append(parts, fmt.Sprintf("%s has %s free", a.Dataset, bytesSaid(d.Available)))
}
}
if ps, err := m.Pools(ctx); err == nil {
for _, p := range ps {
if p.Name == a.Pool {
a.PoolCapacity, a.PoolFree = p.Capacity, p.Free
parts = append(parts, fmt.Sprintf("pool %s %d %% full (%s free)", p.Name, p.Capacity, bytesSaid(p.Free)))
}
}
}
if len(parts) == 0 {
return "The free space could not be read after the act."
}
return "Now " + strings.Join(parts, ", ") + "."
}
func names(ss []Snapshot) map[string]bool {
out := map[string]bool{}
for _, s := range ss {
+8 -36
View File
@@ -26,16 +26,12 @@ func snapshotting(f *fake, cmd string, added ...string) {
func TestTheSnapshotCommandIsFixedWordsThroughSudoWithTheNameAfterDashDash(t *testing.T) {
m := &Machine{UID: 1000}
p, argv := m.Escalated("zfs", SnapshotArgv("tank/media", "before-move", false)...)
p, argv := m.Escalated("zfs", SnapshotArgv("tank/media", "before-move")...)
if got := p + " " + strings.Join(argv, " "); got != takeLine {
t.Errorf("ran %q", got)
}
p, argv = m.Escalated("zfs", SnapshotArgv("tank", "x", true)...)
if got := p + " " + strings.Join(argv, " "); got != "sudo -n zfs snapshot -r -- tank@x" {
t.Errorf("recursive: %q", got)
}
root := &Machine{UID: 0}
if p, argv := root.Escalated("zfs", SnapshotArgv("tank", "x", false)...); p != "zfs" || argv[0] != "snapshot" {
if p, argv := root.Escalated("zfs", SnapshotArgv("tank", "x")...); p != "zfs" || argv[0] != "snapshot" {
t.Errorf("as root, no sudo: %s %v", p, argv)
}
}
@@ -43,7 +39,7 @@ func TestTheSnapshotCommandIsFixedWordsThroughSudoWithTheNameAfterDashDash(t *te
func TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded(t *testing.T) {
f := withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t26938034880512")
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "before moving the library", false)
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "before moving the library")
if err != nil {
t.Fatal(err)
}
@@ -68,7 +64,7 @@ func TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded(t *testing.T) {
func TestASnapshotTheListDoesNotShowIsNotAnsweredAsMade(t *testing.T) {
f := withZFS(t)
f.on(takeLine, "") // zfs exits 0, and the list does not change
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err == nil || a.Done || !strings.Contains(err.Error(), "the list shows 0 of the 1") {
t.Fatalf("err %v, answer %+v", err, a)
}
@@ -80,7 +76,7 @@ func TestASnapshotTheListDoesNotShowIsNotAnsweredAsMade(t *testing.T) {
func TestARefusedSudoIsSaidAndRecorded(t *testing.T) {
f := withZFS(t)
f.answers[takeLine] = Ran{Code: 1, Err: "sudo: a password is required"}
_, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
_, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err == nil || !strings.Contains(err.Error(), "NOT DONE") || !strings.Contains(err.Error(), "sudo refused the operator account") {
t.Fatalf("err: %v", err)
}
@@ -93,7 +89,7 @@ func TestAnUnrecordedActSaysSoLoudly(t *testing.T) {
f := withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1")
f.emitErr = errors.New("the bus did not accept it")
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err != nil {
t.Fatal(err)
}
@@ -105,7 +101,7 @@ func TestAnUnrecordedActSaysSoLoudly(t *testing.T) {
func TestAnotherSnapshotMeanwhileIsSaidNotHidden(t *testing.T) {
f := withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1", "tank/downloads@auto-1200\t1791547201\t0\t1")
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err != nil {
t.Fatal(err)
}
@@ -133,7 +129,7 @@ func TestEveryRefusalRunsNoSnapshotCommand(t *testing.T) {
}
for _, c := range cases {
f := withZFS(t)
_, err := f.machine().TakeSnapshot(context.Background(), c.dataset, c.name, c.why, false)
_, err := f.machine().TakeSnapshot(context.Background(), c.dataset, c.name, c.why)
if c.says == "" {
if len(ran(f, "sudo -n zfs snapshot -- tank/media@-r")) != 1 {
t.Errorf("%q: the name follows -- and runs as a name: %v", c.name, f.calls)
@@ -149,30 +145,6 @@ func TestEveryRefusalRunsNoSnapshotCommand(t *testing.T) {
}
}
func TestRecursiveIsRefusedWhenAChildHasTheNameAlready(t *testing.T) {
f := withZFS(t)
_, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", true)
if err == nil || !strings.Contains(err.Error(), "tank/downloads@weekly already exists") {
t.Fatalf("err: %v", err)
}
// Without recursive, only tank's own name counts.
f = withZFS(t)
snapshotting(f, "sudo -n zfs snapshot -- tank@weekly", "tank@weekly\t1791400000\t0\t1")
if a, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", false); err != nil || len(a.Made) != 1 {
t.Errorf("tank@weekly alone: %v %+v", err, a)
}
}
func TestRecursiveAnswersEveryChildsSnapshot(t *testing.T) {
f := withZFS(t)
snapshotting(f, "sudo -n zfs snapshot -r -- tank@all", "tank@all\t1\t0\t1", "tank/media@all\t1\t0\t1",
"tank/downloads@all\t1\t0\t1", "tank/vm@all\t1\t0\t1")
a, err := f.machine().TakeSnapshot(context.Background(), "tank", "all", "why", true)
if err != nil || len(a.Made) != 4 || !a.Recursive {
t.Fatalf("%v %+v", err, a)
}
}
func TestAMachineWithoutZFSSaysTheModuleDoesNotApply(t *testing.T) {
f := newFake(t) // every command: executable file not found
m := f.machine()
+6 -6
View File
@@ -1,12 +1,12 @@
tank 28797591658086 19184805568512 212 14 221249536 1048576
raidz2-0 28797591658086 19184805568512 212 14 221249536 1048576
ata-WDC_WD120EFBX-68B0EN0_D7JA0001 - - 35 2 36874922 174762
ata-WDC_WD120EFBX-68B0EN0_D7JA0002 - - 36 2 36874922 174762
logs - - - - - -
ata-WDC_WD120EFBX-68B0EN0_D7JA0001 0 0 35 2 36874922 174762
ata-WDC_WD120EFBX-68B0EN0_D7JA0002 0 0 36 2 36874922 174762
mirror-1 12582912 16093544448 0 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100001-part1 - - 0 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100002-part1 - - 0 0 0 0
cache - - - - - -
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100001-part1 0 0 0 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100002-part1 0 0 0 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100001-part2 480000000000 520000000000 40 1 5242880 131072
rpool 85899345920 412316860416 3 25 98304 1310720
nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNX0R200001-part2 85899345920 412316860416 3 25 98304 1310720
@@ -4,8 +4,8 @@ status: One or more devices is currently being resilvered. The pool will
continue to function, possibly in a degraded state.
action: Wait for the resilver to complete.
scan: resilver in progress since Thu Oct 8 21:02:17 2026
9876543210123 / 28797591658086 scanned at 1101004800/s, 6543210987654 / 28797591658086 issued at 734003200/s
1088516226 resilvered, 22.72% done, 08:25:13 to go
8.98T / 26.2T scanned at 1.03G/s, 5.95T / 26.2T issued at 700M/s
1.01G resilvered, 22.72% done, 08:25:13 to go
config:
NAME STATE READ WRITE CKSUM
@@ -0,0 +1,46 @@
pool: fast
state: ONLINE
scrub: scrubbed 12 error blocks in 0 days 00:01:02 on Tue Oct 6 10:00:00 2026
scan: resilvered (mirror-0) 1.20T in 03:22:11 with 0 errors on Wed Oct 7 04:00:00 2026
scan: resilver (mirror-1) in progress since Fri Oct 9 09:00:00 2026
310G / 1.80T scanned at 160M/s, 300G / 1.80T issued at 155M/s
300G resilvered, 16.84% done, 02:40:11 to go
config:
NAME STATE READ WRITE CKSUM
fast ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
nvme-WD_BLACK_SN850X_2000GB_A001 ONLINE 0 0 0
nvme-WD_BLACK_SN850X_2000GB_A002 ONLINE 0 0 0
mirror-1 ONLINE 0 0 0
nvme-WD_BLACK_SN850X_2000GB_A003 ONLINE 0 0 0
nvme-WD_BLACK_SN850X_2000GB_A004 ONLINE 0 0 0 (resilvering)
errors: No known data errors
pool: slow
state: ONLINE
scrub: error scrub paused since Thu Oct 8 12:00:00 2026
error scrub started on Thu Oct 8 11:00:00 2026
42.00% done, issued I/O for 5 error blocks
config:
NAME STATE READ WRITE CKSUM
slow ONLINE 0 0 0
sda ONLINE 0 0 2
errors: No known data errors
pool: paused
state: ONLINE
scan: scrub paused since Thu Oct 8 12:00:00 2026
scrub started on Thu Oct 8 01:00:00 2026
1.00T / 2.00T scanned, 900G / 2.00T issued
0B repaired, 43.95% done
config:
NAME STATE READ WRITE CKSUM
paused ONLINE 0 0 0
sdb ONLINE 0 0 0
errors: No known data errors
@@ -1,8 +1,8 @@
pool: tank
state: ONLINE
scan: scrub in progress since Sun Oct 4 00:24:01 2026
13523942719488 / 28797591658086 scanned at 1298300000/s, 8400000000000 / 28797591658086 issued at 830000000/s
0 repaired, 29.17% done, 06:49:34 to go
12.3T / 26.2T scanned at 1.21G/s, 7.64T / 26.2T issued at 792M/s
0B repaired, 29.17% done, 06:49:34 to go
config:
NAME STATE READ WRITE CKSUM
+4 -4
View File
@@ -5,7 +5,7 @@ status: Some supported and requested features are not enabled on the pool.
action: Enable all features using 'zpool upgrade'. Once this is done,
the pool may no longer be accessible by software that does not support
the features. See zpool-features(7) for details.
scan: scrub repaired 0 in 00:04:11 with 0 errors on Sun Jul 12 00:28:12 2026
scan: scrub repaired 0B in 00:04:11 with 0 errors on Sun Jul 12 00:28:12 2026
config:
NAME STATE READ WRITE CKSUM
@@ -16,7 +16,7 @@ errors: No known data errors
pool: tank
state: ONLINE
scan: scrub repaired 0 in 1 days 02:10:05 with 0 errors on Mon Sep 14 02:34:06 2026
scan: scrub repaired 0B in 1 days 02:10:05 with 0 errors on Mon Sep 14 02:34:06 2026
config:
NAME STATE READ WRITE CKSUM
@@ -26,11 +26,11 @@ config:
ata-WDC_WD120EFBX-68B0EN0_D7JA0002 ONLINE 0 0 0
ata-WDC_WD120EFBX-68B0EN0_D7JA0003 ONLINE 0 0 0
ata-WDC_WD120EFBX-68B0EN0_D7JA0004 ONLINE 0 0 0
logs
logs
mirror-1 ONLINE 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100001-part1 ONLINE 0 0 0
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100002-part1 ONLINE 0 0 0
cache
cache
nvme-Samsung_SSD_980_PRO_1TB_S5P2NG0R100001-part2 ONLINE 0 0 0
spares
ata-WDC_WD120EFBX-68B0EN0_D7JA0009 AVAIL
+58 -24
View File
@@ -37,23 +37,22 @@ func Tools(m *Machine) []stdio.Tool {
Name: "zfs_snapshot",
Description: "Take a snapshot dataset@name of one dataset, a person's act with why: refused for a dataset that is not " +
"on this machine, a name with anything but letters, digits, '.', '_' and '-', a snapshot that already exists, " +
"or no why. Only that dataset unless recursive is true, which snapshots every child at the same instant too. " +
"Runs zfs snapshot -- dataset@name through sudo -n, then answers what zfs list shows: the snapshot made " +
"and its creation time, and any other snapshot that came or went meanwhile. Recorded as the event " +
"snapshot.taken; ZFS keeps it in the pool's history too. It adds a snapshot and changes nothing else. " +
"Replaces zfs snapshot. (a)",
"or no why. Only that dataset, never its children. Runs zfs snapshot -- dataset@name through sudo -n, then " +
"answers what zfs list shows: the snapshot made, its creation time, any other snapshot that came or went " +
"meanwhile, the dataset's free space and the pool's capacity. From then on the snapshot holds every block " +
"of the dataset deleted or overwritten, until it is destroyed by hand. A command that ran out of time is " +
"answered from the list, or as outcome unknown. Recorded as the event snapshot.taken; ZFS keeps it in the " +
"pool's history too. It adds a snapshot and changes nothing else. Replaces zfs snapshot. (a)",
Input: map[string]any{
"dataset": dataset("the dataset, as zfs_datasets names it"),
"name": map[string]any{"type": "string", "description": "the snapshot's name, after the @: letters, digits, '.', '_' and '-'"},
"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"},
"recursive": map[string]any{"type": "boolean", "description": "true to snapshot every child dataset too (default false)"},
"dataset": dataset("the dataset, as zfs_datasets names it"),
"name": map[string]any{"type": "string", "description": "the snapshot's name, after the @: letters, digits, '.', '_' and '-'"},
"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"},
},
Run: func(a map[string]any) (any, error) {
recursive, err := flag(a, "recursive")
if err != nil {
return nil, err
if _, given := a["recursive"]; given {
return nil, fmt.Errorf("recursive does not exist in this version: zfs_snapshot snapshots one dataset, never its children")
}
ans, err := m.TakeSnapshot(ctx, text(a, "dataset"), text(a, "name"), text(a, "why"), recursive)
ans, err := m.TakeSnapshot(ctx, text(a, "dataset"), text(a, "name"), text(a, "why"))
if err != nil {
if _, absentHere := err.(errAbsent); absentHere {
return NotApplicable{Applicable: false, Said: err.Error()}, nil
@@ -89,12 +88,13 @@ func Tools(m *Machine) []stdio.Tool {
},
{
Name: "zfs_scrub",
Description: "Each pool's last scan, as status says it (ZFS keeps only the latest, a scrub's or a resilver's): when it " +
"started and ended, how long it took, what it repaired and the errors it found, or, while one runs, its " +
"progress and the time to go; and how many days since the last finished scrub, with a WARNING past 35. " +
"With history true, also each pool's history (zpool history -i, as root through sudo -n) for the last " +
"finished scrub and resilver status no longer shows; it can take some seconds on a pool with a long life. " +
"Replaces zpool history -i. (r)",
Description: "Each pool's scans as status gives them: its scrub (or error scrub) and its resilver, the pool-wide one or " +
"each vdev's rebuild; when each started and ended, how long it took, what it repaired and the errors it " +
"found, or, while one runs, its progress and the time to go; and how many days since the last finished " +
"scrub, with a WARNING past what the schedule allows. Then the module's scrub schedule: the pool, the " +
"cadence, the day and time, its timer's state and the next run, and how the last run ended. With history " +
"true, also each pool's history (zpool history -i, as root through sudo -n) for the last finished scrub and " +
"resilver status no longer shows. Replaces zpool history -i. (r)",
Input: map[string]any{"history": map[string]any{"type": "boolean", "description": "true to read each pool's history too (default false)"}},
Run: func(a map[string]any) (any, error) {
h, err := flag(a, "history")
@@ -104,6 +104,15 @@ func Tools(m *Machine) []stdio.Tool {
return m.Scrubs(ctx, h)
},
},
scrubAct("start", "Start a scrub of one pool, or resume a paused one: zpool scrub -- pool through sudo -n, a person's "+
"act with why. A scrub reads every block and rewrites from redundancy only a block it finds damaged; it adds "+
"I/O load for its whole length (hours to days on a large pool) and never changes what a file holds. "+
"Answers what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub. (a)", m),
scrubAct("pause", "Pause one pool's running scrub, keeping its place: zpool scrub -p -- pool through sudo -n, with why; "+
"zfs_scrub_start resumes it. Answers what status says afterwards; recorded as the event scrub.requested. "+
"Replaces zpool scrub -p. (a)", m),
scrubAct("stop", "Stop one pool's scrub, losing its place: zpool scrub -s -- pool through sudo -n, with why. Answers "+
"what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub -s. (a)", m),
{
Name: "zfs_io",
Description: "A sample of every pool's I/O over a few seconds (zpool iostat -v, the report since boot left out): " +
@@ -159,16 +168,41 @@ func Tools(m *Machine) []stdio.Tool {
},
{
Name: "zfs_health",
Description: "Whether this machine's ZFS is healthy, as the module's health check (novox/hq ADR 0240): unhealthy " +
"when a pool is not ONLINE (with status's reason and action), a device has read, write or checksum " +
"errors or is not online, a pool reports data errors, a pool is more than 90 % full, no pool is " +
"imported, or ZFS is not on this machine; a pool more than 80 % full and a last scrub more than 35 " +
"days ago are warnings, which leave it healthy. With why, one sentence per finding. (r)",
Description: "Whether this machine's ZFS is healthy, as the module's health check (novox/hq ADR 0240). Unhealthy " +
"for a fault now: a pool not ONLINE (with status's reason and action), a device not online, a device's " +
"error counts that rose since the last look (unhealthy for 15 minutes after the rise), data errors, a " +
"resilver that finished with errors, a pool more than 90 % full, no pool imported, or ZFS not on this " +
"machine. Warnings leave it healthy: error counts from before, to clear by hand once understood; a pool " +
"more than 80 % full; a resilver running; a last scrub later than its schedule allows (or 35 days without " +
"one); a scheduled scrub that failed, or a schedule whose timer does not run. With why, one sentence per " +
"finding. (r)",
Run: func(map[string]any) (any, error) { return m.Health(ctx), nil },
},
}
}
// scrubAct is one of the scrub's acts as a tool.
func scrubAct(act, description string, m *Machine) stdio.Tool {
return stdio.Tool{
Name: "zfs_scrub_" + act,
Description: description,
Input: map[string]any{
"pool": map[string]any{"type": "string", "description": "the pool, as zfs_pools names it"},
"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"},
},
Run: func(a map[string]any) (any, error) {
ans, err := m.ScrubAct(context.Background(), act, text(a, "pool"), text(a, "why"))
if err != nil {
if _, absentHere := err.(errAbsent); absentHere {
return NotApplicable{Applicable: false, Said: err.Error()}, nil
}
return nil, err
}
return ans, nil
},
}
}
func text(a map[string]any, key string) string {
s, _ := a[key].(string)
return strings.TrimSpace(s)
+2 -2
View File
@@ -37,8 +37,8 @@ func (w *Watch) Look(ctx context.Context) {
}
}
// Run looks every WatchEvery until the context ends.
func (w *Watch) Run(ctx context.Context) {
// Loop looks every WatchEvery until the context ends.
func (w *Watch) Loop(ctx context.Context) {
w.Look(ctx)
t := time.NewTicker(WatchEvery)
defer t.Stop()
+89 -1
View File
@@ -5,8 +5,26 @@
"service-manager"
],
"emits": [
"snapshot.taken"
"snapshot.taken",
"scrub.requested"
],
"settings": {
"scrub-cadence": {
"kind": "preference",
"default": "weekly",
"why": "how often the pool named by scrub-pool is scrubbed, weekly or monthly (the first scrub-day of each month); weekly is what the distribution's timer did on the home server before the module, so taking it over moves nothing until the operator chooses"
},
"scrub-day": {
"kind": "preference",
"default": "Mon",
"why": "the weekday the scrub starts, Mon to Sun: for a monthly scrub, the first such day of the month. Checked by the scheduled run, which refuses any other value and runs nothing; Monday is when the distribution's weekly timer fired"
},
"scrub-time": {
"kind": "preference",
"default": "00:00",
"why": "the time the scrub starts, HH:MM from 00:00 to 23:59 in the machine's time zone; the scheduled run checks it every quarter of an hour, so the scrub starts within 15 minutes of it. Midnight is when the distribution's weekly timer fired"
}
},
"tools": [
"zfs_datasets",
"zfs_snapshots",
@@ -15,6 +33,9 @@
"zfs_space",
"zfs_pool_layout",
"zfs_scrub",
"zfs_scrub_start",
"zfs_scrub_pause",
"zfs_scrub_stop",
"zfs_io",
"zfs_arc",
"zfs_events",
@@ -62,6 +83,15 @@
"zfs_properties": [
"zfs get all",
"zfs get"
],
"zfs_scrub_start": [
"zpool scrub"
],
"zfs_scrub_pause": [
"zpool scrub -p"
],
"zfs_scrub_stop": [
"zpool scrub -s"
]
},
"resources": [
@@ -74,6 +104,7 @@
"./zfs-tools",
"watch"
],
"user": "${machine:account}",
"health": {
"kind": "tool",
"tool": "zfs_health",
@@ -83,6 +114,19 @@
"grace": "60s"
}
},
{
"id": "state-dir",
"type": "directory",
"path": "/var/lib/zfs-tools",
"mode": "0755",
"owner": "${machine:account}"
},
{
"id": "cursor-dir",
"type": "directory",
"path": "/run/zfs-events",
"mode": "0700"
},
{
"id": "events",
"type": "process",
@@ -95,6 +139,50 @@
"health": {
"kind": "unit"
}
},
{
"id": "config-dir",
"type": "directory",
"path": "/etc/zfs-tools",
"mode": "0755"
},
{
"id": "scrub-config",
"type": "file",
"path": "/etc/zfs-tools/scrub.conf",
"mode": "0644",
"content": "# Written by the mesh (module zfs) from this machine's assignment. Replaced on every push; change the\n# settings scrub-pool, scrub-cadence, scrub-day and scrub-time, never this file. The module's tools read it.\npool=${setting:scrub-pool}\ncadence=${setting:scrub-cadence}\nday=${setting:scrub-day}\ntime=${setting:scrub-time}\n"
},
{
"id": "scrub-state-dir",
"type": "directory",
"path": "/var/lib/zfs-scrub",
"mode": "0700"
},
{
"id": "scrub-run",
"type": "process",
"name": "zfs-mesh-scrub",
"artifact": "tools",
"run": [
"./zfs-tools",
"scrub-run"
],
"schedule": "*/15 * * * *"
},
{
"id": "distribution-weekly",
"type": "service",
"unit": "zfs-scrub-weekly@${setting:scrub-pool}.timer",
"state": "stopped",
"boot": "disabled"
},
{
"id": "distribution-monthly",
"type": "service",
"unit": "zfs-scrub-monthly@${setting:scrub-pool}.timer",
"state": "stopped",
"boot": "disabled"
}
],
"build": {