Commit Graph
60 Commits
Author SHA1 Message Date
flan 14ef25e3c6 Run CI as a single job so concurrent jobs cannot race
CI / ci (shell + python 3.11-3.13) (push) Successful in 46s
Two CI failures shared one cause: four jobs starting together on the
self-hosted runner.

act caches each action as one shared clone under /root/.cache/act/<hash> and
re-pulls it per job, so concurrent jobs fight over that directory and the
loser dies with "lstat .../<file>: no such file or directory" before any test
runs — a different victim each push. And the runner force-pulls its base image
per job, so four jobs meant four anonymous Docker Hub pulls per push; that hit
429 Too Many Requests and every job started failing before it began, including
the shell job nothing had touched.

Installing uv without an action only shrank the surface, since every job still
used actions/checkout. Concurrency is the ingredient, so this removes it: one
job cannot race itself whatever actions it uses, and one job is one pull. The
version sweep moves inside the job and still runs every version after one
fails, preserving what fail-fast: false bought.
2026-08-26 05:34:11 +00:00
flan 02ba653127 Install uv without an action so the matrix jobs stop racing
CI / python 3.12 (push) Failing after 1s
CI / shell (shellcheck + syntax) (push) Failing after 1s
CI / python 3.11 (push) Failing after 1s
CI / python 3.13 (push) Failing after 0s
act caches each action as one shared git clone under /root/.cache/act/<hash>
and re-pulls it per job. The three python matrix jobs start within the same
second on the self-hosted runner, race on that directory, and the loser dies
with "lstat /root/.cache/act/<hash>/.npmrc: no such file or directory" before
any test runs — a red main with zero suite output and a different victim each
push (3.12, then 3.11).

The action was only fetching a binary; the matrix interpreter is selected per
command by uvx --python. A run: step has no action-cache entry and cannot
race, and keeps the jobs parallel — serialising the matrix would cost 3x the
wall clock and still leave actions/checkout shared across four jobs. The uv
version is pinned under the same rule as ruff.

The accompanying test parses uses: directives rather than the raw text, so the
comment can still name the action it avoids, and uses re instead of PyYAML
because CI runs uvx pytest, whose environment holds pytest and nothing else.
2026-08-26 05:28:26 +00:00
flan 5f8d42f2cf Preserve patched_at across the post-restart re-mount
TrueNAS compatibility / compat (push) Successful in 15s
Release / release (push) Successful in 15s
CI / python 3.11 (push) Failing after 7s
CI / python 3.12 (push) Successful in 17s
CI / python 3.13 (push) Successful in 20s
CI / shell (shellcheck + syntax) (push) Successful in 9s
create_task.py verify decides "loaded" by comparing middlewared's start time
against hook_status.json's patched_at. The post-restart re-mount restores the
same patch the boot pass already applied, but it runs after the restart — so
letting apply.sh re-stamp made patched_at newer than the process that had
correctly imported the patch, and verify reported FAIL while everything was
working. That would have fired on every boot where docker's nvidia sysext
merge detaches the overlay.

Found on hardware while exercising the candidate: a new lying status
introduced by the fix for a lying status. The snapshot lives in /run, not the
repo — a leftover file there would leave the tree dirty, which update.sh
refuses to run over.
2026-08-26 05:20:23 +00:00
flan 3915f92dec Do not restart middlewared a second time on a post-restart miss
TrueNAS compatibility / compat (push) Successful in 12s
Release / release (push) Successful in 17s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.12 (push) Successful in 21s
CI / python 3.11 (push) Successful in 21s
CI / python 3.13 (push) Successful in 24s
try-restart returns as soon as middlewared is READY, and middlewared then
brings docker up — docker.configure_nvidia merges the stock nvidia sysext
over /usr at that point, detaching the overlay after the patched modules
have already been imported. Checking the disk there reports "missing" on a
perfectly healthy system, and the retry that followed would restart a
correctly-patched middlewared straight back into the same race, then log
ERROR when nothing was wrong.

The disk is the right oracle before the restart and the wrong one after it.
After the restart the overlay is re-mounted so the next restart finds patched
files, and whether this middlewared actually holds the patch is left to the
in-process alert, which is the only thing that can answer it exactly.
2026-08-26 04:57:33 +00:00
flan d1db3a3f60 release v0.8.0
CI / shell (shellcheck + syntax) (push) Successful in 12s
CI / python 3.11 (push) Successful in 20s
CI / python 3.13 (push) Successful in 21s
TrueNAS compatibility / compat (push) Successful in 17s
CI / python 3.12 (push) Successful in 39s
Release / release (push) Successful in 20s
2026-08-26 04:37:50 +00:00
flan 0fc994f676 Re-apply and verify the patch before the deferred restart
CI / shell (shellcheck + syntax) (push) Successful in 16s
CI / python 3.12 (push) Successful in 54s
CI / python 3.11 (push) Successful in 55s
CI / python 3.13 (push) Successful in 44s
Patching at PREINIT and restarting minutes later is only sound while the
patched files are still on the live path when middlewared re-imports them,
and PREINIT cannot guarantee that. The overlay sits inside /usr, so anything
that remounts that hierarchy detaches it — a systemd-sysext merge/refresh
from another PREINIT hook, or middlewared's own docker.configure_nvidia at
runtime. Init scripts run sequentially in id order, so a hook registered
after this one always wins, and reordering them would not help because
docker.configure_nvidia fires long after PREINIT is done.

Observed on 25.10.6: the overlay was mounted at 16:41:56, a sysext refresh
unmerged and remerged /usr four seconds later, and the deferred restart at
16:47:24 loaded stock modules. Every B2 cloud_backup task then failed with
NotImplementedError for nineteen hours across four scheduled runs while
apply.log and hook_status.json both reported the patch active.

wait_restart.sh now re-applies immediately before restarting — after boot has
settled, which is also after every sysext merge and docker nvidia
configuration — verifies the marker is on the live path, restarts, and
verifies again, retrying once. It is no longer exec'd, so something can run
after the restart to find out what it loaded. apply.sh records the resolved
middlewared directory in .mw_dir for that check, and honours TRUECLOUD_REAPPLY
so the re-apply pass does not schedule a second restart.

_ensure_writable treated "one of our overlays is listed here" as "already
done", but it only reaches that check when the directory is not writable, and
a live overlay of ours always is — a shadowed overlay was indistinguishable
from a healthy one. It is now detached and re-mounted, reusing the upperdir so
files patched earlier in the boot survive, with a fresh workdir and a retry on
a private one, since overlayfs refuses a workdir a detached mount still holds.

