middlewared's alert.load() imports every file in alert/source/ with NO try/except:
def load(self):
for module in load_modules(.../alert/source):
for cls in load_classes(module, AlertSource, (ThreadedAlertSource,)):
...
and it runs during setup. A module that raises on import therefore takes
middlewared's startup down with it -- exactly the class of failure this project
exists to avoid.
apply.sh now COMPILES the substituted alert source and refuses to write it if it
does not parse. An uninstalled alert is a missing convenience; a broken one is a
broken box.
@PATCH_DIR@ is also substituted with repr() rather than raw, so a repository path
containing a quote or backslash yields a valid Python literal instead of a syntax
error in the installed module.
The alert source no longer mutates sys.path. It loaded tools/release_notes.py via
sys.path.insert(0, ...), which shadows the stdlib for that interpreter -- and
ThreadedAlertSource runs in middlewared's thread pool, so mutating sys.path is a
race. It now loads by file path with importlib.
New tests guard every import-time failure mode: the module compiles, apply.sh
compiles before writing, awkward paths (quotes, backslashes) still produce valid
modules, nothing but imports/constants/classes runs at module scope, every
AlertClass name ends in "AlertClass" (AlertClassMeta raises NameError otherwise),
the alert text placeholders match the args passed, and no git command that writes
to .git is ever used.
152 tests, ruff and shellcheck -S style clean.
33 KiB
Changelog
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