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.
62 KiB
Changelog
Work lands under Unreleased and stays there until a release promotes it. That is deliberate: see Releasing. Twelve releases were cut on 2026-07-13, several of them fixing the release before — and with the update alert live, every one of those interrupts every user. An alert people learn to ignore is worse than no alert, because one day it carries a security fix.
Unreleased
Changed
-
CI's python matrix is green on the self-hosted Gitea runner again. The real failure was that the Gitea runner image executes jobs as root, and the two unreadable-sidecar tests build their scenario with
chmod(0)— which cannot make a file unreadable for root (CAP_DAC_OVERRIDE). Those two tests now skip as root with that reason; GitHub's non-root runner still exercises them. The matrix also moved to uv-managed interpreters (one toolchain across both runners) and ruff is pinned to 0.16.1 so an upstream ruff release can't turnmainred without a code change. -
README badges point at the public GitHub mirror (workflow status and releases) instead of the private forge. The release badge had also been reading the stale Gitea v0.6.1 release instead of the current v0.7.0 on GitHub.
-
masteris now labelled27-dev, because it is not the next release. iX branches each major onto its ownrelease/line and master rolls straight on to the one after — on 2026-07-14 every recent commit on master targeted27.0.0-BETA.1while 26 was still in beta. So a BROKEN master row, rendered as "master (unreleased)", read as "the version you are about to install is broken" when the breakage was a major release away on a line nobody can download. In a table whose entire job is helping somebody decide whether to trust this with their backups, that is a false alarm in the worst possible place. The label is derived from the newest major in the matrix plus one, so it rolls over to28-devby itself once 27 branches.For the record, the breakage is
NAS-141498(2026-06-24), "Convert cloud_backup plugin to the typesafe pattern": it re-signaturesrestic_backupandget_restic_config, splittingentry/credentialsout of thecloud_backupdict. It is deliberately not being chased while the 27 line is still churning.
Fixed
-
The next maintenance release was never checked, and it is the one that reaches users. Shipped versions were discovered from
TS-*tags and unreleased ones fromrelease/*branches carrying-BETA/-RC. A branched-but-untagged maintenance release is neither:release/25.10.5has no tag, and its line has already shipped, so the "a prerelease of a shipped line is history" filter discarded it. It was invisible — and it is precisely what a 25.10.4 box gets on its next update. A break there would have reached real users before the daily check ever looked at it, on the only line anybody is actually running.A plain
release/X.Y.Zbranch is now checked when its line has shipped and it sorts newer than that line's newest tag. Both things that must stay out fall out of the same rule:release/24.10-RC.2sorts older thanTS-24.10.2.4(history, not a warning), and iX's typo branchrelease/25.20.2.2is on a line that has no tag at all, so it is not a release line. This immediately surfaced two refs that had never been checked —release/25.10.5andrelease/24.10.2.5— both of which pass.is_unreleased()now keys off where a ref came from (branch = not yet shipped) rather than looking for-BETA/-RCin its name. Otherwiserelease/25.10.5would count as shipped and a break in it would fail the build as a live outage — on a version nobody is running yet. -
An unchanged fingerprint froze the bug report's body, not just its comments. 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 — and editing an issue body notifies nobody on either forge, so keeping it honest is free. Conflated, the report could never be corrected while the findings held steady, and the fingerprint deliberately ignores everything that moves on its own — healthy rows, the hardware-verified column, point releases, and how a row is labelled. The
master→27-devrelabel above would have reached the README and never the issue anybody actually opens. The body is now rewritten whenever it is out of date (after normalising line endings, so a forge round-tripping\r\ndoes not cause a rewrite every run) and comments remain strictly a changelog of real changes. -
A change to the publisher did not re-run the check.
compat.yml'spush:paths listedtools/compat.pybut nottools/compat_publish.py— so the very commit that taught the bot to refresh a stale report body triggered no run, and the report stayed stale until the next scheduled one. A fix nobody runs is a fix nobody has. -
The compatibility bot filed a new duplicate bug report on every Gitea run.
find_issue()skipped pull requests by testing for the presence of thepull_requestkey. GitHub omits that key on a plain issue; Gitea sends it asnull. So on Gitea every issue was discarded as a PR, the lookup always came back empty, and the bot took the "nothing filed yet" branch and opened a fresh report each run — nine copies on the canonical forge, four of them filed after the commit that was meant to stop precisely this. The mirror was fine, which is why it went unnoticed: GitHub's payload shape is the one the filter was written against.It is the same failure the anti-spam fix was written to prevent, moved from comments to issues, and it survived because
find_issuewas the only function incompat_publish.pywith no test. It now has one, per forge, and the daily cron — which had not yet run once — no longer accumulates a report a day.The issue list is also requested with both paging parameters (
per_pagefor GitHub,limitfor Gitea). Each forge ignores the other's, and Gitea's default page is 30, so the lookup would have started missing the report again once the pile it was creating grew past one page.
v0.7.0 — 2026-07-14
Added
-
TrueNAS 26 support, verified on a real TrueNAS 26 install. 26 deletes
plugins/zfs_/outright, taking the privatezfs.dataset.query,zfs.snapshot.queryandzfs.snapshot.deletewith it. Every one of those was on the nested module's critical path, so nested snapshots were BROKEN on 26 andapply.shcorrectly refused to apply the module there.Snapshot deletion now resolves its namespace at runtime —
pool.snapshoton 25.10 and 26,zfs.snapshoton 24.10 and 25.04, because no single namespace spans every supported release.tools/compat.pychecks the same list the runtime uses, so what CI verifies and what runs cannot drift apart.Hardware-verified on TrueNAS 26.0.0-BETA.1: a 274-snapshot recursive backup of a 292-dataset pool, then a byte-identical restore of a four-level-deep child dataset.
Fixed
-
Enumeration no longer trusts middleware's dataset and snapshot queries — they are filtered. This is the important one, and it is the bug that a test VM caught and no amount of source analysis ever could have.
The obvious port of the deleted private
zfs.dataset.querywas the publicpool.dataset.query. It exists, it is documented, it is covered by iX's deprecation policy — and it is not a like-for-like replacement. It applies a visibility policy: it hides the datasets TrueNAS considers its own —ix-apps/*,.system/*,.ix-virt/*. On a real pool that is 84 of 270 datasets, andix-appsholds live application data.Staging from that view would have silently omitted every one of them. Worse,
plan_staging()would never have seen them, so they would not have appeared in itsskippedlist either — no warning, no failure, just a green backup quietly missing data. That is precisely the failure this module exists to prevent. The snapshot query lies the same way (205 of 274), so the sweep would have orphaned one snapshot per hidden dataset, on every run, forever.The module now reads the truth from ZFS and makes changes through middleware: enumeration is
zfs list, which no policy can filter and which behaves identically on every release; mutation stays a middleware call, so TrueNAS's own bookkeeping stays consistent. A failingzfs listraises 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 call the private
zfs.dataset.query, which returns all 270 datasets. The bug existed only in the unreleased TrueNAS 26 port. -
The patch now owns the snapshot sweep even when it does not stage anything. Stock decides whether to take a recursive snapshot by its own rule, and on TrueNAS 26 that rule stopped being ours.
Up to 25.10, stock's
create_snapshotcalledget_dataset_recursive()— the same function this module vendors — so "stock went recursive" and "we have something to stage" were the same question, and stock's non-recursive delete was correct for everything the patch declined to stage. TrueNAS 26 usesfilesystem.statfs:recursive = (path == the dataset's mountpoint). The two rules now disagree for a dataset whose only descendants are ZVOLs or legacy/none-mountpoint datasets — stock snapshots it recursively, while the patch sees nothing to stage.The patch then handed the snapshot back to stock, which destroys the parent only. With no staging tree there was no sidecar, and the garbage collector only ever ran from the staging path — so nothing on the box would ever have found the children. Reproduced on the test VM: one orphaned snapshot per zvol, on every run, forever, with the backup reporting success. Ownership of the sweep is no longer conditional on staging.
-
The runtime resolved a namespace; the checker verified a method. Those are different questions, and the gap is a false "ok".
get_service()only proves a namespace is registered — it says nothing about whetherdeletestill exists on it. So if iX guts the method while keeping the service (they have already done exactly that topool.snapshot.do_updateon master),tools/compat.pywould fall through tozfs.snapshot, report the box healthy, and let the patch apply — while the runtime pickedpool.snapshotand failed every delete, orphaning the whole tree. Both sides now ask the same question, and a test binds the two lists together. -
query_filesystems()dropped malformedzfs listrows silently — the last remaining silent-omission path, and a direct contradiction of this module's cardinal rule. It raises now. A missingzfsbinary raisedFileNotFoundErrorrather thanZfsError; also fixed. -
The snapshot retry loop discarded the delete error and reported every survivor as "(still busy?)" — naming the one cause that is benign and self-healing, and hiding the ones that are permanent. It keeps and reports the real error.
-
The staging-failure handler could lose the original exception if its own cleanup sweep raised. An error handler must not be able to lose the error.
-
A snapshot delete that returns cleanly is not proof that anything was deleted. The recursive sweep's fast path took the call's word for it and returned "no survivors" — so
cleanup_taskread that as a clean sweep and removed the sidecar, the only record the tree ever existed. Roughly 250 snapshots would have been orphaned on every run, with nothing left able to find them, and the backup reporting success.This is not a hypothetical about a well-behaved API: iX has already gutted
pool.snapshot.do_updateon master into a no-op whose body is commented out and which returnsNone. A source check still sees thedef; a runtime check still sees a callable method. Only asking ZFS can tell. The sweep now confirms against ZFS, and where it cannot confirm it keeps owning the tree rather than claiming success — a false survivor self-heals on the next run, a lost record never does. -
_write_sidecarswallowedOSError. The sidecar is the only thing that survives a middlewared restart; failing to write it is not fatal, but it must never be invisible._read_sidecarhad the mirror bug — it conflated "there is no sidecar" with "I could not read the sidecar", andcleanup_taskthen took the empty branch and unlinked the only record of a tree it had failed to read. -
A dataset from another tree, mounted inside the backup path, was omitted silently. The staging plan scopes by dataset name, which is correct — a dataset with no mountpoint cannot be scoped by path at all. But ZFS lets any dataset mount anywhere, so one from an unrelated tree can sit inside the path:
Tank/photos mountpoint=/mnt/Tap/apps/photosIt holds data inside the backed-up path, and
zfs snapshot -r Tap@…does not cover it: recursion follows the dataset tree, not the directory tree. So there is no snapshot of it to stage, and no way to capture it consistently with the rest. It fell out of the name filter and vanished — not staged, not inskipped, no error, backup green. Stock has the same blind spot, but stock also refuses the nested config outright; this patch is what relaxes that guard, so the hole is this patch's to close. It now refuses, and names the offending datasets.
v0.6.1 — 2026-07-13
Fixed
-
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.
v0.6.0 — 2026-07-13
Added
-
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.pyoutright, takingzfs.dataset.query,zfs.snapshot.queryandzfs.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 — sozfs destroyrefuses for exactly the datasets restic read most recently. Thencleanup_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.shdoeschmod +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 rungit 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 rewrites
cloud_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, andget_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 bycloud_sync.pyas well ascloud_backup/sync.py— so the wrapper sat in the path of every rclone/Storj CloudSync task withsnapshot=true, and issued azfs.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_BLOCKdeliberately 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's
finallydropped 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_threadforwards**kwargsviafunctools.partial; it was a regression, not a limitation. -
do_deleteis recognised asdelete. TrueNAS 24.10 and 25.04 declaredo_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.shreused a "nothing left to do" exit that touchesdisabled, which suppresses patching on every future boot and is cleared only byinstall.sh— never byupdate.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; and
nativeno 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 readssystem.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 theoriginremote 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.tagonworkflow_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.
v0.5.1 — 2026-07-13
Fixed
-
The update alert could have broken middlewared at startup. middlewared's
alert.load()imports every file inalert/source/with no try/except, and it runs during setup — so a module that raises on import takes middlewared down with it.apply.shnow 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 substituted withrepr(), so a repository path containing a quote or a backslash produces a valid Python literal instead of a syntax error in the installed module. -
The alert source no longer mutates
sys.path. It loadedtools/release_notes.pyviasys.path.insert(0, …), which shadows the stdlib for that interpreter — andThreadedAlertSourceruns in middlewared's thread pool, so mutatingsys.pathis a race. It now loads the module by file path withimportlib.
Notes
Timing, for the record: process_alerts is @periodic(60) and
alert_source_last_run is in-memory, so the check runs within 60 seconds of any
middlewared restart (which this patch performs at every boot) and otherwise
within 24 hours of a release.
v0.5.0 — 2026-07-13
Added
-
A TrueNAS alert when an update is available — the bell in the UI, not a log line nobody reads. On by default, checked once a day.
install.sh --no-update-alertsturns it off.It does not nag. A release whose CHANGELOG contains only a
### Docssection changed no code and raises nothing. Anything else raises INFO; a### Securitysection raises WARNING. The CHANGELOG's own section headings are the signal, and a security fix anywhere in the range escalates the whole span — so 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.midcltexposes onlyalert.dismiss,alert.list,alert.list_categories,alert.list_policiesandalert.restore— alert creation is internal to middlewared, and none of its ~60 one-shot classes is generic enough to reuse. So registering anAlertSourceis the only way, and it is also the least invasive thing this patch does: it adds one file and modifies none, where the providers and nested modules both append code to stock middleware files. It is the native mechanism, and TrueNAS polls it itself — no cron, no systemd timer.- Fail-safe: every error path returns
None; it cannot take middlewared down. - Read-only:
git ls-remoteplus an HTTPS fetch of the CHANGELOG. It never writes to.git, so it cannot leave root-owned objects behind the way agit fetchfrom middlewared (running as root) would. - Removed by
uninstall.sh. - It only tells you; it never updates anything.
- Fail-safe: every error path returns
v0.4.2 — 2026-07-13
Docs
-
The Updating section never said how to get
update.sh. It ships inside the patch, so a clone older than v0.4.0 doesn't have it — the docs told you to run a script you didn't have. There is now an explicit bootstrap step (git pull && bash install.sh, once), including the fix for the "insufficient permission for adding an object to repository database" failure that pastsudo git pulls cause. -
After a TrueNAS updaterewritten. It didn't explain that the patch re-applies itself at every boot (so you never reinstall), and it didn't say what each failure actually costs you. "Fail-safe" means the box stays up — not that your backups keep running. A[FAIL] providersis a broken backup, and the docs now say so rather than implying everything degrades gracefully. -
Added a repo map.
patch/mw_patch.pyandtools/release_notes.pywere documented nowhere. -
Developmenttold you to runruff check patch tests, which missestools/. -
Every command and file path in the README is now verified to exist and run.
v0.4.1 — 2026-07-13
Fixed
-
update.shwould have picked a release candidate as "the newest release". Git's version sort ranksv0.5.0-rc1abovev0.5.0(verified), and the release workflow deliberately supports rc/beta tags — so an RC would have been installed as though it were the latest stable. Tag selection is now filtered to plainvX.Y.Z. -
update.shwould have died mid-update on an untracked file. The dirty-tree guard uses--untracked-files=no, so an untracked file that the target tracks slipped past it — andgit checkoutthen aborts. Underset -ethe script died with a raw git error, after recording the rollback point. This is exactly what blocked a pull on a real box (a hand-copiedpatch/wait_restart.sh). It now detects the collision up front and names the files. Gitignored files are correctly not treated as blockers — git overwrites those silently.Special case: if
update.shitself is the blocker, you hand-copied it in to bootstrap — and "deleteupdate.sh, then re-runupdate.sh" is impossible. It now says so and prints the git commands that bootstrap it properly. -
--rollbackskipped that check entirely, so it 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 (history can be rewritten). -
install.sh'schmodaborted underset -eif any listed file was missing. The file set changes between versions, soupdate.sh --rollbackto an older revision must not be killed by a filename this version happens to know about. -
--towith no value was silently ignored and fell back to the default target.
v0.4.0 — 2026-07-13
Added
-
update.sh— fetch a newer release and apply it, preserving your 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 updateRun it by hand. Never from cron or a systemd timer. This patch injects Python into middlewared and re-applies itself at every boot, so an unattended pull would let any bad upstream commit reach your 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:
- Defaults to the newest release tag, not
main.maincan be mid-refactor; a tag is the tested artifact.--mainexists but says so loudly. - Tags are ordered by version, not by date — date order silently downgrades the box the first time a hotfix is tagged out of band (a v0.3.6 released 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.
- Shows the commits you don't have and the target's release notes (read from the
target's CHANGELOG, via
tools/release_notes.py— not a second copy of the extractor), then asks before doing anything. - Records the previous revision before moving, so
--rollbackworks even ifinstall.shdies halfway. - Repairs
.gitownership, which pastsudo git pulls leave root-owned and which then breaks every later non-root git command.
- Defaults to the newest release tag, not
-
update.shis covered by the version-drift check, so it cannot quietly go stale the waycreate_task.py.__version__did.
v0.3.5 — 2026-07-13
Changed
-
delete_snapshot_treeswallowed the error from its recursive-delete fast path. That failure is usually just "parent already gone" — stock'sfinallywinning the race once our mounts are released, which the by-name sweep then handles. But if the cause were anything else, this was the only place it was visible, and it went straight to/dev/null. It is now logged before falling through. -
Annotated the two remaining static-analysis findings as considered-and-accepted rather than leaving them to be re-litigated:
subprocessis always called in list form (no shell, so ZFS dataset names cannot inject), and the partialsystemctlpath is moot in a script that only runs as root.
v0.3.4 — 2026-07-13
Changed
-
One implementation of apply/revert (
patch/mw_patch.py). The "strip theTRUECLOUD_PATCHblock" logic existed twice — inapply.sh's heredoc and in an inline heredoc inuninstall.sh— and the uninstall copy was the untested one. That is exactly how the two could have drifted apart, withapply.shreverting one set of files anduninstall.shanother. Both now call the same tested module (17 new tests, including thatrevert_nestednever touchesrestic.py, which belongs to the providers module and whose removal would silently break B2 backups).apply.shimports it fail-safe: if it cannot, the backend patch is skipped and middlewared starts stock, which is this script's whole design principle. The import usessys.path.append, neverinsert(0)— prepending would givepatch/precedence over the stdlib for that interpreter, so a futurepatch/json.pywould shadow the realjsonand break the boot.
Docs
- The README's
create_task.pyexample still taught--password <secret>, which is how a security fix quietly fails to land. It now shows--password-stdin.
v0.3.3 — 2026-07-13
Security
-
The restic repository password no longer passes through a process's argv.
create_task.pyshelled out tomidclt call cloud_backup.create '<json>', and that JSON contains the repo password — so it appeared in the process's argv, which is world-readable viaps, 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 backsmidcltitself), so the password never leaves the process's memory. -
--passwordno longer required. Passing a secret as a CLI argument writes it to shell history permanently.--password-stdinreads it from stdin, and with neither flag the tool prompts viagetpass.--passwordstill works but now warns.
Fixed
-
uninstall.shcould leave every patch installed. It reverted by unmounting the overlay — butapply.shonly mounts one when the target directory is read-only. On a writable/usrit 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. -
create_task.py.__version__had been stuck at0.2.0for three releases. The version-drift check added in v0.3.1 only looked atVERSION=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.
v0.3.2 — 2026-07-13
Fixed
-
install.sh --disable-nested-snapshotsdid not actually disable anything until the next reboot.apply.shonly ever added patches — there was no revert path. Disabling removed the opt-in marker and then merely skipped re-applying, but the overlay persists for the whole boot, so the previously patchedplugins/cloud/{snapshot,crud}.py,plugins/cloud_backup/sync.pyand_truecloud_nested.pywere all still sitting there — and middlewared re-imported them on the restartinstall.shperforms.It printed "DISABLED (stock guard restored)" while the feature kept running. Someone turning it off because they were worried about it would have believed it was off.
apply.shnow actively reverts: it removes the module first (every injected block is guarded byif _tc_nested is not None, so the stock guard is restored even if a later step fails), then strips its appended blocks from the three patched files.restic.pyalso carries aTRUECLOUD_PATCHblock but belongs to the providers module and is deliberately left alone — reverting it would break B2 backups.install.sh --disablealso tears down any staging tree 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 it is explicitly enabled.
v0.3.1 — 2026-07-13
Added
-
Automated releases. Pushing a
v*tag runs the full test suite and then cuts a GitHub release whose body is the matchingCHANGELOG.mdsection — so release notes have exactly one source of truth, and no second place to go stale. The workflow refuses to publish if the tests fail, if the tag does not match theVERSION=declared by every script, or if the CHANGELOG has no section for it. -
Version-drift check.
VERSION=had silently diverged to three different values acrossinstall.sh,uninstall.sh,recover.sh, andpatch/apply.sh, and nothing noticed. CI now asserts every script agrees with the others and with the newest CHANGELOG entry.
Note
- Releases for
v0.2.0andv0.2.1were backfilled — they had been tagged but never released, so the releases page jumped v0.1.0 → v0.3.0 and hid the fix for the boot race that took every app down.
v0.3.0 — 2026-07-13
Added
-
snapshot = truenow works on datasets that have child datasets — opt-in, off by default (install.sh --enable-nested-snapshots/--disable-nested-snapshots). It changes how backups read their source data, so it is never enabled implicitly; with neither flaginstall.shpreserves the existing setting, so agit pull && bash install.shcannot silently flip it. When disabled,apply.shskips the patch entirely and the stock guard remains.uninstall.shtears down any staging mounts and removes the marker. Stock TrueNAS refuses this with "This option is only available for datasets that have no further nesting", which makes the snapshot option unusable for the single most common case on any box running Apps — every app is its own dataset, often withconfig/pgdatachildren of its own. Without it, the backup reads live files: databases are captured mid-write, and a busy app rewriting its files can stall a backup indefinitely as restic chases a moving target.The stock guard is correct, and it is not an arbitrary limit.
plugins/cloud/snapshot.pyalready takes a recursive ZFS snapshot, but it then points the backup tool at the parent dataset's.zfs/snapshot/<snap>/directory — and ZFS does not expose child datasets through a parent's snapshot directory:/mnt/Tap/.zfs/snapshot/<snap>/apps/ -> 0 entries (children invisible) /mnt/Tap/apps/lidarr/config/.zfs/snapshot/<snap>/ -> the real dataSo without the guard the backup tool would walk a near-empty tree, report SUCCESS, and upload almost nothing. iX gate the config rather than ship a backup that lies about succeeding.
This release implements the missing half. After the (already recursive) snapshot is taken, every descendant dataset's own
.zfs/snapshot/<snap>is bind-mounted into a staging tree mirroring the original layout, and the backup tool is pointed at the staging root — a complete, consistent, point-in-time view of the whole subtree. Only then is the guard relaxed.Safety properties, in order of importance:
- Staging failure is loud. If any descendant cannot be staged, the backup fails. A silently-incomplete backup is the exact outcome the stock guard exists to prevent, and it would be worse than not having the feature.
- A post-mount verification pass asserts every planned target is really a mountpoint and the staging root is non-empty, so this can never regress into the empty-backup failure it is meant to fix.
- The guard is relaxed last.
apply.shinstalls the traversal, patchessnapshot.py, thensync.py, and only thencrud.py. A partial failure leaves the guard intact and the option merely unavailable — never "guard removed, traversal missing". - The patch owns the whole snapshot lifecycle.
zfs.snapshot.deletedefaults torecursive=Falseand stockrestic_backup()calls it with no options. Stock gets away with that only because its validation meansrecursiveis never True in the field — but enabling nested datasets makes recursive snapshots real, so the parent now has one child snapshot per descendant dataset (160+ on a typical Apps pool). Relying on stock's delete would therefore orphan every child snapshot on every successful run. This patch sweeps the parent and all children, is idempotent against stock'sfinallywinning the race, records the snapshot in a sidecar file (so a middlewared restart mid-backup cannot orphan it), reclaims the tree left by a crashed run, and deletes the tree when staging fails — where sync.py's ownfinallywould otherwise delete nothing at all, because itssnapshotlocal never gets assigned. - The dataset list is enumerated after the snapshot, never before. A list read beforehand can miss a dataset created in the gap: the recursive snapshot would capture it but the staging plan would not, silently omitting its data. Read afterwards, an unsnapshotted dataset trips the staging check and fails the run loudly instead.
- Every injected block no-ops if
_truecloud_nestedis absent. - Datasets that cannot contribute to a file tree (
mountpoint=none|legacy, unmounted/locked, encrypted-and-locked) are skipped and reported — never dropped silently. - Scoped to
cloud_backuponly. Cloud Sync (rclone) shares the same validation mixin but has no staging teardown wired in, so its guard is left in place deliberately.
Side benefit: the staging root is a stable path per task, so restic can find its parent snapshot between runs. Stock's
.zfs/snapshot/<name>-<timestamp>/path changes every run, which defeats restic's parent detection and forces a full re-scan each time. -
CI (GitHub Actions): shellcheck +
bash -non every script, ruff, and pytest on Python 3.11/3.12/3.13. Includes tests thatcompile()the*_BLOCKstrings — they are Python source appended to live middlewared modules, so a syntax error there would break the box at boot, and nothing previously checked them.
Changed
-
The patch is now two independent modules, and each retires on its own. Previously the native-support check looked only for native B2 restic support and, on finding it, set the kill switch and disabled everything. With a second capability in the patch that would silently take a still-needed module down with the superseded one — TrueNAS is likely to ship one of these long before the other.
apply.shnow detects each separately (providers: doesB2RcloneRemotecarry a realget_restic_config();nested: is the "no further nesting" validation still inplugins/cloud/crud.py), skips just the superseded one, and only sets the kill switch once both are done. The UI patch belongs toprovidersand is skipped with it. The deferred middlewared restart now fires when any still-needed module landed — keying it offprovidersalone would have left a freshly-patchednestedmodule on disk and never loaded on a native-B2 box.hook_status.jsonreports each module with anactiveflag and a reason. -
README rewritten to be less alarmist: dropped the warning boxes and the disclaimer's fear-bulleting in favour of plain statements, and documented the two-module design. The one caveat kept as a plain sentence: the
mount --bindstaging step has not yet been exercised by a live backup run. -
Version strings in
install.sh,uninstall.sh, andrecover.shwere stale at0.0.4; all scripts now report the same version. -
patch_ui.py: replaced atry/except/passwithcontextlib.suppress(no behaviour change; satisfies the new lint gate).
Removed
patch/__pycache__/create_task.cpython-314.pycwas committed to the repository; it is now untracked and__pycache__/is gitignored.
Fixed (post-merge audit)
-
create_task.py verifyfailed on a default install.hook_status.jsonemitted a per-file entry for the nested module withok: falsewhenever the feature was switched off — which is the default — soverifyprinted[FAIL]and exited 1 right after the README told users to run it. Status is now reported per module with anactiveflag, andverifyrenders an inactive module as[SKIP]rather than a failure. -
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.pyfor 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.shdid not unmount staging trees, so an emergency recovery left bind mounts pinning ZFS snapshots that could then never be destroyed. -
uninstall.shdeleted 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 (zfs destroy -r ...) before clearing it. -
The native-nested probe could never detect the guard, silently disabling the whole module. Stock splits the message 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 the runtime filter works — but the source never contains the whole phrase. The probe's substring search found nothing, concluded iX had removed the guard, and skipped the nested module as "already native".
apply.logwould report "TrueNAS now handles nesting natively" and the feature would never work. It fails safe (the stock guard stays, so no data is at risk) but the module was 100% dead. The probe now strips whitespace and quotes before matching, which is robust to any wrapping style. Caught only by running the probe against real middlewared; there is now a regression test that executes apply.sh's own probe code against the real wrapped source.
Changed (production audit)
delete_snapshot_treenow uses a single recursive delete. It previously removed the parent and each child snapshot one at a time — 252 sequential middleware calls on a real pool. That is slow, but the real problem is that it is not atomic: a run killed part-way through the sweep leaves exactly the orphaned snapshots the function exists to prevent. It now issues onezfs.snapshot.delete(..., {"recursive": True})and falls back to the name-by-name sweep only when that fails (e.g. stock'sfinallyalready removed the parent, which leaves the children behind).
Refactored
- Staging teardown had been copy-pasted into
uninstall.shandrecover.sh— two untested shell copies of the fiddly depth-ordering and lazy-umount logic. Both now callpython3 patch/truecloud_nested.py cleanup, so there is one implementation and it is the one under test. - Dropped the in-memory
ACTIVEdict. The sidecar file was already the source of truth; a second in-process record could only desync — and it is the middlewared-restart case (which empties it) that must not orphan a snapshot tree. One record, on disk, or none.
Validated in production
An unattended scheduled backup of a live 252-dataset pool (/mnt/Tap, TrueNAS
25.10) ran through the staging tree end to end:
- 252 datasets recursively snapshotted, 173 bind mounts built and verified
- completed in 18m14s,
SUCCESS— the same task previously stalled at 74% for over 12 hours reading live files - zero orphaned ZFS snapshots and zero stale mounts afterwards, which is the failure mode that would otherwise have accumulated 251 snapshots per run
Known issues
- Stock
restic_backup()deletes the ZFS snapshot in its ownfinally, which fails withEBUSYwhile the staging bind mounts pin it. It logs one benignError deleting snapshot ...warning per run; the patch then unmounts and deletes the snapshot for real. The warning is expected and harmless.
v0.2.1 — 2026-07-09
Fixed
-
Deferred restart raced the rest of boot, leaving all apps and dashboard stats down. The
truecloud-mw-restartunit introduced in v0.0.4 relied on systemd ordering (After=multi-user.target,After=ix-postinit.service), which cannot see middlewared's internal boot work. Observed on 25.10.4: the restart fired two seconds intoix-reporting.service'smidclt call reporting.start_serviceand before the docker/apps startup task (created on middlewared's system-ready event) had run. Both were killed, and nothing retries them until the next boot — every app stayed down (docker.statusFAILED, the apps dataset never mounted), netdata never started (no dashboard hardware stats), and the SMB middleware backend was left uninitialized.The transient unit now runs
patch/wait_restart.shinstead of restarting directly: it waits for the systemd boot job queue to drain (systemctl is-system-running --wait, covering in-flightix-*oneshots such as ix-reporting), then pollsmidclt call docker.statusuntil the docker state machine leaves its transitional states, then allows a short grace period for middleware-internal tasks with no queryable state before issuingsystemctl try-restart middlewared. The unit no longer setsType=oneshot— a oneshot's start job stays in the very queue the script waits on and would deadlock on itself. All waits are bounded and fail open: worst case the restart still happens, just later.Recovery on a boot that already hit this (without rebooting):
midclt call reporting.start_serviceandmidclt call docker.state.start_service true.
v0.2.0 — 2026-07-08
Changed
create_task.pynow uses the TrueNAS middleware viamidcltinstead of the deprecated/api/v2.0REST API, which is removed in TrueNAS 26.04. Practical effects:- Run the script on the TrueNAS host — it uses the local middleware socket, so it no longer needs a host address or API key.
--host,--api-key, and--insecureare accepted but ignored (a deprecation note is printed); they will be removed in a future release.list-credentials→cloudsync.credentials.query,list-tasks→cloud_backup.query,create→cloud_backup.create.
- Dropped the
ssl/urllibHTTP client; no TLS certificate handling is needed anymore.
v0.1.0 — 2026-07-08
Added
create --cache-path PATH— sets the restic cache directory on the task. Without a cache path, TrueNAS runs restic with--no-cache, which re-reads all repository metadata from the provider on every run and is glacially slow on large repos (a 564 GB dataset estimated 55 days to a first backup). Tasks created without--cache-pathnow print a warning explaining the consequence.
v0.0.4 — 2026-07-06
Fixed
-
Backend patch inactive after every reboot. PREINIT initshutdownscripts are executed by middlewared itself (
ix-preinit.servicerunsmidclt call initshutdownscript.execute_init_tasks PREINIT, ordered afterix-zfs.servicepool import). By the timeapply.shpatchedb2.pyandrestic.pyin the overlay, the running middlewared had already imported the stock modules and never re-imports — so S3/B2 support silently reverted on every reboot until something restarted middlewared.install.shmasked the bug because it restarts middlewared explicitly.Fix: when
apply.shdetects it was invoked by middlewared (boot context), it now schedules a single detached restart via a transient systemd unit (truecloud-mw-restart, ordered aftermulti-user.targetandix-postinit.service) so the patched modules are loaded once boot settles. The restart is never synchronous —apply.shis a child of middlewared's own job runner, and laterix-*boot units still need midclt. Manual runs ofapply.shnever trigger a restart. -
TypeError: string indices must be integerswhen creating a B2 task on TrueNAS 24.10 (Electric Eel) (#1). The credential schema differs between releases: on 24.10credentials["provider"]is the type string ("B2") with the account/key incredentials["attributes"], while 25.04+ moved them into a provider dict. The injectedget_restic_configonly handled the 25.04+ shape. It now detects the schema and reads the credentials from the right place on both;create_task.py list-credentialsandlist-tasksgot the same treatment. -
create_task.py verifyfalse-positive after reboot.verifytrustedhook_status.json, which only records that the files were patched on disk — not that the running process loaded them.verifynow also compares the middlewared main-process start time againstpatched_atand reports FAIL (with recovery instructions) when the process predates the patch.
Changed
- README and script comments no longer claim PREINIT runs "before middlewared starts"; the boot ordering and the deferred restart are now documented.
recover.shanduninstall.shcancel a still-queued deferred restart before performing their own, and their re-enable instructions now include the requiredsystemctl restart middlewared.
v0.0.3 — 2026-06-22
Fixed
-
patch/apply.shsilently killed by the 10-second PREINIT timeout. TrueNAS PREINIT initshutdownscripts have a 10-second default timeout. The previous apply.sh ran approximately 8 Python subprocesses (each ~1-2 s), so it was routinely killed mid-run. Symptoms: patches not applied after reboot, but re-runningbash apply.shmanually (no timeout) always succeeded.Fix:
install.shnow registers the hook with"timeout": 120. Existing installations are updated to the new timeout on the nextbash install.shrun.Additionally,
patch/apply.shconsolidates its Python subprocess invocations from ~8 down to 2, reducing startup overhead from ~12-16 s to ~2-4 s — well within the new 120-second budget.
Changed
find_mw_pythoninapply.shno longer spawns a separate Python process to verify the interpreter can importmiddlewared. Verification is now implicit in the combined path-discovery subprocess that follows.
v0.0.2 — 2026-06-19
Fixed
-
B2 backup failing with
NotImplementedErrorafter a TrueNAS update. The patch block's guard (if "get_restic_config" not in B2RcloneRemote.__dict__) could misfire and silently skip injecting the method — most likely when a TrueNAS version adds a stub that raisesNotImplementedError, causing the dict check to returnFalse. The guard is removed; the assignment is now unconditional. This is safe because the native-support kill switch already prevents patching when TrueNAS ships a real, working implementation. -
Native-support check falsely triggering kill switch on stubs. The check now inspects the source of any pre-existing
get_restic_configbefore concluding that TrueNAS has shipped native B2 support. If the method body containsNotImplementedErrorit is treated as a stub and patching continues; only a method that does not raiseNotImplementedErrortriggers the kill switch and auto-disable.
v0.0.1 — 2026-06-16
Initial public release. Extends TrueNAS SCALE's TrueCloud Backup feature to work with S3-compatible providers and native Backblaze B2 in addition to Storj, using volatile overlayfs patching of the TrueNAS middleware that persists across system updates via a PREINIT initshutdownscript.
Included
install.sh— registers the PREINIT boot hook and applies patches immediatelypatch/apply.sh— PREINIT script; mounts writable overlays, patchesb2.pyandrestic.py, patches the Angular UI bundle to widen the credential dropdownpatch/create_task.py— CLI to create TrueCloud Backup tasks with S3 or B2 credentials, bypassing the Storj-only restriction in the UIrecover.sh— emergency recovery; sets the kill switch and restarts middlewareduninstall.sh— full removal of the patch and PREINIT hook- Kill switch support (
disabledfile) for safe degradation - Auto-disable when TrueNAS ships native B2 restic support
hook_status.jsonwritten on each boot forcreate_task.py verify