Files
truenas-truecloud-patch/patch/apply.sh
T
flan d1db3a3f60
CI / shell (shellcheck + syntax) (push) Successful in 12s
CI / python 3.11 (push) Successful in 20s
CI / python 3.13 (push) Successful in 21s
TrueNAS compatibility / compat (push) Successful in 17s
CI / python 3.12 (push) Successful in 39s
Release / release (push) Successful in 20s
release v0.8.0
2026-08-26 04:37:50 +00:00

1084 lines
50 KiB
Bash
Executable File

#!/bin/bash
# patch/apply.sh — registered as a TrueNAS PREINIT initshutdownscript.
#
# PREINIT scripts are executed BY middlewared itself (ix-preinit.service runs
# `midclt call initshutdownscript.execute_init_tasks PREINIT`, ordered after
# ix-zfs.service pool import). So when this script runs at boot, middlewared
# is already up and has already imported the stock modules — the on-disk
# patch alone cannot reach the running process.
#
# TrueNAS updates replace /usr/ entirely; this script re-applies three patches:
#
# 1. Backend — b2.py and restic.py are patched directly in the overlay.
# On a boot run, a single detached middlewared restart is scheduled
# (Step 3) so the patched modules actually get loaded.
#
# 2. Nested-dataset snapshots — installs _truecloud_nested.py and patches
# plugins/cloud/{snapshot,crud}.py + plugins/cloud_backup/sync.py so the
# "Take Snapshot" option works on a dataset that has child datasets.
# Stock middleware refuses that config, because it points the backup tool
# at the PARENT's .zfs/snapshot/ where children are invisible — it would
# silently back up a near-empty tree. We stage a complete tree of
# per-dataset bind mounts and only then relax the guard.
#
# 3. Angular JS bundle — Widens the TrueCloud Backup credential dropdown
# from Storj-only to include S3 and B2. Served from
# disk per request, so no restart is needed for it.
#
# Design principle: every step is independently fail-safe.
# A failed patch logs a warning and continues; middlewared always starts.
# Never use `set -e` in a PREINIT script.
# 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.8.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.
if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 524288 ]; then
[ -f "${LOG}.1" ] && mv "${LOG}.1" "${LOG}.2"
mv "$LOG" "${LOG}.1"
fi
exec >> "$LOG" 2>&1
echo "=== $(date -Iseconds) [v${VERSION}] ==="
# Kill switch: if this file exists, skip all patching and exit cleanly.
# Recovery: touch "$PATCH_DIR/disabled" (then reboot or restart middlewared).
if [ -f "$PATCH_DIR/disabled" ]; then
echo "Kill switch active ($PATCH_DIR/disabled exists) — patch not applied."
echo "To re-enable: rm $PATCH_DIR/disabled"
echo "=== done ==="
exit 0
fi
# Mounts a writable overlayfs on $1 using /run (tmpfs) for the upper/work dirs
# when the directory is read-only. The overlay is volatile per boot; this
# PREINIT script recreates it on every boot.
# Returns 0 if the directory is now writable, 1 if it could not be made so.
_ensure_writable() {
local dir="$1" tag="$2"
# Already writable?
if touch "$dir/.truecloud-probe" 2>/dev/null; then
rm -f "$dir/.truecloud-probe"
return 0
fi
# Not writable, so any overlay of ours listed on this directory is a
# SHADOWED leftover rather than a working mount: something remounted the
# hierarchy above it -- a systemd-sysext merge/refresh over /usr, or
# middlewared's own docker.configure_nvidia -- and buried it. A live
# overlay of ours is always writable, so this must never be treated as
# "already done"; doing so is what let a buried overlay pass for a healthy
# one and left the backend patch on disk but never loaded.
if mount | grep -qF "truecloud-${tag} on ${dir} "; then
echo "NOTICE: a previous truecloud-${tag} overlay on $dir is shadowed --"
echo "NOTICE: the hierarchy above it was remounted. Detaching and re-mounting."
umount -l "$dir" 2>/dev/null
fi
# Keep the SAME upperdir across re-mounts: it holds everything patched
# earlier this boot, so re-mounting restores those files intact instead of
# re-deriving them. The workdir is scratch and must be empty, so it is
# recreated -- a stale one left behind by a detached mount fails the mount.
local upper="/run/truecloud-${tag}-upper" work="/run/truecloud-${tag}-work"
mkdir -p "$upper"
rm -rf "$work" 2>/dev/null
mkdir -p "$work"
if mount -t overlay "truecloud-${tag}" \
-o "lowerdir=$dir,upperdir=$upper,workdir=$work" "$dir" 2>/dev/null; then
echo "OK: Mounted writable overlay on $dir"
return 0
fi
# A lazily-detached overlay releases its workdir only once its last user is
# gone, and overlayfs refuses a workdir that is still in use. That would turn
# the re-mount this function exists to perform into a hard failure, so retry
# once on a private workdir. It is scratch in /run (tmpfs) and goes away at
# the next boot; the upperdir, which holds the patched files, is unchanged.
work="/run/truecloud-${tag}-work.$$"
rm -rf "$work" 2>/dev/null
mkdir -p "$work"
if mount -t overlay "truecloud-${tag}" \
-o "lowerdir=$dir,upperdir=$upper,workdir=$work" "$dir" 2>/dev/null; then
echo "OK: Mounted writable overlay on $dir (fresh workdir)"
return 0
fi
rmdir "$work" 2>/dev/null
echo "WARNING: overlay mount failed on $dir — backend patch will be skipped."
return 1
}
# Find the Python interpreter that middlewared actually uses.
# On TrueNAS SCALE, /usr/bin/middlewared is usually a Python entry-point script
# with a shebang pointing at the right interpreter (system or venv).
# The shebang is read with dd (no Python startup cost); import verification is
# deferred to the combined subprocess below which handles failure gracefully.
find_mw_python() {
local py="python3"
local shebang=""
if [ -x /usr/bin/middlewared ]; then
# Read the first line safely (max 256 bytes) — avoids reading a binary ELF
shebang=$(dd if=/usr/bin/middlewared bs=256 count=1 2>/dev/null | head -1 || true)
if [[ "$shebang" =~ ^'#!'(/[^[:space:]]+python[^[:space:]]*) ]]; then
py="${BASH_REMATCH[1]}"
elif [[ "$shebang" =~ ^'#!/usr/bin/env '(python[^[:space:]]*) ]]; then
py=$(command -v "${BASH_REMATCH[1]}" 2>/dev/null || echo "python3")
fi
fi
echo "$py"
}
PYTHON=$(find_mw_python)
echo "Using Python: $PYTHON"
# ── Discover paths + native support check (single Python subprocess) ──────────
# Combines what were previously four separate Python invocations into one to
# avoid repeated interpreter startup overhead under the PREINIT timeout budget.
# The patch has two independent modules, and each retires on its own:
#
# providers — B2/S3 credentials for TrueCloud Backup (b2.py, restic.py, UI)
# nested — snapshots on datasets that have child datasets (cloud/*.py)
#
# TrueNAS may well ship one natively long before the other, so a single
# all-or-nothing kill switch would silently take a still-needed module down with
# the superseded one. Each module is detected separately and skipped on its own;
# the global kill switch fires only once BOTH are native.
_tc_info=$("$PYTHON" -c "
import inspect, os, sys
result = {'native_b2': 'no', 'native_nested': 'no', 'site_pkg': '', 'mw_dir': ''}
try:
import middlewared
mw_file = os.path.abspath(middlewared.__file__)
result['mw_dir'] = os.path.dirname(mw_file)
result['site_pkg'] = os.path.dirname(os.path.dirname(mw_file))
except ImportError:
try:
import site
result['site_pkg'] = site.getsitepackages()[0]
except Exception:
pass
# providers: does B2RcloneRemote already carry a real get_restic_config()?
try:
import middlewared.rclone.remote.b2 as _b2_mod
from middlewared.rclone.remote.b2 import B2RcloneRemote
if 'get_restic_config' in B2RcloneRemote.__dict__:
src = open(inspect.getfile(_b2_mod), encoding='utf-8', errors='replace').read()
if 'TRUECLOUD_PATCH' not in src:
try:
method_src = inspect.getsource(B2RcloneRemote.get_restic_config)
except (OSError, TypeError):
method_src = ''
if 'NotImplementedError' not in method_src:
result['native_b2'] = 'yes'
except Exception:
pass
# nested: stock gates nested datasets with a validation in plugins/cloud/crud.py.
# If that guard is gone, iX removed it, which means they implemented the traversal.
#
# Only look at the STOCK part of the file. Our own CRUD_BLOCK quotes the guard
# message (it filters on it), so scanning the whole file would find the string in
# our own patch and conclude the guard is still there. That happens to be
# harmless today because detection runs before patching, but it makes the probe
# silently order-dependent -- so cut our block off explicitly.
#
# If the file cannot be read we assume 'no' and keep patching: worst case the
# patch declines to apply and the option simply stays unavailable.
try:
crud = os.path.join(result['mw_dir'], 'plugins', 'cloud', 'crud.py')
with open(crud, encoding='utf-8', errors='replace') as fh:
stock_src = fh.read().split('\n# TRUECLOUD_PATCH', 1)[0]
# The guard message is SPLIT across adjacent string literals in the source:
#
# verrors.add(..., 'This option is only available for datasets that have no further '
# 'nesting')
#
# Python concatenates those at runtime, so the errmsg is contiguous -- but the
# SOURCE never contains the whole phrase. A raw search finds nothing, concludes
# iX removed the guard, and silently skips this module FOREVER. (Caught only by
# running the probe against real middlewared.)
#
# Strip whitespace and quote characters, then match the compacted phrase. That
# is robust to any wrapping or concatenation style iX may use.
_drop = str.maketrans('', '', ' \\t\\n\\r' + chr(34) + chr(39))
if 'nofurthernesting' not in stock_src.translate(_drop):
result['native_nested'] = 'yes'
except Exception:
pass
print(result['native_b2'])
print(result['native_nested'])
print(result['site_pkg'])
print(result['mw_dir'])
" 2>/dev/null || printf 'no\nno\n\n\n')
_tc_native_b2=$(printf '%s' "$_tc_info" | sed -n '1p')
_tc_native_nested=$(printf '%s' "$_tc_info" | sed -n '2p')
SITE_PKG=$(printf '%s' "$_tc_info" | sed -n '3p')
_MW_DIR=$(printf '%s' "$_tc_info" | sed -n '4p')
# Record the resolved middlewared directory so patch/wait_restart.sh can check,
# without re-deriving any of this, whether the patched modules are still on the
# live filesystem path at the moment it restarts middlewared.
if [ -n "$_MW_DIR" ]; then
printf '%s\n' "$_MW_DIR" > "$PATCH_DIR/.mw_dir" 2>/dev/null
fi
# Nested support is opt-in; if it was never enabled, it cannot be the reason to
# keep the patch alive.
if [ -f "$PATCH_DIR/nested_snapshots_enabled" ]; then
_NESTED_ENABLED=1
else
_NESTED_ENABLED=0
fi
# ── compatibility preflight ──────────────────────────────────────────────────
#
# The native probes above ask "has iX made this module unnecessary?". This asks the
# other question, the dangerous one: "has iX changed middleware so that this module
# no longer WORKS?"
#
# middlewared is internal API with no stability contract, and TrueNAS 26 rewrites
# the entire cloud_backup path from async to synchronous. Every block the nested
# module injects is an `async def` wrapping an `await`ed original; on 26 that hands
# sync.py a coroutine where it unpacks a tuple. The backup does not fail cleanly --
# it fails at the point where you needed it.
#
# tools/compat.py records what each module assumes and checks it against the
# middlewared ACTUALLY INSTALLED HERE. A module whose assumptions no longer hold is
# not applied. Stock TrueNAS without a feature beats TrueNAS with a broken one.
#
# Fail direction, deliberately asymmetric:
# * a definite "assumption violated" -> disable that module. Strong evidence.
# * the checker cannot run at all -> change nothing. That is a tooling glitch,
# not evidence, and turning it into a disabled module would break working boxes.
_TC_COMPAT_JSON="$PATCH_DIR/incompatible.json"
rm -f "$_TC_COMPAT_JSON"
_tc_incompatible=0
_tc_compat=unknown
# No middlewared directory means the checker has nothing to read -- every module
# would look "broken" because every file is missing, which is the strongest possible
# evidence derived from the weakest possible input. Skip the preflight entirely and
# let the existing "Cannot determine middlewared directory" path handle it.
if [ -z "$_MW_DIR" ]; then
echo "NOTICE: middlewared directory unknown; skipping the compatibility preflight."
_tc_compat=$(printf 'unknown\nunknown\n')
else
_tc_compat=$("$PYTHON" - "$PATCH_DIR" "$_MW_DIR" "$_TC_COMPAT_JSON" 2>/dev/null <<'PYEOF' || printf 'unknown\nunknown\n'
import json, os, sys
patch_dir, mw_dir, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
# APPEND, never insert(0) -- see the note by the mw_patch import below. Shadowing
# the stdlib for this interpreter is a much worse failure than not finding compat.
sys.path.append(os.path.join(patch_dir, 'tools'))
try:
import compat
result = compat.check_tree(mw_dir)
except Exception:
print('unknown')
print('unknown')
raise SystemExit(0)
def verdict(r):
# Deliberately NOT exempting 'native' here, unlike the CI matrix.
#
# 'native' answers "do we still NEED this module?"; 'ok' answers "is it still
# SAFE to inject?". They are different questions, and letting native mask a
# broken assumption conflates them: a future TrueNAS that both reworded the
# nesting guard (-> native) AND changed the signatures (-> broken) would read
# as safe, and we would patch it anyway.
#
# Refusing to apply is the correct action for BOTH answers -- a native module
# is unnecessary and a broken one is dangerous -- so the apply path only has to
# ask whether the assumptions hold. Whether the feature went native is decided
# separately, by the probes above, and only affects the wording of the notice.
return 'ok' if r['ok'] else 'broken'
broken = {m: r for m, r in result.items() if verdict(r) == 'broken'}
if broken:
# The alert source reads this. Written before we print, so a box that is
# incompatible always has the evidence on disk even if apply.sh dies later.
try:
with open(out_path, 'w', encoding='utf-8') as fh:
json.dump(broken, fh, indent=2)
except OSError:
pass
print(verdict(result['providers']))
print(verdict(result['nested']))
PYEOF
)
fi
_tc_compat_providers=$(printf '%s' "$_tc_compat" | sed -n '1p')
_tc_compat_nested=$(printf '%s' "$_tc_compat" | sed -n '2p')
# Is either module still doing something useful -- and can it still be applied?
_providers_needed=1
[ "$_tc_native_b2" = "yes" ] && _providers_needed=0
_nested_needed=0
if [ "$_NESTED_ENABLED" = "1" ] && [ "$_tc_native_nested" != "yes" ]; then
_nested_needed=1
fi
# The native checks above have already zeroed _*_needed for anything TrueNAS now
# does itself, and printed the (good) news. Only complain about a module that is
# still NEEDED and no longer fits -- otherwise a version that took a feature native
# AND reshaped the module would be announced as "NOT COMPATIBLE", which is alarming
# and false.
if [ "$_tc_compat_providers" = "broken" ] && [ "$_providers_needed" = "1" ]; then
echo "WARNING: truecloud-patch is NOT COMPATIBLE with this TrueNAS version."
echo "WARNING: The B2/S3 providers module will NOT be applied. TrueCloud is"
echo "WARNING: left stock, so B2/S3 tasks will not run until this is fixed."
echo "WARNING: Details: $_TC_COMPAT_JSON"
_providers_needed=0
_tc_incompatible=1
fi
if [ "$_tc_compat_nested" = "broken" ] && [ "$_nested_needed" = "1" ]; then
echo "WARNING: truecloud-patch's nested-snapshot module is NOT COMPATIBLE with"
echo "WARNING: this TrueNAS version and will NOT be applied. Backups still"
echo "WARNING: run; datasets nested under the target are not included."
echo "WARNING: Details: $_TC_COMPAT_JSON"
_nested_needed=0
_tc_incompatible=1
fi
_tc_unmount_overlays() {
for _tag in mw ui; do
if mount | grep -qF "truecloud-${_tag} on "; then
_mnt=$(mount | grep "truecloud-${_tag} on " | awk '{print $3}' | head -1)
if umount "$_mnt" 2>/dev/null; then
echo "NOTICE: Unmounted overlay on $_mnt"
fi
fi
done
}
# INCOMPATIBLE is not the same as RETIRED, and must never take the same exit.
#
# The kill switch below is permanent -- apply.sh checks for it and returns early on
# every future boot -- and only install.sh removes it, NOT update.sh. That is right
# for retirement ("TrueNAS does this natively now; stop forever"), and catastrophic
# for incompatibility: on TrueNAS 26 the providers module fails its assumptions and
# nested is opt-out by default, so BOTH would be zero, the kill switch would fire,
# and the very release that fixes 26 could never re-enable itself. The user would
# run `bash update.sh` -- exactly what the update alert tells them to do -- and the
# patch would stay dead, silently, with their B2 backups off.
#
# So: incompatible means "apply nothing THIS boot, and try again next boot". The
# fix ships, update.sh checks it out, the next boot re-runs the preflight, the
# assumptions hold, and the patch comes back by itself.
if [ "$_tc_incompatible" = "1" ] && [ "$_providers_needed" = "0" ] && [ "$_nested_needed" = "0" ]; then
echo "NOTICE: Nothing can be applied on this TrueNAS version — see the WARNINGs above."
echo "NOTICE: The kill switch is deliberately NOT set: this is an incompatibility,"
echo "NOTICE: not a retirement. Install a release that supports this TrueNAS"
echo "NOTICE: bash $PATCH_DIR/update.sh"
echo "NOTICE: and the patch will re-apply itself on the next boot."
_tc_unmount_overlays
echo "=== done ==="
exit 0
fi
if [ "$_providers_needed" = "0" ] && [ "$_nested_needed" = "0" ]; then
echo "NOTICE: Nothing left for truecloud-patch to do:"
[ "$_tc_native_b2" = "yes" ] && echo "NOTICE: - TrueNAS now provides native B2 restic support."
if [ "$_tc_native_nested" = "yes" ]; then
echo "NOTICE: - TrueNAS now handles snapshots on nested datasets natively."
elif [ "$_NESTED_ENABLED" = "0" ]; then
echo "NOTICE: - Nested-dataset snapshots are not enabled (opt-in)."
fi
echo "NOTICE: Setting kill switch; patching will be skipped on all future boots."
echo "NOTICE: Run the following to fully remove the patch:"
echo "NOTICE: bash $PATCH_DIR/uninstall.sh"
touch "$PATCH_DIR/disabled"
_tc_unmount_overlays
echo "=== done ==="
exit 0
fi
if [ "$_providers_needed" = "0" ]; then
echo "NOTICE: TrueNAS now provides native B2 restic support — the providers module"
echo "NOTICE: is superseded and will be skipped. The nested-snapshot module is still"
echo "NOTICE: active, so the patch stays installed."
fi
if [ "$_NESTED_ENABLED" = "1" ] && [ "$_tc_native_nested" = "yes" ]; then
echo "NOTICE: TrueNAS now handles nested-dataset snapshots natively — that module is"
echo "NOTICE: superseded and will be skipped. You can drop --enable-nested-snapshots."
fi
# ── Step 1: backend patch ─────────────────────────────────────────────────────
echo "--- backend patch ---"
_backend_ok=0
if [ -z "$SITE_PKG" ]; then
echo "WARNING: Cannot determine site-packages directory; skipping backend patch."
echo " Run: $PYTHON -c \"import site; print(site.getsitepackages())\""
elif ! _ensure_writable "$SITE_PKG" "mw"; then
echo "WARNING: Cannot make site-packages writable; skipping backend patch."
elif [ -z "$_MW_DIR" ]; then
echo "WARNING: Cannot determine middlewared directory; skipping backend patch."
else
_B2_PY="$_MW_DIR/rclone/remote/b2.py"
_RESTIC_PY="$_MW_DIR/plugins/cloud_backup/restic.py"
_CLOUD_DIR="$_MW_DIR/plugins/cloud"
_SYNC_PY="$_MW_DIR/plugins/cloud_backup/sync.py"
_NESTED_SRC="$PATCH_DIR/patch/truecloud_nested.py"
# Each module is applied only if it is still needed. _providers_needed and
# _nested_needed were computed above (native-support detection + the opt-in
# marker), so one module going native never disables the other.
# ── patch b2.py + restic.py + nested-snapshot + hook_status.json ────────
# (single subprocess: PREINIT has a tight timeout budget)
if "$PYTHON" - "$_B2_PY" "$_RESTIC_PY" "$PATCH_DIR/hook_status.json" \
"$_CLOUD_DIR" "$_SYNC_PY" "$_NESTED_SRC" \
"$_providers_needed" "$_nested_needed" "$_NESTED_ENABLED" \
"$_tc_native_nested" << 'PYEOF'
import json, os, shutil, sys, time
b2_path, restic_path, status_path = sys.argv[1], sys.argv[2], sys.argv[3]
cloud_dir, sync_path, nested_src = sys.argv[4], sys.argv[5], sys.argv[6]
providers_needed = sys.argv[7] == "1"
nested_needed = sys.argv[8] == "1"
nested_enabled = sys.argv[9] == "1"
nested_native = sys.argv[10] == "yes"
B2_BLOCK = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
def _tc_get_restic_config(task):
p = task["credentials"]["provider"]
if not isinstance(p, dict):
# TrueNAS <= 24.10: provider is the type string ("B2") and the
# account/key live in the credential's attributes dict. 25.04+
# moved them into a provider dict.
p = task["credentials"]["attributes"]
return "", {"B2_ACCOUNT_ID": p["account"], "B2_ACCOUNT_KEY": p["key"]}
B2RcloneRemote.get_restic_config = staticmethod(_tc_get_restic_config)
B2RcloneRemote.restic = True
"""
RESTIC_BLOCK = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
try:
_tc_orig_get_restic_config = get_restic_config
except NameError:
pass # get_restic_config not in this module; TrueNAS restructured restic.py
else:
def get_restic_config(cloud_backup):
import dataclasses as _dc
result = _tc_orig_get_restic_config(cloud_backup)
cmd = list(result.cmd)
for i, part in enumerate(cmd):
if part.startswith("--repo=") or part.startswith("--repository="):
pfx, _, url = part.partition("=")
pfx += "="
elif i and cmd[i - 1] in ("-r", "--repo", "--repository"):
pfx = None
url = part
else:
continue
scheme, sep, rest = url.partition(":")
if not sep:
break
changed = False
if rest.startswith("/") and not rest.startswith("//"):
rest = rest[1:]
changed = True
if scheme == "b2" and "/" in rest:
rest = rest.replace("/", ":", 1)
changed = True
if changed:
new_url = scheme + ":" + rest
cmd[i] = pfx + new_url if pfx is not None else new_url
try:
return _dc.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
break
return result
get_restic_config._truecloud_patched = True
"""
# ── nested-dataset snapshot support ───────────────────────────────────────────
# Stock middleware refuses `snapshot=true` on a path containing child datasets,
# because it points the backup tool at the PARENT dataset's .zfs/snapshot/,
# where child datasets are INVISIBLE -- it would silently back up a near-empty
# tree. That guard is correct. We implement the missing traversal (a staging
# tree of per-dataset bind mounts) and only then relax the guard.
#
# Fail-safe direction: if any of these three blocks fails to apply, the stock
# guard remains and the option simply stays unavailable. We never end up with
# the guard removed but the traversal missing -- that would be a silently empty
# backup, the worst possible outcome.
# ── nested blocks: one core, two wrappers ─────────────────────────────────────
#
# TrueNAS <= 25.10 has an ASYNC cloud_backup path; TrueNAS 26 rewrote it SYNCHRONOUS
# (`middleware.call_sync` throughout, no awaits). An `async def` wrapper on 26 hands
# sync.py a coroutine where it unpacks a tuple, and a `def` wrapper on 25.10 blocks
# the event loop. So each block is assembled from:
#
# * a CORE, written once, synchronous, using middleware.call_sync -- which is safe
# from a worker thread and deadlocks on the event loop; and
# * a WRAPPER matching the stock function's own flavour, chosen at apply time by
# reading whether the installed middlewared declares it `async def`.
#
# On <= 25.10 the async wrapper hops to a thread via `await middleware.run_in_thread`
# -- exactly the thread call_sync needs. On 26 the stock function is already running
# in middlewared's thread pool (its own code calls call_sync), so the sync wrapper
# calls the core directly.
#
# The logic that matters -- snapshots, bind mounts, failure modes -- exists once.
# An async twin would mean every future fix had to land twice, and the one that got
# missed would be the one that eats a backup.
_NESTED_IMPORT = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
try:
from middlewared.plugins.cloud import _truecloud_nested as _tc_nested
except ImportError:
_tc_nested = None
"""
SNAPSHOT_CORE = _NESTED_IMPORT + """
if _tc_nested is not None:
_tc_orig_create_snapshot = create_snapshot
def _tc_stage(middleware, path, name, snapshot, snap_path):
# Synchronous, and always called from a worker thread (see above).
#
# ONLY cloud_backup. Bail out before touching anything otherwise.
#
# create_snapshot is module-global in plugins/cloud/snapshot.py and is
# imported by cloud_sync.py as well as cloud_backup/sync.py -- so this
# wrapper sits in the path of every rclone/Storj CloudSync task with
# snapshot=true, not just ours. Two consequences, and the second is worse:
#
# * everything below is a NEW failure mode for tasks that worked before we
# were installed. A `zfs list` that errors would break a CloudSync job
# we have no business touching.
# * if a CloudSync task ever were staged, nothing would ever tear it down:
# the teardown is wired into cloud_backup's restic_backup finally, and
# CRUD_BLOCK deliberately leaves CloudSync's nesting guard intact. The
# bind mounts would pin the snapshot forever.
#
# cloud_backup names its snapshot "cloud_backup-<id>"; cloud_sync names it
# "cloud_sync-<id>"; the stock default is "cloud_task-onetime". Anything that
# is not ours gets stock behaviour, untouched, with no extra middleware call.
if not name.startswith("cloud_backup"):
return snapshot, snap_path
_logger = getattr(middleware, "logger", None)
try:
# Enumerate datasets AFTER the snapshot, never before. The snapshot is
# the point-in-time truth; a list read beforehand could miss a dataset
# created in the gap, which the recursive snapshot WOULD capture but
# our staging plan would not -- silently omitting it from the backup.
# Read afterwards, an unsnapshotted dataset instead trips the isdir()
# check in plan_staging and fails the run loudly. Loud beats silent.
# query_filesystems() reads ZFS directly. It deliberately does NOT use
# pool.dataset.query: that applies a visibility policy and hides
# TrueNAS-internal datasets (ix-apps/*, .system/*, .ix-virt/*) -- 84 of
# 270 on a real pool, including live app data. Staging from the filtered
# view omits them silently, which is the one thing this must never do.
datasets = _tc_nested.query_filesystems(middleware)
# OUR copy of get_dataset_recursive, not the host module's: TrueNAS 26
# deleted that helper (create_snapshot uses filesystem.statfs now), so
# calling it out of the module namespace is a NameError there.
dataset, nested = _tc_nested.get_dataset_recursive(datasets, path)
if not nested:
# Nothing to STAGE -- but we still own the SWEEP, and that is not a
# formality. Stock decides `recursive` by its own rule, and on 26 that
# rule is no longer ours: it snapshots recursively whenever the backup
# path IS the dataset's mountpoint (filesystem.statfs), while
# get_dataset_recursive() sees nothing to stage when the only
# descendants are ZVOLs or legacy/none-mountpoint datasets. Stock then
# deletes the PARENT ONLY. Without this, one snapshot per descendant is
# orphaned on every run, forever, with no sidecar and no GC to find it --
# and the backup still reports success.
_tc_nested.own_snapshot(middleware, name, snapshot, logger=_logger)
return snapshot, snap_path
staging_root = _tc_nested.stage_nested(
middleware, path, snapshot,
dataset["name"], dataset["properties"]["mountpoint"]["value"],
name, datasets, logger=_logger,
)
except Exception:
# The snapshot exists, but this exception means sync.py never completes
# `snapshot, local_path = create_snapshot(...)`, so its local `snapshot`
# stays None and its `finally` deletes NOTHING. Sweep the tree ourselves
# or leak the parent plus one snapshot per descendant dataset (160+ here)
# on every failed run.
#
# The sweep is itself wrapped: a cleanup that raises would REPLACE the
# original exception with its own, hiding why the backup actually failed.
# An error handler must not be able to lose the error.
try:
_tc_nested.delete_snapshot_tree(middleware, snapshot, logger=_logger)
except Exception as _tc_sweep_err:
if _logger:
_logger.error(
"truecloud-patch: could not sweep %s after a staging failure "
"(%r) -- it is orphaned and must be deleted by hand",
snapshot, _tc_sweep_err,
)
raise
return snapshot, staging_root
"""
SNAPSHOT_ASYNC = SNAPSHOT_CORE + """
async def create_snapshot(middleware, path, name="cloud_task-onetime"):
snapshot, snap_path = await _tc_orig_create_snapshot(middleware, path, name)
return await middleware.run_in_thread(
_tc_stage, middleware, path, name, snapshot, snap_path
)
create_snapshot._truecloud_patched = True
"""
SNAPSHOT_SYNC = SNAPSHOT_CORE + """
def create_snapshot(middleware, path, name="cloud_task-onetime"):
snapshot, snap_path = _tc_orig_create_snapshot(middleware, path, name)
return _tc_stage(middleware, path, name, snapshot, snap_path)
create_snapshot._truecloud_patched = True
"""
_CRUD_FILTER = """
# Only cloud_backup: staging teardown is wired into cloud_backup.sync's
# finally. cloudsync would leak bind mounts, so leave its guard intact.
if getattr(getattr(self, "_config", None), "namespace", "") != "cloud_backup":
return
# Drop ONLY the nested-dataset guard. If iX ever rewords the message the
# filter stops matching, the guard survives, and the option merely stays
# unavailable -- the safe direction to fail.
verrors.errors = [
e for e in verrors.errors
if not (
getattr(e, "attribute", "") == f"{name}.snapshot"
and "no further nesting" in getattr(e, "errmsg", "")
)
]
CloudTaskServiceMixin._validate = _tc_validate
CloudTaskServiceMixin._validate._truecloud_patched = True
"""
CRUD_ASYNC = _NESTED_IMPORT + """
if _tc_nested is not None:
_tc_orig_validate = CloudTaskServiceMixin._validate
async def _tc_validate(self, app, verrors, name, data):
await _tc_orig_validate(self, app, verrors, name, data)
""" + _CRUD_FILTER
CRUD_SYNC = _NESTED_IMPORT + """
if _tc_nested is not None:
_tc_orig_validate = CloudTaskServiceMixin._validate
def _tc_validate(self, app, verrors, name, data):
_tc_orig_validate(self, app, verrors, name, data)
""" + _CRUD_FILTER
# *args/**kwargs, not the stock signature spelled out.
#
# 24.10 and 25.04 have `restic_backup(middleware, job, cloud_backup, dry_run)`;
# 25.10 added `rate_limit`. Naming them and forwarding all five raised
# `TypeError: takes 4 positional arguments but 5 were given` on every nested backup
# on the two older releases. Forwarding whatever we were handed makes this wrapper
# indifferent to iX adding or dropping a trailing parameter -- which they have now
# done twice.
#
# Our bind mounts pin the ZFS snapshot, so stock's `finally` cannot destroy it
# (EBUSY) and logs one benign warning. We unmount here and then delete it for real.
SYNC_ASYNC = _NESTED_IMPORT + """
if _tc_nested is not None:
_tc_orig_restic_backup = restic_backup
async def restic_backup(middleware, job, cloud_backup, *args, **kwargs):
try:
return await _tc_orig_restic_backup(middleware, job, cloud_backup, *args, **kwargs)
finally:
try:
# logger= must be passed here too, exactly as the sync variant does.
# run_in_thread forwards **kwargs (functools.partial), and without it
# cleanup_task gets logger=None -- so every teardown warning ("could
# not unmount X") is silently swallowed on <= 25.10, which is most
# boxes. The two wrappers must differ ONLY in how they reach the core.
await middleware.run_in_thread(
_tc_nested.cleanup_task,
middleware,
f"cloud_backup-{cloud_backup.get('id', 'onetime')}",
logger=getattr(middleware, "logger", None),
)
except Exception as e:
middleware.logger.warning("truecloud-patch: staging cleanup failed: %r", e)
restic_backup._truecloud_patched = True
"""
SYNC_SYNC = _NESTED_IMPORT + """
if _tc_nested is not None:
_tc_orig_restic_backup = restic_backup
def restic_backup(middleware, job, cloud_backup, *args, **kwargs):
try:
return _tc_orig_restic_backup(middleware, job, cloud_backup, *args, **kwargs)
finally:
try:
_tc_nested.cleanup_task(
middleware,
f"cloud_backup-{cloud_backup.get('id', 'onetime')}",
logger=getattr(middleware, "logger", None),
)
except Exception as e:
middleware.logger.warning("truecloud-patch: staging cleanup failed: %r", e)
restic_backup._truecloud_patched = True
"""
# Single implementation of the block apply/revert logic (patch/mw_patch.py), so
# uninstall.sh and apply.sh cannot drift apart. Fail-safe: if it cannot be
# imported, skip the backend patch entirely -- middlewared then starts stock,
# which is the whole design principle of this script.
# APPEND, never insert(0): this dir would otherwise take precedence over the
# stdlib for this interpreter, so a future patch/json.py (say) would shadow the
# real json module and break the boot. Appending fails safe -- worst case our
# import misses and the backend patch is skipped.
sys.path.append(os.path.dirname(nested_src))
try:
from mw_patch import patch_file, revert_nested
except ImportError as _e:
print(f'WARNING: cannot import patch/mw_patch.py ({_e}) — skipping backend patch.')
print('WARNING: middlewared will start with stock (unpatched) modules.')
sys.exit(1)
# .../middlewared/plugins/cloud -> .../middlewared
mw_dir = os.path.dirname(os.path.dirname(cloud_dir))
b2_ok = restic_ok = False
nested_ok = False
# ── module: providers (B2/S3) ─────────────────────────────────────────────────
# Skipped entirely once TrueNAS ships native B2 restic support. That must not
# take the nested module down with it, so the two are gated independently.
providers_detail = ''
if not providers_needed:
providers_detail = 'superseded: TrueNAS provides native B2 restic support'
print('INFO: Providers module skipped — TrueNAS now supports B2 natively.')
else:
if os.path.exists(b2_path):
try:
patch_file(b2_path, B2_BLOCK)
b2_ok = True
print(f"OK: Patched b2.py → {b2_path}")
except Exception as e:
print(f"WARNING: Failed to patch b2.py: {e}")
else:
print(f"WARNING: b2.py not found at {b2_path}")
if os.path.exists(restic_path):
try:
patch_file(restic_path, RESTIC_BLOCK)
restic_ok = True
print(f"OK: Patched restic.py → {restic_path}")
except Exception as e:
print(f"WARNING: Failed to patch restic.py: {e}")
else:
print(f"WARNING: restic.py not found at {restic_path}")
providers_detail = (
'patched on disk in overlay at boot' if (b2_ok and restic_ok)
else 'b2.py/restic.py not found or write failed'
)
# ── module: nested-dataset snapshots ──────────────────────────────────────────
# Order matters: install the traversal machinery FIRST, relax the validation
# guard LAST. If anything fails partway, the guard is still in place and the
# option stays unavailable -- we never expose "guard removed, traversal missing".
nested_detail = ''
if not nested_needed:
# Not just "skip": actively revert. The overlay lives for the whole boot, so a
# previously-applied patch is still sitting there and middlewared would
# re-import it on restart. See revert_nested().
if not nested_enabled:
nested_detail = 'disabled (opt-in; enable with: install.sh --enable-nested-snapshots)'
print('INFO: Nested-dataset snapshot support is disabled (opt-in feature).')
elif nested_native:
nested_detail = 'superseded: TrueNAS handles nested-dataset snapshots natively'
print('INFO: Nested module skipped — TrueNAS now handles nesting natively.')
else:
nested_detail = 'not needed'
print('INFO: Nested module skipped.')
reverted = revert_nested(mw_dir)
if reverted:
print('OK: Reverted a previously-applied nested patch (' + ', '.join(reverted) + ').')
print(' The stock nesting guard is restored once middlewared restarts.')
nested_detail += ' — previous patch reverted'
if not nested_enabled:
print('INFO: Enable with: bash install.sh --enable-nested-snapshots')
else:
try:
snapshot_py = os.path.join(cloud_dir, 'snapshot.py')
crud_py = os.path.join(cloud_dir, 'crud.py')
nested_dst = os.path.join(cloud_dir, '_truecloud_nested.py')
missing = [p for p in (snapshot_py, crud_py, sync_path, nested_src) if not os.path.exists(p)]
if missing:
raise FileNotFoundError('missing: ' + ', '.join(missing))
# Which flavour of cloud_backup is installed? <= 25.10 is async; TrueNAS 26
# rewrote it synchronous. Inject the wrapper that matches: an `async def` on
# 26 hands sync.py a coroutine where it unpacks a tuple, and a plain `def` on
# 25.10 blocks the event loop.
#
# None means the three stock functions disagree, or one could not be read.
# Refuse rather than guess -- a half-converted middleware is one this patch
# has never seen, and guessing wrong there costs a backup, not a feature.
# nested_src is <repo>/patch/truecloud_nested.py, so tools/ is its sibling.
# APPEND, never insert(0) -- shadowing the stdlib for this interpreter is a
# far worse failure than not finding compat.
sys.path.append(
os.path.join(os.path.dirname(os.path.dirname(nested_src)), 'tools')
)
import compat
_flavour = compat.async_flavour_tree(mw_dir)
if _flavour is None:
raise RuntimeError(
'cannot tell whether this TrueNAS cloud_backup path is async or '
'sync (the wrapped functions disagree, or could not be read)'
)
_snapshot_block = SNAPSHOT_ASYNC if _flavour else SNAPSHOT_SYNC
_sync_block = SYNC_ASYNC if _flavour else SYNC_SYNC
_crud_block = CRUD_ASYNC if _flavour else CRUD_SYNC
shutil.copyfile(nested_src, nested_dst) # 1. traversal implementation
patch_file(snapshot_py, _snapshot_block) # 2. build the staging tree
patch_file(sync_path, _sync_block) # 3. tear it down afterwards
patch_file(crud_py, _crud_block) # 4. ONLY NOW allow nested tasks
print('OK: cloud_backup is %s; injected the matching wrappers.'
% ('async (TrueNAS <= 25.10)' if _flavour else 'synchronous (TrueNAS 26+)'))
nested_ok = True
nested_detail = 'nested-dataset snapshots enabled (staging tree)'
print(f'OK: Installed nested-snapshot support → {nested_dst}')
print(f'OK: Patched snapshot.py, sync.py, crud.py → {cloud_dir}')
except Exception as e:
nested_detail = f'not applied: {e}'
print(f'WARNING: Failed to apply nested-snapshot patch: {e}')
print('WARNING: Stock nesting guard remains; snapshot option stays unavailable')
print('WARNING: for nested datasets. Existing backups are unaffected.')
# One entry per MODULE, not per file. `ok` means "nothing is wrong", so a module
# 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
# 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 = {
'providers': {
'ok': (not providers_needed) or bool(b2_ok and restic_ok),
'active': providers_needed,
'detail': providers_detail,
},
'nested_snapshots': {
'ok': (not nested_needed) or nested_ok,
'active': nested_needed,
'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}
tmp = status_path + '.tmp'
try:
with open(tmp, 'w') as f:
json.dump(payload, f, indent=2)
os.replace(tmp, status_path)
print('OK: Wrote hook_status.json')
except OSError as e:
print(f'WARNING: Could not write hook_status.json: {e}')
# Exit code tells the caller whether a middlewared restart is still worth doing:
#
# 0 every module that was needed applied cleanly
# 2 PARTIAL -- one module failed but another landed, so there IS something new
# on disk waiting to be loaded
# 1 nothing landed; a restart would accomplish nothing
#
# Collapsing 2 into 1 would mean a failing providers patch suppresses the restart
# that a freshly-applied nested patch needs, leaving it on disk and never loaded.
_providers_done = (not providers_needed) or bool(b2_ok and restic_ok)
_nested_done = (not nested_needed) or nested_ok
_landed = (providers_needed and b2_ok and restic_ok) or (nested_needed and nested_ok)
if _providers_done and _nested_done:
sys.exit(0)
sys.exit(2 if _landed else 1)
PYEOF
then
_backend_ok=1
else
_rc=$?
if [ "$_rc" = "2" ]; then
# One module failed, but another was applied and still needs loading.
_backend_ok=1
echo "WARNING: a module failed to apply; the other landed and will be loaded."
else
_backend_ok=0
fi
fi
fi
# ── Step 2: Angular bundle ────────────────────────────────────────────────────
# Belongs to the providers module (it widens the credential dropdown), so it is
# skipped along with it once TrueNAS supports B2 natively.
echo "--- UI patch ---"
if [ "$_providers_needed" = "0" ]; then
echo "Skipped — providers module superseded by native B2 support."
else
# Ensure the webui directory is writable before patch_ui.py tries to create a
# backup and write the patched bundle. On immutable OS we mount an overlay.
_webui_dir=""
for _d in /usr/share/truenas/webui /usr/share/truenas-ui /var/www/truenas; do
if [ -d "$_d" ]; then
_webui_dir="$_d"
break
fi
done
if [ -n "$_webui_dir" ]; then
_ensure_writable "$_webui_dir" "ui" || true # non-fatal; patch_ui.py reports the error
fi
"$PYTHON" "$PATCH_DIR/patch/patch_ui.py" || echo "WARNING: patch_ui.py exited non-zero; UI dropdown may still show Storj only."
fi
# ── Step 3: deferred middlewared restart (boot runs only) ─────────────────────
# At boot this script is spawned by middlewared, which already imported the
# stock modules — the backend patch is on disk but not in the process. Schedule
# ONE detached restart for after boot settles. Never restart synchronously
# here: this script is a child of middlewared's own job runner, and the later
# ix-* boot units still need midclt to answer.
# Boot context is detected by the parent process being middlewared; manual
# runs (install.sh, recovery) never trigger a restart.
#
# The unit runs wait_restart.sh, which blocks until boot has actually
# settled (systemd job queue drained, docker/apps state terminal), then
# RE-APPLIES this script before restarting. The re-apply is not belt-and-
# braces: our overlay lives inside /usr, and a systemd-sysext merge or
# middlewared's docker.configure_nvidia remounts /usr *after* PREINIT and
# detaches it, so what we patch here can be gone by restart time (seen
# 2026-08-19). wait_restart.sh re-mounts and re-verifies at the moment it
# matters. systemd ordering alone (After=multi-user.target, ≤ v0.0.4)
# fired while ix-reporting and the docker/apps startup were still in flight
# and killed both — apps and dashboard stats stayed down until the next
# boot. No Type=oneshot: a oneshot's start job would hold the boot queue
# open against the `is-system-running --wait` inside the script.
echo "--- deferred restart ---"
# Restart when ANY still-needed backend module landed (_backend_ok, incl. the
# partial case). Keying this off the providers module alone would skip the restart
# on a box where B2 has gone native but the nested module was freshly patched —
# leaving it on disk and never loaded.
#
# "No module active at all" cannot reach here: that is the kill-switch branch
# above, which exits.
if [ "${TRUECLOUD_REAPPLY:-0}" = "1" ]; then
# Invoked by patch/wait_restart.sh as its pre-restart re-apply pass. That
# unit already exists to do the restart and verifies the result, so
# scheduling another one here would be a loop.
echo "Re-apply pass from wait_restart.sh — that unit owns the restart."
elif ! grep -aq middlewared "/proc/$PPID/cmdline" 2>/dev/null; then
echo "Manual run (parent is not middlewared) — no restart scheduled."
elif [ "$_backend_ok" != "1" ]; then
echo "Nothing landed on disk — no restart scheduled (nothing new to load)."
else
# A failed unit from an earlier attempt this boot would block systemd-run.
systemctl reset-failed truecloud-mw-restart.service 2>/dev/null
if systemd-run --no-block --collect --unit=truecloud-mw-restart \
/bin/bash "$PATCH_DIR/patch/wait_restart.sh"; then
echo "OK: Scheduled deferred middlewared restart (unit: truecloud-mw-restart)."
echo " It waits for boot to fully settle (apps started, reporting up),"
echo " then restarts middlewared so the backend patch actually loads."
else
echo "WARNING: Could not schedule deferred restart — backend patch is on disk but NOT loaded."
echo " Activate manually: systemctl restart middlewared"
fi
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo "=== done ==="