From 347c415aa77077bd757a7b5c8025f647e927c43c Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 13 Jul 2026 16:20:37 +0000 Subject: [PATCH] v0.4.0: add update.sh Fetch a newer release and apply it, preserving the nested-snapshot opt-in setting. bash update.sh # to the newest release, with a confirmation bash update.sh --check # show what would happen; change nothing bash update.sh --rollback # undo the last update Deliberately NOT automated. This patch injects Python into middlewared and re-applies itself at every boot, so an unattended pull would let any bad upstream commit reach a box with no human in the loop and take effect on the next reboot. v0.0.4 shipped exactly such a bug and took every app on the box down. The manual step is the safety gate. Design decisions worth keeping: - Defaults to the newest RELEASE TAG, not main. main can be mid-refactor; a tag is the tested artifact. --main exists but says so loudly. - Tags ordered by version, not date. Date order silently downgrades the box the first time a hotfix is tagged out of band: a v0.3.6 cut after v0.4.0 would sort as "newest". - Refuses to run over a dirty working tree rather than merging across hand-edited or scp'd files. (Verified: the guard fires.) - Shows the commits and release notes you do not have, read from the TARGET's CHANGELOG via tools/release_notes.py -- not a second copy of the extractor. - Records the previous revision BEFORE moving, so --rollback works even if install.sh dies halfway. - Repairs .git ownership, which past `sudo git pull`s leave root-owned and which then breaks every later non-root git command. update.sh is covered by the version-drift check, so it cannot go stale the way create_task.py's __version__ did. Tested end to end in a throwaway clone: detects v0.3.2 -> v0.3.5, lists missing commits, handles already-up-to-date, and the dirty-tree guard fires. 132 tests, ruff and shellcheck clean. --- CHANGELOG.md | 39 ++++++++ README.md | 33 +++--- install.sh | 4 +- patch/apply.sh | 2 +- patch/create_task.py | 2 +- recover.sh | 2 +- tools/release_notes.py | 7 +- uninstall.sh | 2 +- update.sh | 222 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 update.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 474ce9f..b36fe41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 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 diff --git a/README.md b/README.md index 66f3de5..c524eaf 100644 --- a/README.md +++ b/README.md @@ -341,27 +341,28 @@ Refresh your browser. S3 and B2 credentials now appear in the ## Updating -To update to a new version of the patch: - ```bash -cd /mnt/tank/truenas-truecloud-patch - -# 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 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 ``` -`install.sh` clears any stale kill switch, re-applies the updated patches, -and restarts middlewared. Run `python3 patch/create_task.py verify` afterwards -to confirm the patches loaded successfully. +It preserves your nested-snapshot opt-in setting, shows you the commits and +release notes you don't have yet, and asks before changing anything. It records +the previous revision *before* moving, so `--rollback` works even if `install.sh` +dies halfway. -Check [CHANGELOG.md](CHANGELOG.md) to see what changed between versions. +**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 — if you want convenience, watch +the [releases](https://github.com/sudolulo/truenas-truecloud-patch/releases) feed, +don't automate the pull. ---- +It updates to the newest **release tag**, not `main` — `main` can be mid-refactor, +and a tag is the tested artifact. `--main` exists if you want unreleased code, and +says so loudly. ## Creating a task via CLI diff --git a/install.sh b/install.sh index 5dcd848..55da8e1 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ set -euo pipefail -VERSION="0.3.5" +VERSION="0.4.0" # The directory containing install.sh is the permanent install location. PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -96,7 +96,7 @@ fi echo "Setting permissions ..." chmod +x "$PATCH_DIR/patch/apply.sh" "$PATCH_DIR/patch/create_task.py" \ - "$PATCH_DIR/recover.sh" "$PATCH_DIR/uninstall.sh" + "$PATCH_DIR/recover.sh" "$PATCH_DIR/uninstall.sh" "$PATCH_DIR/update.sh" echo "Done." echo "" diff --git a/patch/apply.sh b/patch/apply.sh index 7aae4c3..bb0135b 100755 --- a/patch/apply.sh +++ b/patch/apply.sh @@ -32,7 +32,7 @@ # Derive PATCH_DIR from this script's location (parent of the patch/ directory). PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)" LOG="$PATCH_DIR/apply.log" -VERSION="0.3.5" +VERSION="0.4.0" # 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. diff --git a/patch/create_task.py b/patch/create_task.py index 8f99bd1..6962391 100755 --- a/patch/create_task.py +++ b/patch/create_task.py @@ -52,7 +52,7 @@ import subprocess import sys import time -__version__ = "0.3.5" +__version__ = "0.4.0" _PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json") diff --git a/recover.sh b/recover.sh index 7a25a90..7e92a23 100755 --- a/recover.sh +++ b/recover.sh @@ -17,7 +17,7 @@ # bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh # systemctl restart middlewared -VERSION="0.3.5" +VERSION="0.4.0" PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/tools/release_notes.py b/tools/release_notes.py index 872bbe6..260dd8c 100644 --- a/tools/release_notes.py +++ b/tools/release_notes.py @@ -29,6 +29,7 @@ VERSIONED_FILES = [ "recover.sh", os.path.join("patch", "apply.sh"), os.path.join("patch", "create_task.py"), # exposes `--version` to users + "update.sh", ] # `VERSION="x"` (shell) or `__version__ = "x"` (python). @@ -139,7 +140,11 @@ def main(argv): version = argv[2] if cmd == "notes": - with open(CHANGELOG, encoding="utf-8") as fh: + # An explicit path lets update.sh show the notes from the CHANGELOG of the + # version it is about to install (`git show :CHANGELOG.md`), not the + # one already checked out. + path = argv[3] if len(argv) > 3 else CHANGELOG + with open(path, encoding="utf-8") as fh: print(extract_notes(fh.read(), version)) return 0 diff --git a/uninstall.sh b/uninstall.sh index 173a256..f52ad9e 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -3,7 +3,7 @@ set -euo pipefail -VERSION="0.3.5" +VERSION="0.4.0" PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" _HOOK_COMMENT='TrueCloud provider patch (S3/B2)' diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..47e5f3d --- /dev/null +++ b/update.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# update.sh — fetch a newer release of truecloud-patch and apply it. +# +# ── RUN THIS BY HAND. NEVER FROM CRON OR A SYSTEMD TIMER. ───────────────────── +# +# This patch injects Python into middlewared and re-applies itself at every boot. +# An unattended pull would let any bad upstream commit reach your box with no +# human in the loop, and take effect on the next reboot. That is not theoretical: +# v0.0.4 shipped a boot-time bug that took every app on the box down. +# +# The manual step IS the safety gate. Keep it. +# +# By default this updates to the newest RELEASE TAG, not to main. main can be +# mid-refactor; a tag is the tested artifact. Use --main only if you know why. +# +# bash update.sh # to the newest release, with a confirmation +# bash update.sh --check # show what would happen; change nothing +# bash update.sh --rollback # undo the last update + +set -euo pipefail + +VERSION="0.4.0" + +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 < 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 +} + +while [ $# -gt 0 ]; do + case "$1" in + --to) _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")" + echo "Rolling back to $_prev ..." + git checkout -q "$_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). + _target="$(git tag -l 'v*' --sort=-version:refname | 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 + +# ── 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 "$_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 "" +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"