Files
truenas-truecloud-patch/CHANGELOG.md
T
flan bb26edf351 Make nested snapshots opt-in; fix snapshot leaks found in audit
Opt-in
------
Nested-dataset snapshot support changes how backups read their source data, so
it is now off by default and gated behind a marker file:

  install.sh --enable-nested-snapshots
  install.sh --disable-nested-snapshots

With neither flag install.sh preserves the current setting, so a routine
`git pull && bash install.sh` can never silently flip it. When disabled,
apply.sh skips the patch entirely and the stock guard remains. uninstall.sh
tears down staging mounts and removes the marker.

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 recursive is
never True in the field. Enabling nested datasets makes recursive snapshots real:
the parent then has one child snapshot per descendant dataset (160+ on an Apps
pool), so stock's delete would orphan every child on EVERY successful run.

The patch now owns the lifecycle end to end:

- delete_snapshot_tree() sweeps the parent and all children, and is idempotent
  against stock's finally winning the race once our mounts are released
- on a staging failure the tree is deleted here, because sync.py never completes
  `snapshot, local_path = await create_snapshot(...)` and so its finally deletes
  nothing at all
- the snapshot is recorded in a sidecar file before anything is mounted, so a
  middlewared restart mid-backup cannot orphan it
- a crashed run's snapshot tree is reclaimed on the next run instead of being
  overwritten and leaked

Silent-omission fix
-------------------
The dataset list is now enumerated AFTER the snapshot. Read beforehand it could
miss a dataset created in the gap, which the recursive snapshot would capture but
the staging plan would not -- silently omitting its data. Read afterwards, an
unsnapshotted dataset trips the staging check and fails the run loudly.

Also from the audit
-------------------
- plan_staging scopes by dataset name, so skipped-dataset warnings no longer
  include every mountpoint-less dataset on the box, which buried the ones that
  matter
- staging_root_for rejects "." / ".." components that would escape the staging
  base, and resolves STAGING_BASE at call time rather than freezing it into a
  default argument
- uninstall.sh no longer `rm -rf`s a tree that may still contain live bind
  mounts, and unmounts by path depth rather than string length
- apply_plan takes an injectable isdir; verify_staged drops an unused parameter
- pin the shellcheck action instead of tracking @master

61 tests, ruff and shellcheck clean.
2026-07-12 21:52:09 +00:00

278 lines
14 KiB
Markdown