Add a CRITICAL hourly alert for the case none of this can prevent: the patch
being on disk but not in the running process. apply.log can only report the
first. The alert asks the second question from inside middlewared, where the
patch's own stamps make it exact, and checks both halves since either can go
missing alone. It stays quiet when the kill switch is set or the providers
module has been retired as native, and is not muted by update_alerts_disabled.

wait_restart.sh also logs to apply.log now: journald retention on a busy box
is easily shorter than the interval between reboots, and the boot that caused
this had already rotated away by the time it was investigated.
2026-08-26 04:37:04 +00:00
flan 827c4358fd Skip the chmod-based unreadable-sidecar tests when running as root
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 20s
CI / python 3.12 (push) Successful in 13s
CI / python 3.13 (push) Successful in 17s
The Gitea runner image executes jobs as root, and chmod(0) cannot make a file
unreadable for root (CAP_DAC_OVERRIDE) — the two tests failed there while
passing on GitHub's non-root runner. Skipping as root keeps the scenario
exercised everywhere it is constructible.
2026-08-03 20:28:37 +00:00
flan 5a3d4288a2 Fix Gitea-runner python matrix: uv-managed interpreters, pin ruff
CI / shell (shellcheck + syntax) (push) Successful in 7s
CI / python 3.13 (push) Failing after 16s
CI / python 3.11 (push) Failing after 16s
CI / python 3.12 (push) Failing after 19s
Swap README badges to the public GitHub mirror (workflows and releases).
2026-08-03 19:47:29 +00:00
flan 6b7034a8cd A change to the publisher did not re-run the check
CI / shell (shellcheck + syntax) (push) Successful in 9s
CI / python 3.11 (push) Failing after 13s
CI / python 3.12 (push) Failing after 13s
CI / python 3.13 (push) Failing after 11s
TrueNAS compatibility / compat (push) Successful in 9s
compat.yml's push paths listed tools/compat.py but not tools/compat_publish.py.
So the commit that taught the bot to refresh a stale report body fired no run, and
the report stayed stale until the next scheduled one -- caught by watching for the
run that never came. A fix nobody runs is a fix nobody has.
2026-07-14 03:06:44 +00:00
flan e426045255 An unchanged fingerprint froze the report's body, not just its comments
CI / python 3.11 (push) Failing after 13s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.12 (push) Failing after 12s
CI / python 3.13 (push) Failing after 13s
Two questions were sharing one answer.

  "Have the findings changed?" gates COMMENTS. They notify, and a daily "still
  broken, same as yesterday" is what teaches everyone to ignore the one that
  finally matters.

  "Is the body still true?" gates the BODY. Editing an issue body notifies nobody
  on either forge, so keeping it honest is free.

Conflated, an unchanged fingerprint froze the body -- and the fingerprint ignores,
by design, everything that moves on its own: healthy rows, the hardware-verified
column, point releases, and how a row is LABELLED. So the master -> 27-dev relabel
would have shipped to the README and never to the issue anybody actually opens.
The report would have gone on saying "master (unreleased) BROKEN" -- the precise
false alarm the relabel exists to kill -- until iX happened to break something
else.

The body is now rewritten whenever it is stale; comments stay strictly a changelog
of real changes. Bodies are compared after normalising line endings, because a
forge that round-trips \r\n would otherwise trigger a silent rewrite every run and
leave the issue looking freshly touched every morning.
2026-07-14 03:05:01 +00:00
flan 862bdd3399 master is 27-dev, not the next release; and check the next maintenance release
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Failing after 13s
CI / python 3.12 (push) Failing after 13s
TrueNAS compatibility / compat (push) Successful in 11s
CI / python 3.13 (push) Failing after 13s
Two ways the matrix misled the person it exists for -- somebody deciding whether
to trust this with their backups.

master is not the next release. iX branches each major onto its own release/ line
and master rolls straight on to the one after: every recent commit on master
targets 27.0.0-BETA.1 while 26 is still in beta. So a BROKEN master row, rendered
"master (unreleased)", read as "the version you are about to install is broken"
when the breakage is a major release away on a line nobody can download. It is now
labelled from the newest major in the matrix plus one, so it becomes 28-dev by
itself once 27 branches.

The break, for the record, is NAS-141498 (2026-06-24), "Convert cloud_backup
plugin to the typesafe pattern": it re-signatures restic_backup and
get_restic_config, splitting entry/credentials out of the cloud_backup dict. Not
being chased while the 27 line churns.

