Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6d37e621 | ||
|
|
4e0814028c | ||
|
|
345741e1f1 | ||
|
|
092bdeae29 | ||
|
|
347c415aa7 | ||
|
|
45f957af23 | ||
|
|
126756498c |
@@ -13,3 +13,4 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
/update_alerts_disabled
|
||||||
|
|||||||
+193
@@ -1,5 +1,198 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.5.1 — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **The update alert could have broken middlewared at startup.** middlewared's
|
||||||
|
`alert.load()` imports every file in `alert/source/` with **no try/except**, and
|
||||||
|
it runs during setup — so a module that raises on import takes middlewared down
|
||||||
|
with it. `apply.sh` now **compiles the substituted alert source and refuses to
|
||||||
|
write it** if it does not parse. An uninstalled alert is a missing convenience;
|
||||||
|
a broken one is a broken box.
|
||||||
|
|
||||||
|
- **`@PATCH_DIR@` is substituted with `repr()`**, so a repository path containing
|
||||||
|
a quote or a backslash produces a valid Python literal instead of a syntax error
|
||||||
|
in the installed module.
|
||||||
|
|
||||||
|
- **The alert source no longer mutates `sys.path`.** It loaded
|
||||||
|
`tools/release_notes.py` via `sys.path.insert(0, …)`, which shadows the stdlib
|
||||||
|
for that interpreter — and `ThreadedAlertSource` runs in middlewared's thread
|
||||||
|
pool, so mutating `sys.path` is a race. It now loads the module by file path with
|
||||||
|
`importlib`.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
Timing, for the record: `process_alerts` is `@periodic(60)` and
|
||||||
|
`alert_source_last_run` is in-memory, so the check runs **within 60 seconds of any
|
||||||
|
middlewared restart** (which this patch performs at every boot) and otherwise
|
||||||
|
**within 24 hours** of a release.
|
||||||
|
|
||||||
|
## v0.5.0 — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A TrueNAS alert when an update is available** — the bell in the UI, not a log
|
||||||
|
line nobody reads. On by default, checked once a day.
|
||||||
|
`install.sh --no-update-alerts` turns it off.
|
||||||
|
|
||||||
|
**It does not nag.** A release whose CHANGELOG contains only a `### Docs`
|
||||||
|
section changed no code and raises nothing. Anything else raises INFO; a
|
||||||
|
`### Security` section raises WARNING. The CHANGELOG's own section headings are
|
||||||
|
the signal, and a security fix anywhere in the range escalates the whole span —
|
||||||
|
so a docs-only release sitting on top of a security fix still reports as
|
||||||
|
security, rather than hiding it.
|
||||||
|
|
||||||
|
**Why an AlertSource and not `midclt`:** TrueNAS cannot raise an alert from the
|
||||||
|
CLI. `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 classes is generic enough to reuse. So
|
||||||
|
registering an `AlertSource` is the only way, and it is also the least invasive
|
||||||
|
thing this patch does: it **adds one file and modifies none**, where the
|
||||||
|
providers and nested modules both append code to stock middleware files. It is
|
||||||
|
the native mechanism, and TrueNAS polls it itself — no cron, no systemd timer.
|
||||||
|
|
||||||
|
- Fail-safe: every error path returns `None`; it cannot take middlewared down.
|
||||||
|
- Read-only: `git ls-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 (running as root) would.
|
||||||
|
- Removed by `uninstall.sh`.
|
||||||
|
- It only *tells* you; it never updates anything.
|
||||||
|
|
||||||
|
## 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
|
## v0.3.3 — 2026-07-13
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ The patch is two independent modules — **providers** (B2/S3) and **nested**
|
|||||||
(snapshots on nested datasets) — and each retires on its own once TrueNAS ships
|
(snapshots on nested datasets) — and each retires on its own once TrueNAS ships
|
||||||
that capability natively. See [Native support](#if-truenas-adds-native-support).
|
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. |
|
||||||
|
| `tools/release_notes.py` | Extracts a version's CHANGELOG section; enforces version consistency. Used by CI. |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Parts of this project were written with AI assistance (Claude). All of it is
|
Parts of this project were written with AI assistance (Claude). All of it is
|
||||||
@@ -54,14 +71,24 @@ make that review meaningful. Bugs are mine.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pytest ruff
|
pip install pytest ruff
|
||||||
ruff check patch tests
|
ruff check patch tests tools
|
||||||
pytest tests
|
pytest tests
|
||||||
```
|
```
|
||||||
|
|
||||||
CI runs shellcheck, `bash -n`, ruff, and pytest on Python 3.11–3.13. The tests
|
CI runs shellcheck, `bash -n`, ruff, and pytest on Python 3.11–3.13. Two checks
|
||||||
include a pass that `compile()`s the `*_BLOCK` strings in `patch/apply.sh` —
|
are worth calling out, because nothing else would catch what they catch:
|
||||||
those are Python source appended into live `middlewared` modules, so a syntax
|
|
||||||
error there would break the box at boot.
|
- 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 +368,116 @@ Refresh your browser. S3 and B2 credentials now appear in the
|
|||||||
|
|
||||||
## Updating
|
## 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
|
```bash
|
||||||
cd /mnt/tank/truenas-truecloud-patch
|
cd /mnt/tank/truenas-truecloud-patch
|
||||||
|
git pull # or: git checkout v0.4.1
|
||||||
# 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
|
|
||||||
|
|
||||||
bash install.sh
|
bash install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` clears any stale kill switch, re-applies the updated patches,
|
Every update after that is just `bash update.sh`.
|
||||||
and restarts middlewared. Run `python3 patch/create_task.py verify` afterwards
|
|
||||||
to confirm the patches loaded successfully.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## 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
|
## Creating a task via CLI
|
||||||
|
|
||||||
@@ -376,18 +492,25 @@ host address or API key:
|
|||||||
# List your cloud credentials to find the right ID
|
# List your cloud credentials to find the right ID
|
||||||
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py list-credentials
|
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 \
|
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py create \
|
||||||
--name "tank-to-b2" \
|
--name "tank-to-b2" \
|
||||||
--path /mnt/tank/data \
|
--path /mnt/tank/data \
|
||||||
--credential 3 \
|
--credential 3 \
|
||||||
--bucket my-bucket \
|
--bucket my-bucket \
|
||||||
--folder backups/tank \
|
--folder backups/tank \
|
||||||
--password "restic-repo-password" \
|
--password-stdin \
|
||||||
--cache-path /mnt/tank/.restic-cache \
|
--cache-path /mnt/tank/.restic-cache \
|
||||||
--keep-last 14
|
--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`,
|
> **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
|
> 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.
|
> on large repos. Point it at a writable dir on a pool with free space.
|
||||||
@@ -452,12 +575,34 @@ tail -20 /mnt/tank/truenas-truecloud-patch/apply.log
|
|||||||
|
|
||||||
## After a TrueNAS update
|
## After a TrueNAS update
|
||||||
|
|
||||||
1. Check the log: `cat /mnt/tank/truenas-truecloud-patch/apply.log | tail -30`
|
A TrueNAS update replaces `/usr/` wholesale, wiping the patch. You do **not** need
|
||||||
2. If you see "WARNING: … pattern not found", the UI patch needs updating.
|
to reinstall: `patch/apply.sh` runs at every boot and re-applies itself from your
|
||||||
[Open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues)
|
clone. But it targets internal APIs with no stability contract, so an update *can*
|
||||||
with your TrueNAS version number.
|
break it — and the failure is quiet by design (middlewared starts fine; the patch
|
||||||
3. The backend patch (B2 support + URL fix) is more stable — check that a
|
just doesn't).
|
||||||
B2 backup job still completes successfully after any update.
|
|
||||||
|
**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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+43
-3
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="0.3.3"
|
VERSION="0.5.1"
|
||||||
|
|
||||||
# The directory containing install.sh is the permanent install location.
|
# The directory containing install.sh is the permanent install location.
|
||||||
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -31,6 +31,8 @@ _NESTED_MARKER="$PATCH_DIR/nested_snapshots_enabled"
|
|||||||
# `git pull`) must never flip it on or off by itself: with neither flag given,
|
# `git pull`) must never flip it on or off by itself: with neither flag given,
|
||||||
# whatever was chosen previously is preserved.
|
# whatever was chosen previously is preserved.
|
||||||
_nested_choice=""
|
_nested_choice=""
|
||||||
|
_alert_choice=""
|
||||||
|
_ALERT_MARKER="$PATCH_DIR/update_alerts_disabled"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
@@ -42,6 +44,8 @@ Options:
|
|||||||
Stock TrueNAS refuses this; see README. Off by
|
Stock TrueNAS refuses this; see README. Off by
|
||||||
default because it changes how backups read data.
|
default because it changes how backups read data.
|
||||||
--disable-nested-snapshots Turn it back off; the stock guard is restored.
|
--disable-nested-snapshots Turn it back off; the stock guard is restored.
|
||||||
|
--no-update-alerts Do not raise a TrueNAS alert when an update exists.
|
||||||
|
--update-alerts Re-enable those alerts (they are on by default).
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
|
|
||||||
With neither flag, the current setting is left unchanged.
|
With neither flag, the current setting is left unchanged.
|
||||||
@@ -52,6 +56,8 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--enable-nested-snapshots) _nested_choice="on" ;;
|
--enable-nested-snapshots) _nested_choice="on" ;;
|
||||||
--disable-nested-snapshots) _nested_choice="off" ;;
|
--disable-nested-snapshots) _nested_choice="off" ;;
|
||||||
|
--no-update-alerts) _alert_choice="off" ;;
|
||||||
|
--update-alerts) _alert_choice="on" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*)
|
*)
|
||||||
echo "ERROR: unknown option: $1" >&2
|
echo "ERROR: unknown option: $1" >&2
|
||||||
@@ -95,8 +101,14 @@ fi
|
|||||||
# ── Set permissions ───────────────────────────────────────────────────────────
|
# ── Set permissions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "Setting permissions ..."
|
echo "Setting permissions ..."
|
||||||
chmod +x "$PATCH_DIR/patch/apply.sh" "$PATCH_DIR/patch/create_task.py" \
|
# Guard each path: under `set -e` a chmod on a missing file aborts the install.
|
||||||
"$PATCH_DIR/recover.sh" "$PATCH_DIR/uninstall.sh"
|
# 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 "Done."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -186,6 +198,34 @@ case "$_nested_choice" in
|
|||||||
esac
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# ── Update alerts (on by default) ─────────────────────────────────────────────
|
||||||
|
# TrueNAS cannot raise an alert from the CLI (midclt exposes only dismiss/list/
|
||||||
|
# restore), so this installs an AlertSource into middlewared/alert/source/ — the
|
||||||
|
# same mechanism every built-in TrueNAS alert uses. It ADDS a file and modifies
|
||||||
|
# none, which makes it the least invasive thing this patch does.
|
||||||
|
#
|
||||||
|
# It only alerts for releases that changed something: a documentation-only release
|
||||||
|
# raises nothing.
|
||||||
|
|
||||||
|
case "$_alert_choice" in
|
||||||
|
off)
|
||||||
|
touch "$_ALERT_MARKER"
|
||||||
|
echo "Update alerts: DISABLED (apply.sh will remove the alert source)."
|
||||||
|
;;
|
||||||
|
on)
|
||||||
|
rm -f "$_ALERT_MARKER"
|
||||||
|
echo "Update alerts: enabled."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ -f "$_ALERT_MARKER" ]; then
|
||||||
|
echo "Update alerts: disabled (unchanged)."
|
||||||
|
else
|
||||||
|
echo "Update alerts: enabled (checks daily; docs-only releases are ignored)."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ── Apply now ─────────────────────────────────────────────────────────────────
|
# ── Apply now ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "Applying patches ..."
|
echo "Applying patches ..."
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""TrueNAS alert: a truecloud-patch update is available.
|
||||||
|
|
||||||
|
Installed by patch/apply.sh into middlewared/alert/source/, where middlewared
|
||||||
|
discovers and polls it natively — no cron job, no systemd timer.
|
||||||
|
|
||||||
|
@PATCH_DIR@ is substituted at install time.
|
||||||
|
|
||||||
|
Two rules govern this file:
|
||||||
|
|
||||||
|
1. **It must never break middlewared.** It runs inside the alert framework on a
|
||||||
|
timer. Every failure path returns None (no alert) rather than raising.
|
||||||
|
|
||||||
|
2. **It must not nag.** A release whose CHANGELOG only has a "### Docs" section
|
||||||
|
changed no code, and nobody wants an alert because a README was reworded. The
|
||||||
|
CHANGELOG's own section headings are the signal — see tools/release_notes.py.
|
||||||
|
|
||||||
|
It also never writes to the repository. `git ls-remote` is read-only and the
|
||||||
|
CHANGELOG is fetched over HTTPS, so this cannot leave root-owned objects in .git
|
||||||
|
the way a `git fetch` from middlewared (running as root) would.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from middlewared.alert.base import (
|
||||||
|
Alert,
|
||||||
|
AlertCategory,
|
||||||
|
AlertClass,
|
||||||
|
AlertLevel,
|
||||||
|
ThreadedAlertSource,
|
||||||
|
)
|
||||||
|
from middlewared.alert.schedule import IntervalSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PATCH_DIR = "@PATCH_DIR@"
|
||||||
|
DISABLED_MARKER = os.path.join(PATCH_DIR, "update_alerts_disabled")
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
|
||||||
|
_VERSION_RE = re.compile(r'^VERSION="([^"]+)"', re.M)
|
||||||
|
_GITHUB_RE = re.compile(r"github\.com[:/]([^/]+)/([^/.]+)")
|
||||||
|
|
||||||
|
_TIMEOUT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class TrueCloudPatchUpdateAlertClass(AlertClass):
|
||||||
|
category = AlertCategory.SYSTEM
|
||||||
|
level = AlertLevel.INFO
|
||||||
|
title = "truecloud-patch update available"
|
||||||
|
text = (
|
||||||
|
"truecloud-patch %(current)s is installed; %(latest)s is available.%(summary)s "
|
||||||
|
"Update with: bash %(dir)s/update.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrueCloudPatchSecurityUpdateAlertClass(AlertClass):
|
||||||
|
category = AlertCategory.SYSTEM
|
||||||
|
level = AlertLevel.WARNING
|
||||||
|
title = "truecloud-patch security update available"
|
||||||
|
text = (
|
||||||
|
"truecloud-patch %(current)s is installed; %(latest)s contains a SECURITY "
|
||||||
|
"fix.%(summary)s Update with: bash %(dir)s/update.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrueCloudPatchUpdateAlertSource(ThreadedAlertSource):
|
||||||
|
schedule = IntervalSchedule(datetime.timedelta(hours=24))
|
||||||
|
run_on_backup_node = False
|
||||||
|
|
||||||
|
def check_sync(self):
|
||||||
|
try:
|
||||||
|
return self._check()
|
||||||
|
except Exception:
|
||||||
|
# An alert source must never take middlewared down with it.
|
||||||
|
logger.debug("truecloud-patch update check failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── internals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _git(self, *args):
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", PATCH_DIR, *args],
|
||||||
|
capture_output=True, text=True, timeout=_TIMEOUT, check=True,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
def _check(self):
|
||||||
|
if os.path.exists(DISABLED_MARKER):
|
||||||
|
return None
|
||||||
|
if not os.path.isdir(os.path.join(PATCH_DIR, ".git")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
current = self._installed_version()
|
||||||
|
if not current:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest = self._latest_release_tag()
|
||||||
|
if not latest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rn = self._release_notes()
|
||||||
|
if rn is None:
|
||||||
|
return None
|
||||||
|
significance, version_tuple = rn.significance, rn.version_tuple
|
||||||
|
|
||||||
|
if version_tuple(latest) <= version_tuple(current):
|
||||||
|
return None
|
||||||
|
|
||||||
|
level, versions, summary = self._classify(
|
||||||
|
current, latest, significance
|
||||||
|
)
|
||||||
|
|
||||||
|
# Documentation-only releases are not worth an alert. This is the whole
|
||||||
|
# point: nobody should get a notification because a README was reworded.
|
||||||
|
if level == "docs":
|
||||||
|
logger.debug(
|
||||||
|
"truecloud-patch %s -> %s is documentation-only; not alerting",
|
||||||
|
current, latest,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"current": f"v{current}",
|
||||||
|
"latest": latest,
|
||||||
|
"summary": summary,
|
||||||
|
"dir": PATCH_DIR,
|
||||||
|
}
|
||||||
|
klass = (
|
||||||
|
TrueCloudPatchSecurityUpdateAlertClass if level == "security"
|
||||||
|
else TrueCloudPatchUpdateAlertClass
|
||||||
|
)
|
||||||
|
return Alert(klass, args, key=[current, latest])
|
||||||
|
|
||||||
|
def _release_notes(self):
|
||||||
|
"""Load tools/release_notes.py by path.
|
||||||
|
|
||||||
|
NOT via sys.path: prepending would shadow the stdlib for this interpreter,
|
||||||
|
and this runs in middlewared's thread pool, so mutating sys.path is a race.
|
||||||
|
"""
|
||||||
|
path = os.path.join(PATCH_DIR, "tools", "release_notes.py")
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location("_tc_release_notes", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
except Exception:
|
||||||
|
logger.debug("could not load release_notes", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _installed_version(self):
|
||||||
|
"""The version of the patch actually checked out here."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(PATCH_DIR, "patch", "apply.sh"), encoding="utf-8") as fh:
|
||||||
|
m = _VERSION_RE.search(fh.read())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
def _latest_release_tag(self):
|
||||||
|
"""Newest plain vX.Y.Z tag on the remote. Read-only: no .git writes.
|
||||||
|
|
||||||
|
Pre-release tags (-rc, -beta) are excluded: git's version sort ranks
|
||||||
|
v0.5.0-rc1 above v0.5.0, so including them would advertise a release
|
||||||
|
candidate as the latest stable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = self._git("ls-remote", "--tags", "--refs", "origin")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split("refs/tags/")
|
||||||
|
if len(parts) == 2 and _TAG_RE.match(parts[1].strip()):
|
||||||
|
tags.append(parts[1].strip())
|
||||||
|
if not tags:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return max(tags, key=lambda t: tuple(int(x) for x in t.lstrip("v").split(".")))
|
||||||
|
|
||||||
|
def _classify(self, current, latest, significance):
|
||||||
|
"""(level, versions, one-line summary). Falls back to alerting."""
|
||||||
|
text = self._remote_changelog(latest)
|
||||||
|
if text is None:
|
||||||
|
# Cannot tell whether it matters. Alert rather than risk hiding a
|
||||||
|
# security fix -- but say that we could not tell.
|
||||||
|
return "notable", [], " (could not read the changelog)"
|
||||||
|
|
||||||
|
level, versions, headings = significance(text, current, latest)
|
||||||
|
if level == "docs":
|
||||||
|
return level, versions, ""
|
||||||
|
|
||||||
|
seen, ordered = set(), []
|
||||||
|
for h in headings:
|
||||||
|
if h not in seen:
|
||||||
|
seen.add(h)
|
||||||
|
ordered.append(h.capitalize())
|
||||||
|
detail = ", ".join(ordered)
|
||||||
|
return level, versions, f" Changes: {detail}." if detail else ""
|
||||||
|
|
||||||
|
def _remote_changelog(self, tag):
|
||||||
|
"""CHANGELOG.md at `tag`, over HTTPS. None if it cannot be read."""
|
||||||
|
try:
|
||||||
|
remote = self._git("remote", "get-url", "origin").strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = _GITHUB_RE.search(remote)
|
||||||
|
if not m:
|
||||||
|
return None # not a GitHub remote; skip classification
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/"
|
||||||
|
f"{tag}/CHANGELOG.md"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310
|
||||||
|
if resp.status != 200:
|
||||||
|
return None
|
||||||
|
return resp.read().decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
+68
-59
@@ -32,7 +32,7 @@
|
|||||||
# Derive PATCH_DIR from this script's location (parent of the patch/ directory).
|
# Derive PATCH_DIR from this script's location (parent of the patch/ directory).
|
||||||
PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
LOG="$PATCH_DIR/apply.log"
|
LOG="$PATCH_DIR/apply.log"
|
||||||
VERSION="0.3.3"
|
VERSION="0.5.1"
|
||||||
|
|
||||||
# Rotate log at 512 KB to avoid unbounded growth on a system volume.
|
# 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.
|
# 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):
|
# Single implementation of the block apply/revert logic (patch/mw_patch.py), so
|
||||||
with open(path, encoding="utf-8") as fh:
|
# uninstall.sh and apply.sh cannot drift apart. Fail-safe: if it cannot be
|
||||||
content = fh.read()
|
# imported, skip the backend patch entirely -- middlewared then starts stock,
|
||||||
marker = "\n# TRUECLOUD_PATCH"
|
# which is the whole design principle of this script.
|
||||||
idx = content.find(marker)
|
# APPEND, never insert(0): this dir would otherwise take precedence over the
|
||||||
base = content[:idx] if idx != -1 else content
|
# stdlib for this interpreter, so a future patch/json.py (say) would shadow the
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
# real json module and break the boot. Appending fails safe -- worst case our
|
||||||
fh.write(base.rstrip("\n") + "\n" + block)
|
# import misses and the backend patch is skipped.
|
||||||
|
sys.path.append(os.path.dirname(nested_src))
|
||||||
|
|
||||||
def unpatch_file(path):
|
|
||||||
"""Strip our appended block, restoring the stock file. True if it was patched."""
|
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
from mw_patch import patch_file, revert_nested
|
||||||
content = fh.read()
|
except ImportError as _e:
|
||||||
except OSError:
|
print(f'WARNING: cannot import patch/mw_patch.py ({_e}) — skipping backend patch.')
|
||||||
return False
|
print('WARNING: middlewared will start with stock (unpatched) modules.')
|
||||||
idx = content.find("\n# TRUECLOUD_PATCH")
|
sys.exit(1)
|
||||||
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
|
|
||||||
|
|
||||||
|
# .../middlewared/plugins/cloud -> .../middlewared
|
||||||
def revert_nested(cloud_dir, sync_path):
|
mw_dir = os.path.dirname(os.path.dirname(cloud_dir))
|
||||||
"""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
|
|
||||||
|
|
||||||
b2_ok = restic_ok = False
|
b2_ok = restic_ok = False
|
||||||
nested_ok = False
|
nested_ok = False
|
||||||
@@ -582,7 +541,7 @@ if not nested_needed:
|
|||||||
nested_detail = 'not needed'
|
nested_detail = 'not needed'
|
||||||
print('INFO: Nested module skipped.')
|
print('INFO: Nested module skipped.')
|
||||||
|
|
||||||
reverted = revert_nested(cloud_dir, sync_path)
|
reverted = revert_nested(mw_dir)
|
||||||
if reverted:
|
if reverted:
|
||||||
print('OK: Reverted a previously-applied nested patch (' + ', '.join(reverted) + ').')
|
print('OK: Reverted a previously-applied nested patch (' + ', '.join(reverted) + ').')
|
||||||
print(' The stock nesting guard is restored once middlewared restarts.')
|
print(' The stock nesting guard is restored once middlewared restarts.')
|
||||||
@@ -619,6 +578,51 @@ else:
|
|||||||
# that is inactive (superseded, or opt-in and off) is ok -- reporting a disabled
|
# that is inactive (superseded, or opt-in and off) is ok -- reporting a disabled
|
||||||
# opt-in feature as FAIL would make `create_task.py verify` fail on a default
|
# opt-in feature as FAIL would make `create_task.py verify` fail on a default
|
||||||
# install. `active` says whether the module is doing anything.
|
# install. `active` says whether the module is doing anything.
|
||||||
|
# ── update-available alert ────────────────────────────────────────────────────
|
||||||
|
# Dropped into middlewared/alert/source/, where middlewared discovers and polls it
|
||||||
|
# natively — no cron, no timer. It only raises an alert for releases that actually
|
||||||
|
# changed something: a docs-only release is ignored (see tools/release_notes.py).
|
||||||
|
alert_ok = False
|
||||||
|
alert_detail = ''
|
||||||
|
_patch_dir = os.path.dirname(os.path.dirname(nested_src))
|
||||||
|
alert_src = os.path.join(os.path.dirname(nested_src), 'alert_source.py')
|
||||||
|
alert_dst = os.path.join(mw_dir, 'alert', 'source', 'truecloud_patch_update.py')
|
||||||
|
|
||||||
|
if os.path.exists(os.path.join(_patch_dir, 'update_alerts_disabled')):
|
||||||
|
alert_detail = 'disabled (update_alerts_disabled)'
|
||||||
|
try:
|
||||||
|
os.unlink(alert_dst)
|
||||||
|
print('OK: Removed update alert (disabled).')
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
elif not os.path.exists(alert_src):
|
||||||
|
alert_detail = 'alert_source.py not found'
|
||||||
|
print(f'WARNING: {alert_src} missing — no update alert.')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(alert_src, encoding='utf-8') as fh:
|
||||||
|
_body = fh.read()
|
||||||
|
|
||||||
|
# repr() so ANY path becomes a valid Python literal -- a directory
|
||||||
|
# containing a quote or backslash would otherwise produce a syntax error.
|
||||||
|
_body = _body.replace('"@PATCH_DIR@"', repr(_patch_dir))
|
||||||
|
|
||||||
|
# COMPILE BEFORE WRITING. middlewared's alert.load() imports every file in
|
||||||
|
# alert/source/ with NO try/except, and it runs at startup -- a module that
|
||||||
|
# raises on import takes middlewared's setup down with it. An uninstalled
|
||||||
|
# alert is a missing convenience; a broken one is a broken box.
|
||||||
|
compile(_body, alert_dst, 'exec')
|
||||||
|
|
||||||
|
with open(alert_dst, 'w', encoding='utf-8') as fh:
|
||||||
|
fh.write(_body)
|
||||||
|
alert_ok = True
|
||||||
|
alert_detail = 'update alert installed'
|
||||||
|
print(f'OK: Installed update alert → {alert_dst}')
|
||||||
|
except Exception as e:
|
||||||
|
alert_detail = f'not applied: {e}'
|
||||||
|
print(f'WARNING: could not install update alert: {e}')
|
||||||
|
print('WARNING: no update alert; everything else is unaffected.')
|
||||||
|
|
||||||
patches = {
|
patches = {
|
||||||
'providers': {
|
'providers': {
|
||||||
'ok': (not providers_needed) or bool(b2_ok and restic_ok),
|
'ok': (not providers_needed) or bool(b2_ok and restic_ok),
|
||||||
@@ -630,6 +634,11 @@ patches = {
|
|||||||
'active': nested_needed,
|
'active': nested_needed,
|
||||||
'detail': nested_detail,
|
'detail': nested_detail,
|
||||||
},
|
},
|
||||||
|
'update_alert': {
|
||||||
|
'ok': True, # never a failure: it is a convenience, not a patch
|
||||||
|
'active': alert_ok,
|
||||||
|
'detail': alert_detail,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
payload = {'patched_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'patches': patches}
|
payload = {'patched_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'patches': patches}
|
||||||
tmp = status_path + '.tmp'
|
tmp = status_path + '.tmp'
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
__version__ = "0.3.3"
|
__version__ = "0.5.1"
|
||||||
|
|
||||||
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")
|
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")
|
||||||
@@ -95,8 +95,11 @@ def midclt_call(method, *args):
|
|||||||
def _middlewared_start_epoch():
|
def _middlewared_start_epoch():
|
||||||
"""Epoch timestamp of the running middlewared main process, or None."""
|
"""Epoch timestamp of the running middlewared main process, or None."""
|
||||||
try:
|
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(
|
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,
|
capture_output=True, text=True, timeout=10, check=True,
|
||||||
).stdout.strip())
|
).stdout.strip())
|
||||||
if pid <= 0:
|
if pid <= 0:
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/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")
|
||||||
|
|
||||||
|
#: The update-available alert source. Not a "patch" (it appends nothing to a stock
|
||||||
|
#: file), but it is a file we install into middlewared and must therefore remove.
|
||||||
|
ALERT_MODULE = ("alert", "source", "truecloud_patch_update.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, and remove every file it installs."""
|
||||||
|
reverted = revert(mw_dir, NESTED_RELPATHS + PROVIDER_RELPATHS, NESTED_MODULE)
|
||||||
|
try:
|
||||||
|
os.unlink(os.path.join(mw_dir, *ALERT_MODULE))
|
||||||
|
reverted.append(ALERT_MODULE[-1])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return reverted
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -279,7 +279,11 @@ def current_mounts_under(root, mounts_file="/proc/self/mounts"):
|
|||||||
|
|
||||||
|
|
||||||
def _run(cmd):
|
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):
|
def apply_plan(mounts, runner=_run, isdir=os.path.isdir):
|
||||||
@@ -379,8 +383,16 @@ async def delete_snapshot_tree(middleware, snapshot, logger=None):
|
|||||||
try:
|
try:
|
||||||
await middleware.call("zfs.snapshot.delete", snapshot, {"recursive": True})
|
await middleware.call("zfs.snapshot.delete", snapshot, {"recursive": True})
|
||||||
return
|
return
|
||||||
except Exception: # noqa: BLE001 - fall through to the explicit sweep
|
except Exception as e: # noqa: BLE001 - fall through to the explicit sweep
|
||||||
pass
|
# 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
|
# 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
|
# our mounts are released -- which fails the recursive delete while the
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
# bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
|
# bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
|
||||||
# systemctl restart middlewared
|
# systemctl restart middlewared
|
||||||
|
|
||||||
VERSION="0.3.3"
|
VERSION="0.5.1"
|
||||||
|
|
||||||
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for the update-available alert source.
|
||||||
|
|
||||||
|
This file is imported by middlewared's `alert.load()`, which runs at STARTUP and
|
||||||
|
has **no try/except**:
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
for module in load_modules(.../alert/source):
|
||||||
|
for cls in load_classes(module, AlertSource, (ThreadedAlertSource,)):
|
||||||
|
source = cls(self.middleware)
|
||||||
|
if source.name in ALERT_SOURCES:
|
||||||
|
raise RuntimeError(...)
|
||||||
|
|
||||||
|
So a module that raises on import takes middlewared's setup down with it. These
|
||||||
|
tests guard the realistic ways that could happen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ALERT_SRC = os.path.join(os.path.dirname(__file__), "..", "patch", "alert_source.py")
|
||||||
|
APPLY_SH = os.path.join(os.path.dirname(__file__), "..", "patch", "apply.sh")
|
||||||
|
|
||||||
|
|
||||||
|
def source():
|
||||||
|
with open(ALERT_SRC, encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
def tree():
|
||||||
|
return ast.parse(source())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCannotBreakMiddlewaredAtImport:
|
||||||
|
def test_it_compiles(self):
|
||||||
|
compile(source(), "alert_source.py", "exec")
|
||||||
|
|
||||||
|
def test_apply_sh_compiles_it_before_writing_it(self):
|
||||||
|
# The substituted file is what middlewared imports. If it does not compile,
|
||||||
|
# installing it would break startup — so apply.sh must refuse to write it.
|
||||||
|
with open(APPLY_SH, encoding="utf-8") as fh:
|
||||||
|
sh = fh.read()
|
||||||
|
i = sh.index("alert_dst = os.path.join(mw_dir, 'alert', 'source'")
|
||||||
|
block = sh[i:i + 1600]
|
||||||
|
assert "compile(_body, alert_dst, 'exec')" in block
|
||||||
|
assert block.index("compile(_body") < block.index("open(alert_dst, 'w'")
|
||||||
|
|
||||||
|
def test_patch_dir_is_substituted_with_repr(self):
|
||||||
|
# A directory containing a quote or backslash would otherwise produce a
|
||||||
|
# syntax error in the installed module.
|
||||||
|
with open(APPLY_SH, encoding="utf-8") as fh:
|
||||||
|
sh = fh.read()
|
||||||
|
assert "_body.replace('\"@PATCH_DIR@\"', repr(_patch_dir))" in sh
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/mnt/tank/patch",
|
||||||
|
'/mnt/we"ird/patch', # a quote in the path
|
||||||
|
"/mnt/back\\slash/patch", # a backslash
|
||||||
|
])
|
||||||
|
def test_substituted_module_compiles_for_awkward_paths(self, path):
|
||||||
|
body = source().replace('"@PATCH_DIR@"', repr(path))
|
||||||
|
compile(body, "alert_source.py", "exec") # must not raise
|
||||||
|
|
||||||
|
def test_no_io_at_module_import_time(self):
|
||||||
|
# Anything at module scope runs during alert.load(). Only imports,
|
||||||
|
# constants and class definitions are allowed.
|
||||||
|
allowed = (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign,
|
||||||
|
ast.ClassDef, ast.FunctionDef, ast.Expr)
|
||||||
|
for node in tree().body:
|
||||||
|
assert isinstance(node, allowed), f"module-level {type(node).__name__}"
|
||||||
|
if isinstance(node, ast.Expr):
|
||||||
|
assert isinstance(node.value, ast.Constant), "only the docstring"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlertClassNaming:
|
||||||
|
"""middlewared's AlertClassMeta raises NameError unless the name ends in
|
||||||
|
'AlertClass' — at import, inside alert.load(), which has no try/except."""
|
||||||
|
|
||||||
|
def alert_classes(self):
|
||||||
|
return [n for n in tree().body
|
||||||
|
if isinstance(n, ast.ClassDef)
|
||||||
|
and any(getattr(b, "id", "") == "AlertClass" for b in n.bases)]
|
||||||
|
|
||||||
|
def test_there_are_alert_classes(self):
|
||||||
|
assert self.alert_classes()
|
||||||
|
|
||||||
|
def test_every_alert_class_name_ends_in_AlertClass(self):
|
||||||
|
for cls in self.alert_classes():
|
||||||
|
assert cls.name.endswith("AlertClass"), (
|
||||||
|
f"{cls.name}: AlertClassMeta raises NameError on this"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_alert_class_defines_the_required_attrs(self):
|
||||||
|
# category/level/title are NotImplemented on the base; a missing one shows
|
||||||
|
# up as a broken alert rather than an error.
|
||||||
|
for cls in self.alert_classes():
|
||||||
|
names = {t.id for n in cls.body if isinstance(n, ast.Assign)
|
||||||
|
for t in n.targets if isinstance(t, ast.Name)}
|
||||||
|
assert {"category", "level", "title", "text"} <= names, cls.name
|
||||||
|
|
||||||
|
def test_alert_text_placeholders_match_the_args_we_pass(self):
|
||||||
|
src = source()
|
||||||
|
placeholders = set(re.findall(r"%\((\w+)\)s", src))
|
||||||
|
# These are the keys built in _check().
|
||||||
|
assert placeholders <= {"current", "latest", "summary", "dir"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoSysPathMutation:
|
||||||
|
def test_release_notes_is_loaded_by_path_not_sys_path(self):
|
||||||
|
# sys.path.insert(0, ...) would shadow the stdlib for this interpreter, and
|
||||||
|
# ThreadedAlertSource runs in a thread pool — mutating sys.path is a race.
|
||||||
|
#
|
||||||
|
# Check for actual MUTATION, not the string: the docstring legitimately
|
||||||
|
# mentions sys.path to explain why it is avoided.
|
||||||
|
src = source()
|
||||||
|
assert "sys.path.insert" not in src
|
||||||
|
assert "sys.path.append" not in src
|
||||||
|
assert not re.search(r"^import sys$", src, re.M), "sys is not needed"
|
||||||
|
assert "spec_from_file_location" in src
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeverWritesToGit:
|
||||||
|
def test_only_read_only_git_commands(self):
|
||||||
|
# A `git fetch` from middlewared (running as root) would leave root-owned
|
||||||
|
# objects in .git and break every later non-root git command — which is
|
||||||
|
# exactly the breakage this project already hit once.
|
||||||
|
src = source()
|
||||||
|
for forbidden in ("fetch", "pull", "checkout", "clone", "reset"):
|
||||||
|
assert f'"{forbidden}"' not in src, f"git {forbidden} writes to .git"
|
||||||
|
assert '"ls-remote"' in src
|
||||||
+10
-30
@@ -277,42 +277,22 @@ class TestOptIn:
|
|||||||
running until the next reboot.
|
running until the next reboot.
|
||||||
"""
|
"""
|
||||||
src = heredoc_source()
|
src = heredoc_source()
|
||||||
assert "def unpatch_file(" in src
|
# The implementation lives in patch/mw_patch.py (see test_mw_patch.py);
|
||||||
assert "def revert_nested(" in src
|
# apply.sh must import and actually call it.
|
||||||
# The revert must run on every not-needed path (opt-out, superseded).
|
assert "from mw_patch import patch_file, revert_nested" in src
|
||||||
gate = src.index("if not nested_needed:")
|
gate = src.index("if not nested_needed:")
|
||||||
revert = src.index("reverted = revert_nested(")
|
revert = src.index("reverted = revert_nested(")
|
||||||
patch = src.index("patch_file(crud_py, CRUD_BLOCK)")
|
patch = src.index("patch_file(crud_py, CRUD_BLOCK)")
|
||||||
assert gate < revert < patch, "revert belongs in the not-needed branch"
|
assert gate < revert < patch, "revert belongs in the not-needed branch"
|
||||||
|
|
||||||
def test_revert_removes_the_module_before_unpatching_files(self):
|
def test_import_failure_skips_the_patch_rather_than_crashing(self):
|
||||||
# Every injected block is guarded by `if _tc_nested is not None`, so
|
# apply.sh runs at PREINIT. If mw_patch.py cannot be imported it must
|
||||||
# deleting the module first means the guard is restored even if a later
|
# degrade to "middlewared starts stock", never take the boot down.
|
||||||
# unpatch step fails.
|
|
||||||
src = heredoc_source()
|
src = heredoc_source()
|
||||||
body = src[src.index("def revert_nested("):src.index("def patch_file(") if
|
i = src.index("from mw_patch import")
|
||||||
src.index("def patch_file(") > src.index("def revert_nested(") else len(src)]
|
tail = src[i:i + 400]
|
||||||
body = src[src.index("def revert_nested("):]
|
assert "except ImportError" in tail
|
||||||
body = body[:body.index("\n\n\n")] if "\n\n\n" in body else body
|
assert "skipping backend patch" in tail
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_is_relaxed_only_after_traversal_is_installed():
|
def test_guard_is_relaxed_only_after_traversal_is_installed():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,6 +21,8 @@ from release_notes import ( # noqa: E402
|
|||||||
extract_notes,
|
extract_notes,
|
||||||
normalise,
|
normalise,
|
||||||
script_versions,
|
script_versions,
|
||||||
|
significance,
|
||||||
|
version_tuple,
|
||||||
)
|
)
|
||||||
|
|
||||||
REPO = os.path.join(os.path.dirname(__file__), "..")
|
REPO = os.path.join(os.path.dirname(__file__), "..")
|
||||||
@@ -120,3 +122,81 @@ class TestCheckCatchesMistakes:
|
|||||||
def test_reports_a_missing_changelog_section(self):
|
def test_reports_a_missing_changelog_section(self):
|
||||||
problems = check("v9.9.9", REPO)
|
problems = check("v9.9.9", REPO)
|
||||||
assert any("no section" in p for p in problems)
|
assert any("no section" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignificance:
|
||||||
|
"""Drives the TrueNAS update alert: what is worth bothering a human about.
|
||||||
|
|
||||||
|
The rule: a release whose CHANGELOG only has a "### Docs" section changed no
|
||||||
|
code, and nobody should get an alert because a README was reworded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEXT = """\
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.4.2 — 2026-07-13
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- reworded the README
|
||||||
|
|
||||||
|
## v0.4.1 — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- a real bug
|
||||||
|
|
||||||
|
## v0.4.0 — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- a feature
|
||||||
|
|
||||||
|
## v0.3.3 — 2026-07-13
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- keep a password out of argv
|
||||||
|
|
||||||
|
## v0.3.2 — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- something
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_docs_only_release_does_not_alert(self):
|
||||||
|
level, versions, _ = significance(self.TEXT, "0.4.1", "0.4.2")
|
||||||
|
assert level == "docs"
|
||||||
|
assert versions == ["0.4.2"]
|
||||||
|
|
||||||
|
def test_a_real_fix_alerts(self):
|
||||||
|
level, _v, _h = significance(self.TEXT, "0.4.0", "0.4.1")
|
||||||
|
assert level == "notable"
|
||||||
|
|
||||||
|
def test_security_in_range_escalates(self):
|
||||||
|
level, _v, _h = significance(self.TEXT, "0.3.2", "0.3.3")
|
||||||
|
assert level == "security"
|
||||||
|
|
||||||
|
def test_security_wins_even_when_the_newest_release_is_docs_only(self):
|
||||||
|
# A docs-only v0.4.2 sitting on top of a security-fixing v0.3.3 must still
|
||||||
|
# be reported as security — classify the whole span, not just the tip.
|
||||||
|
level, versions, _ = significance(self.TEXT, "0.3.2", "0.4.2")
|
||||||
|
assert level == "security"
|
||||||
|
assert set(versions) == {"0.3.3", "0.4.0", "0.4.1", "0.4.2"}
|
||||||
|
|
||||||
|
def test_same_version_is_never_notable(self):
|
||||||
|
level, versions, _ = significance(self.TEXT, "0.4.2", "0.4.2")
|
||||||
|
assert level == "docs"
|
||||||
|
assert versions == []
|
||||||
|
|
||||||
|
def test_range_is_exclusive_of_current_inclusive_of_latest(self):
|
||||||
|
_l, versions, _h = significance(self.TEXT, "0.4.0", "0.4.2")
|
||||||
|
assert "0.4.0" not in versions
|
||||||
|
assert "0.4.2" in versions
|
||||||
|
|
||||||
|
def test_version_tuple_orders_correctly(self):
|
||||||
|
assert version_tuple("v0.10.0") > version_tuple("v0.9.9")
|
||||||
|
assert version_tuple("0.4.2") > version_tuple("0.4.1")
|
||||||
|
# Pre-release suffixes are dropped, not ranked above the release.
|
||||||
|
assert version_tuple("v0.5.0-rc1") == version_tuple("v0.5.0")
|
||||||
|
|||||||
+60
-1
@@ -29,6 +29,7 @@ VERSIONED_FILES = [
|
|||||||
"recover.sh",
|
"recover.sh",
|
||||||
os.path.join("patch", "apply.sh"),
|
os.path.join("patch", "apply.sh"),
|
||||||
os.path.join("patch", "create_task.py"), # exposes `--version` to users
|
os.path.join("patch", "create_task.py"), # exposes `--version` to users
|
||||||
|
"update.sh",
|
||||||
]
|
]
|
||||||
|
|
||||||
# `VERSION="x"` (shell) or `__version__ = "x"` (python).
|
# `VERSION="x"` (shell) or `__version__ = "x"` (python).
|
||||||
@@ -87,6 +88,60 @@ def extract_notes(text: str, version: str) -> str:
|
|||||||
return "\n".join(lines[start:end]).strip()
|
return "\n".join(lines[start:end]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── significance ──────────────────────────────────────────────────────────────
|
||||||
|
# Used by the TrueNAS update alert to decide whether a release is worth bothering
|
||||||
|
# anyone about. The CHANGELOG's own section headings are the signal: a release that
|
||||||
|
# only has "### Docs" changed no code, and nobody should get an alert for a README.
|
||||||
|
|
||||||
|
_SECTION_RE = re.compile(r"^###\s+(.+?)\s*$", re.M)
|
||||||
|
|
||||||
|
#: Headings that mean "nothing about the running system changed".
|
||||||
|
QUIET_SECTIONS = {"docs", "documentation"}
|
||||||
|
|
||||||
|
|
||||||
|
def version_tuple(v: str) -> tuple:
|
||||||
|
"""Sortable version. Pre-release suffixes are dropped, not ranked."""
|
||||||
|
return tuple(int(x) for x in normalise(v).split("-")[0].split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def section_headings(body: str) -> list[str]:
|
||||||
|
"""The `### ...` headings inside one version's body, lowercased."""
|
||||||
|
return [h.strip().lower() for h in _SECTION_RE.findall(body)]
|
||||||
|
|
||||||
|
|
||||||
|
def significance(text: str, current: str, latest: str):
|
||||||
|
"""How much does upgrading `current` -> `latest` actually matter?
|
||||||
|
|
||||||
|
Returns ``(level, versions, headings)`` where level is one of:
|
||||||
|
|
||||||
|
"security" a release in the range has a Security section -> alert loudly
|
||||||
|
"notable" something about the system changed -> alert quietly
|
||||||
|
"docs" only documentation changed -> DO NOT alert
|
||||||
|
|
||||||
|
Considers every release in the range, not just the newest: a docs-only v0.4.2
|
||||||
|
on top of a security-fixing v0.4.1 must still be reported as security.
|
||||||
|
"""
|
||||||
|
cur, lat = version_tuple(current), version_tuple(latest)
|
||||||
|
|
||||||
|
versions = [
|
||||||
|
v for v in changelog_versions(text)
|
||||||
|
if cur < version_tuple(v) <= lat
|
||||||
|
]
|
||||||
|
|
||||||
|
headings = []
|
||||||
|
for v in versions:
|
||||||
|
try:
|
||||||
|
headings.extend(section_headings(extract_notes(text, v)))
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(h.startswith("security") for h in headings):
|
||||||
|
return "security", versions, headings
|
||||||
|
if [h for h in headings if h not in QUIET_SECTIONS]:
|
||||||
|
return "notable", versions, headings
|
||||||
|
return "docs", versions, headings
|
||||||
|
|
||||||
|
|
||||||
def check(version: str, root: str = ROOT) -> list[str]:
|
def check(version: str, root: str = ROOT) -> list[str]:
|
||||||
"""Every reason this version is not releasable. Empty list means it is."""
|
"""Every reason this version is not releasable. Empty list means it is."""
|
||||||
want = normalise(version)
|
want = normalise(version)
|
||||||
@@ -139,7 +194,11 @@ def main(argv):
|
|||||||
version = argv[2]
|
version = argv[2]
|
||||||
|
|
||||||
if cmd == "notes":
|
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))
|
print(extract_notes(fh.read(), version))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+5
-48
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="0.3.3"
|
VERSION="0.5.1"
|
||||||
|
|
||||||
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
|
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
|
||||||
@@ -98,54 +98,11 @@ echo ""
|
|||||||
# would then remove the boot hook and report success while leaving every patch
|
# would then remove the boot hook and report success while leaving every patch
|
||||||
# applied. Strip our appended blocks explicitly.
|
# 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 ..."
|
echo "Reverting any file-level patches ..."
|
||||||
python3 - <<'PYEOF'
|
python3 "$PATCH_DIR/patch/mw_patch.py" revert-all || \
|
||||||
import os
|
echo " WARNING: could not revert file-level patches."
|
||||||
|
|
||||||
try:
|
|
||||||
import middlewared
|
|
||||||
except ImportError:
|
|
||||||
print(" middlewared not importable — nothing to revert.")
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
mw = os.path.dirname(os.path.abspath(middlewared.__file__))
|
|
||||||
targets = [
|
|
||||||
os.path.join(mw, "rclone", "remote", "b2.py"),
|
|
||||||
os.path.join(mw, "plugins", "cloud_backup", "restic.py"),
|
|
||||||
os.path.join(mw, "plugins", "cloud_backup", "sync.py"),
|
|
||||||
os.path.join(mw, "plugins", "cloud", "crud.py"),
|
|
||||||
os.path.join(mw, "plugins", "cloud", "snapshot.py"),
|
|
||||||
]
|
|
||||||
|
|
||||||
reverted = []
|
|
||||||
for path in targets:
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
idx = content.find("\n# TRUECLOUD_PATCH")
|
|
||||||
if idx == -1:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(content[:idx].rstrip("\n") + "\n")
|
|
||||||
reverted.append(os.path.basename(path))
|
|
||||||
except OSError as e:
|
|
||||||
print(f" WARNING: could not revert {path}: {e}")
|
|
||||||
|
|
||||||
nested = os.path.join(mw, "plugins", "cloud", "_truecloud_nested.py")
|
|
||||||
try:
|
|
||||||
os.unlink(nested)
|
|
||||||
reverted.append("_truecloud_nested.py")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if reverted:
|
|
||||||
print(" Reverted: " + ", ".join(reverted))
|
|
||||||
else:
|
|
||||||
print(" Nothing to revert (overlay already removed, or never patched).")
|
|
||||||
PYEOF
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
|
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
|
||||||
|
|||||||
@@ -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.5.1"
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user