Compare commits

..
6 Commits
Author SHA1 Message Date
flan 345741e1f1 v0.4.2: README — document updating properly
The Updating section told you to run update.sh, but never said how to GET it. It
ships inside the patch, so any clone older than v0.4.0 does not have it -- the docs
described a script the reader did not possess. There is now an explicit bootstrap
step, including the fix for the "insufficient permission for adding an object to
repository database" failure that past `sudo git pull`s leave behind.

Rewrote "After a TrueNAS update". It never explained that apply.sh re-applies the
patch at every boot (so you never reinstall), and it soft-pedalled what a failure
costs: "fail-safe" means the BOX stays up, not that your backups keep running. A
[FAIL] providers is a broken backup, and the docs now say that plainly instead of
implying everything degrades gracefully.

Added a repo map -- patch/mw_patch.py and tools/release_notes.py were documented
nowhere -- and fixed `ruff check patch tests`, which skips tools/.

Every command and file path in the README was then verified to exist and run:
all five update.sh flags, the ruff/pytest commands, every file in the repo map,
and the truecloud_nested.py cleanup CLI.

132 tests, ruff and shellcheck -S style clean.
2026-07-13 16:36:28 +00:00
flan 092bdeae29 v0.4.1: fix three real bugs in update.sh found by auditing it
Release-candidate tags would have been installed as stable
-----------------------------------------------------------
git's version sort ranks v0.5.0-rc1 ABOVE v0.5.0 (verified empirically), and the
release workflow deliberately supports rc/beta/alpha tags. update.sh would have
offered an RC as "the newest release". Tag selection is now filtered to plain
vX.Y.Z.

update.sh would have died mid-update on an untracked file
----------------------------------------------------------
The dirty-tree guard uses --untracked-files=no, so an untracked file that the
TARGET tracks slips past it -- and `git checkout` then aborts. Under set -e the
script died with a raw git error, after already recording the rollback point.

Not hypothetical: a hand-copied patch/wait_restart.sh blocked a pull on a real box
in exactly this way. It is now detected up front, by name. Gitignored files are
correctly not treated as blockers, since git overwrites those silently.

Special case: if update.sh ITSELF is the blocker, it was hand-copied in to
bootstrap -- and "delete update.sh, then re-run update.sh" is impossible. It now
says so and prints the git commands that bootstrap it properly.

--rollback skipped that check entirely and 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.

Also: install.sh's chmod aborted under set -e if a listed file was missing (the
file set changes between versions, so --rollback must not be killed by a name this
version happens to know about), and --to with no value was silently ignored.

Verified end to end in a throwaway clone: forward v0.4.1 -> v0.4.2 and rollback
back, with files appearing and disappearing correctly; both guards fire.

132 tests, ruff and shellcheck -S style clean.
2026-07-13 16:30:50 +00:00
flan 347c415aa7 v0.4.0: add update.sh
Fetch a newer release and apply it, preserving the 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 update

Deliberately NOT automated. This patch injects Python into middlewared and
re-applies itself at every boot, so an unattended pull would let any bad upstream
commit reach a 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 decisions worth keeping:

- Defaults to the newest RELEASE TAG, not main. main can be mid-refactor; a tag is
  the tested artifact. --main exists but says so loudly.
- Tags ordered by version, not date. Date order silently downgrades the box the
  first time a hotfix is tagged out of band: a v0.3.6 cut 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. (Verified: the guard fires.)
- Shows the commits and release notes you do not have, read from the TARGET's
  CHANGELOG via tools/release_notes.py -- not a second copy of the extractor.
- Records the previous revision BEFORE moving, so --rollback works even if
  install.sh dies halfway.
- Repairs .git ownership, which past `sudo git pull`s leave root-owned and which
  then breaks every later non-root git command.

update.sh is covered by the version-drift check, so it cannot go stale the way
create_task.py's __version__ did.

Tested end to end in a throwaway clone: detects v0.3.2 -> v0.3.5, lists missing
commits, handles already-up-to-date, and the dirty-tree guard fires.

132 tests, ruff and shellcheck clean.
2026-07-13 16:20:37 +00:00
flan 45f957af23 v0.3.5: log the recursive-delete failure instead of swallowing it
delete_snapshot_tree tries one recursive delete first, then falls back to sweeping
the tree by name. The exception from the fast path was discarded.

That failure is usually benign -- stock's finally already removed the parent once
our mounts were released, which is exactly what the sweep exists to handle. But if
the cause were anything else, this was the only place it was ever visible, and it
went straight to /dev/null. The sweep would then report some different, downstream
symptom. It is now logged before falling through.

Also annotated the two remaining static-analysis findings as considered rather than
leaving them to be re-litigated every audit: subprocess is always invoked in list
form (no shell, so ZFS dataset names cannot inject), and a partial `systemctl` path
is moot in a script that only ever runs as root.

Extended ruleset (E,F,W,B,S,SIM,UP,C4,RET,ARG,A,ISC) and shellcheck -S style both
report zero. 132 tests.
2026-07-13 16:06:12 +00:00
flan 126756498c v0.3.4: one implementation of apply/revert (patch/mw_patch.py)
The "strip the TRUECLOUD_PATCH block" logic existed twice -- in apply.sh's heredoc
and in an inline heredoc in uninstall.sh -- and the uninstall copy was the untested
one. That is precisely how the two could have drifted apart, with apply.sh
reverting one set of files and uninstall.sh another.

Both now call patch/mw_patch.py. 17 new tests cover it, including that
revert_nested never touches restic.py: that file carries a TRUECLOUD_PATCH block
too, but it belongs to the providers module, and removing it would silently break
B2 backups.

apply.sh imports it fail-safe -- on ImportError the backend patch is skipped and
middlewared starts stock, which is this script's whole design principle. The
import uses sys.path.append, never insert(0): prepending would give patch/
precedence over the stdlib for that interpreter, so a future patch/json.py would
shadow the real json module and break the boot.

Also: the README's create_task.py example still taught `--password <secret>`, which
is how a security fix quietly fails to land. It now shows --password-stdin.

132 tests, ruff and shellcheck clean.
2026-07-13 16:02:53 +00:00
flan 60b3ac4557 v0.3.3: keep the restic repo password out of argv and shell history
Security
--------
create_task.py shelled out to `midclt call cloud_backup.create '<json>'`, and that
JSON carries the restic repository password -- so it sat in the subprocess's argv,
which is world-readable via ps, 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 backs
midclt itself, so the password never leaves this process's memory. Verified on a
live box: list-tasks and list-credentials work through the new transport.

--password is also no longer required, because passing a secret as a CLI argument
writes it to shell history permanently. --password-stdin reads it from stdin, and
with neither flag the tool prompts via getpass. --password still works but warns.