The next maintenance release was never checked -- and it is the one that reaches
users. Shipped came from TS-* tags, unreleased from release/* branches carrying
-BETA/-RC. A branched-but-untagged MAINTENANCE release is neither: release/25.10.5
has no tag, and its line has already shipped, so the "a prerelease of a shipped
line is history" filter threw it out. It was invisible, and it is exactly what a
25.10.4 box gets on its next update; a break in it reaches real users before the
daily check ever looks, on the only line anybody runs.

A plain release/X.Y.Z branch now counts when its line HAS shipped and it sorts
NEWER than that line's newest tag. Both exclusions fall out of the same rule:
release/24.10-RC.2 sorts older than TS-24.10.2.4 (history), and the typo branch
release/25.20.2.2 is on a line with no tag at all (not a release line). This
surfaced two refs never checked before -- release/25.10.5 and release/24.10.2.5 --
both of which pass.

is_unreleased() keys off where a ref came from (a branch is by construction not
shipped) rather than hunting -BETA/-RC in the name. Otherwise release/25.10.5
counts as shipped and a break in it fails the build as a live outage, on a version
nobody is running yet.
2026-07-14 03:03:21 +00:00
flan 52b11eada2 The bug-report bot re-filed itself every run on Gitea; nine copies
CI / python 3.12 (push) Failing after 13s
CI / python 3.13 (push) Failing after 13s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Failing after 7s
`find_issue()` skipped pull requests by testing for the PRESENCE of the
`pull_request` key. GitHub omits that key on a plain issue. Gitea sends it as
`null`. So on Gitea every issue was thrown away as if it were a PR, the lookup
came back empty on every run, and the bot took the "nothing filed yet" branch and
opened a brand-new report instead of editing the one already open.

Nine of them piled up on the canonical forge. Four were filed AFTER the commit
that was supposed to stop exactly this -- the same failure it was written to
prevent, moved from comments to issues. It survived because `find_issue` was the
one function here with no test; the mirror deduped correctly, and the mirror is
what anybody would have looked at.

Test the value, not the key: it is the only form that is true on both forges.
Pinned by tests that carry each forge's payload shape by hand.

Also send both paging parameters. GitHub reads `per_page`, Gitea reads `limit`,
and each ignores the other's; Gitea's default page is 30, so the lookup would
have begun missing the report once the pile it was creating outgrew one page.
2026-07-14 02:37:32 +00:00
flan 30b9f18166 release v0.7.0
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.13 (push) Failing after 16s
CI / python 3.12 (push) Failing after 17s
Release / release (push) Failing after 15s
CI / python 3.11 (push) Failing after 12s
TrueNAS compatibility / compat (push) Successful in 14s
2026-07-14 01:45:23 +00:00
flan 677c90481c docs: record the third-audit findings in the changelog 2026-07-14 00:39:40 +00:00
flan 0fea5c40bd docs: record the second-audit findings in the changelog 2026-07-14 00:21:06 +00:00
flan ce6998a935 fix: second audit — the delete check did nothing on a real box, and five guards were untestable
The most important finding is that the FIRST audit's fix was wrong.

_can_delete() asked `callable(getattr(service, "delete"))`. But CRUDService defines
`delete` on the BASE class and dispatches to self.do_delete at call time, so a bound
`delete` exists on every CRUDService subclass whether or not it still implements one.
The check was therefore answering "is this a CRUDService?" — precisely the weaker "is
the namespace registered?" question its own docstring said must never be asked. It
would still have picked a gutted pool.snapshot and failed every delete. It now walks
the MRO and ignores middlewared.service.* plumbing, so only a PLUGIN class defining
delete/do_delete counts. The test double was equally wrong: it modelled a gutted
service as object(), a shape middlewared cannot produce, so the test passed against a
fake it could never have caught in the field. It is now CRUDService-shaped.

Also:

- The recursive delete's fast path returned [] without confirming anything was
  destroyed. A delete that returns cleanly is not proof — iX has already gutted
  pool.snapshot.do_update on master into a no-op that returns None. cleanup_task read
  "no survivors" as a clean sweep, dropped the sidecar (the only record), and would
  have orphaned ~250 snapshots per run, silently. It confirms against ZFS now, and the
  by-name sweep trusts ZFS rather than the API's return value.

- When ZFS cannot be read, the sweep no longer claims success. The two mistakes are not
  symmetric: a false survivor self-heals (sidecar kept, next run reclaims, record
  clears), a lost record does not.

- _write_sidecar swallowed OSError. The sidecar is the only record the snapshots exist;
  failing to write it must never be invisible.

- stage_nested now refuses UP FRONT when middleware has no usable snapshot delete,
  rather than discovering it after restic has already run.

Tests. The autouse fixture added in the last commit did not work: `runner=_run`,
`mounts_file="/proc/self/mounts"` and `sleep=time.sleep` are frozen into __defaults__
at def time, so monkeypatching the module attribute never reached them. 19 tests were
still reading the real mount table — one matching name from running a real `umount` on
the NAS — and the retry loop really slept. All three are late-bound now; the suite
reads nothing outside tmp_path and runs in 1.1s.

Every mutation the audit reported as SURVIVING now fails the suite: the naive delete
check, the unconfirmed fast path, the malformed-row guard, a disconnected GC, eager
service resolution, compat's method check, compat's unknown handling, a single-quoted
filtered query in apply.sh, and the get-service assumption.

Also: fingerprint() folded `unknown` problems into a broken module, so one transient
429 rewrote the bug report and the next clean run rewrote it back. Problems are
state-tagged; only definite breakage is digested.

Verified on TrueNAS 26.0.0-BETA.1: zvol-orphan case 0 orphans, 292-dataset backup
0 orphans / 0 leaked mounts / 0 stale sidecars, byte-identical restore of a 4-deep
child dataset.
2026-07-14 00:10:10 +00:00
flan 605231b39f feat: TrueNAS 26 support; enumerate datasets and snapshots from ZFS, not middleware
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 13s
CI / python 3.13 (push) Successful in 17s
CI / python 3.12 (push) Successful in 18s
TrueNAS compatibility / compat (push) Successful in 9s
TrueNAS 26 deletes plugins/zfs_/ outright, taking the private zfs.dataset.query,
zfs.snapshot.query and zfs.snapshot.delete with it. All three were on the nested
module's critical path, so nested snapshots were BROKEN on 26.

Snapshot deletion now resolves its namespace at runtime: pool.snapshot on 25.10
and 26, zfs.snapshot on 24.10 and 25.04. No single namespace spans every supported
release. tools/compat.py checks the same list the runtime uses, so what CI verifies
and what runs cannot drift apart.

Enumeration does NOT move to pool.dataset.query / pool.snapshot.query, and that is
the point of this commit. Those methods exist, are documented, and are covered by
iX's deprecation policy — and they are not like-for-like replacements. They apply a
visibility policy that hides ix-apps/*, .system/* and .ix-virt/*: 84 of 270 datasets
on a real pool, including live application data. Staging from that view omits them
silently, and plan_staging never sees them, so they do not even reach the skipped
list. The snapshot query hides the same datasets' snapshots, so the sweep orphans one
per hidden dataset on every run.

So: read the truth from ZFS, make changes through middleware. zfs list cannot be
filtered by policy and behaves identically on every release. A failing zfs list raises
rather than returning an empty list — "no datasets" and "the command broke" must never
look the same.

No shipped release is affected: v0.6.1 and earlier use the private zfs.dataset.query,
which returns all 270 datasets. The bug existed only in this port.

Verified on a real TrueNAS 26.0.0-BETA.1 install: 274-snapshot recursive backup of a
292-dataset pool, zero orphaned snapshots, zero leaked mounts, and a byte-identical
restore of a four-level-deep child dataset that pool.dataset.query hides.
2026-07-13 22:43:20 +00:00
flan c6b252ac6b release v0.6.1
CI / shell (shellcheck + syntax) (push) Successful in 11s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 11s
Release / release (push) Successful in 14s
2026-07-13 19:59:38 +00:00
flan 841e0364fd CHANGELOG: repair a section spliced into the middle of a bullet, and guard it
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 16s
CI / python 3.12 (push) Successful in 15s
CI / python 3.13 (push) Successful in 14s
An edit matched the literal '## Unreleased' inside a backticked phrase in a prose
bullet and spliced a whole new section into the middle of it, splitting the sentence
in half. The release body IS this file, so that would have shipped to every user.

Tests now assert: no empty version section, versions descend, no heading is indented
inside a list item, and every bullet's bold phrases are balanced (ignoring code spans
-- '*args, **kwargs' is a literal, not markup).
2026-07-13 19:59:35 +00:00
flan 0d04c2cd1c Collect orphaned snapshots by name: the sidecar lives in tmpfs
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 16s
CI / python 3.13 (push) Successful in 16s
A reboot mid-backup orphaned the entire tree, permanently. The sidecar is the record
of which snapshots a run pinned -- and /run is tmpfs. A reboot or crash between the
recursive snapshot and its cleanup destroyed that record, leaving one snapshot per
descendant dataset (250+ on a real pool) with nothing pointing at them. Nothing would
ever have found them.

gc_stale_snapshots() identifies leftovers by NAME, so it works when the record is
gone. It runs after the sidecar reclaim -- the recorded path stays authoritative and
the collector only mops up what the record lost.

It deletes data on a name match, which is a weaker claim than a recorded fact, so the
selection is a pure function with the harshest tests here. A snapshot is collected
only if the name is exactly <dataset>@<task>-<YYYYMMDDHHMMSS>, it is not the current
run's, NOTHING IS MOUNTED FROM IT (this, not the age guard, is what protects a
concurrent backup), and it is over an hour old.

Checked against the real pool: of 4728 snapshots including 2341 periodic ones, it
selects exactly the orphans of the task being run and nothing else.
2026-07-13 19:56:32 +00:00
flan 2b8ef107f7 The sidecar must carry EVERY pending tree, not just the newest
CI / shell (shellcheck + syntax) (push) Successful in 9s
CI / python 3.12 (push) Successful in 13s
CI / python 3.13 (push) Successful in 17s
CI / python 3.11 (push) Successful in 15s
TrueNAS compatibility / compat (push) Successful in 11s
Release / release (push) Successful in 15s
Found live, in the code written to prevent exactly this.

The sidecar held ONE snapshot. So a run that reclaimed an older tree, failed to
finish reclaiming it, and then recorded its own snapshot OVERWROTE the only record of
the survivor -- orphaning it permanently.

Observed: job 24 left one snapshot busy and kept the sidecar (correct). Job 46
reclaimed it, hit ZFS's 300s automount window (the runs were minutes apart), left it
behind again, and then wrote its own snapshot over the record. Permanent orphan,
created by the safety net.

The sidecar is now a list. stage_nested carries forward whatever a reclaim could not
delete; cleanup_task sweeps every pending tree and writes back only the survivors.
cleanup_all reports them one per line instead of formatting a list into an f-string
at the user during uninstall.

Job 46 also confirms the automount fix itself: it swept all 256 of its own snapshots
with no straggler.
2026-07-13 19:35:44 +00:00
flan b50567e9a7 A few snapshots leaked on every nested run, forever
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 12s
CI / python 3.12 (push) Successful in 15s
CI / python 3.13 (push) Successful in 17s
TrueNAS compatibility / compat (push) Successful in 13s
Release / release (push) Successful in 14s
Found on real hardware: a 256-snapshot backup of /mnt/Tap swept 253 and left 3 with
'dataset is busy'.

ZFS AUTOMOUNTS <dataset>/.zfs/snapshot/<snap> when it is read, and keeps it mounted
for zfs_expire_snapshot seconds (300 default) after the last access. teardown()
unmounts OUR bind mounts but not the automount underneath, so zfs destroy refuses for
exactly the datasets restic read most recently. cleanup_task() then removed the
sidecar anyway -- destroying the only record those snapshots existed. Nothing would
ever have reclaimed them.

- release_snapdirs() unmounts ZFS's own automounts (deepest first) before deleting.
- delete_snapshot_tree() retries the transient busy and RETURNS what it could not
  delete, instead of swallowing it.
- The sidecar is removed only on a confirmed-clean sweep -- including on the
  staging-failure path, which used to remove it before the caller swept. A sidecar
  left behind when the tree is gone costs one no-op delete; a sidecar removed while
  the tree exists is unrecoverable.
2026-07-13 19:18:22 +00:00
flan 469a2e4651 Installing the patch permanently blocked updating it
CI / shell (shellcheck + syntax) (push) Successful in 9s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 16s
CI / python 3.13 (push) Successful in 17s
TrueNAS compatibility / compat (push) Successful in 11s
Release / release (push) Successful in 14s
install.sh chmod +x's update.sh, and git recorded update.sh as 100644 -- so the chmod
was a TRACKED modification, and update.sh refuses to run over a dirty tree. Install
once and you could never update again. The error even told you to 'git checkout -- .',
which just undoes the exec bit so the next install can re-dirty it.

Found on the real box, which had been sitting on v0.4.1 for exactly this reason.

Fixed on both sides: the scripts install.sh chmods are executable in git (so the
chmod is a no-op), and update.sh's dirty check now looks at CONTENT, not mode --
git diff --numstat reports 0 0 for a mode-only change. A test asserts every script in
install.sh's chmod loop is already 100755 in git.
2026-07-13 18:58:46 +00:00
flan c250bc8f5d release v0.6.0
CI / shell (shellcheck + syntax) (push) Successful in 9s
CI / python 3.12 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 13s
Release / release (push) Failing after 13s
CI / python 3.11 (push) Successful in 13s
CI / python 3.13 (push) Successful in 13s
2026-07-13 18:35:14 +00:00
flan 1c46ef66b0 CHANGELOG: merge the duplicate Added/Fixed sections in Unreleased
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 17s
CI / python 3.13 (push) Successful in 17s
Incremental edits had produced two of each. The release body IS this section, so a
duplicated heading is what users would have read.
2026-07-13 18:35:11 +00:00
flan 252696f1de docs: split the 969-line README; put Install at the top
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.13 (push) Successful in 14s
CI / python 3.12 (push) Successful in 14s
CI / python 3.11 (push) Successful in 13s
Install was at line 517 of 969, under the boot sequence, the snapshot lifecycle and
the release process. Someone deciding whether to trust this with their backups should
not have to scroll past any of that.

README is now 211 lines: what it does, the minimum version, install, the support
matrix, updating, uninstall. Everything else moved to docs/ (nested snapshots, how it
works, recovery, CLI, releasing).

A test enforces it: every internal link resolves, Install stays near the top, and the
README does not grow back. Moving Markdown breaks cross-references -- it broke eight
of them here, including one in a recovery doc, where the person following the link is
by definition already having a bad day.
2026-07-13 18:34:44 +00:00
flan a1e31e9c0a CHANGELOG: the barrier does not check the candidate's CI run
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 15s
CI / python 3.11 (push) Failing after 5s
CI / shell (shellcheck + syntax) (push) Successful in 7s
That gate was removed as redundant -- the release job re-runs the full suite against
the tagged commit, and release_gate proves a candidate points at it. The notes
described a check that does not exist.
2026-07-13 18:30:59 +00:00
flan a54b4dd7f6 Never touch CloudSync tasks; restore the logger the async cleanup path dropped
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 14s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.13 (push) Successful in 13s
create_snapshot is module-global in plugins/cloud/snapshot.py, and cloud_sync.py
imports it as well as cloud_backup/sync.py. So the wrapper sat in the path of every
rclone/Storj CloudSync task with snapshot=true, and ran a zfs.dataset.query before
concluding it had nothing to do -- a new failure mode for jobs that worked before
this patch existed.

Worse: a CloudSync task that ever got staged would never be torn down. The teardown
is wired into cloud_backup's restic_backup finally, and CRUD_BLOCK deliberately
leaves CloudSync's guard intact, so the bind mounts would pin the snapshot forever.
The staging path now bails out unless the snapshot is named cloud_backup-*, before
any middleware call.

Separately: the async wrapper's finally dropped logger=, which the sync one passes.
run_in_thread forwards **kwargs, so a cleanup that failed to unmount a bind mount or
delete a snapshot tree logged nothing at all -- on the only platform anyone runs.
2026-07-13 18:30:35 +00:00
flan ecb64878ff compat: check the middlewared METHODS we call, not just the symbols we wrap
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 16s
CI / python 3.13 (push) Successful in 17s
TrueNAS compatibility / compat (push) Successful in 10s
This gap was hiding a catastrophe. TrueNAS 26 deletes plugins/zfs_/dataset.py and
plugins/zfs_/snapshot.py outright, taking zfs.dataset.query, zfs.snapshot.query and
zfs.snapshot.delete with them (26 uses filesystem.statfs and zfs.resource.*).

Nothing about the five cloud_backup files reveals that, so every other check went
green -- including the one I had just added. The patch would have applied cleanly
and then failed on the first backup, or worse: snapshotted fine and failed to
DELETE, orphaning one snapshot per descendant dataset (250 on a real pool) on every
run, forever.

So 26 is BROKEN and the nested module will not apply there. The async/sync wrapper
work and the vendored get_dataset_recursive stay -- they are correct and necessary
-- but 26 is not supported until the ZFS calls are ported, and that needs a real 26
box to verify. Shipping a port nobody has run is the failure this project exists to
avoid.

Also: do_delete is recognised as delete (24.10/25.04 use the CRUDService
convention), which was reporting both as BROKEN -- a false verdict that would have
disabled nested snapshots on boxes where they work.
2026-07-13 18:25:14 +00:00
flan 498b2690e1 TrueNAS 26 support: one sync implementation, two wrappers
CI / python 3.13 (push) Successful in 15s
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 16s
TrueNAS compatibility / compat (push) Successful in 9s
26 rewrites cloud_backup from async to synchronous AND deletes
get_dataset_recursive(), which SNAPSHOT_BLOCK called out of the host module's
namespace. Either is a broken backup found at restore time.

The nested module is now one synchronous implementation talking to middlewared via
call_sync, behind two thin wrappers. apply.sh reads which flavour the installed
middleware declares and injects the matching one: <= 25.10 reaches it through
'await middleware.run_in_thread(...)', 26 is already in a worker thread and calls
it directly. The snapshot/bind-mount/failure logic exists once -- an async twin
would mean every future fix had to land twice.

A middleware whose three wrapped functions disagree about asyncness is refused,
not guessed at. get_dataset_recursive is vendored, removing the dependency on both
versions rather than asserting it.

master stays BROKEN on purpose: iX are still renaming middleware->context,
cloud_backup->entry and adding a required credentials param there. Chasing a
branch that moves daily is how you ship a patch nobody tested.
2026-07-13 18:18:28 +00:00
flan cf2c6a8a02 README: state and enforce the 24.10 minimum; document the compat preflight
CI / python 3.11 (push) Successful in 16s
CI / python 3.13 (push) Successful in 15s
CI / shell (shellcheck + syntax) (push) Successful in 13s
CI / python 3.12 (push) Successful in 16s
TrueCloud Backup does not exist before 24.10, so on anything older the patch would
attach to nothing and do nothing -- silently, while the user believed their backups
were set up. install.sh now reads system.version and refuses, naming the reason.

Also: the boot sequence now documents the preflight (and that an incompatible module
is skipped for one boot, NOT kill-switched); the troubleshooting table covers the
incompatibility warning; forge URLs point at Gitea.
2026-07-13 18:09:23 +00:00
flan ea090c7f72 Audit fixes: a compat verdict must never be able to brick a working box
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 12s
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 38s
The audit found the new machinery could do more harm than the bugs it prevents.

- apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT
  kill switch, cleared only by install.sh, never by update.sh -- for the
  incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go
  quiet, so the switch would fire and the release that fixed 26 could never
  re-enable itself. Retirement and incompatibility now take different exits.
- A network blip, a re-export, or a conditional def all read as BROKEN. Each is
  now 'unknown', which changes nothing, rather than evidence strong enough to
  disable a module.
- 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded
  the guard AND reshaped the functions rendered as good news.
- compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the
  documented way to check a live box lied on every patched machine.
- The signature check was a name-subset test. It passed reorders, kw-only
  conversions, and added required params -- and it had already passed a real bug:
  restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested
  backups have been raising TypeError on those releases the whole time. The
  wrapper now forwards *args/**kwargs.
- release.sh --promote was unreachable: it died if the tag existed, the gate died
  if it did not. The tests hid it by always tagging first.
2026-07-13 17:58:15 +00:00
flan f927773f81 docs: TrueNAS compatibility matrix and the two-stage release process
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 11s
CI / python 3.13 (push) Successful in 14s
The matrix is regenerated daily by CI rather than typed once and forgotten — a
support table that quietly goes stale is a false promise to someone deciding
whether to trust this with their backups.
2026-07-13 17:35:03 +00:00
flan 4ced730d65 Make Gitea canonical; derive the changelog URL from the remote instead of hard-coding GitHub 2026-07-13 17:21:30 +00:00
flan bf6d37e621 v0.5.1: the update alert could have broken middlewared at startup
middlewared's alert.load() imports every file in alert/source/ with NO try/except:

    def load(self):
        for module in load_modules(.../alert/source):
            for cls in load_classes(module, AlertSource, (ThreadedAlertSource,)):
                ...

and it runs during setup. A module that raises on import therefore takes
middlewared's startup down with it -- exactly the class of failure this project
exists to avoid.

apply.sh now COMPILES the substituted alert source and refuses to write it if it
does not parse. An uninstalled alert is a missing convenience; a broken one is a
broken box.

@PATCH_DIR@ is also substituted with repr() rather than raw, so a repository path
containing a quote or backslash yields a valid Python literal instead of a syntax
error in the installed module.

The alert source no longer mutates sys.path. It loaded tools/release_notes.py via
sys.path.insert(0, ...), which shadows the stdlib for that interpreter -- and
ThreadedAlertSource runs in middlewared's thread pool, so mutating sys.path is a
race. It now loads by file path with importlib.

New tests guard every import-time failure mode: the module compiles, apply.sh
compiles before writing, awkward paths (quotes, backslashes) still produce valid
modules, nothing but imports/constants/classes runs at module scope, every
AlertClass name ends in "AlertClass" (AlertClassMeta raises NameError otherwise),
the alert text placeholders match the args passed, and no git command that writes
to .git is ever used.

152 tests, ruff and shellcheck -S style clean.
2026-07-13 16:52:23 +00:00
flan 4e0814028c v0.5.0: TrueNAS alert when an update is available
Raises a real alert in the TrueNAS UI bell -- not a log line nobody reads. On by
default, checked once a day. install.sh --no-update-alerts turns it off.

It does not nag
---------------
A release whose CHANGELOG contains only a "### Docs" section changed no code and
raises nothing. Anything else raises INFO; a "### Security" section raises WARNING.

The CHANGELOG's own section headings are the signal, and a security fix anywhere in
the range escalates the whole span -- a docs-only release sitting on top of a
security fix still reports as security rather than hiding it.

Why an AlertSource and not midclt
----------------------------------
TrueNAS cannot raise an alert from the CLI. midclt exposes only alert.dismiss,
alert.list, alert.list_categories, alert.list_policies and alert.restore -- alert
CREATION is internal to middlewared, and none of its ~60 one-shot classes is
generic enough to reuse. Registering an AlertSource is the only way.

It is also the least invasive thing this patch does. The providers and nested
modules both APPEND CODE TO STOCK middleware files; the alert source ADDS ONE FILE
and modifies none. It is the native mechanism -- the same one every built-in
TrueNAS alert uses -- and TrueNAS polls it itself, so there is no cron job and no
systemd timer.

- Fail-safe: every error path returns None; it cannot take middlewared down.
- Read-only: `git ls-remote` plus an HTTPS fetch of the CHANGELOG. It never writes
  to .git, so it cannot leave root-owned objects behind the way a `git fetch` from
  middlewared (running as root) would.
- Removed by uninstall.sh (mw_patch.revert_all).
- It only tells you; it never updates anything.

Verified against the real repo and remote, with middlewared stubbed:
  on v0.4.1, only a README-only v0.4.2 available -> NO ALERT
  on v0.4.0, v0.4.1 fixed real bugs             -> INFO
  on v0.3.2, v0.3.3 was the password fix        -> SECURITY / WARNING

139 tests, ruff and shellcheck -S style clean.
2026-07-13 16:45:26 +00:00
flan 345741e1f1 v0.4.2: README — document updating properly
The Updating section told you to run update.sh, but never said how to GET it. It
ships inside the patch, so any clone older than v0.4.0 does not have it -- the docs
described a script the reader did not possess. There is now an explicit bootstrap
step, including the fix for the "insufficient permission for adding an object to
repository database" failure that past `sudo git pull`s leave behind.

Rewrote "After a TrueNAS update". It never explained that apply.sh re-applies the
patch at every boot (so you never reinstall), and it soft-pedalled what a failure
costs: "fail-safe" means the BOX stays up, not that your backups keep running. A
[FAIL] providers is a broken backup, and the docs now say that plainly instead of
implying everything degrades gracefully.

Added a repo map -- patch/mw_patch.py and tools/release_notes.py were documented
nowhere -- and fixed `ruff check patch tests`, which skips tools/.

Every command and file path in the README was then verified to exist and run:
all five update.sh flags, the ruff/pytest commands, every file in the repo map,
and the truecloud_nested.py cleanup CLI.

132 tests, ruff and shellcheck -S style clean.
2026-07-13 16:36:28 +00:00
flan 092bdeae29 v0.4.1: fix three real bugs in update.sh found by auditing it
Release-candidate tags would have been installed as stable
-----------------------------------------------------------
git's version sort ranks v0.5.0-rc1 ABOVE v0.5.0 (verified empirically), and the
release workflow deliberately supports rc/beta/alpha tags. update.sh would have
offered an RC as "the newest release". Tag selection is now filtered to plain
vX.Y.Z.

update.sh would have died mid-update on an untracked file
----------------------------------------------------------
The dirty-tree guard uses --untracked-files=no, so an untracked file that the
TARGET tracks slips past it -- and `git checkout` then aborts. Under set -e the
script died with a raw git error, after already recording the rollback point.

Not hypothetical: a hand-copied patch/wait_restart.sh blocked a pull on a real box
in exactly this way. It is now detected up front, by name. Gitignored files are
correctly not treated as blockers, since git overwrites those silently.

Special case: if update.sh ITSELF is the blocker, it was hand-copied in to
bootstrap -- and "delete update.sh, then re-run update.sh" is impossible. It now
says so and prints the git commands that bootstrap it properly.

--rollback skipped that check entirely and would have hit the identical failure.
The check is now a shared function used by both paths, and rollback also validates
that the recorded revision still exists.

Also: install.sh's chmod aborted under set -e if a listed file was missing (the
file set changes between versions, so --rollback must not be killed by a name this
version happens to know about), and --to with no value was silently ignored.

Verified end to end in a throwaway clone: forward v0.4.1 -> v0.4.2 and rollback
back, with files appearing and disappearing correctly; both guards fire.

132 tests, ruff and shellcheck -S style clean.
2026-07-13 16:30:50 +00:00
flan 347c415aa7 v0.4.0: add update.sh
Fetch a newer release and apply it, preserving the nested-snapshot opt-in setting.

  bash update.sh              # to the newest release, with a confirmation
  bash update.sh --check      # show what would happen; change nothing
  bash update.sh --rollback   # undo the last update

Deliberately NOT automated. This patch injects Python into middlewared and
re-applies itself at every boot, so an unattended pull would let any bad upstream
commit reach a box with no human in the loop and take effect on the next reboot.
v0.0.4 shipped exactly such a bug and took every app on the box down. The manual
step is the safety gate.

Design decisions worth keeping:

- Defaults to the newest RELEASE TAG, not main. main can be mid-refactor; a tag is
  the tested artifact. --main exists but says so loudly.
- Tags ordered by version, not date. Date order silently downgrades the box the
  first time a hotfix is tagged out of band: a v0.3.6 cut after v0.4.0 would sort
  as "newest".
- Refuses to run over a dirty working tree rather than merging across hand-edited
  or scp'd files. (Verified: the guard fires.)
- Shows the commits and release notes you do not have, read from the TARGET's
  CHANGELOG via tools/release_notes.py -- not a second copy of the extractor.
- Records the previous revision BEFORE moving, so --rollback works even if
  install.sh dies halfway.
- Repairs .git ownership, which past `sudo git pull`s leave root-owned and which
  then breaks every later non-root git command.

update.sh is covered by the version-drift check, so it cannot go stale the way
create_task.py's __version__ did.

Tested end to end in a throwaway clone: detects v0.3.2 -> v0.3.5, lists missing
commits, handles already-up-to-date, and the dirty-tree guard fires.

132 tests, ruff and shellcheck clean.
2026-07-13 16:20:37 +00:00
flan 45f957af23 v0.3.5: log the recursive-delete failure instead of swallowing it
delete_snapshot_tree tries one recursive delete first, then falls back to sweeping
the tree by name. The exception from the fast path was discarded.

That failure is usually benign -- stock's finally already removed the parent once
our mounts were released, which is exactly what the sweep exists to handle. But if
the cause were anything else, this was the only place it was ever visible, and it
went straight to /dev/null. The sweep would then report some different, downstream
symptom. It is now logged before falling through.

Also annotated the two remaining static-analysis findings as considered rather than
leaving them to be re-litigated every audit: subprocess is always invoked in list
form (no shell, so ZFS dataset names cannot inject), and a partial `systemctl` path
is moot in a script that only ever runs as root.

Extended ruleset (E,F,W,B,S,SIM,UP,C4,RET,ARG,A,ISC) and shellcheck -S style both
report zero. 132 tests.
2026-07-13 16:06:12 +00:00
flan 126756498c v0.3.4: one implementation of apply/revert (patch/mw_patch.py)
The "strip the TRUECLOUD_PATCH block" logic existed twice -- in apply.sh's heredoc
and in an inline heredoc in uninstall.sh -- and the uninstall copy was the untested
one. That is precisely how the two could have drifted apart, with apply.sh
reverting one set of files and uninstall.sh another.

Both now call patch/mw_patch.py. 17 new tests cover it, including that
revert_nested never touches restic.py: that file carries a TRUECLOUD_PATCH block
too, but it belongs to the providers module, and removing it would silently break
B2 backups.

apply.sh imports it fail-safe -- on ImportError the backend patch is skipped and
middlewared starts stock, which is this script's whole design principle. The
import uses sys.path.append, never insert(0): prepending would give patch/
precedence over the stdlib for that interpreter, so a future patch/json.py would
shadow the real json module and break the boot.

Also: the README's create_task.py example still taught `--password <secret>`, which
is how a security fix quietly fails to land. It now shows --password-stdin.

132 tests, ruff and shellcheck clean.
2026-07-13 16:02:53 +00:00
flan 60b3ac4557 v0.3.3: keep the restic repo password out of argv and shell history
Security
--------
create_task.py shelled out to `midclt call cloud_backup.create '<json>'`, and that
JSON carries the restic repository password -- so it sat in the subprocess's argv,
which is world-readable via ps, for the duration of the call. That password is the
encryption key for the entire cloud backup repository.

It now talks to the middleware through truenas_api_client, the library that backs
midclt itself, so the password never leaves this process's memory. Verified on a
live box: list-tasks and list-credentials work through the new transport.

--password is also no longer required, because passing a secret as a CLI argument
writes it to shell history permanently. --password-stdin reads it from stdin, and
with neither flag the tool prompts via getpass. --password still works but warns.

Fixed
-----
uninstall.sh could leave every patch installed. It reverted by unmounting the
overlay -- but apply.sh only mounts one when the target directory is read-only. On
a writable /usr it patches the real files in place, and uninstall would remove the
boot hook, report success, and leave the patch applied. It now strips the appended
blocks from the middleware files explicitly. This also covers the case where the
overlay unmount fails.

create_task.py's __version__ had been stuck at 0.2.0 through three releases. The
version-drift check added in v0.3.1 only looked at VERSION= in shell scripts, so it
missed the one file that actually shows a version to users (--version). The check
now covers __version__ too -- and caught this immediately.

118 tests, ruff and shellcheck clean.
2026-07-13 15:43:24 +00:00
flan 8aa9038226 Fix: --disable-nested-snapshots did not disable anything until reboot
apply.sh only ever ADDED patches; there was no revert path anywhere. Disabling
removed the opt-in marker and then merely skipped re-applying -- but the overlay
persists for the whole boot, so the previously patched cloud/{snapshot,crud}.py,
cloud_backup/sync.py and _truecloud_nested.py were all still on disk, and
middlewared re-imported them on the restart install.sh performs.

It printed "DISABLED (stock guard restored)" while the feature kept running until
the next reboot. Someone disabling it because they were worried about it would
have believed it was off.

apply.sh now reverts on every not-needed path (opt-out, or superseded by native
support): remove the module FIRST -- every injected block is guarded by
`if _tc_nested is not None`, so the stock guard comes back even if a later step
fails -- then strip the appended blocks from the three patched files.

restic.py also carries a TRUECLOUD_PATCH block but belongs to the providers
module; reverting it would silently break B2 backups, so it is explicitly
excluded. Verified: the three nested files restore byte-for-byte to stock, the
module is removed, and restic.py's block survives.

install.sh --disable also tears the staging tree down first, since those bind
mounts pin ZFS snapshots that could otherwise never be destroyed.

Updating WITHOUT the flag was always correct and is unchanged: the nested module
is never installed into middleware unless explicitly enabled.

109 tests, ruff and shellcheck clean.
2026-07-13 15:23:27 +00:00
flan ba533dc8ae v0.3.1: automated releases + version-drift check
The "Automated releases" entry was written under v0.3.0, but that commit landed
after the v0.3.0 tag -- the CHANGELOG was claiming the release contained something
it did not. Moved to its own version rather than left as a quiet inaccuracy.

Bumps VERSION to 0.3.1 across all four scripts, which the new consistency check
now enforces.
2026-07-13 15:07:29 +00:00
flan eb91a337cd Automate releases from tags
Releases were manual and had drifted: v0.2.0 and v0.2.1 were tagged but never
released, so the releases page jumped v0.1.0 -> v0.3.0 and hid the fix for the
incident that took every app down.

Pushing a v* tag now runs the full suite and cuts a GitHub release whose body is
the matching CHANGELOG.md section -- one source of truth for release notes, so
there is no second place for them to be wrong.

The workflow refuses to publish when:
  - the tests, ruff, or bash -n fail (a tagged commit is what people install; it
    must be at least as good as main)
  - the tag does not match the VERSION= declared by every script
  - CHANGELOG.md has no section for the tag, or the section is empty

That version check is not theoretical: VERSION= had drifted to three different
values across install.sh / uninstall.sh / recover.sh / apply.sh and nothing
noticed until this release. tests/test_release_notes.py now asserts the scripts
agree with each other and with the newest CHANGELOG entry, so the drift cannot
come back.

workflow_dispatch takes an existing tag, so releases can be backfilled for tags
that were pushed before this existed.

106 tests, ruff and shellcheck clean.
2026-07-13 15:05:46 +00:00
flan f3ea6b301c CHANGELOG: set v0.3.0 release date 2026-07-13 15:02:26 +00:00
flan 8aae261018 Nested snapshots validated in production; drop the untested caveat
An unattended scheduled backup of a live 252-dataset pool ran through the staging
tree end to end:

  task 5  /mnt/Tap  SUCCESS  18m14s

- 252 datasets recursively snapshotted; 173 bind mounts built and verified
- zero orphaned ZFS snapshots and zero stale mounts afterwards -- the failure
  that would otherwise have accumulated 251 snapshots on every single run
- the same task previously stalled at 74% for over 12 hours reading live files

The README said the mount --bind staging step had not been exercised by a live
backup run. That is no longer true, so it is removed rather than left to
understate the state of the code.

The advice to verify your own first backup actually contains child-dataset data
stays -- that one is not boilerplate.
2026-07-13 14:52:41 +00:00
flan 8421a34d8d Delete the snapshot tree atomically instead of 252 calls
delete_snapshot_tree removed the parent and every child snapshot individually.
On a real pool `zfs snapshot -r` creates one snapshot per descendant dataset --
252 on Tap -- so cleanup was 252 sequential middleware calls.

Slow, but the real problem is that it is not atomic: a job killed part-way
through the sweep leaves behind exactly the orphaned snapshots this function
exists to prevent.

zfs.snapshot.delete accepts {"recursive": True}, which destroys the parent and
all children in one call. Use that as the fast path and keep the name-by-name
sweep as the fallback -- it is still needed when the parent is already gone
(stock's finally can win the race once our mounts are released), which makes a
recursive delete fail while the children survive.

The test fake now emulates real `zfs destroy -r` semantics, so a test cannot pass
while the shipped code deletes only the parent.

76 tests, ruff and shellcheck clean.
2026-07-13 14:35:04 +00:00
flan 150a241a0f Fix native-nested probe: guard message is split across string literals
The probe searched plugins/cloud/crud.py for the contiguous string
"no further nesting". Stock does not contain it. The message is split across
adjacent string literals:

    verrors.add(f"{name}.snapshot", "This option is only available for datasets that have no further "
                                    "nesting")

Python concatenates those at runtime, so the errmsg IS contiguous and CRUD_BLOCK's
runtime filter matches correctly -- but the SOURCE never contains the whole
phrase. The probe therefore found nothing, concluded iX had removed the guard, and
skipped the nested module as "already native" on every boot. apply.log would say
"TrueNAS now handles nesting natively" and the feature would never work.

It fails safe -- the stock guard stays in place, so no backup could be
misconfigured and no data was at risk -- but the module was 100% dead.

Verified against real middlewared on a live box: the probe returned native=yes
(wrong) before this change and native=no (correct) after.

The probe now strips whitespace and quote characters before matching, which is
robust to any wrapping or concatenation style. Added a regression test that
EXECUTES apply.sh's own probe code (extracted, not reimplemented -- a
reimplementation would pass while the shipped probe stayed broken) against the
real wrapped source, a single-line variant, and a three-way split.

75 tests, ruff and shellcheck clean.
2026-07-12 22:44:26 +00:00
flan 24f1f2c648 Fix post-merge audit findings; unify staging teardown
Audit
-----
- create_task.py verify failed on a DEFAULT install. hook_status.json emitted a
  per-file entry for the nested module with ok:false whenever the feature was
  switched off -- the default -- so verify printed [FAIL] and exited 1, right
  after the README tells users to run it. Status is now per MODULE with an
  `active` flag, and verify renders an inactive module as [SKIP].

- A partial apply suppressed the middlewared restart. The exit code conflated
  "nothing applied" with "one module applied, one failed", so a failing providers
  patch would prevent the restart that a freshly-applied nested patch needs,
  leaving it on disk and never loaded. Exit 2 now means partial and the restart
  still fires.

- The native-nested probe could never fire. It scanned crud.py for the guard
  message, but our own injected block quotes that message, so once applied the
  probe would always conclude the guard was still present. It now reads only the
  stock portion of the file.

- recover.sh did not unmount staging trees, so an emergency recovery left bind
  mounts pinning ZFS snapshots that could then never be destroyed.

- uninstall.sh deleted sidecar files without reading them. A sidecar is the only
  record that an interrupted run's snapshot tree is still on disk; both scripts
  now name the snapshot before clearing it.

- Removed a dead branch in the restart gate (unreachable: the kill switch exits).

Refactor
--------
- Staging teardown had been copy-pasted into uninstall.sh and recover.sh -- two
  untested shell copies of the fiddly depth-ordering and lazy-umount logic. Both
  now call `python3 patch/truecloud_nested.py cleanup`, so there is exactly one
  implementation and it is the one under test.

- Dropped the in-memory ACTIVE dict. The sidecar file was already the source of
  truth; a second in-process record could only desync -- and it is precisely the
  middlewared-restart case (which empties it) that must not orphan a snapshot
  tree. One record, on disk, or none.

Not done: the overlay-unmount loop is duplicated across apply.sh/uninstall.sh/
recover.sh. It is pre-existing, and apply.sh runs at PREINIT under a tight
timeout -- giving it a source dependency would trade 10 lines of duplication for
a boot-time failure mode.

74 tests, ruff and shellcheck clean.
2026-07-12 22:29:31 +00:00