-
v0.6.1
StableCI / shell (shellcheck + syntax) (push) Successful in 9sCI / python 3.11 (push) Successful in 13sCI / python 3.13 (push) Successful in 16sCI / python 3.12 (push) Successful in 16sTrueNAS compatibility / compat (push) Failing after 6sRelease / release (push) Successful in 16sreleased this
2026-07-13 16:42:22 -04:00 | 28 commits to main since this releaseFixed
-
A reboot mid-backup orphaned the entire snapshot tree, permanently. The sidecar
is the record of which snapshots a run pinned — and it lives in/run, which is
tmpfs. A reboot (or a crash) between taking the recursive snapshot and cleaning
it up destroyed that record, leaving one snapshot per descendant dataset — 250+ on
a real pool — with nothing left pointing at them. Nothing would ever have found
them again.gc_stale_snapshots()is the backstop: it identifies leftovers by name, so it
works when the record is gone. It runs at the start of every backup, after the
sidecar reclaim — the recorded path stays authoritative, and the collector only ever
mops up what the record lost.Because it deletes data on a name match — a weaker claim than a recorded fact — the
selection is a pure function with the harshest tests in the suite. A snapshot is
collected only if all of these hold:name is exactly <dataset>@<task>-<YYYYMMDDHHMMSS>so cloud_backup-5never matchescloud_backup-50, anauto-*periodic snapshot, or anything a human madeit is not the current run's parent and children are excluded nothing is mounted from it an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup it is over an hour old covers the seconds-long window where a live run has snapshotted but not yet mounted Verified against the real pool: of 4,728 snapshots — including 2,341 periodic
ones — it selects exactly the orphans of the task being run, and nothing else.
Downloads
-
-
v0.6.1-rc2
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 9sCI / python 3.11 (push) Successful in 13sCI / python 3.13 (push) Successful in 16sCI / python 3.12 (push) Successful in 16sTrueNAS compatibility / compat (push) Failing after 6sRelease / release (push) Successful in 16sreleased this
2026-07-13 16:40:46 -04:00 | 28 commits to main since this releaseFixed
-
A reboot mid-backup orphaned the entire snapshot tree, permanently. The sidecar
is the record of which snapshots a run pinned — and it lives in/run, which is
tmpfs. A reboot (or a crash) between taking the recursive snapshot and cleaning
it up destroyed that record, leaving one snapshot per descendant dataset — 250+ on
a real pool — with nothing left pointing at them. Nothing would ever have found
them again.gc_stale_snapshots()is the backstop: it identifies leftovers by name, so it
works when the record is gone. It runs at the start of every backup, after the
sidecar reclaim — the recorded path stays authoritative, and the collector only ever
mops up what the record lost.Because it deletes data on a name match — a weaker claim than a recorded fact — the
selection is a pure function with the harshest tests in the suite. A snapshot is
collected only if all of these hold:name is exactly <dataset>@<task>-<YYYYMMDDHHMMSS>so cloud_backup-5never matchescloud_backup-50, anauto-*periodic snapshot, or anything a human madeit is not the current run's parent and children are excluded nothing is mounted from it an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup it is over an hour old covers the seconds-long window where a live run has snapshotted but not yet mounted Verified against the real pool: of 4,728 snapshots — including 2,341 periodic
ones — it selects exactly the orphans of the task being run, and nothing else.
Downloads
-
-
v0.6.1-rc1
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 11sCI / python 3.11 (push) Successful in 13sCI / python 3.12 (push) Successful in 14sCI / python 3.13 (push) Successful in 13sTrueNAS compatibility / compat (push) Successful in 11sRelease / release (push) Successful in 14sreleased this
2026-07-13 15:59:38 -04:00 | 30 commits to main since this releaseFixed
-
A reboot mid-backup orphaned the entire snapshot tree, permanently. The sidecar
is the record of which snapshots a run pinned — and it lives in/run, which is
tmpfs. A reboot (or a crash) between taking the recursive snapshot and cleaning
it up destroyed that record, leaving one snapshot per descendant dataset — 250+ on
a real pool — with nothing left pointing at them. Nothing would ever have found
them again.gc_stale_snapshots()is the backstop: it identifies leftovers by name, so it
works when the record is gone. It runs at the start of every backup, after the
sidecar reclaim — the recorded path stays authoritative, and the collector only ever
mops up what the record lost.Because it deletes data on a name match — a weaker claim than a recorded fact — the
selection is a pure function with the harshest tests in the suite. A snapshot is
collected only if all of these hold:name is exactly <dataset>@<task>-<YYYYMMDDHHMMSS>so cloud_backup-5never matchescloud_backup-50, anauto-*periodic snapshot, or anything a human madeit is not the current run's parent and children are excluded nothing is mounted from it an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup it is over an hour old covers the seconds-long window where a live run has snapshotted but not yet mounted Verified against the real pool: of 4,728 snapshots — including 2,341 periodic
ones — it selects exactly the orphans of the task being run, and nothing else.
Downloads
-
-
v0.6.0
StableCI / shell (shellcheck + syntax) (push) Successful in 9sCI / python 3.12 (push) Successful in 13sCI / python 3.13 (push) Successful in 17sCI / python 3.11 (push) Successful in 15sTrueNAS compatibility / compat (push) Successful in 11sRelease / release (push) Successful in 15sreleased this
2026-07-13 15:46:11 -04:00 | 33 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
A few snapshots leaked on every nested run, forever. Found on real hardware, in
the one place it could be: a 256-snapshot backup of/mnt/Tapswept 253 cleanly and
left 3 behind withdataset is busy.The cause is ZFS's own automount. Reading anything under
<dataset>/.zfs/snapshot/<snap>/makes ZFS automount that snapshot, and it stays
mounted forzfs_expire_snapshotseconds (300 by default) after the last access.
teardown()unmounts our bind mounts — but not the automount underneath — so
zfs destroyrefuses for exactly the datasets restic read most recently. Then
cleanup_task()removed the sidecar anyway, destroying the only record that those
snapshots existed. Nothing would ever have reclaimed them.Three changes, and the third is the one that makes it safe rather than merely
unlikely:release_snapdirs()unmounts ZFS's own.zfs/snapshotautomounts (deepest first)
before deleting, so the snapshots are not busy in the first place.delete_snapshot_tree()retries the transient busy, and returns the
snapshots it could not delete instead of swallowing them.- The sidecar is now removed only on a confirmed-clean sweep — including on the
staging-failure path, which used to remove it before the caller swept. The
asymmetry is deliberate: a sidecar left behind when the tree is already gone costs
one no-op delete on the next run, while a sidecar removed while the tree still
exists is unrecoverable. Survivors are reclaimed by the next run.
Expect the occasional straggler, and expect it to clean itself up. On a
256-snapshot tree this reliably sweeps ~255 immediately and may leave one: it is
whatever restic read last, so its 300-second window has barely opened. That one is
logged, its sidecar is kept, and the next run reclaims it before doing anything else.
The leak is bounded at a single cycle rather than growing without limit — which is
the property that actually matters. Blocking a backup job for five minutes to chase
the last snapshot would be a worse trade, so it is not made. -
Installing the patch permanently blocked updating it.
install.shdoes
chmod +x update.sh, and git recordedupdate.shas100644— so the chmod was a
tracked modification, andupdate.shrefuses to run over a dirty tree. Install
once and you could never update again; the error even told you to run
git checkout -- ., which just undoes the exec bit so the next install can re-dirty
it. A real box sat on an old version for exactly this reason.Fixed on both sides: the scripts
install.shchmods are now executable in git (so
the chmod is a no-op), andupdate.sh's dirty check now looks at content, not
file mode —git diff --numstatreports0 0for a mode-only change. A test
asserts every script ininstall.sh's chmod loop is already100755in git. -
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-
-
v0.6.0-rc6
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 9sCI / python 3.12 (push) Successful in 13sCI / python 3.13 (push) Successful in 17sCI / python 3.11 (push) Successful in 15sTrueNAS compatibility / compat (push) Successful in 11sRelease / release (push) Successful in 15sreleased this
2026-07-13 15:35:47 -04:00 | 33 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
A few snapshots leaked on every nested run, forever. Found on real hardware, in
the one place it could be: a 256-snapshot backup of/mnt/Tapswept 253 cleanly and
left 3 behind withdataset is busy.The cause is ZFS's own automount. Reading anything under
<dataset>/.zfs/snapshot/<snap>/makes ZFS automount that snapshot, and it stays
mounted forzfs_expire_snapshotseconds (300 by default) after the last access.
teardown()unmounts our bind mounts — but not the automount underneath — so
zfs destroyrefuses for exactly the datasets restic read most recently. Then
cleanup_task()removed the sidecar anyway, destroying the only record that those
snapshots existed. Nothing would ever have reclaimed them.Three changes, and the third is the one that makes it safe rather than merely
unlikely:release_snapdirs()unmounts ZFS's own.zfs/snapshotautomounts (deepest first)
before deleting, so the snapshots are not busy in the first place.delete_snapshot_tree()retries the transient busy, and returns the
snapshots it could not delete instead of swallowing them.- The sidecar is now removed only on a confirmed-clean sweep — including on the
staging-failure path, which used to remove it before the caller swept. The
asymmetry is deliberate: a sidecar left behind when the tree is already gone costs
one no-op delete on the next run, while a sidecar removed while the tree still
exists is unrecoverable. Survivors are reclaimed by the next run.
Expect the occasional straggler, and expect it to clean itself up. On a
256-snapshot tree this reliably sweeps ~255 immediately and may leave one: it is
whatever restic read last, so its 300-second window has barely opened. That one is
logged, its sidecar is kept, and the next run reclaims it before doing anything else.
The leak is bounded at a single cycle rather than growing without limit — which is
the property that actually matters. Blocking a backup job for five minutes to chase
the last snapshot would be a worse trade, so it is not made. -
Installing the patch permanently blocked updating it.
install.shdoes
chmod +x update.sh, and git recordedupdate.shas100644— so the chmod was a
tracked modification, andupdate.shrefuses to run over a dirty tree. Install
once and you could never update again; the error even told you to run
git checkout -- ., which just undoes the exec bit so the next install can re-dirty
it. A real box sat on an old version for exactly this reason.Fixed on both sides: the scripts
install.shchmods are now executable in git (so
the chmod is a no-op), andupdate.sh's dirty check now looks at content, not
file mode —git diff --numstatreports0 0for a mode-only change. A test
asserts every script ininstall.sh's chmod loop is already100755in git. -
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-
-
v0.6.0-rc5
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 10sCI / python 3.11 (push) Successful in 12sCI / python 3.12 (push) Successful in 15sCI / python 3.13 (push) Successful in 17sTrueNAS compatibility / compat (push) Successful in 13sRelease / release (push) Successful in 14sreleased this
2026-07-13 15:18:25 -04:00 | 34 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
A few snapshots leaked on every nested run, forever. Found on real hardware, in
the one place it could be: a 256-snapshot backup of/mnt/Tapswept 253 cleanly and
left 3 behind withdataset is busy.The cause is ZFS's own automount. Reading anything under
<dataset>/.zfs/snapshot/<snap>/makes ZFS automount that snapshot, and it stays
mounted forzfs_expire_snapshotseconds (300 by default) after the last access.
teardown()unmounts our bind mounts — but not the automount underneath — so
zfs destroyrefuses for exactly the datasets restic read most recently. Then
cleanup_task()removed the sidecar anyway, destroying the only record that those
snapshots existed. Nothing would ever have reclaimed them.Three changes, and the third is the one that makes it safe rather than merely
unlikely:release_snapdirs()unmounts ZFS's own.zfs/snapshotautomounts (deepest first)
before deleting, so the snapshots are not busy in the first place.delete_snapshot_tree()retries the transient busy, and returns the
snapshots it could not delete instead of swallowing them.- The sidecar is now removed only on a confirmed-clean sweep — including on the
staging-failure path, which used to remove it before the caller swept. The
asymmetry is deliberate: a sidecar left behind when the tree is already gone costs
one no-op delete on the next run, while a sidecar removed while the tree still
exists is unrecoverable. Survivors are reclaimed by the next run.
-
Installing the patch permanently blocked updating it.
install.shdoes
chmod +x update.sh, and git recordedupdate.shas100644— so the chmod was a
tracked modification, andupdate.shrefuses to run over a dirty tree. Install
once and you could never update again; the error even told you to run
git checkout -- ., which just undoes the exec bit so the next install can re-dirty
it. A real box sat on an old version for exactly this reason.Fixed on both sides: the scripts
install.shchmods are now executable in git (so
the chmod is a no-op), andupdate.sh's dirty check now looks at content, not
file mode —git diff --numstatreports0 0for a mode-only change. A test
asserts every script ininstall.sh's chmod loop is already100755in git. -
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-
-
v0.6.0-rc4
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 9sCI / python 3.11 (push) Successful in 14sCI / python 3.12 (push) Successful in 16sCI / python 3.13 (push) Successful in 17sTrueNAS compatibility / compat (push) Successful in 11sRelease / release (push) Successful in 14sreleased this
2026-07-13 14:58:49 -04:00 | 37 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
Installing the patch permanently blocked updating it.
install.shdoes
chmod +x update.sh, and git recordedupdate.shas100644— so the chmod was a
tracked modification, andupdate.shrefuses to run over a dirty tree. Install
once and you could never update again; the error even told you to run
git checkout -- ., which just undoes the exec bit so the next install can re-dirty
it. A real box sat on an old version for exactly this reason.Fixed on both sides: the scripts
install.shchmods are now executable in git (so
the chmod is a no-op), andupdate.sh's dirty check now looks at content, not
file mode —git diff --numstatreports0 0for a mode-only change. A test
asserts every script ininstall.sh's chmod loop is already100755in git. -
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-
-
v0.6.0-rc3
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 8sCI / python 3.11 (push) Successful in 13sCI / python 3.12 (push) Successful in 14sCI / python 3.13 (push) Successful in 15sTrueNAS compatibility / compat (push) Failing after 6sRelease / release (push) Successful in 14sreleased this
2026-07-13 14:42:59 -04:00 | 38 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-
-
v0.6.0-rc2
Pre-ReleaseCI / shell (shellcheck + syntax) (push) Successful in 14sCI / python 3.11 (push) Successful in 17sCI / python 3.12 (push) Successful in 20sCI / python 3.13 (push) Successful in 21sTrueNAS compatibility / compat (push) Successful in 10sRelease / release (push) Successful in 14sreleased this
2026-07-13 14:38:05 -04:00 | 39 commits to main since this releaseAdded
-
release.sh— a two-stage release process, and a barrier that enforces it.
A stablevX.Y.Ztag is now only publishable if avX.Y.Z-rcNtag points at the
same commit, and the release job re-runs the entire suite against that tagged
commit before publishing. Candidates are invisible to users —update.shand the
update alert both take the newest plainvX.Y.Ztag — so debugging happens across
rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
everybody's.bash release.sh 0.6.0 --rc # candidate. Invisible to users. bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE.The rule is enforced in
tools/release_gate.py, whichrelease.shruns locally
(so you fail in 200 ms) and.github/workflows/release.ymlruns again where it
cannot be bypassed (so failing locally is not optional). "The candidate passed,
then I pushed one more little fix" is refused by name — that is precisely how
v0.5.1 happened. -
TrueNAS compatibility is now checked, not hoped for.
tools/compat.pyis a written-down record of everything each
module assumes about middlewared, checked in two places:- CI, daily — against iXsystems' source at every release line including
masterand the current BETA/RC. When an unreleased TrueNAS breaks the patch
it files a bug report automatically, so there is time to fix it before that
version reaches anyone. It also refreshes the README's support matrix, so the
table cannot quietly become a false promise. apply.sh, at every boot — against the middleware actually installed on the
box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
without a feature beats TrueNAS with a broken backup.
It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
that had been shipping for two releases (below). - CI, daily — against iXsystems' source at every release line including
-
The compatibility check now covers the middlewared methods the patch calls,
not only the symbols it wraps — and that gap was hiding a catastrophe.TrueNAS 26 deletes
plugins/zfs_/dataset.pyandplugins/zfs_/snapshot.py
outright, takingzfs.dataset.query,zfs.snapshot.queryand
zfs.snapshot.deletewith them (26 usesfilesystem.statfsandzfs.resource.*).
Nothing about the fivecloud_backupfiles reveals that, so every other check went
green. The patch would have applied perfectly and then failed on the first
backup — or, far worse, snapshotted successfully and failed to delete,
orphaning one snapshot per descendant dataset (250 on a real pool) on every
single run, forever.This is now an assumption class of its own, so a method disappearing is a BROKEN
verdict rather than a silent time bomb. -
Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
26 is still reported BROKEN and the nested module will not apply there, because
the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
verify against, and shipping a port nobody has run is exactly the failure this
project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
datasets are simply not covered.
Fixed
-
Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
arguments — but those releases declarerestic_backup(middleware, job, cloud_backup, dry_run);rate_limitonly arrived in 25.10. Every nested backup on
24.10/25.04 raisedTypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes*args, **kwargsand forwards whatever it is
handed, so a trailing parameter appearing or disappearing is a non-event.Found by the new compatibility check, not by a user — which is the whole argument
for having it. The check it replaced only asked whether the parameter names still
appeared somewhere in the signature, so it happily passed a call that could never
work. -
The nested module is now one synchronous implementation behind two thin
wrappers. TrueNAS 26 rewritescloud_backupfrom async to synchronous and
separately deletesget_dataset_recursive(), which an injected block called out
of the host module's namespace. Either alone is a broken backup found at restore
time: anasync defwrapper handssync.pya coroutine where it unpacks a tuple,
and the vanished helper is a straightNameError.The module now talks to middlewared through
call_sync, andapply.shreads which
flavour the installed middleware declares and injects the matching wrapper —
TrueNAS ≤ 25.10 reaches it viaawait middleware.run_in_thread(...); a synchronous
TrueNAS, already in a worker thread, calls it directly. The logic that owns the
snapshots, the bind mounts and the failure modes exists once; an async twin
would mean every future fix had to land twice, and the one that got missed would be
the one that eats a backup. A middleware whose three wrapped functions disagree
about async-ness is refused outright rather than guessed at, and
get_dataset_recursiveis carried as our own copy — removing the dependency on both
versions instead of asserting it. -
The patch no longer reaches into CloudSync tasks it has no business touching.
create_snapshotis module-global inplugins/cloud/snapshot.pyand is imported by
cloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the
path of every rclone/Storj CloudSync task withsnapshot=true, and issued a
zfs.dataset.querybefore deciding it had nothing to do. That added a brand-new
failure mode to jobs that worked fine before this patch was installed, and worse: a
CloudSync task that ever did get staged would never be torn down, because the
teardown is wired intocloud_backup'srestic_backupandCRUD_BLOCK
deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
is namedcloud_backup-*, before any middleware call. -
Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
wrapper'sfinallydropped thelogger=kwarg that the sync one passes, so a
cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
nothing at all — on the only platform anyone actually runs.run_in_thread
forwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declare
do_delete(theCRUDServiceconvention); 25.10 renamed it todelete. Both
answer tozfs.snapshot.delete. Accepting only the literal name reported both older
releases as BROKEN — a false verdict that would have switched nested snapshots off
on boxes where they work perfectly. -
An incompatible TrueNAS no longer sets the permanent kill switch.
apply.sh
reused a "nothing left to do" exit that touchesdisabled, which suppresses
patching on every future boot and is cleared only byinstall.sh— never by
update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
branch would have fired, and the very release that fixed 26 could not have
re-enabled itself: the user would runbash update.sh, exactly as the update alert
tells them to, and the patch would stay dead with their B2 backups off.
Incompatibility now means "apply nothing this boot, try again next boot".
Retirement and incompatibility are opposite situations and no longer share an exit. -
The compatibility check itself could be fooled, in ways that each had teeth: a
reordered, keyword-only, or newly-required parameter now reads as broken (the patch
calls these positionally); a re-exported or conditionally-defined symbol reads
as unknown rather than broken, so an innocent upstream refactor cannot make a
working module decline to apply; an unreadable source (rate limit, DNS, timeout)
is unknown rather than "iXsystems deleted this file", so a network blip cannot
file a bug report, fail CI, and repaint the published support matrix; andnative
no longer masksBROKEN, which used to render a TrueNAS that both reworded the
nesting guard and reshaped the functions as good news. -
compat.py --treeno longer reads the patch's own code as native support.
B2_BLOCKwritesB2RcloneRemote.restic = Trueintob2.py— exactly the string
the providers native-probe looks for — so the one command the docs recommend for
checking a live box said "retire the providers module" on every patched machine.
It now reads only the part of the file iXsystems wrote. -
release.sh --promotecould never succeed. It refused to run if the stable tag
existed, and the gate refused if it did not — mutually exclusive, so the only way to
cut a stable release was to hand-tag and bypass every gate this work exists to
enforce. The gate now resolves the tag's commit if it exists andHEADotherwise.
The tests hid it by always tagging first.
Changed
-
The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
does not exist before it —plugins/cloud_backup/is simply absent — so the patch
had nothing to attach to and would have done nothing at all, silently, while the
user believed their backups were configured.install.shnow reads
system.versionand refuses, naming the reason. A version it cannot parse is a
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented. -
A stable release may not leave work stranded under
## Unreleased. Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it. -
release.shrefuses to run on an installed box. The whole repo is cloned onto
every box, so this file is there too;update.shpins the checkout to a tag in
detached HEAD, andrelease.shnow recognises that and says so, rather than
emitting a confusing branch error. -
Gitea (
git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
GitHub as a mirror. Both forges run the same workflows and publish the same
releases. The update alert now derives the changelog URL from theorigin
remote instead of hard-coding GitHub — which matters more than it sounds: when
the changelog cannot be read, the alert deliberately fires anyway rather than risk
hiding a security fix, so a stale URL would not have disabled the alert, it would
have made it nag on every release, including documentation-only ones.
Security
- Workflow expressions are no longer interpolated into shell.
echo "${{ steps.report.outputs.body }}"pasted the compatibility report into the
script text, and the report is full of backticks — bash rancreate-snapshot,
defandasyncas commands. Since that report is built from iXsystems' source,
anything landing in their tree would have executed on the runner.inputs.tagon
workflow_dispatchhad the same shape, and that one is attacker-chosen. Data now
moves through files and scalars throughenv:; a test enforces it across every
workflow.
Internal
- Static-analysis annotations in
patch/alert_source.py(# noqaplacement). No
runtime change.
Downloads
-