# Changelog
## v0.3.0 — 2026-07-12
### Added
- **`snapshot = true` now 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 flag `install.sh` preserves
the existing setting, so a `git pull && bash install.sh` cannot silently flip
it. When disabled, `apply.sh` skips the patch entirely and the stock guard
remains. `uninstall.sh` tears 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 with `config`/`pgdata` children 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.py` already 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 data
```
So 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.sh` installs the traversal, patches
`snapshot.py`, then `sync.py`, and only then `crud.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.delete`
defaults to `recursive=False` and stock `restic_backup()` calls it with no
options. Stock gets away with that only because its validation means
`recursive` is 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's `finally` winning 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 own `finally` would otherwise delete nothing at all, because its
`snapshot` local 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_nested` is 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_backup` only. 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 -n` on every script, ruff, and
pytest on Python 3.11/3.12/3.13. Includes tests that `compile()` the
`*_BLOCK` strings — 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
- Version strings in `install.sh`, `uninstall.sh`, and `recover.sh` were stale
at `0.0.4`; all scripts now report the same version.
- `patch_ui.py`: replaced a `try`/`except`/`pass` with `contextlib.suppress`
(no behaviour change; satisfies the new lint gate).
### Removed
- `patch/__pycache__/create_task.cpython-314.pyc` was committed to the
repository; it is now untracked and `__pycache__/` is gitignored.
### Known issues
- Stock `restic_backup()` deletes the ZFS snapshot in its own `finally`, which
fails with `EBUSY` while the staging bind mounts pin it. It logs one benign
`Error 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-restart` unit 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 into `ix-reporting.service`'s
`midclt call reporting.start_service` and 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.status` FAILED, 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.sh` instead of restarting
directly: it waits for the systemd boot job queue to drain
(`systemctl is-system-running --wait`, covering in-flight `ix-*` oneshots
such as ix-reporting), then polls `midclt call docker.status` until the
docker state machine leaves its transitional states, then allows a short
grace period for middleware-internal tasks with no queryable state before
issuing `systemctl try-restart middlewared`. The unit no longer sets
`Type=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_service` and
`midclt call docker.state.start_service true`.
## v0.2.0 — 2026-07-08
### Changed
- **`create_task.py` now uses the TrueNAS middleware via `midclt` instead of the
deprecated `/api/v2.0` REST 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 `--insecure` are 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`/`urllib` HTTP 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-path` now 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.service` runs
`midclt call initshutdownscript.execute_init_tasks PREINIT`, ordered after
`ix-zfs.service` pool import). By the time `apply.sh` patched `b2.py` and
`restic.py` in 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.sh` masked the
bug because it restarts middlewared explicitly.
Fix: when `apply.sh` detects it was invoked by middlewared (boot context),
it now schedules a single detached restart via a transient systemd unit
(`truecloud-mw-restart`, ordered after `multi-user.target` and
`ix-postinit.service`) so the patched modules are loaded once boot settles.
The restart is never synchronous — `apply.sh` is a child of middlewared's
own job runner, and later `ix-*` boot units still need midclt. Manual runs
of `apply.sh` never trigger a restart.
- **`TypeError: string indices must be integers` when creating a B2 task on
TrueNAS 24.10 (Electric Eel)** (#1). The credential schema differs between
releases: on 24.10 `credentials["provider"]` is the type string (`"B2"`)
with the account/key in `credentials["attributes"]`, while 25.04+ moved
them into a provider dict. The injected `get_restic_config` only handled
the 25.04+ shape. It now detects the schema and reads the credentials from
the right place on both; `create_task.py list-credentials` and `list-tasks`
got the same treatment.
- **`create_task.py verify` false-positive after reboot.** `verify` trusted
`hook_status.json`, which only records that the files were patched on disk —
not that the running process loaded them. `verify` now also compares the
middlewared main-process start time against `patched_at` and 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.sh` and `uninstall.sh` cancel a still-queued deferred restart
before performing their own, and their re-enable instructions now include
the required `systemctl restart middlewared`.
---
## v0.0.3 — 2026-06-22
### Fixed
- **`patch/apply.sh` silently 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-running `bash apply.sh` manually (no timeout) always succeeded.
Fix: `install.sh` now registers the hook with `"timeout": 120`. Existing
installations are updated to the new timeout on the next `bash install.sh` run.
Additionally, `patch/apply.sh` consolidates 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_python` in `apply.sh` no longer spawns a separate Python process to
verify the interpreter can import `middlewared`. Verification is now implicit in
the combined path-discovery subprocess that follows.
---
## v0.0.2 — 2026-06-19
### Fixed
- **B2 backup failing with `NotImplementedError` after 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 raises `NotImplementedError`, causing the dict
check to return `False`. 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_config`
before concluding that TrueNAS has shipped native B2 support. If the method
body contains `NotImplementedError` it is treated as a stub and patching
continues; only a method that does not raise `NotImplementedError` triggers
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 immediately
- `patch/apply.sh` — PREINIT script; mounts writable overlays, patches `b2.py`
and `restic.py`, patches the Angular UI bundle to widen the credential dropdown
- `patch/create_task.py` — CLI to create TrueCloud Backup tasks with S3 or B2
credentials, bypassing the Storj-only restriction in the UI
- `recover.sh` — emergency recovery; sets the kill switch and restarts middlewared
- `uninstall.sh` — full removal of the patch and PREINIT hook
- Kill switch support (`disabled` file) for safe degradation
- Auto-disable when TrueNAS ships native B2 restic support
- `hook_status.json` written on each boot for `create_task.py verify`