The matrix is regenerated daily by CI rather than typed once and forgotten — a support table that quietly goes stale is a false promise to someone deciding whether to trust this with their backups.
913 lines
42 KiB
Markdown
913 lines
42 KiB
Markdown
# truenas-truecloud-patch
|
||
|
||
Extends TrueNAS SCALE's **TrueCloud Backup** feature to:
|
||
|
||
- work with S3-compatible providers and native Backblaze B2, instead of Storj only;
|
||
- take **consistent snapshots of datasets that have child datasets** — which is
|
||
every box running Apps (see [Nested-dataset snapshots](#nested-dataset-snapshots)).
|
||
|
||
---
|
||
|
||
## Why this exists
|
||
|
||
In 2026, Storj raised the price of their TrueNAS-integrated storage tier from
|
||
**$5/month to $50/month** — a 10× increase. For many home lab and small-office
|
||
users, the TrueCloud Backup feature became unaffordable overnight.
|
||
|
||
TrueCloud Backup is the only native TrueNAS mechanism that provides:
|
||
- Integrated ZFS snapshot support before each backup
|
||
- Restic-based incremental deduplication
|
||
- Scheduled tasks with progress and log tracking in the UI
|
||
- Dataset lock integration
|
||
|
||
Running restic manually is possible but loses all of the above.
|
||
This patch restores access to the TrueCloud Backup feature for users who
|
||
need a provider other than Storj, with storage they already pay for or that
|
||
costs a fraction of the new Storj price.
|
||
|
||
---
|
||
|
||
## TrueNAS compatibility
|
||
|
||
<!-- BEGIN COMPAT MATRIX (generated by tools/compat.py --matrix --markdown) -->
|
||
| TrueNAS | B2/S3 providers | Nested snapshots | Hardware-verified |
|
||
| --- | --- | --- | --- |
|
||
| 24.10.2.4 | ok | ok | — |
|
||
| 25.04.2.6 | ok | ok | — |
|
||
| 25.10.4 | ok | ok | nested + providers; 252-snapshot recursive backup of /mnt/Tap, 18m |
|
||
| 26.0.0-BETA.3 _(unreleased)_ | ok | **BROKEN** | — |
|
||
| master _(unreleased)_ | **BROKEN** | **BROKEN** | — |
|
||
|
||
| verdict | meaning |
|
||
| --- | --- |
|
||
| **ok** | Every assumption the patch makes about middleware still holds. |
|
||
| **BROKEN** | middleware changed underneath the patch. `apply.sh` **refuses to apply that module** on this version and leaves TrueNAS stock, so backups keep working — without the module's feature. |
|
||
| **native** | TrueNAS does this itself now. The module retires; it is not a failure. |
|
||
|
||
"ok" means *the patch's assumptions hold*, checked automatically against iX's
|
||
source. It does not mean a human ran a backup on it — that is the
|
||
**Hardware-verified** column, which is filled in by hand and only by doing it.
|
||
<!-- END COMPAT MATRIX -->
|
||
|
||
The table above is **regenerated daily by CI** — it is not a claim somebody typed
|
||
once and forgot. **TrueCloud Backup does not exist before 24.10**, so earlier
|
||
versions are absent rather than "unsupported".
|
||
|
||
### ⚠️ TrueNAS 26 breaks nested snapshots (not yet released)
|
||
|
||
TrueNAS 26 rewrites the whole `cloud_backup` path **from async to synchronous**.
|
||
Every block the nested module injects is an `async def` wrapping an `await`ed
|
||
original, so on 26 it would hand `sync.py` a coroutine where it unpacks a tuple.
|
||
|
||
You do not need to do anything. `apply.sh` checks these assumptions against the
|
||
middleware **actually installed on your box** at every boot, and will not apply a
|
||
module that no longer fits. On TrueNAS 26 the nested module simply stays off:
|
||
backups keep running, without nested-dataset coverage. A broken backup is worse
|
||
than a missing feature.
|
||
|
||
### How this is kept honest
|
||
|
||
[`tools/compat.py`](tools/compat.py) is the single written-down record of what each
|
||
module assumes about middleware. It is checked in two places:
|
||
|
||
- **[CI](.github/workflows/compat.yml), daily** — against iXsystems' source at every
|
||
release line, *including `master` and the current BETA/RC*. When an unreleased
|
||
version breaks the patch, it files a bug report automatically. The point is to
|
||
find out while it is still a beta, rather than after it ships to you.
|
||
- **`apply.sh`, at every boot** — against the middleware installed on your machine.
|
||
A module whose assumptions no longer hold is not applied.
|
||
|
||
Regenerate the table with:
|
||
|
||
```bash
|
||
python3 tools/compat.py --matrix # human-readable
|
||
python3 tools/compat.py --matrix --markdown # the table above
|
||
python3 tools/compat.py --ref master # one version, with reasons
|
||
```
|
||
|
||
---
|
||
|
||
## Before you install
|
||
|
||
This project is unofficial and not affiliated with iXsystems. A few things worth
|
||
knowing:
|
||
|
||
- It targets **internal middleware APIs** with no stability contract, so a
|
||
TrueNAS update can break it. Every patch is fail-safe: if it can't apply,
|
||
middlewared starts normally and the reason is logged to `apply.log`. Check the
|
||
log after an update. See [TrueNAS compatibility](#truenas-compatibility) for
|
||
which versions currently work.
|
||
- If you file a TrueNAS bug report, **remove the patch first** and reproduce on a
|
||
stock system.
|
||
- **Test your restores.** True of any backup, but it matters more here — see
|
||
[Verifying it works](#verifying-it-works).
|
||
- Provided as-is, no warranty. See LICENSE.
|
||
|
||
The patch is two independent modules — **providers** (B2/S3) and **nested**
|
||
(snapshots on nested datasets) — and each retires on its own once TrueNAS ships
|
||
that capability natively. See [Native support](#if-truenas-adds-native-support).
|
||
|
||
## What's in the repo
|
||
|
||
| Path | What it is |
|
||
|---|---|
|
||
| `install.sh` | Registers the PREINIT boot hook, applies the patch, restarts middlewared. Also `--enable/--disable-nested-snapshots`. |
|
||
| `update.sh` | Fetch a newer **release** and apply it. `--check`, `--rollback`, `--to`, `--main`. |
|
||
| `uninstall.sh` | Remove everything: boot hook, patched files, UI bundle, staging mounts. |
|
||
| `recover.sh` | Emergency: set the kill switch and restart middlewared against stock files. |
|
||
| `patch/apply.sh` | The PREINIT script. Runs at **every boot**; re-applies the patch into a fresh overlay. |
|
||
| `patch/mw_patch.py` | The one implementation of apply/revert for the `TRUECLOUD_PATCH` blocks. Used by `apply.sh` *and* `uninstall.sh`. |
|
||
| `patch/truecloud_nested.py` | Nested-dataset staging: plan, mount, verify, tear down, sweep snapshots. Also `… cleanup` as a CLI. |
|
||
| `patch/patch_ui.py` | Widens the Angular credential dropdown. Refuses to write a bundle whose parens it unbalanced. |
|
||
| `patch/create_task.py` | Create TrueCloud tasks with S3/B2 credentials; `verify` the patch state. |
|
||
| `patch/alert_source.py` | The TrueNAS alert for "an update is available". Installed into `middlewared/alert/source/`. |
|
||
| `patch/wait_restart.sh` | Waits for boot to actually settle before restarting middlewared. |
|
||
| `release.sh` | Cut a release. Two stages, and the second is refused without the first. See [Releasing](#releasing). |
|
||
| `tools/compat.py` | What each module assumes about middlewared — and the checker. Run by CI daily *and* by `apply.sh` at every boot. |
|
||
| `tools/release_notes.py` | Extracts a version's CHANGELOG section; enforces version consistency. |
|
||
| `tools/release_gate.py` | The barrier: a stable release must have been a release candidate on the same commit. |
|
||
|
||
## Development
|
||
|
||
Parts of this project were written with AI assistance (Claude). All of it is
|
||
reviewed and tested before release; the test suite and CI exist in large part to
|
||
make that review meaningful. Bugs are mine.
|
||
|
||
```bash
|
||
pip install pytest ruff
|
||
ruff check patch tests tools
|
||
pytest tests
|
||
```
|
||
|
||
CI runs shellcheck, `bash -n`, ruff, and pytest on Python 3.11–3.13. Two checks
|
||
are worth calling out, because nothing else would catch what they catch:
|
||
|
||
- The tests **`compile()` the `*_BLOCK` strings** in `patch/apply.sh`. Those are
|
||
Python source appended into live `middlewared` modules — a syntax error there
|
||
breaks the box at boot, and they're string literals, so nothing else type-checks
|
||
them.
|
||
- CI asserts **every script declares the same version**, and that it matches the
|
||
newest CHANGELOG entry. `VERSION=` had silently drifted to three different
|
||
values across the scripts before anything checked.
|
||
|
||
The project is hosted on **Gitea** (`git.onetick.ninja/flan/truenas-truecloud-patch`)
|
||
and mirrored to GitHub. Both run the same workflows — Gitea reads
|
||
`.github/workflows/` too — so a change is checked twice, on two independent runners.
|
||
|
||
---
|
||
|
||
## Releasing
|
||
|
||
**Every release interrupts every user.** An update alert fires on each installed
|
||
box (see [Update alerts](#update-alerts)), so a release that exists only to fix the
|
||
last release teaches people to dismiss the alert — and one day that alert will be
|
||
carrying a security fix. This project cut twelve releases in a single day once.
|
||
Never again, and not by good intentions: by a gate.
|
||
|
||
### The rule
|
||
|
||
> A stable `vX.Y.Z` may only be published if a `vX.Y.Z-rcN` tag points at the
|
||
> **same commit**.
|
||
|
||
Release candidates are **invisible to users**: `update.sh` and the update alert both
|
||
take the newest plain `vX.Y.Z` tag, so an `-rc` is never offered as an update. All
|
||
the debugging therefore happens across `rc1`, `rc2`, `rc3` — at nobody's expense —
|
||
instead of across `v0.5.0`, `v0.5.1`, `v0.5.2`, at everybody's.
|
||
|
||
"The candidate passed, then I pushed one more little fix" is refused **by name**.
|
||
That is not hypothetical; it is exactly how v0.5.1 happened.
|
||
|
||
### Day to day
|
||
|
||
You don't touch the release machinery. Write your changes under `## Unreleased` in
|
||
`CHANGELOG.md` and push to `main`. `main` is a work surface — it is allowed to be
|
||
mid-thought. Releasing is a separate, deliberate act.
|
||
|
||
### Cutting a release
|
||
|
||
```bash
|
||
bash release.sh 0.6.0 --check # what would ship? what is the next rc?
|
||
|
||
bash release.sh 0.6.0 --rc # promotes `## Unreleased` -> v0.6.0, stamps every
|
||
# VERSION=, tags v0.6.0-rc1, pushes. Users see nothing.
|
||
|
||
# ... install it on a real box. Exercise it. Break it. ...
|
||
# Found a bug? Fix it on main, then `bash release.sh 0.6.0 --rc` again -> rc2.
|
||
|
||
bash release.sh 0.6.0 --promote # publishes v0.6.0. REFUSED unless an rc points here.
|
||
```
|
||
|
||
### The gates, and where they live
|
||
|
||
The logic is Python so it can be unit-tested; CI is the enforcement boundary
|
||
because it is the only actor holding the token that publishes. `release.sh` runs the
|
||
**same** code locally so you fail in 200 ms instead of after a push.
|
||
|
||
| gate | enforces | where |
|
||
| --- | --- | --- |
|
||
| [`release_notes.py check`](tools/release_notes.py) | every script's `VERSION=` matches the tag; the CHANGELOG section exists and is non-empty; **nothing is stranded under `## Unreleased`** | `release.sh` + CI |
|
||
| [`release_gate.py`](tools/release_gate.py) | **an rc points at this exact commit** | `release.sh` + CI |
|
||
| the full suite | ruff, pytest, shellcheck, `bash -n` — re-run against the *tagged* commit | CI |
|
||
|
||
A release's body **is** its `CHANGELOG.md` section — there is no second place to
|
||
write release notes, and therefore no second place for them to go stale. Releases
|
||
are published on both forges.
|
||
|
||
### Why the alert doesn't nag
|
||
|
||
A release whose CHANGELOG contains only a `### Docs` section changed no code, and
|
||
raises **no alert**. Candidates raise no alert either. So the only thing that ever
|
||
interrupts a user is a real, complete change — which is the entire point.
|
||
|
||
---
|
||
|
||
## What is actually patched
|
||
|
||
**Nothing in TrueNAS's persistent database or configuration is modified**
|
||
(other than the boot-hook entry itself). On every boot, `patch/apply.sh` runs
|
||
as a PREINIT script. It mounts a writable
|
||
[overlayfs](https://docs.kernel.org/filesystems/overlayfs.html) over the
|
||
relevant directories in `/usr/` (upper layer in `/run` tmpfs), then patches
|
||
`b2.py` and `restic.py` inside that overlay. The overlay is volatile — it
|
||
exists only for the current boot — but the PREINIT script recreates it
|
||
automatically on every subsequent boot. Nothing in `/usr/` is written to
|
||
directly.
|
||
|
||
PREINIT scripts are executed *by* middlewared, which by then has already
|
||
imported the stock modules — so after patching, `apply.sh` schedules a single
|
||
detached middlewared restart (transient systemd unit `truecloud-mw-restart`
|
||
running `patch/wait_restart.sh`) that loads the patched modules once boot has
|
||
*actually* settled: the script waits for the systemd boot job queue to drain
|
||
and for the docker/apps state machine to reach a terminal state before
|
||
restarting. Expect one middlewared restart shortly after every boot; the UI
|
||
and API are briefly unavailable while it happens, and running services are
|
||
not affected.
|
||
|
||
| Module | What changes | Technique |
|
||
|---|---|---|
|
||
| **providers** | `B2RcloneRemote` gains `get_restic_config()` — skipped automatically if TrueNAS already provides one on the class. `restic.py` URL builder is fixed: strips the stray leading slash and converts the slash separator to a colon (`b2:bucket:path`), which is the format restic 0.16.x expects. URL wrapper is a no-op if the URL is already correctly formed. | File patch applied inside the overlayfs upper layer |
|
||
| **providers** (UI) | The Angular bundle's `filterByProviders` binding is widened from `["STORJ_IX"]` to `["STORJ_IX","S3","B2"]` | In-place text replacement in the compiled JS chunk; original is backed up before patching |
|
||
| **nested** (opt-in) | `_truecloud_nested.py` is installed into `plugins/cloud/`, and `plugins/cloud/{snapshot,crud}.py` + `plugins/cloud_backup/sync.py` are patched so `snapshot = true` works on a dataset that has child datasets. See [below](#nested-dataset-snapshots). | New module + file patches inside the overlayfs upper layer |
|
||
|
||
All changes are **fail-safe**: if a patch cannot be applied (e.g. TrueNAS
|
||
restructured the relevant code), middlewared starts normally, the affected module
|
||
is simply inactive, and the reason is logged to `apply.log` in your repo root. The
|
||
two modules are independent — one failing or going native does not disable the
|
||
other.
|
||
|
||
## Nested-dataset snapshots
|
||
|
||
**Opt-in, off by default.** It changes how backups read their source data, so it
|
||
is never enabled implicitly:
|
||
|
||
```bash
|
||
bash install.sh --enable-nested-snapshots
|
||
bash install.sh --disable-nested-snapshots
|
||
```
|
||
|
||
With neither flag `install.sh` leaves the setting alone, so `git pull && bash
|
||
install.sh` won't flip it. The providers module is unaffected either way.
|
||
|
||
Validated end to end on a live 252-dataset pool: an unattended scheduled backup
|
||
of `/mnt/Tap` built a 173-mount staging tree, completed in **18m14s**, and left
|
||
**zero** orphaned snapshots and **zero** stale mounts behind. The same backup
|
||
previously stalled at 74% for over 12 hours reading live files.
|
||
|
||
Still: verify your own first run actually contains child-dataset data before you
|
||
rely on it — see [Verifying it works](#verifying-it-works). That advice is not
|
||
boilerplate; it is the specific thing this feature exists to make true.
|
||
|
||
TrueCloud Backup's **Take Snapshot** option makes restic read from a frozen ZFS
|
||
snapshot instead of live files. Without it the backup reads data *while apps are
|
||
writing to it* — databases get captured mid-write, and an app that rewrites its
|
||
files continuously can stall a backup indefinitely as restic chases a moving
|
||
target.
|
||
|
||
Stock TrueNAS refuses to enable it on most real-world paths:
|
||
|
||
```
|
||
[EINVAL] cloud_backup_update.snapshot:
|
||
This option is only available for datasets that have no further nesting
|
||
```
|
||
|
||
That rules out **any pool running Apps** — every app is its own dataset, usually
|
||
with `config`/`pgdata` children of its own. On a typical box that is 100+ nested
|
||
datasets, so the feature is effectively unusable exactly where it matters most.
|
||
|
||
### Why stock refuses
|
||
|
||
The guard is **correct**. `plugins/cloud/snapshot.py` already takes a *recursive*
|
||
ZFS snapshot — but it then points restic 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 data
|
||
```
|
||
|
||
So if you just remove the validation, restic walks a near-empty tree, reports
|
||
SUCCESS, and uploads almost nothing — a green backup job protecting no data. iX
|
||
gate the config rather than ship a backup that lies about succeeding.
|
||
|
||
That is worth spelling out, because deleting those four lines in
|
||
`plugins/cloud/crud.py` is the obvious "fix" and it is the wrong one. The guard
|
||
is load-bearing: it has to be *replaced* with a working traversal, not removed.
|
||
|
||
### What this patch does instead
|
||
|
||
After the (already recursive) snapshot is taken, every descendant dataset's own
|
||
`.zfs/snapshot/<snap>` is bind-mounted into a **staging tree** that mirrors the
|
||
original layout, and restic 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 precisely what the stock guard exists
|
||
to prevent, and it would be worse than not having the feature at all.
|
||
- **Post-mount verification** asserts every planned target really is a mountpoint
|
||
and the staging root is non-empty — so this cannot regress into the empty
|
||
backup it exists to fix.
|
||
- **The guard is relaxed last.** `apply.sh` installs the traversal, patches
|
||
`snapshot.py`, then `sync.py`, and only then `crud.py`. Any partial failure
|
||
leaves the guard intact and the option merely unavailable — never
|
||
"guard removed, traversal missing".
|
||
- Datasets that cannot contribute to a file tree (`mountpoint=none|legacy`,
|
||
unmounted, locked/encrypted) are skipped and **reported to the log** — never
|
||
dropped silently.
|
||
- Scoped to **cloud_backup only**. Cloud Sync (rclone) shares the same
|
||
validation mixin but has no staging teardown wired in, so its guard is
|
||
deliberately left in place.
|
||
|
||
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, defeating restic's parent detection and forcing a full
|
||
re-scan each time.
|
||
|
||
### Snapshot lifecycle
|
||
|
||
`zfs.snapshot.delete` defaults to **`recursive=False`**, and stock
|
||
`restic_backup()` calls it with no options. Stock is safe only because its
|
||
validation means a *recursive* snapshot never actually happens in the field.
|
||
Enabling nested datasets makes them real: on a 250-dataset pool,
|
||
`zfs snapshot -r` creates **250 snapshots**, and stock's delete removes only the
|
||
parent — orphaning **249 on every successful run** (measured, not theorised).
|
||
|
||
So the patch owns the whole lifecycle:
|
||
|
||
- **Sweeps the parent and every child**, and is idempotent against stock's
|
||
`finally` winning the race once the mounts are released.
|
||
- **Records the snapshot in a sidecar file before mounting anything**, so a
|
||
middlewared restart mid-backup cannot orphan the tree (this patch *schedules*
|
||
a restart at boot, so that is not hypothetical).
|
||
- **Reclaims the tree left by a crashed run** instead of overwriting the record.
|
||
- **Deletes the tree when staging fails** — sync.py's own `finally` deletes
|
||
*nothing* in that case, because its `snapshot` local never gets assigned.
|
||
- **Enumerates datasets *after* the snapshot, never before.** A list read
|
||
beforehand can miss a dataset created in the gap, which the recursive snapshot
|
||
*would* capture but the staging plan would not — a silent omission.
|
||
|
||
**Expected log noise:** stock's delete fails with `EBUSY` while the staging
|
||
mounts pin the snapshot. You will see one benign `Error deleting snapshot ...`
|
||
warning per run; the patch then unmounts and deletes the tree for real.
|
||
|
||
### Verifying it works
|
||
|
||
This feature exists because a backup can report SUCCESS while containing
|
||
nothing, so check the contents rather than the exit status:
|
||
|
||
```bash
|
||
# 1. Does the restic snapshot actually contain child-dataset data?
|
||
# Pick a path that lives in a CHILD dataset (e.g. an app's config).
|
||
midclt call cloud_backup.list_snapshots <task_id> | head
|
||
|
||
# 2. List a child-dataset path inside the newest restic snapshot.
|
||
# If this is empty, the staging tree did not work and you are backing up NOTHING.
|
||
midclt call cloud_backup.list_snapshot_directory <task_id> "<snapshot_id>" "/apps/lidarr/config"
|
||
```
|
||
|
||
You should see the app's real files (`lidarr.db`, `config.xml`, …). An empty
|
||
listing means the child datasets were not staged; disable the feature and open an
|
||
issue.
|
||
|
||
```bash
|
||
# 3. No snapshots may be left behind after a run.
|
||
zfs list -t snapshot -r <pool> | grep -c cloud_backup- # expect 0 between runs
|
||
|
||
# 4. No staging mounts may be left behind.
|
||
mount | grep truecloud-nested # expect no output
|
||
```
|
||
|
||
### Troubleshooting
|
||
|
||
| Symptom | Cause |
|
||
|---|---|
|
||
| `This option is only available for datasets that have no further nesting` | Feature not enabled. Run `install.sh --enable-nested-snapshots`, then restart middlewared. |
|
||
| Backup fails: `dataset '…' has no snapshot '…'; refusing to back up an incomplete tree` | Working as designed — a descendant dataset was not covered by the snapshot. The backup is refused rather than silently omitting that data. |
|
||
| Backup fails: `snapshot '…' cannot be read (Permission denied)` | The snapshot exists but is unreadable. Middleware runs as root, so this indicates a real permissions problem, not a missing snapshot. |
|
||
| `cloud_backup-*` snapshots accumulating | The sweep is not running. Check `apply.log` for the nested patch applying, and confirm `sync.py` carries the `TRUECLOUD_PATCH` block. |
|
||
| Web UI blank after a patch | A bad pattern unbalanced the bundle. `apply.sh` now refuses to write in that case, but if you hit it on an older version: restore `chunk-*.js.pre-truecloud-patch` over the live chunk, then re-run `install.sh`. (`MARKER` makes an already-patched file skip, so the patch cannot heal a corrupted bundle by itself.) |
|
||
| Stale mounts under `/run/truecloud-nested` | A crashed run. The next backup tears them down. To clear them now: `python3 patch/truecloud_nested.py cleanup` (also run by `uninstall.sh` and `recover.sh`). It names any ZFS snapshot an interrupted run left pinned. |
|
||
|
||
## Supported providers after patching
|
||
|
||
| Provider | Credential type in TrueNAS |
|
||
|---|---|
|
||
| Backblaze B2 (native B2 API) | `B2` |
|
||
| AWS S3, Wasabi, Cloudflare R2, MinIO, and any S3-compatible endpoint | `S3` |
|
||
| Storj (unchanged) | `STORJ_IX` |
|
||
|
||
## How persistence works
|
||
|
||
Two different things must survive two different events:
|
||
|
||
| Event | What would be lost | What makes it survive |
|
||
|---|---|---|
|
||
| **Reboot** | The overlay holding the patched files lives in `/run` (tmpfs) and vanishes | The PREINIT hook re-runs `apply.sh` on every boot and schedules one middlewared restart to load the result |
|
||
| **TrueNAS update** | `/usr/` is replaced entirely; custom files in `/etc/` are wiped with the new boot environment | This repo lives on your **data pool**, and the hook registration lives in the **TrueNAS config database** — both survive updates. The first boot after an update is just a normal boot |
|
||
|
||
### What happens on every boot
|
||
|
||
1. **middlewared starts** with the stock (unpatched) modules. This is
|
||
unavoidable: PREINIT scripts are executed *by* middlewared
|
||
(`ix-preinit.service` → `midclt call initshutdownscript.execute_init_tasks`),
|
||
so nothing registered there can run before it.
|
||
2. **Pools import** (`ix-zfs.service`), making `/mnt/<pool>` — and this
|
||
repository — available.
|
||
3. **`apply.sh` runs** (`ix-preinit.service`): mounts the writable overlay
|
||
(upper layer in `/run`), patches `b2.py` and `restic.py` on disk inside it,
|
||
patches the UI bundle, and writes `apply.log` and `hook_status.json`.
|
||
4. **A deferred restart is scheduled.** The middlewared that is running
|
||
imported the stock modules in step 1 and never re-imports, so the on-disk
|
||
patch alone is not enough. `apply.sh` detects it was invoked by middlewared
|
||
and creates a transient systemd unit (`truecloud-mw-restart`, via
|
||
`systemd-run --no-block`) running `patch/wait_restart.sh` — detached so it
|
||
cannot disrupt the remainder of the boot sequence.
|
||
5. **Once boot has settled, middlewared restarts once** and imports the
|
||
patched modules from the overlay. `wait_restart.sh` holds the restart until
|
||
the systemd boot job queue has drained (so in-flight `ix-*` units like
|
||
`ix-reporting` finish first) *and* middlewared's docker/apps startup has
|
||
reached a terminal state — plain unit ordering cannot see either, and
|
||
restarting middlewared while they run kills apps and dashboard reporting
|
||
for the whole boot. S3/B2 backup support is then active until the next
|
||
reboot, when the cycle repeats.
|
||
|
||
What you will observe: one middlewared restart shortly after every boot (a
|
||
brief web UI/API blip; running services are unaffected). Between steps 3
|
||
and 5 there is a short window — typically well under a minute — where the UI
|
||
already shows S3/B2 (the JS bundle is read from disk per request) but the
|
||
backend is still stock. A backup job that fires inside that window fails once
|
||
with `NotImplementedError` and succeeds on its next run; see
|
||
[Troubleshooting](#troubleshooting) if it persists beyond boot.
|
||
|
||
Manual runs of `bash patch/apply.sh` never trigger the restart — that only
|
||
happens in boot context. `install.sh` and `recover.sh` perform their own
|
||
explicit restarts instead, which is why a manual re-apply must be followed by
|
||
`systemctl restart middlewared`.
|
||
|
||
---
|
||
|
||
## Install
|
||
|
||
Clone the repository to a **persistent ZFS pool** so it survives OS updates,
|
||
then run `install.sh` from there:
|
||
|
||
```bash
|
||
# Replace /mnt/tank with your pool name
|
||
git clone https://github.com/sudolulo/truenas-truecloud-patch.git \
|
||
/mnt/tank/truenas-truecloud-patch
|
||
cd /mnt/tank/truenas-truecloud-patch
|
||
bash install.sh
|
||
```
|
||
|
||
The directory you clone into becomes the **permanent install location**. The
|
||
PREINIT boot hook is registered with the exact path you chose, and TrueNAS will
|
||
call that path on every boot.
|
||
|
||
> **Do not delete or move the repository after install.**
|
||
> If you need to relocate it, run `bash uninstall.sh` first, move the directory,
|
||
> then run `bash install.sh` again from the new location. Deleting the repo
|
||
> without uninstalling leaves a dangling PREINIT hook in the TrueNAS database —
|
||
> if that happens, see [Emergency recovery](#emergency-recovery) below.
|
||
|
||
Refresh your browser. S3 and B2 credentials now appear in the
|
||
**Data Protection → TrueCloud Backup → Add** credential dropdown.
|
||
|
||
## Updating
|
||
|
||
```bash
|
||
cd /mnt/tank/truenas-truecloud-patch
|
||
bash update.sh # to the newest release, with a confirmation
|
||
```
|
||
|
||
| | |
|
||
|---|---|
|
||
| `bash update.sh` | Update to the newest release tag. Shows what's coming, asks first. |
|
||
| `bash update.sh --check` | Show what *would* happen. Changes nothing. |
|
||
| `bash update.sh --rollback` | Undo the last update. |
|
||
| `bash update.sh --to v0.3.5` | Go to a specific tag or commit. |
|
||
| `bash update.sh --main` | Track **unreleased** `main`. You're on your own. |
|
||
| `bash update.sh --yes` | Skip the confirmation (for a scripted, *attended* run). |
|
||
|
||
Run it as **root** — it calls `install.sh`, which needs to reload middlewared.
|
||
|
||
### First time: bootstrapping `update.sh`
|
||
|
||
`update.sh` ships *inside* the patch, so a clone older than v0.4.0 doesn't have it
|
||
yet. Bootstrap it once with git:
|
||
|
||
```bash
|
||
cd /mnt/tank/truenas-truecloud-patch
|
||
git pull # or: git checkout v0.4.1
|
||
bash install.sh
|
||
```
|
||
|
||
Every update after that is just `bash update.sh`.
|
||
|
||
> If a plain `git pull` fails with *"insufficient permission for adding an object
|
||
> to repository database"*, past `sudo git pull`s left root-owned objects in
|
||
> `.git`. Fix it once as root: `chown -R <you>:<you> .git`. (`update.sh` repairs
|
||
> this automatically from then on.)
|
||
|
||
### What it does for you
|
||
|
||
- **Preserves your nested-snapshot opt-in setting** — updating never flips it.
|
||
- **Shows the commits and release notes you don't have**, then asks before moving.
|
||
- **Records the previous revision *before* checking out**, so `--rollback` works
|
||
even if `install.sh` dies halfway through.
|
||
- **Refuses to run over a dirty working tree**, rather than merging across
|
||
hand-edited or scp'd files and losing them.
|
||
- **Detects untracked files that would be clobbered** by the checkout and names
|
||
them, instead of dying on a raw git error mid-update.
|
||
- **Repairs `.git` ownership** left root-owned by past `sudo git pull`s.
|
||
|
||
It updates to the newest **release tag**, not `main`. `main` can be mid-refactor;
|
||
a tag is the tested artifact, and CI gates every release. Pre-release tags
|
||
(`-rc`, `-beta`) are skipped — `--to` them explicitly if you want one.
|
||
|
||
After updating, the checkout is pinned to a release tag (detached HEAD). That is
|
||
what you want for a deployment: plain `git pull` no longer applies, and
|
||
`update.sh` is the supported path.
|
||
|
||
### Why there is no auto-update
|
||
|
||
**Never put this in 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
|
||
detonate on the next reboot. That is not hypothetical: **v0.0.4 shipped exactly
|
||
such a bug and took all 54 apps on a box down.**
|
||
|
||
The manual step *is* the safety gate. If you want convenience, watch the
|
||
[releases feed](https://github.com/sudolulo/truenas-truecloud-patch/releases);
|
||
don't automate the pull.
|
||
|
||
## Update alerts
|
||
|
||
When a newer release exists, the patch raises a **TrueNAS alert** (the bell in the
|
||
UI) telling you so. It's on by default and checks once a day.
|
||
|
||
```bash
|
||
bash install.sh --no-update-alerts # turn it off
|
||
bash install.sh --update-alerts # turn it back on
|
||
```
|
||
|
||
**It will not nag you about a README.** A release whose CHANGELOG contains only a
|
||
`### Docs` section changed no code, and raises nothing. Anything that touched the
|
||
system raises an INFO alert; a release with a `### Security` section raises a
|
||
WARNING. The CHANGELOG's own section headings are the signal, and a security fix
|
||
anywhere in the range escalates the whole span — a docs-only release on top of a
|
||
security fix still reports as security.
|
||
|
||
### How it works, and why it's built this way
|
||
|
||
TrueNAS **cannot raise an alert from the CLI** — `midclt` exposes only
|
||
`alert.dismiss`, `alert.list`, `alert.list_categories`, `alert.list_policies` and
|
||
`alert.restore`. Alert *creation* is internal to middlewared, and none of its ~60
|
||
one-shot alert classes is generic enough to reuse. So the only way to get a real
|
||
alert is to register an `AlertSource`, which is what `patch/alert_source.py` does.
|
||
|
||
That is also the **least invasive** thing this patch does:
|
||
|
||
| | |
|
||
|---|---|
|
||
| providers module | **modifies** stock files (appends code to `b2.py`, `restic.py`) |
|
||
| nested module | **modifies** stock files (3 middleware modules) |
|
||
| **update alert** | **adds one file. Modifies nothing.** |
|
||
|
||
It's the native mechanism — the same one every built-in TrueNAS alert uses — and
|
||
TrueNAS polls it itself, so there is no cron job and no systemd timer.
|
||
|
||
- **Fail-safe.** Every error path returns `None`. It cannot take middlewared down.
|
||
- **Read-only.** `git ls-remote` plus an HTTPS fetch of the CHANGELOG. It never
|
||
writes to `.git`, so it cannot leave root-owned objects behind the way a
|
||
`git fetch` from middlewared (which runs as root) would.
|
||
- **Removed by `uninstall.sh`.**
|
||
|
||
It only *tells* you. It never updates anything — see
|
||
[Why there is no auto-update](#why-there-is-no-auto-update).
|
||
|
||
## Creating a task via CLI
|
||
|
||
If the UI still shows only Storj after refreshing (e.g. the JS bundle pattern
|
||
changed in a new TrueNAS version), create tasks directly. Run this **on the
|
||
TrueNAS host** — it talks to the local middleware via `midclt`, so it needs no
|
||
host address or API key:
|
||
|
||
```bash
|
||
# Replace /mnt/tank/truenas-truecloud-patch with your clone path
|
||
|
||
# List your cloud credentials to find the right ID
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py list-credentials
|
||
|
||
# Create a task with a B2 credential (id=3).
|
||
# The restic repo password is read from stdin, so it never lands in your shell
|
||
# history — nor in any process's argv, where `ps` would expose it.
|
||
printf '%s' 'restic-repo-password' | \
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py create \
|
||
--name "tank-to-b2" \
|
||
--path /mnt/tank/data \
|
||
--credential 3 \
|
||
--bucket my-bucket \
|
||
--folder backups/tank \
|
||
--password-stdin \
|
||
--cache-path /mnt/tank/.restic-cache \
|
||
--keep-last 14
|
||
```
|
||
|
||
Omit `--password-stdin` and you'll be prompted for the password instead. `--password
|
||
<secret>` still works but warns: that password is the encryption key for the whole
|
||
repository, and a CLI argument persists in your shell history forever.
|
||
|
||
> **Always pass `--cache-path`.** Without it TrueNAS runs restic with `--no-cache`,
|
||
> which re-fetches all repo metadata from the provider every run — glacially slow
|
||
> on large repos. Point it at a writable dir on a pool with free space.
|
||
|
||
> Versions ≤ 0.1.0 used the `/api/v2.0` REST API with `--host`/`--api-key`; those
|
||
> flags are now accepted-but-ignored (REST is removed in TrueNAS 26.04).
|
||
|
||
---
|
||
|
||
## Uninstall
|
||
|
||
```bash
|
||
bash /mnt/tank/truenas-truecloud-patch/uninstall.sh
|
||
```
|
||
|
||
Replace the path with your clone location. Removes the PREINIT hook,
|
||
unmounts the overlay (restoring the original backend files immediately),
|
||
and restores the original UI bundle from backup.
|
||
|
||
---
|
||
|
||
## If TrueNAS adds native support
|
||
|
||
The patch is **two independent modules**, and each retires on its own — TrueNAS
|
||
is likely to ship one of these natively long before the other, and a module
|
||
going native must not take the other one down with it.
|
||
|
||
| Module | What it does | Detected as native when |
|
||
|---|---|---|
|
||
| **providers** | B2/S3 credentials for TrueCloud Backup (`b2.py`, `restic.py`, UI dropdown) | `B2RcloneRemote` carries a real `get_restic_config()` |
|
||
| **nested** | Snapshots on datasets with child datasets (`plugins/cloud/*`) | the *"no further nesting"* validation is gone from `plugins/cloud/crud.py` |
|
||
|
||
At every boot `apply.sh` checks both:
|
||
|
||
- **One module goes native** → that module is skipped and logged; the other keeps
|
||
working, and the patch stays installed.
|
||
- **Both are done** (native, or nested was never enabled) → the kill switch
|
||
(`disabled` file) is set, overlays are unmounted, and `apply.log` tells you to
|
||
run `uninstall.sh`.
|
||
|
||
So on a box using only the provider patch, native B2 support retires the whole
|
||
thing as before. On a box that also uses nested snapshots, native B2 support
|
||
retires *just* that half.
|
||
|
||
Check the log after any TrueNAS update:
|
||
```bash
|
||
tail -20 /mnt/tank/truenas-truecloud-patch/apply.log
|
||
```
|
||
|
||
`hook_status.json` reports each module separately (`module.providers`,
|
||
`module.nested_snapshots`) with an `active` flag and a reason.
|
||
|
||
**Scenarios where the auto-detect may not fire** (manual check needed):
|
||
|
||
| Scenario | What happens | Action |
|
||
|---|---|---|
|
||
| B2 support added to a **base class** (not `B2RcloneRemote` directly) | `__dict__` check misses it; our method shadows native | Uninstall manually |
|
||
| B2 **credential schema changed** (e.g. `provider["account"]` renamed) | `KeyError` on first backup | Uninstall or update the patch |
|
||
| **URL builder** fixed but B2 class unchanged | URL wrapper becomes a no-op; no harm, but patch is dead weight | Uninstall at your convenience |
|
||
|
||
---
|
||
|
||
## After a TrueNAS update
|
||
|
||
A TrueNAS update replaces `/usr/` wholesale, wiping the patch. You do **not** need
|
||
to reinstall: `patch/apply.sh` runs at every boot and re-applies itself from your
|
||
clone. But it targets internal APIs with no stability contract, so an update *can*
|
||
break it — and the failure is quiet by design (middlewared starts fine; the patch
|
||
just doesn't).
|
||
|
||
**Check the log after any TrueNAS update:**
|
||
|
||
```bash
|
||
tail -30 /mnt/tank/truenas-truecloud-patch/apply.log
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
|
||
```
|
||
|
||
| What you see | What it means |
|
||
|---|---|
|
||
| `[OK] providers`, `[OK]`/`[SKIP] nested_snapshots` | Fine. Nothing to do. |
|
||
| `WARNING: … pattern not found` (UI) | The Angular bundle changed. The UI dropdown reverts to Storj-only, but **backups keep working** — create tasks with `create_task.py` meanwhile, and [open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues) with your TrueNAS version. |
|
||
| `[FAIL] providers` | **Your B2/S3 backups will not run.** middlewared is fine, but the credential/URL handling is gone. Open an issue with your version. |
|
||
| `[FAIL] nested_snapshots` | The stock guard is back, so tasks with `snapshot = true` on a nested dataset will fail validation. Turn the option off on those tasks until it's fixed. |
|
||
|
||
"Fail-safe" means *the box stays up* — not that your backups keep running. A
|
||
`[FAIL] providers` is a broken backup, so check the log rather than assume.
|
||
|
||
Then update the patch itself if a newer release fixes it:
|
||
|
||
```bash
|
||
bash /mnt/tank/truenas-truecloud-patch/update.sh
|
||
```
|
||
|
||
---
|
||
|
||
## Emergency recovery
|
||
|
||
### middlewared won't start
|
||
|
||
Run this from the TrueNAS shell (local console, SSH, or the debug shell in
|
||
the UI):
|
||
|
||
```bash
|
||
bash /mnt/tank/truenas-truecloud-patch/recover.sh
|
||
```
|
||
|
||
Replace the path with your clone location. This creates a kill-switch file
|
||
(`disabled`) in the repo root, unmounts the overlay so the original files are
|
||
visible immediately, then restarts middlewared. No reboot required.
|
||
|
||
If you cannot run a script and only have a bare shell prompt:
|
||
|
||
```bash
|
||
touch /mnt/tank/truenas-truecloud-patch/disabled
|
||
systemctl restart middlewared
|
||
```
|
||
|
||
If you don't remember where you cloned the repo (midclt won't work while middlewared is
|
||
down), find the path two ways:
|
||
|
||
```bash
|
||
# Option 1 — search the filesystem:
|
||
find /mnt -name "recover.sh" -path "*/truenas-truecloud-patch/*" 2>/dev/null
|
||
|
||
# Option 2 — query the TrueNAS database directly:
|
||
sqlite3 /data/freenas-v1.db \
|
||
"SELECT script FROM initshutdownscript WHERE comment = 'TrueCloud provider patch (S3/B2)';"
|
||
```
|
||
|
||
The `script` column shows the full path to `patch/apply.sh`; your clone root is one
|
||
level up (strip `/patch/apply.sh` from the end). Then run the `touch` command above
|
||
with that path.
|
||
|
||
If middlewared **still** won't start after the kill switch is set, the problem
|
||
is unrelated to this patch. Check:
|
||
|
||
```bash
|
||
journalctl -u middlewared -n 50
|
||
```
|
||
|
||
To re-enable the patch once you have investigated:
|
||
|
||
```bash
|
||
rm /mnt/tank/truenas-truecloud-patch/disabled
|
||
bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
|
||
systemctl restart middlewared # manual apply.sh runs never restart for you
|
||
```
|
||
|
||
---
|
||
|
||
### Web UI is blank or broken
|
||
|
||
If the TrueNAS web interface loads blank or shows JavaScript errors, the
|
||
Angular bundle may have been interrupted mid-write (e.g. power cut during
|
||
boot). The original bundle is always backed up before patching, so recovery
|
||
is straightforward:
|
||
|
||
```bash
|
||
# Find the backup (the path varies by TrueNAS version):
|
||
find /usr/share/truenas /usr/share/truenas-ui /var/www/truenas -name "*.js.pre-truecloud-patch" 2>/dev/null
|
||
|
||
# Restore it — substitute the actual path from the find output:
|
||
mv /usr/share/truenas/webui/main.XXXXXXXX.js.pre-truecloud-patch \
|
||
/usr/share/truenas/webui/main.XXXXXXXX.js
|
||
```
|
||
|
||
Refresh your browser. The UI will return to normal (Storj-only until the
|
||
patch re-runs at next reboot, or you run
|
||
`bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh` manually).
|
||
|
||
---
|
||
|
||
### Backend verify shows FAIL
|
||
|
||
```bash
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
|
||
```
|
||
|
||
`verify` reports one line per module:
|
||
|
||
| Label | Meaning |
|
||
|---|---|
|
||
| `[OK ]` | Module is active and applied. |
|
||
| `[SKIP]` | Module is inactive — either TrueNAS now does it natively, or it is opt-in and switched off. **Not a failure.** `nested_snapshots` shows SKIP on a default install. |
|
||
| `[FAIL]` | Module is needed but did not apply. |
|
||
|
||
If a module shows `[FAIL]`:
|
||
|
||
1. **Check the apply log** for errors during the last boot:
|
||
```bash
|
||
tail -40 /mnt/tank/truenas-truecloud-patch/apply.log
|
||
```
|
||
2. **Check middlewared's own log** for Python tracebacks:
|
||
```bash
|
||
grep -i "truecloud\|traceback\|error" /var/log/middlewared.log 2>/dev/null | tail -30
|
||
journalctl -u middlewared -n 50
|
||
```
|
||
3. **A FAIL is non-fatal.** middlewared runs normally and the other module is
|
||
unaffected; the failed one is simply inactive. Existing backups are not at
|
||
risk.
|
||
4. **If the detail says the module doesn't exist**, a TrueNAS update renamed
|
||
or restructured the internal API.
|
||
[Open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues)
|
||
with your TrueNAS version number and the full verify output.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
**Backups fail with `NotImplementedError` after a reboot**
|
||
|
||
The traceback ends in `rclone/base.py` → `raise NotImplementedError` and
|
||
contains no `_tc_` frames: the running middlewared is executing stock code.
|
||
Either the deferred restart never fired, or the patch never landed on disk
|
||
this boot. Diagnose in this order:
|
||
|
||
```bash
|
||
# Did apply.sh run this boot, at which version, and did it schedule the restart?
|
||
tail -40 /mnt/tank/truenas-truecloud-patch/apply.log
|
||
|
||
# Full check — compares the running process against the patch timestamp
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
|
||
|
||
# Did the deferred restart unit run, fail, or never get created?
|
||
systemctl status truecloud-mw-restart.service
|
||
journalctl -u truecloud-mw-restart.service --no-pager | tail -20
|
||
```
|
||
|
||
- `verify` reports the process started **before** the patch → the restart
|
||
didn't happen. `systemctl restart middlewared` fixes it immediately; the
|
||
journal output above tells you why it was missed.
|
||
- `apply.log` shows the kill switch is active → `rm .../disabled`, then
|
||
`bash install.sh`.
|
||
- `apply.log` has no entry for this boot → the hook didn't run; re-run
|
||
`bash install.sh` to re-register it.
|
||
- `apply.log` header shows `[v0.0.3]` or older → update:
|
||
`git pull && bash install.sh` (v0.0.4 fixed patches not loading after
|
||
reboot).
|
||
|
||
**Apply log** (check after each reboot or install):
|
||
```bash
|
||
cat /mnt/tank/truenas-truecloud-patch/apply.log
|
||
```
|
||
|
||
**Verify backend patch is loaded** (while middlewared is running):
|
||
```bash
|
||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
|
||
```
|
||
Reads `hook_status.json` written by `apply.sh` at boot **and** checks that the
|
||
running middlewared process started *after* the patches were applied — an
|
||
on-disk patch that middlewared has not loaded yet is reported as FAIL with
|
||
instructions. Does not require `--host` or `--api-key`.
|
||
|
||
**Middlewared log:**
|
||
```bash
|
||
grep truecloud-patch /var/log/middlewared.log 2>/dev/null | tail -20
|
||
journalctl -u middlewared -n 100 2>/dev/null | grep truecloud-patch
|
||
```
|
||
|
||
**Verify the UI patch** (should print your TrueNAS version):
|
||
```bash
|
||
grep -c 'STORJ_IX.*S3.*B2' \
|
||
$(find /usr/share/truenas -name '*.js' 2>/dev/null) 2>/dev/null \
|
||
| grep -v ':0'
|
||
```
|
||
|
||
**`create_task.py` — "midclt not found" or permission errors**
|
||
`create_task.py` now talks to the local middleware via `midclt`, so run it **on
|
||
the TrueNAS host** (not remotely) as a user with middleware access (root). There
|
||
is no HTTPS/API-key call anymore, so there is no TLS certificate to configure.
|