Merge pull request 'Design 42 zfs-owns-zfs: /etc/hostid by the apply, and the lead's answers A-1 to A-26 (issue 1979)' (#599) from design/42-zfs-hostid into main

This commit was merged in pull request #599.
This commit is contained in:
2026-11-12 23:53:39 +00:00
3 changed files with 149 additions and 32 deletions
@@ -0,0 +1,100 @@
---
layer: to-be
status: in-progress
design: 42
milestone: zfs-owns-zfs
updated: 2026-11-13
decisions:
- "ADR 0679"
---
# 42, milestone zfs-owns-zfs — the lead's answers to the prepare's questions
The lead's answers to the prepare's design questions (Q-n) and to the places where a design line and the code
disagree (C-n), for the milestone part [zfs-owns-zfs](zfs-owns-zfs.md): each one line, naming the principle that
settles it (`00-META/principles.md`), cited as "design 42 zfs-owns-zfs A-n". The code read is the prepare's, at the
`origin/main` commits its milestone file names.
- **A-1** (Q-1, §8; principles 2 and 7) The pool's name is not held by mesh-config: it is the zfs module's
`values` in the home server's assignment (seat `mesh-module-assigner`), a layer mesh-config's rename does not move.
So the module keeps `renamed-from: scrub-pool` for the values mesh-config holds, and at the delivery the lead moves
the assignment's value: `pool` set to that name through mesh-config for the home server, and the assignment
proposed without `scrub-pool` through `mesh-module-assigner.propose`, both before the new version is placed.
- **A-2** (Q-2, §14; principle 7) The module's ZED folder also holds the package's `zed-functions.sh`, from the
module's files archive, root's, mode 0644, under `if-config: [zed=on]` beside `zed.rc`; the handlers find it where
ZED points them, and nothing else changes.
- **A-3** (Q-3, §13; principles 2 and 7) `trim-schedule: none` is a set value meaning "no trim by the mesh": the
distribution's trim timers are held stopped for `none`, `weekly` and `monthly` alike; only an unset value leaves
them as found.
- **A-4** (Q-4, §3; principle 3) The validator refuses a `:` in a `config-lines` prefix, beside `}`, `$` and a
newline, naming the key.
- **A-5** (Q-5, §3; principle 3) The validator refuses `mib-bytes` on a value whose type is not a number, and a
`config-lines` key the module does not declare, as it refuses `${config:<key>}` of an undeclared key today.
- **A-6** (Q-6, §18, §24; principles 1 and 6) The follower's process bus declares
`invokes: ["seat:node-service-manager.start"]`, and the module's `invokes` carries the same for its tools bundle.
- **A-7** (Q-7, §14, §18; principle 7) `/etc/zfs/mesh-zed.d` is a directory resource under `if-config: [zed=on]`
with `unknown: module`; `/run/zfs-tools` is a directory resource with `unknown: program`, as `/run/zfs-events` is.
- **A-8** (Q-8, §18; principle 3) `zfs-tools apply` exits 0 on a refusal and on a wait, which it says by event and
in its state `applied` and which make the module unhealthy by §29; it exits non-zero only when a command it ran
failed, so the node-engine gates the module on a real failure.
- **A-9** (Q-9, §30; principle 7) When the module leaves a node, its kept data (the states `applied`, `schedule` and
`findings`, the ledger `snapshots`, the events stream) is kept as every module's kept data is on leaving; a return
finds its scheduled ZFS snapshots' records.
- **A-10** (Q-10, §27; principle 7) The findings stream is the one design 47 G §383 defines for every observer:
`zfs-findings`, its events `finding-written.*`, `finding-cleared.*` and `findings-stated.*`, kept 7 days and
16 MiB, said by the writer generated from the seat `mesh-conditions`' definition; the module declares it as §383
says and builds nothing of its own for it.
- **A-11** (Q-11, acceptance test 8; principles 5 and 7) Test 8 runs in the test lab, on a pool built on a
file-backed device, never on the home server's pool.
- **A-12** (Q-12, §24; principles 3 and 4) A hand act's tool waits at most 30 seconds for the act's event; past it,
it answers "started", naming the act and that `zfs_module_events` reads its outcome, and never answers "done"
for what it did not see end.
- **A-13** (C-1, §4; principle 7) The node-engine states the new reading as `word:resources.if-config` and
`form:config-lines` (and `form:config-json`, A-15), computed by `ManifestReads` and marked by `ManifestNeeds`;
stopping at the first failure is stated by no capability, since the node-engine build that reads those words is
the build that stops.
- **A-14** (C-2, §11; ADR 0679 §3; principle 7) The validator accepts a config value with neither a default nor
`required` as "not managed until set"; built in the validator's subtasks of this milestone.
- **A-15** (C-3, §9; principles 2 and 7) The config file's content is one new node-engine placeholder
`${config-json}`: the module's set config values as one JSON object, an unset value absent, lists and objects as
JSON; no unset value refuses the module. It is built with §2 and §3 in the node-engine, the validator and
mesh-sdk's form list, and stated as `form:config-json`; no existing form writes an unset value as absent.
- **A-16** (C-4, §18; principle 7) The follower, which runs from boot, starts the apply unit once at its own start
and on the events §27 names; the apply's own wait for an unimported pool (§18) stands for the order after
`zfs-import.target`. No new process field.
- **A-17** (C-5, §24; principle 7) A hand act writes its act into the state `schedule` under its own key and starts
`zfs-mesh-run.service`; the run reads the key, acts under the same lock and guards, and deletes it. No instance
units.
- **A-18** (C-6, §24; principle 7) `node-service-manager.start` is called with `{scope, unit}`; the tool keeps
`why` in its own event. The seat is not changed.
- **A-19** (C-7, §26; principle 7) The ledger `snapshots` declares a fixed `max-mib` sized from the bound of 168 per
kind (§11); a full ledger takes §21's "ledger full" path.
- **A-20** (C-8, §21, §22, §26; principles 1 and 7) A process cannot write a ledger: the ledger is served to a tools
bundle alone, through the tool runner (mesh-sdk `buscalls`, `ProcessBus` has no ledger). The scheduled run records
`taken` and `removed` by calling its own module's tool `zfs_snapshots_record` (declared in the run's process bus
`invokes`), whose bundle applies the operation to the ledger; the tool refuses a snapshot not named `mesh-`. No new
field in mesh-sdk.
- **A-21** (C-9, §13; principle 2) When one record of a unit goes while another still declares it, the found state
passes to the record that stays, as a file is handed to the resource still declaring its path (ADR 0223); built in
the node-engine's §2 subtask.
- **A-22** (C-10, the test budget; the test-budget instruction) The node-engine's repository is over its budget (about 1,742 unit tests
against 500): each new table test merges or replaces at least as many tests in the file it touches, named in the
build map, so the count does not rise.
- **A-23** (C-11, §9, §11; principle 6) The config file carries `"trusted": false`; each value's own `trusted` (§11)
decides who sets it.
- **A-24** (C-12, §2, §3; principle 3) `config-lines` and `config-json` are added to the validator's and the
composer's placeholder tables, and the validator counts `if-config`, `config-lines` and `config-json` as placing
their keys.
- **A-25** (A-15, S2; principle 3) `${config-json}` holds no colon, so the form search that finds `${<form>:` does not
find it: the manifest's reading also finds the exact token `${config-json}`, and a manifest that holds it needs
`form:config-json`, so no node-engine that cannot write it is given the module.
- **A-26** (§1, met in develop; principles 3 and 7) §1 stands: a module declares a resource another of its resources
needs before that one, since the apply now stops at the first failure. The pacman module declares its package
before its config file, and its package fails while the config file is missing, so the stop would keep a node
from restoring its package manager: in this milestone the pacman module's config file moves before its package,
and the node-engine's probe test of that restore keeps the pacman module's own resource names, in that order.
- **A-27** (§11, met in develop; principle 7) `snapshots` is an object, so its `none` is the object holding only the
key `none` set to true; keep counts are its other form, and the text `none` is refused as the type refuses it.
- **A-28** (§11, met in develop; principle 3) One refused value refuses the whole config: nothing is converged until
every value is valid, and each refused value is its own `config.refused`.
- **A-29** (§11, §18, met in develop; principle 3) The node's memory is read by the apply and given to the parse; a
memory that cannot be read refuses `arc-max` and `arc-min`, never guesses them.
@@ -160,13 +160,20 @@ capabilities of §4 (a reader's capability the delivery plan derives, ADR 0614
`/etc/zfs/zed.d` is not read while the drop-in holds. The mesh creates no symlinks. The node-engine restarts
`zfs-zed.service` on a change of the drop-in, a handler or `zed.rc` (`restart-on`). Checked by the manifest test (no
link resource, each handler's condition).
15. **The node's ZFS id** (ADR 0679 §1, §3, §4). `/etc/hostid` holds four bytes, which no template writes, so the apply
(§18) writes it with ZFS's own writer, `zgenhostid -f <value>`, only while `hostid` is set, and only when the pool's
label (`zdb -C <pool>`, its `hostid`) is 0 or equals the value: then the next boot imports without `zpool import
-f`. Otherwise it writes nothing and emits `config.refused` naming why ("the pool's import would need -f"). Before
the Approve, the value's description and `zfs_config` say the change needs a check of the pool's import at the next
boot; the module never reboots. After the reboot, `zfs_pools` shows the pool imported. Emits `config.applied` with
target `file`. Checked by §31 (unset: no `zgenhostid`; a label of another id: refused; 0 or equal: one run).
15. **The node's ZFS id** (ADR 0679 §1, §3, §4, §17). `/etc/hostid` holds four bytes, binary, which no template writes,
so it is no resource of the manifest and the apply (§18) writes it with ZFS's own writer, `zgenhostid -f <value>`,
only while `hostid` is set, and only when the pool's label (`zdb -C <pool>`, its `hostid`) is 0 or equals the value:
then the next boot imports without `zpool import -f`. When the label shows another value, or cannot be read, it
writes nothing and emits `config.refused` naming why ("the pool's import would need -f", "the pool's label cannot be
read"). Before the Approve, the value's description and `zfs_config` say the change needs a check of the pool's
import at the next boot; the module never reboots. After the reboot, `zfs_pools` shows the pool imported. Emits
`config.applied` with target `file`. The apply reads `/etc/hostid` back at every start and after every write: while
`hostid` is set, a value there other than the set one emits `config.refused` naming it, because the node-engine's
composer cannot see a second writer of a file that is no resource. Each refusal of this line makes `zfs_health`
unhealthy (§29), naming the key and why, until an apply finds the label and the file agreeing with `hostid`, or
`hostid` is unset. No resource or template of the manifest writes `/etc/hostid`. Checked by §31 (unset: no
`zgenhostid`; a label of another id, or one that cannot be read: refused; 0 or equal: one run; a file holding
another value: refused; unhealthy, then healthy again; no manifest resource for the file).
## The pool
@@ -194,7 +201,7 @@ capabilities of §4 (a reader's capability the delivery plan derives, ADR 0614
waits-on, since}.
A declared device that is not present is a wait. Each change is read back from `zpool status -P` and emitted.
Checked by §31's convergence tests (the empty list among them) and acceptance test 2.
18. **The apply: who, when, and what wakes a wait** (ADR 0679 §9). A `process` resource `zfs-config-apply`, `run:
18. **The apply: who, when, and what wakes a wait** (ADR 0679 §9, §17). A `process` resource `zfs-config-apply`, `run:
./zfs-tools apply`, as root, oneshot, ordered after `zfs-import.target`, enabled at boot, with `restart-on` the
config file (§9); it converges §15 to §17 and writes the ARC values live. It holds an exclusive lock on
`/run/zfs-tools/apply.lock`; a second start waits for it. It converges nothing while a scrub or resilver runs on
@@ -207,7 +214,7 @@ capabilities of §4 (a reader's capability the delivery plan derives, ADR 0614
to `/sys/module/zfs/parameters/zfs_arc_max` and `zfs_arc_min` as well as to §12's file, and reads both back; each
`module-options` entry whose live value differs is a warning naming it, "takes effect at the next load of the
kernel module", never success and never unhealthy.
19. **The call-site test** (ADR 0679 §7). Every command the bundle runs is built by one of its argument functions, and
19. **The call-site test** (ADR 0679 §7, §17). Every command the bundle runs is built by one of its argument functions, and
the test (`TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub`, renamed `TestTheBundleRunsOnlyTheFormsOfADR0679`)
allows only: `zpool add <pool> cache|log`, `zpool remove` of a cache or log device, `zpool set`, `zfs set` and `zfs
inherit` of a listed property, `zfs snapshot`, `zfs set mesh:schedule=<kind>` on a ZFS snapshot named `mesh-`,
@@ -217,7 +224,9 @@ capabilities of §4 (a reader's capability the delivery plan derives, ADR 0614
data vdevs, spares, special and dedup devices, `upgrade`, a dataset's destroy, `rollback`, `rename` and `clear` are
never run (each an act of its own, asked of the operator). The same test pins the module's other root forms:
`wipefs --no-act <device>`, `zdb -l <device>`, `zdb -C <pool>`, `zgenhostid -f <value>`, `udevadm monitor --udev
--subsystem-match=block` and `systemctl show`. Fate: the README's list of allowed forms is replaced by this one.
--subsystem-match=block` and `systemctl show`, and refuses every other `zgenhostid` or `zdb` form: `zgenhostid`
only as `-f` with the value §11 validated, `zdb` only as these two reads. Fate: the README's list of allowed forms
is replaced by this one.
## The scheduled run and ZFS snapshots
@@ -302,22 +311,27 @@ capabilities of §4 (a reader's capability the delivery plan derives, ADR 0614
`zfs.conf`, `/etc/zfs/mesh-zed.d` and the drop-in are removed, and before leaving `zfs_config` says that the next
load of the kernel module uses ZFS's defaults. The distribution's scrub and trim timers are put back as the
module's declaration found them, started and enabled again where it stopped or disabled them. Pool and dataset
properties and devices stay as the last apply set them, also on a rollback (§8).
properties and devices stay as the last apply set them, also on a rollback (§8). `/etc/hostid` stays, and the
module says so before it leaves (`zfs_config`): it is no resource the node-engine removes, and removing it would
change the node's hostid, so that the pool's next import could need `zpool import -f` (ADR 0679 §15).
## How it is checked
31. **Unit tests**, table-driven, one test per area, within the module's 500: no values gives no `zpool` or `zfs`
write but the due scrub; each convergence plan from parsed `zpool status` and `zfs get` output (an add, a kernel
name to its stable name, a removal, an empty list removing nothing, a log removal refused while not healthy, a
`log-mirror` change, a wait during a scrub); every refused form (a newline, quote, `$`, backtick or semicolon in
each field; a parameter or property not on its list; a `sharenfs` value; `-f`; a device with a signature or another
pool's label; a hostid of the wrong form or a label of another id); the call-site test of §19; the lock (a second
apply waits, a run with the lock held does nothing); the apply started only by `config-changed`, never a timer;
one scheduled resource; both ARC writes read back; applying from the last config file with the bus down; the
three templates (config file, options file, `zed.rc`); pruning never removes a ZFS snapshot whose name, property
or ledger entry does not fit, nor one with a hold or clone, and removes every surplus in one run; no ZFS snapshot
taken without a ledger record; `snapshots` back to `none` removes nothing; `zfs_snapshot` refuses a `mesh-`
31. **Unit tests**, table-driven, one test per area, within the module's 500: no values gives no `zpool` or `zfs` write
but the due scrub; each convergence plan from parsed `zpool status` and `zfs get` output (an add, a kernel name to
its stable name, a removal, an empty list removing nothing, a log removal refused while not healthy, a `log-mirror`
change, a wait during a scrub); every refused form (a newline, quote, `$`, backtick or semicolon in each field; a
parameter or property not on its list; a `sharenfs` value; `-f`; a device with a signature or another pool's label;
a hostid of the wrong form, a label of another id or one that cannot be read, and a hostid file holding another
value than the set one, each unhealthy and healthy again once the label and the file agree or `hostid` is unset);
the call-site test of §19, refusing any other `zgenhostid` or `zdb` form and a `zgenhostid` value not validated; the
lock (a second apply waits, a run with the lock held does nothing); the apply started only by `config-changed`,
never a timer; one scheduled resource; both ARC writes read back; applying from the last config file with the bus
down; the three templates (config file, options file, `zed.rc`); pruning never removes a ZFS snapshot whose name,
property or ledger entry does not fit, nor one with a hold or clone, and removes every surplus in one run; no ZFS
snapshot taken without a ledger record; `snapshots` back to `none` removes nothing; `zfs_snapshot` refuses a `mesh-`
name; a scheduled `snapshot.taken.schedule` does not wake a person's waiter; a parameter awaiting a load is a
warning; the `.pacnew` warning; each change emits its event with before and after; one test per tool. The module
adds no integration test; §5 is the milestone's, and the main file's live tests stand for the module whole, said
in the pull request.
warning; the `.pacnew` warning; each change emits its event with before and after; one test per tool; no resource or
template of the manifest for `/etc/hostid`; the hostid converged at the apply's start after `zfs-import.target`; the
leave keeps `/etc/hostid` and says so. The module adds no integration test; §5 is the milestone's, and the main
file's live tests stand for the module whole, said in the pull request.
@@ -170,7 +170,8 @@ named ZFS snapshot, a scrub's start, pause and stop.
the states of ZFS's import, mount, share, volume and target units and of the distribution's scrub and trim timers;
and, for the one pool its config value `pool` names, that pool's cache and log devices, its properties and its
datasets' properties from fixed lists. The node-engine's composer refuses a second module owning one of its paths
(ADR 0593 §7, ADR 0051 §9).
(ADR 0593 §7, ADR 0051 §9), all but `/etc/hostid`, which is no resource: the module's apply reads it back instead
(ADR 0679 §1, §17).
- **What it never writes** (ADR 0679 §1, §2): the package's own `/etc/default/zfs` and `/etc/zfs/zed.d` with its
`zed.rc`, and what ZFS writes itself; it reads and shows them. A dataset's `sharenfs` is the seat
`node-nfs-server`'s, `sharesmb` the SMB module's; the pool's files are the operator's (ADR 0051); the weekly
@@ -178,10 +179,11 @@ named ZFS snapshot, a scrub's start, pause and stop.
- **An unset or empty value changes nothing** (ADR 0679 §3): every new config value is "not managed" until the
operator sets it through mesh-config, on the Approve for a trusted value (ADR 0348 §4, §8). Placing the new version
on a node runs no `zpool` or `zfs` write but the scrub its schedule already runs, and places none of its new files.
- **Who applies** (ADR 0679 §9): the node-engine places the packages, files and units as the module's resources,
some only while a value holds; the module's root process `zfs-tools apply` converges the pool's devices and
properties at its start and on `config-changed`, never on a timer; the module's one scheduled run, under the same
lock, starts the scrub and the trim when due and takes and removes the scheduled ZFS snapshots (ADR 0679 §8).
- **Who applies** (ADR 0679 §9, §17): the node-engine places the packages, files and units as the module's resources,
some only while a value holds, all but `/etc/hostid`; the module's root process `zfs-tools apply` converges the pool's
devices and properties at its start and on `config-changed`, never on a timer, and writes `/etc/hostid` with ZFS's own
writer only while `hostid` is set and the pool's label agrees; the module's one scheduled run, under the same lock,
starts the scrub and the trim when due and takes and removes the scheduled ZFS snapshots (ADR 0679 §8).
- **What it says** (ADR 0679 §10, §11, §12): every change, refusal and wait is an event, each kind on a subject of
its own, on an events stream asked for by template; the last change per value, the schedule's pause and every
scheduled ZFS snapshot are kept on the bus in its own states and ledger (ADR 0595). Its health names a refusal or a
@@ -190,7 +192,8 @@ named ZFS snapshot, a scrub's start, pause and stop.
- **What it never does** (ADR 0679 §7): it converges no data device, spare, special or dedup device, upgrades no
pool, destroys no dataset, rolls back, renames and clears nothing; each is an act of its own, decided separately.
- **When it leaves** (ADR 0679 §15): ZFS keeps running (the import, mount and ZFS target are never stopped), the
packages stay, its own files are removed and the distribution's timers are put back as found.
packages stay, its own files are removed but `/etc/hostid`, which stays and is said, and the distribution's timers
are put back as found.
- **Its prerequisites** (ADR 0679 §16): three node-engine capabilities, built first in the same milestone under issue
1979, with their readers in mesh-sdk and the manifest validator.
@@ -200,7 +203,7 @@ flowchart LR
config -->|the module's config file| engine[the node-engine]
engine -->|packages, files, units| node[ZFS on the node]
engine -->|restarts on its config file| apply[zfs-tools apply, root]
apply -->|zpool add, remove, set; zfs set, inherit| node
apply -->|zpool add, remove, set; zfs set, inherit; zgenhostid| node
sched[the scheduled run] -->|scrub, trim, ZFS snapshots and their removal| node
follower[the event follower] -->|scrub ended, device appeared: start| apply
apply -->|config.applied, refused, waiting| stream[(events stream)]