Fixed
-----
uninstall.sh could leave every patch installed. It reverted by unmounting the
overlay -- but apply.sh only mounts one when the target directory is read-only. On
a writable /usr it 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. This also covers the case where the
overlay unmount fails.

create_task.py's __version__ had been stuck at 0.2.0 through three releases. The
version-drift check added in v0.3.1 only looked at VERSION= 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.

118 tests, ruff and shellcheck clean.
2026-07-13 15:43:24 +00:00
14 changed files with 1144 additions and 153 deletions
+166
View File
@@ -1,5 +1,171 @@
# Changelog
## 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 past `sudo git pull`s
cause.
- **`After a TrueNAS update` rewritten.** 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] providers` is a **broken backup**, and the
docs now say so rather than implying everything degrades gracefully.
- Added a repo map. `patch/mw_patch.py` and `tools/release_notes.py` were
documented nowhere.
- `Development` told you to run `ruff check patch tests`, which misses `tools/`.
- Every command and file path in the README is now verified to exist and run.
## v0.4.1 — 2026-07-13
### Fixed
- **`update.sh` would have picked a release candidate as "the newest release".**
Git's version sort ranks `v0.5.0-rc1` *above* `v0.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
plain `vX.Y.Z`.
- **`update.sh` would 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 — and `git checkout` then aborts. Under `set -e` the 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-copied `patch/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.sh` *itself* is the blocker, you hand-copied it in to
bootstrap — and "delete `update.sh`, then re-run `update.sh`" is impossible. It
now says so and prints the git commands that bootstrap it properly.
- **`--rollback` skipped 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`'s `chmod` aborted under `set -e` if any listed file was missing. The
file set changes between versions, so `update.sh --rollback` to an older revision
must not be killed by a filename this version happens to know about.
- `--to` with 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
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 update
```
**Run 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`.** `main` can be mid-refactor;
a tag is the tested artifact. `--main` exists 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 `--rollback` works even if
`install.sh` dies halfway.
- Repairs `.git` ownership, which past `sudo git pull`s leave root-owned and
which then breaks every later non-root git command.
- `update.sh` is covered by the version-drift check, so it cannot quietly go stale
the way `create_task.py.__version__` did.
## v0.3.5 — 2026-07-13
### Changed
- `delete_snapshot_tree` swallowed the error from its recursive-delete fast path.
That failure is *usually* just "parent already gone" — stock's `finally` winning
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: `subprocess` is always called in
list form (no shell, so ZFS dataset names cannot inject), and the partial
`systemctl` path 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 the
`TRUECLOUD_PATCH` block" logic existed twice — in `apply.sh`'s heredoc and in an
inline heredoc in `uninstall.sh` — and the uninstall copy was the untested one.
That is exactly how the two could have drifted apart, with `apply.sh` reverting
one set of files and `uninstall.sh` another. Both now call the same tested
module (17 new tests, including that `revert_nested` never touches `restic.py`,
which belongs to the providers module and whose removal would silently break B2
backups).
`apply.sh` imports 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 uses `sys.path.append`, never `insert(0)` — prepending would give
`patch/` precedence over the stdlib for that interpreter, so a future
`patch/json.py` would shadow the real `json` and break the boot.
### Docs
- The README's `create_task.py` example 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.py` shelled out to `midclt call cloud_backup.create '<json>'`, and
that JSON contains the repo password — so it appeared in the process's argv,
which is world-readable via `ps`, 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
backs `midclt` itself), so the password never leaves the process's memory.
- **`--password` no longer required.** Passing a secret as a CLI argument writes it
to shell history permanently. `--password-stdin` reads it from stdin, and with
neither flag the tool prompts via `getpass`. `--password` still works but now
warns.
### Fixed
- **`uninstall.sh` could leave every patch installed.** It reverted by unmounting
the overlay — but `apply.sh` only mounts one when the target directory is
read-only. On a writable `/usr` it 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 at `0.2.0`** for three releases.
The version-drift check added in v0.3.1 only looked at `VERSION=` 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
+125 -26
View File
@@ -46,6 +46,22 @@ 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/wait_restart.sh` | Waits for boot to actually settle before restarting middlewared. |
| `tools/release_notes.py` | Extracts a version's CHANGELOG section; enforces version consistency. Used by CI. |
## Development
Parts of this project were written with AI assistance (Claude). All of it is
@@ -54,14 +70,24 @@ make that review meaningful. Bugs are mine.
```bash
pip install pytest ruff
ruff check patch tests
ruff check patch tests tools
pytest tests
```
CI runs shellcheck, `bash -n`, ruff, and pytest on Python 3.11–3.13. The tests
include a pass that `compile()`s the `*_BLOCK` strings in `patch/apply.sh` —
those are Python source appended into live `middlewared` modules, so a syntax
error there would break the box at boot.
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.
Releases are automated: push a `vX.Y.Z` tag and the workflow runs the full suite,
verifies the version matches, and cuts a GitHub release whose body **is** the
matching `CHANGELOG.md` section — one source of truth for release notes.
---
@@ -341,27 +367,71 @@ Refresh your browser. S3 and B2 credentials now appear in the
## Updating
To update to a new version of the patch:
```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
# If install.sh was previously run as root, the .git directory may be owned
# by root. Fix it first, or just pull as root:
sudo git pull # easiest option
# — or —
sudo chown -R $(whoami) .git && git pull
git pull # or: git checkout v0.4.1
bash install.sh
```
`install.sh` clears any stale kill switch, re-applies the updated patches,
and restarts middlewared. Run `python3 patch/create_task.py verify` afterwards
to confirm the patches loaded successfully.
Every update after that is just `bash update.sh`.
Check [CHANGELOG.md](CHANGELOG.md) to see what changed between versions.
> 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.
## Creating a task via CLI
@@ -376,18 +446,25 @@ host address or API key:
# 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)
# 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 "restic-repo-password" \
--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.
@@ -452,12 +529,34 @@ tail -20 /mnt/tank/truenas-truecloud-patch/apply.log
## After a TrueNAS update
1. Check the log: `cat /mnt/tank/truenas-truecloud-patch/apply.log | tail -30`
2. If you see "WARNING: … pattern not found", the UI patch needs updating.
[Open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues)
with your TrueNAS version number.
3. The backend patch (B2 support + URL fix) is more stable — check that a
B2 backup job still completes successfully after any 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
```
---
+9 -3
View File
@@ -18,7 +18,7 @@
set -euo pipefail
VERSION="0.3.2"
VERSION="0.4.2"
# The directory containing install.sh is the permanent install location.
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -95,8 +95,14 @@ fi
# ── Set permissions ───────────────────────────────────────────────────────────
echo "Setting permissions ..."
chmod +x "$PATCH_DIR/patch/apply.sh" "$PATCH_DIR/patch/create_task.py" \
"$PATCH_DIR/recover.sh" "$PATCH_DIR/uninstall.sh"
# Guard each path: under `set -e` a chmod on a missing file aborts the install.
# The file set changes between versions, so `update.sh --rollback` to an older
# revision must not be killed by a name this version happens to know about.
for _exe in patch/apply.sh patch/create_task.py recover.sh uninstall.sh update.sh; do
if [ -f "$PATCH_DIR/$_exe" ]; then
chmod +x "$PATCH_DIR/$_exe"
fi
done
echo "Done."
echo ""
+19 -60
View File
@@ -32,7 +32,7 @@
# Derive PATCH_DIR from this script's location (parent of the patch/ directory).
PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOG="$PATCH_DIR/apply.log"
VERSION="0.3.2"
VERSION="0.4.2"
# Rotate log at 512 KB to avoid unbounded growth on a system volume.
# Keep two prior generations (.1 and .2) so the last three boots are always available.
@@ -468,65 +468,24 @@ if _tc_nested is not None:
"""
def patch_file(path, block):
with open(path, encoding="utf-8") as fh:
content = fh.read()
marker = "\n# TRUECLOUD_PATCH"
idx = content.find(marker)
base = content[:idx] if idx != -1 else content
with open(path, "w", encoding="utf-8") as fh:
fh.write(base.rstrip("\n") + "\n" + block)
# Single implementation of the block apply/revert logic (patch/mw_patch.py), so
# uninstall.sh and apply.sh cannot drift apart. Fail-safe: if it cannot be
# imported, skip the backend patch entirely -- middlewared then starts stock,
# which is the whole design principle of this script.
# APPEND, never insert(0): this dir would otherwise take precedence over the
# stdlib for this interpreter, so a future patch/json.py (say) would shadow the
# real json module and break the boot. Appending fails safe -- worst case our
# import misses and the backend patch is skipped.
sys.path.append(os.path.dirname(nested_src))
try:
from mw_patch import patch_file, revert_nested
except ImportError as _e:
print(f'WARNING: cannot import patch/mw_patch.py ({_e}) — skipping backend patch.')
print('WARNING: middlewared will start with stock (unpatched) modules.')
sys.exit(1)
def unpatch_file(path):
"""Strip our appended block, restoring the stock file. True if it was patched."""
try:
with open(path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return False
idx = content.find("\n# TRUECLOUD_PATCH")
if idx == -1:
return False
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content[:idx].rstrip("\n") + "\n")
except OSError:
return False
return True
def revert_nested(cloud_dir, sync_path):
"""Undo the nested patch. Returns the names of what was actually reverted.
Skipping the patch is NOT enough to disable the feature. The overlay persists
for the whole boot, so an earlier run this boot may already have written the
patched files -- and middlewared re-imports them on the restart that
install.sh performs. Without this, `install.sh --disable-nested-snapshots`
would report "disabled" while the feature kept running until the next reboot.
"""
reverted = []
# Remove the module FIRST. Every injected block is guarded by
# `if _tc_nested is not None`, so once it is gone they all no-op even if a
# later step here fails -- the guard is restored no matter what.
try:
os.unlink(os.path.join(cloud_dir, '_truecloud_nested.py'))
reverted.append('_truecloud_nested.py')
except OSError:
pass
# NB: restic.py also carries a TRUECLOUD_PATCH block, but that belongs to the
# providers module. Only these three are ours to revert.
for name, path in (
('crud.py', os.path.join(cloud_dir, 'crud.py')),
('sync.py', sync_path),
('snapshot.py', os.path.join(cloud_dir, 'snapshot.py')),
):
if unpatch_file(path):
reverted.append(name)
return reverted
# .../middlewared/plugins/cloud -> .../middlewared
mw_dir = os.path.dirname(os.path.dirname(cloud_dir))
b2_ok = restic_ok = False
nested_ok = False
@@ -582,7 +541,7 @@ if not nested_needed:
nested_detail = 'not needed'
print('INFO: Nested module skipped.')
reverted = revert_nested(cloud_dir, sync_path)
reverted = revert_nested(mw_dir)
if reverted:
print('OK: Reverted a previously-applied nested patch (' + ', '.join(reverted) + ').')
print(' The stock nesting guard is restored once middlewared restarts.')
+74 -24
View File
@@ -26,8 +26,9 @@ Create a task backed by a B2 credential (id=3):
--credential 3 \\
--bucket my-bucket \\
--folder backups/tank \\
--password "restic-repo-password" \\
--password-stdin \\
--keep-last 14
(pipe the password in: echo -n "s3cret" | python3 create_task.py create ... )
Create a task using an S3-compatible credential (Wasabi, R2, etc.):
python3 create_task.py create \\
@@ -36,7 +37,7 @@ Create a task using an S3-compatible credential (Wasabi, R2, etc.):
--credential 5 \\
--bucket my-bucket \\
--folder backups \\
--password "restic-repo-password"
--password-stdin
List existing TrueCloud Backup tasks:
python3 create_task.py list-tasks
@@ -44,39 +45,49 @@ List existing TrueCloud Backup tasks:
import argparse
import calendar
import getpass
import json
import os
import subprocess
import sys
import time
__version__ = "0.2.0"
__version__ = "0.4.2"
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")
def midclt_call(method, *args):
"""Call a middleware method locally via `midclt`, the supported JSON-RPC transport
that replaces the deprecated /api/v2.0 REST API (removed in TrueNAS 26.04). Must run
on the TrueNAS host. Each arg is JSON-encoded (a dict for create; none for queries).
Exits with a clear message on failure."""
cmd = ["midclt", "call", method] + [json.dumps(a) for a in args]
"""Call a middleware method on the local host.
Uses `truenas_api_client` -- the library that backs `midclt` itself -- rather
than shelling out to `midclt`.
This is a SECURITY requirement, not a style choice. `midclt call <method>
<json>` puts its arguments in the process's **argv**, and `cloud_backup.create`
carries the restic repository password. argv is world-readable via `ps`, so
shelling out would expose the key to the entire backup repo to every local
user for the duration of the call. Going through the client library keeps it
in this process's memory.
"""
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print("ERROR: `midclt` not found — run this script ON the TrueNAS host.",
file=sys.stderr)
from truenas_api_client import Client
except ImportError:
print(
"ERROR: `truenas_api_client` not importable — run this script ON the\n"
" TrueNAS host. (It ships with midclt.)",
file=sys.stderr,
)
sys.exit(1)
except subprocess.SubprocessError as exc:
print(f"ERROR: midclt call failed: {exc}", file=sys.stderr)
try:
with Client() as client:
return client.call(method, *args)
except Exception as exc: # noqa: BLE001 - surface any middleware error verbatim
# Never echo `args` here: for cloud_backup.create it contains the password.
print(f"ERROR: {method}: {exc}", file=sys.stderr)
sys.exit(1)
if proc.returncode != 0:
print(f"ERROR: midclt {method}: {(proc.stderr or proc.stdout).strip()}",
file=sys.stderr)
sys.exit(1)
out = proc.stdout.strip()
return json.loads(out) if out else None
# ── Sub-commands ──────────────────────────────────────────────────────────────
@@ -84,8 +95,11 @@ def midclt_call(method, *args):
def _middlewared_start_epoch():
"""Epoch timestamp of the running middlewared main process, or None."""
try:
# Partial path (S607) is fine here: this runs as root on TrueNAS, so an
# attacker who can poison PATH already has root. Hard-coding a path would
# be less portable (/bin vs /usr/bin) for no security gain.
pid = int(subprocess.run(
["systemctl", "show", "--property=MainPID", "--value", "middlewared"],
["systemctl", "show", "--property=MainPID", "--value", "middlewared"], # noqa: S607
capture_output=True, text=True, timeout=10, check=True,
).stdout.strip())
if pid <= 0:
@@ -213,6 +227,37 @@ def cmd_list_tasks(_args):
print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t.get('description', '')}")
def _resolve_password(args):
"""Get the restic repo password without writing it to the user's shell history.
That password is the key to the whole backup repository. `--password <secret>`
persists it in ~/.bash_history and exposes it in `ps` for the lifetime of the
shell command, so it is accepted but warned about; stdin and an interactive
prompt are the safe paths.
"""
if args.password_stdin:
if args.password:
print("ERROR: use either --password or --password-stdin, not both.",
file=sys.stderr)
sys.exit(1)
password = sys.stdin.readline().rstrip("\n")
elif args.password:
print(
"WARNING: --password puts the restic repository password in your shell\n"
" history. Prefer: echo -n 'pw' | ... --password-stdin",
file=sys.stderr,
)
password = args.password
else:
password = getpass.getpass("Restic repository password: ")
if not password:
print("ERROR: the restic repository password must not be empty.",
file=sys.stderr)
sys.exit(1)
return password
def cmd_create(args):
parts = args.schedule.split()
if len(parts) != 5:
@@ -223,6 +268,8 @@ def cmd_create(args):
sys.exit(1)
minute, hour, dom, month, dow = parts
password = _resolve_password(args)
body = {
"description": args.name,
"path": args.path,
@@ -231,7 +278,7 @@ def cmd_create(args):
"bucket": args.bucket,
"folder": args.folder,
},
"password": args.password,
"password": password,
"keep_last": args.keep_last,
"transfer_setting": args.transfer_setting,
"schedule": {
@@ -297,8 +344,11 @@ def main():
help="Bucket (S3) or container (B2) name")
c.add_argument("--folder", default="",
help="Path within the bucket (default: root)")
c.add_argument("--password", required=True,
help="Restic repository encryption password (choose a strong one)")
c.add_argument("--password", default=None,
help="Restic repository password. UNSAFE: it lands in your shell "
"history. Prefer --password-stdin, or omit both and be prompted.")
c.add_argument("--password-stdin", action="store_true",
help="Read the restic repository password from stdin (recommended)")
c.add_argument("--keep-last", type=int, default=14, metavar="N",
help="Snapshots to retain after each run (default: 14)")
c.add_argument("--schedule", default="0 2 * * *",
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Apply and revert truecloud-patch's blocks in middlewared's modules.
Every patch this project makes to a middlewared module is an appended block that
begins with the MARKER line. That makes patching idempotent (truncate at the
marker, re-append) and reverting exact (truncate at the marker, stop).
This is the single implementation of that. It used to live in two places --
apply.sh's heredoc and an inline heredoc in uninstall.sh -- and the uninstall copy
was the untested one.
python3 mw_patch.py revert-all # remove every block + the nested module
python3 mw_patch.py revert-nested # remove only the nested module's blocks
"""
from __future__ import annotations
import os
import sys
MARKER = "\n# TRUECLOUD_PATCH"
#: Modules the providers module (B2/S3) patches.
PROVIDER_RELPATHS = [
("rclone", "remote", "b2.py"),
("plugins", "cloud_backup", "restic.py"),
]
#: Modules the nested-snapshot module patches. Order matters on revert -- see
#: revert(): the loadable module goes first.
NESTED_RELPATHS = [
("plugins", "cloud", "crud.py"),
("plugins", "cloud_backup", "sync.py"),
("plugins", "cloud", "snapshot.py"),
]
#: The importable module the nested blocks depend on.
NESTED_MODULE = ("plugins", "cloud", "_truecloud_nested.py")
def patch_file(path, block):
"""Append `block`, replacing any block we appended before. Idempotent."""
with open(path, encoding="utf-8") as fh:
content = fh.read()
idx = content.find(MARKER)
base = content[:idx] if idx != -1 else content
with open(path, "w", encoding="utf-8") as fh:
fh.write(base.rstrip("\n") + "\n" + block)
def unpatch_file(path):
"""Strip our appended block, restoring the stock file. True if it was patched."""
try:
with open(path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return False
idx = content.find(MARKER)
if idx == -1:
return False
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content[:idx].rstrip("\n") + "\n")
except OSError:
return False
return True
def revert(mw_dir, relpaths, module_relpath=None):
"""Remove our blocks from `relpaths`, and the module at `module_relpath`.
The module is deleted FIRST. Every injected block is guarded by
`if _tc_nested is not None`, so once the module is gone the blocks all no-op
even if a later unpatch fails -- the stock guard comes back regardless.
Returns the names of what was actually reverted.
"""
reverted = []
if module_relpath:
try:
os.unlink(os.path.join(mw_dir, *module_relpath))
reverted.append(module_relpath[-1])
except OSError:
pass
for rel in relpaths:
if unpatch_file(os.path.join(mw_dir, *rel)):
reverted.append(rel[-1])
return reverted
def revert_nested(mw_dir):
"""Undo the nested-snapshot patch only. Leaves the providers patch alone.
restic.py also carries a block, but it belongs to the providers module --
reverting it would silently break B2 backups.
"""
return revert(mw_dir, NESTED_RELPATHS, NESTED_MODULE)
def revert_all(mw_dir):
"""Undo every patch this project applies."""
return revert(mw_dir, NESTED_RELPATHS + PROVIDER_RELPATHS, NESTED_MODULE)
def find_middlewared_dir():
"""Directory of the installed `middlewared` package, or None."""
try:
import middlewared
except ImportError:
return None
return os.path.dirname(os.path.abspath(middlewared.__file__))
def main(argv):
if len(argv) < 2 or argv[1] not in ("revert-all", "revert-nested"):
print(__doc__, file=sys.stderr)
return 2
mw_dir = find_middlewared_dir()
if mw_dir is None:
print(" middlewared not importable — nothing to revert.")
return 0
fn = revert_all if argv[1] == "revert-all" else revert_nested
reverted = fn(mw_dir)
if reverted:
print(" Reverted: " + ", ".join(reverted))
else:
print(" Nothing to revert (overlay already removed, or never patched).")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+15 -3
View File
@@ -279,7 +279,11 @@ def current_mounts_under(root, mounts_file="/proc/self/mounts"):
def _run(cmd):
return subprocess.run(cmd, capture_output=True, text=True, check=False)
# List form, never shell=True: `cmd` is built from our own mount plan, so ZFS
# dataset names cannot inject. Runs as root by definition (it mounts).
return subprocess.run( # noqa: S603
cmd, capture_output=True, text=True, check=False
)
def apply_plan(mounts, runner=_run, isdir=os.path.isdir):
@@ -379,8 +383,16 @@ async def delete_snapshot_tree(middleware, snapshot, logger=None):
try:
await middleware.call("zfs.snapshot.delete", snapshot, {"recursive": True})
return
except Exception: # noqa: BLE001 - fall through to the explicit sweep
pass
except Exception as e: # noqa: BLE001 - fall through to the explicit sweep
# Usually just "parent already gone" (stock's finally won the race once our
# mounts were released), which the sweep below handles. Log it rather than
# swallow it: if the real cause is something else, this is the only place
# it is visible -- the sweep would report a different, downstream failure.
if logger:
logger.debug(
"truecloud-patch: recursive delete of %s failed (%r); sweeping "
"the tree by name instead", snapshot, e,
)
# The parent may already be gone -- stock's `finally` can win the race once
# our mounts are released -- which fails the recursive delete while the
+1 -1
View File
@@ -17,7 +17,7 @@
# bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
# systemctl restart middlewared
VERSION="0.3.2"
VERSION="0.4.2"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
+10 -30
View File
@@ -277,42 +277,22 @@ class TestOptIn:
running until the next reboot.
"""
src = heredoc_source()
assert "def unpatch_file(" in src
assert "def revert_nested(" in src
# The revert must run on every not-needed path (opt-out, superseded).
# The implementation lives in patch/mw_patch.py (see test_mw_patch.py);
# apply.sh must import and actually call it.
assert "from mw_patch import patch_file, revert_nested" in src
gate = src.index("if not nested_needed:")
revert = src.index("reverted = revert_nested(")
patch = src.index("patch_file(crud_py, CRUD_BLOCK)")
assert gate < revert < patch, "revert belongs in the not-needed branch"
def test_revert_removes_the_module_before_unpatching_files(self):
# Every injected block is guarded by `if _tc_nested is not None`, so
# deleting the module first means the guard is restored even if a later
# unpatch step fails.
def test_import_failure_skips_the_patch_rather_than_crashing(self):
# apply.sh runs at PREINIT. If mw_patch.py cannot be imported it must
# degrade to "middlewared starts stock", never take the boot down.
src = heredoc_source()
body = src[src.index("def revert_nested("):src.index("def patch_file(") if
src.index("def patch_file(") > src.index("def revert_nested(") else len(src)]
body = src[src.index("def revert_nested("):]
body = body[:body.index("\n\n\n")] if "\n\n\n" in body else body
assert body.index("_truecloud_nested.py") < body.index("crud.py")
def test_revert_never_touches_the_providers_patch(self):
# restic.py also carries a TRUECLOUD_PATCH block, but it belongs to the
# providers module. Reverting it would silently break B2 backups.
src = heredoc_source()
body = src[src.index("def revert_nested("):]
body = body[:body.index("return reverted")]
# Comments legitimately *mention* restic.py to explain why it is excluded;
# what matters is that no code line touches it.
code = "\n".join(
ln for ln in body.splitlines() if not ln.lstrip().startswith("#")
)
assert "restic" not in code
assert "b2.py" not in code
# It must only ever revert these three, plus the module itself.
assert "crud.py" in code
assert "sync_path" in code
assert "snapshot.py" in code
i = src.index("from mw_patch import")
tail = src[i:i + 400]
assert "except ImportError" in tail
assert "skipping backend patch" in tail
def test_guard_is_relaxed_only_after_traversal_is_installed():
+107
View File
@@ -0,0 +1,107 @@
"""Tests for create_task.py, focused on the restic repository password.
That password is the encryption key for the entire cloud backup repository. It
used to travel through `midclt call cloud_backup.create '<json>'` -- i.e. through
the subprocess's **argv**, which is world-readable via `ps` -- and `--password`
wrote it into the user's shell history forever.
"""
import importlib.util
import io
import os
import sys
import types
import pytest
SPEC = importlib.util.spec_from_file_location(
"create_task",
os.path.join(os.path.dirname(__file__), "..", "patch", "create_task.py"),
)
def load():
mod = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(mod)
return mod
class Args:
def __init__(self, password=None, password_stdin=False):
self.password = password
self.password_stdin = password_stdin
class TestPasswordNeverReachesArgv:
"""The whole reason this module talks to the client library."""
def test_midclt_call_spawns_no_subprocess(self):
import inspect
src = inspect.getsource(load().midclt_call)
assert "subprocess" not in src, (
"shelling out to `midclt` puts cloud_backup.create's JSON -- including "
"the restic repo password -- into argv, which any local user can read "
"with ps"
)
assert "truenas_api_client" in src
def test_errors_never_echo_the_call_arguments(self):
# A failed cloud_backup.create must not print the body back at the user;
# it contains the password.
import inspect
src = inspect.getsource(load().midclt_call)
assert "{args}" not in src
assert "args!r" not in src
class TestResolvePassword:
def test_reads_from_stdin(self, monkeypatch):
mod = load()
monkeypatch.setattr(sys, "stdin", io.StringIO("s3cret\n"))
assert mod._resolve_password(Args(password_stdin=True)) == "s3cret"
def test_strips_only_the_trailing_newline(self, monkeypatch):
# A password may legitimately contain spaces; only the line ending goes.
mod = load()
monkeypatch.setattr(sys, "stdin", io.StringIO(" pass word \n"))
assert mod._resolve_password(Args(password_stdin=True)) == " pass word "
def test_cli_password_still_works_but_warns(self, monkeypatch, capsys):
mod = load()
pw = mod._resolve_password(Args(password="cli-secret"))
assert pw == "cli-secret"
assert "shell" in capsys.readouterr().err.lower(), "must warn about history"
def test_prompts_when_neither_flag_given(self, monkeypatch):
mod = load()
monkeypatch.setattr(
mod, "getpass", types.SimpleNamespace(getpass=lambda _p: "prompted")
)
assert mod._resolve_password(Args()) == "prompted"
def test_rejects_both_flags(self, monkeypatch):
mod = load()
monkeypatch.setattr(sys, "stdin", io.StringIO("x\n"))
with pytest.raises(SystemExit):
mod._resolve_password(Args(password="a", password_stdin=True))
def test_rejects_an_empty_password(self, monkeypatch):
# An empty restic password would silently create an unencrypted-ish repo.
mod = load()
monkeypatch.setattr(sys, "stdin", io.StringIO("\n"))
with pytest.raises(SystemExit):
mod._resolve_password(Args(password_stdin=True))
class TestVersion:
def test_version_is_not_stale(self):
# __version__ sat at 0.2.0 through three releases because the drift check
# only looked at VERSION= in shell scripts. It covers this file now.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
from release_notes import normalise, script_versions
repo = os.path.join(os.path.dirname(__file__), "..")
versions = {normalise(v) for v in script_versions(repo).values()}
assert len(versions) == 1, f"version drift: {sorted(versions)}"
+160
View File
@@ -0,0 +1,160 @@
"""Tests for mw_patch — the single implementation of apply/revert.
apply.sh and uninstall.sh both go through this. It used to be duplicated in an
untested shell heredoc, which is exactly how the two could have drifted apart:
apply.sh reverting one set of files and uninstall.sh another.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "patch"))
from mw_patch import ( # noqa: E402
MARKER,
NESTED_MODULE,
NESTED_RELPATHS,
PROVIDER_RELPATHS,
patch_file,
revert_all,
revert_nested,
unpatch_file,
)
STOCK = "import os\n\n\ndef stock():\n return 1\n"
BLOCK = "\n# TRUECLOUD_PATCH\ninjected = 1\n"
def build_mw(root):
"""A fake middlewared tree with every file this project touches."""
mw = os.path.join(root, "middlewared")
for rel in NESTED_RELPATHS + PROVIDER_RELPATHS:
path = os.path.join(mw, *rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(STOCK)
with open(os.path.join(mw, *NESTED_MODULE), "w", encoding="utf-8") as fh:
fh.write("# module\n")
return mw
def read(mw, rel):
with open(os.path.join(mw, *rel), encoding="utf-8") as fh:
return fh.read()
class TestPatchFile:
def test_appends_the_block(self, tmp_path):
p = tmp_path / "m.py"
p.write_text(STOCK)
patch_file(str(p), BLOCK)
assert MARKER in p.read_text()
assert p.read_text().startswith("import os")
def test_is_idempotent(self, tmp_path):
# apply.sh runs on EVERY boot. Without truncate-then-append, repeated runs
# would stack duplicate copies of the block into a middlewared module.
p = tmp_path / "m.py"
p.write_text(STOCK)
for _ in range(5):
patch_file(str(p), BLOCK)
assert p.read_text().count("# TRUECLOUD_PATCH") == 1
assert p.read_text().count("injected = 1") == 1
def test_round_trips_back_to_stock(self, tmp_path):
p = tmp_path / "m.py"
p.write_text(STOCK)
patch_file(str(p), BLOCK)
assert unpatch_file(str(p)) is True
assert p.read_text() == STOCK
class TestUnpatchFile:
def test_returns_false_on_an_unpatched_file(self, tmp_path):
p = tmp_path / "m.py"
p.write_text(STOCK)
assert unpatch_file(str(p)) is False
assert p.read_text() == STOCK
def test_returns_false_on_a_missing_file(self, tmp_path):
assert unpatch_file(str(tmp_path / "nope.py")) is False
class TestRevertNested:
def test_reverts_only_the_nested_files(self, tmp_path):
mw = build_mw(str(tmp_path))
for rel in NESTED_RELPATHS + PROVIDER_RELPATHS:
patch_file(os.path.join(mw, *rel), BLOCK)
reverted = revert_nested(mw)
for rel in NESTED_RELPATHS:
assert read(mw, rel) == STOCK, f"{rel[-1]} should be stock"
assert rel[-1] in reverted
def test_never_touches_the_providers_patch(self, tmp_path):
# restic.py carries a TRUECLOUD_PATCH block too, but it belongs to the
# providers module. Reverting it would silently break B2 backups.
mw = build_mw(str(tmp_path))
for rel in NESTED_RELPATHS + PROVIDER_RELPATHS:
patch_file(os.path.join(mw, *rel), BLOCK)
revert_nested(mw)
for rel in PROVIDER_RELPATHS:
assert MARKER in read(mw, rel), f"{rel[-1]} must keep its providers block"
def test_removes_the_module(self, tmp_path):
mw = build_mw(str(tmp_path))
assert os.path.exists(os.path.join(mw, *NESTED_MODULE))
reverted = revert_nested(mw)
assert not os.path.exists(os.path.join(mw, *NESTED_MODULE))
assert "_truecloud_nested.py" in reverted
def test_module_is_removed_before_the_files_are_unpatched(self, tmp_path):
# Every injected block is guarded by `if _tc_nested is not None`, so once
# the module is gone they all no-op — the stock guard is restored even if
# a later unpatch fails.
mw = build_mw(str(tmp_path))
for rel in NESTED_RELPATHS:
patch_file(os.path.join(mw, *rel), BLOCK)
reverted = revert_nested(mw)
assert reverted[0] == "_truecloud_nested.py"
def test_is_idempotent(self, tmp_path):
mw = build_mw(str(tmp_path))
for rel in NESTED_RELPATHS:
patch_file(os.path.join(mw, *rel), BLOCK)
revert_nested(mw)
assert revert_nested(mw) == []
class TestRevertAll:
def test_reverts_providers_and_nested(self, tmp_path):
mw = build_mw(str(tmp_path))
for rel in NESTED_RELPATHS + PROVIDER_RELPATHS:
patch_file(os.path.join(mw, *rel), BLOCK)
revert_all(mw)
for rel in NESTED_RELPATHS + PROVIDER_RELPATHS:
assert read(mw, rel) == STOCK, f"{rel[-1]} should be stock"
assert not os.path.exists(os.path.join(mw, *NESTED_MODULE))
def test_is_a_noop_on_a_stock_tree(self, tmp_path):
mw = build_mw(str(tmp_path))
os.unlink(os.path.join(mw, *NESTED_MODULE))
assert revert_all(mw) == []
class TestTargetsAreDisjoint:
def test_no_file_is_in_both_module_lists(self):
# If restic.py ever appeared in NESTED_RELPATHS, revert_nested would break
# B2 backups.
assert not set(NESTED_RELPATHS) & set(PROVIDER_RELPATHS)
@pytest.mark.parametrize("rel", PROVIDER_RELPATHS)
def test_provider_targets_are_not_nested_targets(self, rel):
assert rel not in NESTED_RELPATHS
+13 -5
View File
@@ -19,17 +19,21 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHANGELOG = os.path.join(ROOT, "CHANGELOG.md")
# Every script prints a version; they must all agree, and agree with the tag.
# They drifted to three different values once (0.0.4 / 0.2.1) before anything
# checked them.
# Everything that announces a version must agree with everything else. They drifted
# to three different values once (0.0.4 / 0.2.1) before anything checked them --
# and create_task.py's __version__ then sat at 0.2.0 through three more releases,
# because the first version of this check only looked at VERSION= in shell scripts.
VERSIONED_FILES = [
"install.sh",
"uninstall.sh",
"recover.sh",
os.path.join("patch", "apply.sh"),
os.path.join("patch", "create_task.py"), # exposes `--version` to users
"update.sh",
]
_VERSION_RE = re.compile(r'^VERSION="([^"]+)"', re.M)
# `VERSION="x"` (shell) or `__version__ = "x"` (python).
_VERSION_RE = re.compile(r'^(?:VERSION=|__version__\s*=\s*)"([^"]+)"', re.M)
_HEADING_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+[^\s]*)", re.M)
@@ -136,7 +140,11 @@ def main(argv):
version = argv[2]
if cmd == "notes":
with open(CHANGELOG, encoding="utf-8") as fh:
# An explicit path lets update.sh show the notes from the CHANGELOG of the
# version it is about to install (`git show <tag>:CHANGELOG.md`), not the
# one already checked out.
path = argv[3] if len(argv) > 3 else CHANGELOG
with open(path, encoding="utf-8") as fh:
print(extract_notes(fh.read(), version))
return 0
+15 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
VERSION="0.3.2"
VERSION="0.4.2"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
@@ -91,6 +91,20 @@ if [ "$_ov_found" -eq 0 ]; then
fi
echo ""
# ── Revert file-level patches ─────────────────────────────────────────────────
# Unmounting the overlay is what normally reverts everything — the lower layer is
# the untouched /usr. But apply.sh only mounts an overlay when the directory is
# read-only; on a writable /usr it patches the real files in place. Uninstall
# would then remove the boot hook and report success while leaving every patch
# applied. Strip our appended blocks explicitly.
# Same implementation apply.sh uses (patch/mw_patch.py) — a second shell copy of
# this would be the untested one.
echo "Reverting any file-level patches ..."
python3 "$PATCH_DIR/patch/mw_patch.py" revert-all || \
echo " WARNING: could not revert file-level patches."
echo ""
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
# These bind mounts pin their ZFS snapshots, so they must go before anything
# tries to destroy those snapshots. Deepest first.
+293
View File
@@ -0,0 +1,293 @@
#!/bin/bash
# update.sh — fetch a newer release of truecloud-patch and apply it.
#
# ── RUN THIS BY HAND. NEVER FROM CRON OR A SYSTEMD TIMER. ─────────────────────
#
# This patch injects Python into middlewared and re-applies itself at every boot.
# 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. That is not theoretical:
# v0.0.4 shipped a boot-time bug that took every app on the box down.
#
# The manual step IS the safety gate. Keep it.
#
# By default this updates to the newest RELEASE TAG, not to main. main can be
# mid-refactor; a tag is the tested artifact. Use --main only if you know why.
#
# 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 update
set -euo pipefail
VERSION="0.4.2"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_PREV_FILE="$PATCH_DIR/.update_previous"
_target=""
_use_main=0
_assume_yes=0
_check_only=0
_rollback=0
usage() {
cat <<USAGE
Usage: bash update.sh [options]
Options:
--to <ref> Update to a specific tag or commit (default: newest release tag)
--main Update to origin/main — UNRELEASED code, no guarantees
--check Show what an update would do and exit; changes nothing
--rollback Return to the revision recorded before the last update
--yes, -y Skip the confirmation prompt
-h, --help Show this help
Updating preserves your nested-snapshot opt-in setting either way.
USAGE
}
# An UNTRACKED file that the target tracks makes `git checkout` abort. The dirty-
# tree check deliberately ignores untracked files, so this slips past it and the
# checkout then dies mid-operation. Not hypothetical: a hand-copied
# patch/wait_restart.sh blocked a pull on a real box exactly this way.
#
# Used by BOTH the update and the rollback path -- rolling back moves the tree too,
# and would hit the identical failure.
_abort_if_untracked_blockers() {
local ref="$1" blocking
# Set intersection of {untracked, not ignored} and {tracked by the target}. Two
# git calls, not one `ls-files --error-unmatch` per file in the target tree.
# --exclude-standard is deliberate: git silently overwrites *ignored* files on
# checkout, so those are not blockers — only untracked-and-not-ignored ones are.
blocking="$(comm -12 \
<(git ls-files --others --exclude-standard | sort) \
<(git ls-tree -r --name-only "$ref" | sort) \
| sed 's/^/ /')"
[ -n "$blocking" ] || return 0
echo "ERROR: these untracked files would be overwritten:" >&2
printf '%s\n\n' "$blocking" >&2
echo " They exist here but git does not track them — most likely hand-copied" >&2
echo " or scp'd in. Move or delete them, then re-run." >&2
# "Delete update.sh, then re-run update.sh" is impossible. If the script itself
# is a blocker, it was hand-copied in to bootstrap; the honest answer is to
# bootstrap with git instead, which installs it properly.
case "$blocking" in
*update.sh*)
echo "" >&2
echo " update.sh itself is untracked here — you copied it in to bootstrap." >&2
echo " Do that with git instead, once; it installs update.sh properly:" >&2
echo "" >&2
echo " rm -f $PATCH_DIR/update.sh" >&2
echo " git -C $PATCH_DIR checkout $ref" >&2
echo " bash $PATCH_DIR/install.sh" >&2
echo "" >&2
echo " Every later update is then just: bash update.sh" >&2
;;
esac
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--to)
if [ -z "${2:-}" ]; then
echo "ERROR: --to needs a tag, branch, or commit." >&2
exit 1
fi
_target="$2"; shift ;;
--main) _use_main=1 ;;
--check) _check_only=1 ;;
--rollback) _rollback=1 ;;
--yes|-y) _assume_yes=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown option: $1" >&2; echo "" >&2; usage >&2; exit 1 ;;
esac
shift
done
echo "=== TrueNAS TrueCloud Provider Patch — Update (v${VERSION}) ==="
echo ""
# ── Preflight ─────────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must be run as root (install.sh needs it)." >&2
exit 1
fi
cd "$PATCH_DIR"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "ERROR: $PATCH_DIR is not a git clone — nothing to update." >&2
echo " Re-clone from https://github.com/sudolulo/truenas-truecloud-patch" >&2
exit 1
fi
# Past `sudo git pull`s can leave root-owned objects in .git that then break any
# non-root git command. We run as root, so we would only make that worse.
_owner="$(stat -c '%U' "$PATCH_DIR")"
if [ -n "$_owner" ] && [ "$_owner" != "root" ]; then
chown -R "$_owner" "$PATCH_DIR/.git" 2>/dev/null || true
fi
# A dirty tree means someone edited or scp'd files in place; merging over that
# silently loses their changes, or conflicts halfway through.
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: the working tree has uncommitted changes:" >&2
git status --short --untracked-files=no >&2
echo "" >&2
echo " Refusing to update over them. Commit, stash, or discard them first:" >&2
echo " git -C $PATCH_DIR checkout -- ." >&2
exit 1
fi
# ── Rollback ──────────────────────────────────────────────────────────────────
if [ "$_rollback" -eq 1 ]; then
if [ ! -f "$_PREV_FILE" ]; then
echo "ERROR: no previous revision recorded — nothing to roll back to." >&2
exit 1
fi
_prev="$(cat "$_PREV_FILE")"
if ! git rev-parse --verify --quiet "${_prev}^{commit}" >/dev/null; then
echo "ERROR: recorded revision '$_prev' is not a valid commit." >&2
echo " The history may have been rewritten. Pick a target explicitly:" >&2
echo " bash update.sh --to <tag>" >&2
exit 1
fi
_abort_if_untracked_blockers "$_prev"
echo "Rolling back to $_prev ..."
git checkout -q --detach "$_prev"
echo "Reverted. Re-applying ..."
echo ""
bash "$PATCH_DIR/install.sh"
exit 0
fi
# ── Work out where we are and where we are going ──────────────────────────────
echo "Fetching ..."
git fetch --quiet --tags --prune origin
_current="$(git rev-parse HEAD)"
_current_desc="$(git describe --tags --always 2>/dev/null || echo "$_current")"
if [ -n "$_target" ]; then
:
elif [ "$_use_main" -eq 1 ]; then
_target="origin/main"
else
# Newest release tag by VERSION order, not by tag date. Date order is only
# correct while tags are created in ascending version order; it breaks the
# moment a hotfix is tagged out of band (a v0.3.6 released after v0.4.0 would
# sort as "newest" by date and silently downgrade the box).
#
# Filter to PLAIN vX.Y.Z: git's version sort ranks `v0.5.0-rc1` ABOVE `v0.5.0`
# (verified), so without this a release candidate would be installed as though
# it were the newest release. The release workflow deliberately supports
# rc/beta/alpha tags, so they will exist.
_target="$(git tag -l 'v*' --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)"
if [ -z "$_target" ]; then
echo "ERROR: no release tags found; use --main to track unreleased code." >&2
exit 1
fi
fi
if ! _target_sha="$(git rev-parse --verify --quiet "${_target}^{commit}")"; then
echo "ERROR: '$_target' is not a valid tag, branch, or commit." >&2
exit 1
fi
echo " current: $_current_desc"
echo " target: $_target ($(git rev-parse --short "$_target_sha"))"
echo ""
if [ "$_current" = "$_target_sha" ]; then
echo "Already up to date. Nothing to do."
exit 0
fi
_abort_if_untracked_blockers "$_target_sha"
# ── Show what is coming ───────────────────────────────────────────────────────
echo "Commits you do not have yet:"
git log --oneline --no-decorate "$_current..$_target_sha" | sed 's/^/ /' || true
echo ""
# Reuse tools/release_notes.py rather than re-implementing the extractor here —
# a second copy would be the untested one. Read the CHANGELOG *of the target*, so
# the notes describe what you are about to install.
if [ -f "$PATCH_DIR/tools/release_notes.py" ] && [ "$_use_main" -eq 0 ] \
&& [ -z "${_target##v*}" ]; then
_cl="$(mktemp)"
if git show "$_target_sha:CHANGELOG.md" > "$_cl" 2>/dev/null && [ -s "$_cl" ]; then
echo "Release notes for $_target:"
python3 "$PATCH_DIR/tools/release_notes.py" notes "$_target" "$_cl" \
2>/dev/null | sed 's/^/ /' || echo " (no notes for $_target)"
echo ""
fi
rm -f "$_cl"
fi
if [ "$_use_main" -eq 1 ]; then
echo "NOTE: --main tracks UNRELEASED code. It has passed CI, but it is not a"
echo " tested release, and apply.sh runs at every boot."
echo ""
fi
if [ "$_check_only" -eq 1 ]; then
echo "--check given; nothing changed."
exit 0
fi
# ── Confirm ───────────────────────────────────────────────────────────────────
if [ "$_assume_yes" -eq 0 ]; then
printf "Apply this update and restart middlewared? [y/N] "
read -r _answer </dev/tty || _answer=""
case "$_answer" in
y|Y|yes|YES) ;;
*) echo "Aborted. Nothing changed."; exit 0 ;;
esac
echo ""
fi
# ── Apply ─────────────────────────────────────────────────────────────────────
# Record where we were BEFORE moving, so --rollback works even if install.sh dies.
echo "$_current" > "$_PREV_FILE"
echo "Checking out $_target ..."
git checkout -q --detach "$_target_sha"
echo " now at $(git describe --tags --always)"
echo ""
echo "Applying (this preserves your nested-snapshot setting) ..."
echo ""
if ! bash "$PATCH_DIR/install.sh"; then
echo ""
echo "ERROR: install.sh failed after updating." >&2
echo " Roll back with: bash $PATCH_DIR/update.sh --rollback" >&2
echo " Or disable the patch entirely: bash $PATCH_DIR/recover.sh" >&2
exit 1
fi
echo ""
echo "=== Update complete ==="
echo " $_current_desc -> $(git describe --tags --always)"
echo ""
if ! git symbolic-ref -q HEAD >/dev/null; then
echo "NOTE: the checkout is now pinned to a release tag (detached HEAD), which is"
echo " what you want for a deployment. Plain \`git pull\` will not work here —"
echo " use \`bash update.sh\` from now on."
echo ""
fi
echo "If anything looks wrong:"
echo " bash $PATCH_DIR/update.sh --rollback # back to $_current_desc"
echo " bash $PATCH_DIR/recover.sh # kill switch + restart"