The trailing ) ended $( on the same line, so the Python body parsed as shell and the real closer was unmatched. Caught by shellcheck in CI (SC1089).
832 lines
37 KiB
Bash
Executable File
832 lines
37 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.5.1"
|
|
|
|
# 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
|
|
# Already our overlay on this exact directory from an earlier run this boot?
|
|
if mount | grep -qF "truecloud-${tag} on ${dir} "; then
|
|
return 0
|
|
fi
|
|
local upper="/run/truecloud-${tag}-upper" work="/run/truecloud-${tag}-work"
|
|
mkdir -p "$upper" "$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
|
|
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')
|
|
|
|
# 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_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):
|
|
return 'broken' if (not r['ok'] and not r['native']) else 'ok'
|
|
|
|
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
|
|
)
|
|
|
|
_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
|
|
|
|
if [ "$_tc_compat_providers" = "broken" ]; 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
|
|
fi
|
|
|
|
if [ "$_tc_compat_nested" = "broken" ] && [ "$_NESTED_ENABLED" = "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
|
|
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"
|
|
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)
|
|
umount "$_mnt" 2>/dev/null && echo "NOTICE: Unmounted overlay on $_mnt" || true
|
|
fi
|
|
done
|
|
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.
|
|
|
|
SNAPSHOT_BLOCK = """
|
|
# 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
|
|
|
|
if _tc_nested is not None:
|
|
_tc_orig_create_snapshot = create_snapshot
|
|
|
|
async def create_snapshot(middleware, path, name="cloud_task-onetime"):
|
|
# Stock takes the (already recursive) snapshot; we only replace the PATH.
|
|
snapshot, snap_path = await _tc_orig_create_snapshot(middleware, path, name)
|
|
|
|
_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.
|
|
datasets = await middleware.call(
|
|
"zfs.dataset.query", [["type", "=", "FILESYSTEM"]]
|
|
)
|
|
dataset, nested = get_dataset_recursive(datasets, path)
|
|
|
|
if not nested:
|
|
# No children: stock behaviour, untouched. Stock's `finally` owns
|
|
# the snapshot from here (its non-recursive delete is correct,
|
|
# because a non-nested snapshot has no children).
|
|
return snapshot, snap_path
|
|
|
|
staging_root = await _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 = await 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.
|
|
await _tc_nested.delete_snapshot_tree(middleware, snapshot, logger=_logger)
|
|
raise
|
|
|
|
return snapshot, staging_root
|
|
|
|
create_snapshot._truecloud_patched = True
|
|
"""
|
|
|
|
CRUD_BLOCK = """
|
|
# 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
|
|
|
|
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)
|
|
|
|
# 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
|
|
"""
|
|
|
|
SYNC_BLOCK = """
|
|
# 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
|
|
|
|
if _tc_nested is not None:
|
|
_tc_orig_restic_backup = restic_backup
|
|
|
|
async def restic_backup(middleware, job, cloud_backup, dry_run=False, rate_limit=None):
|
|
# 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 the snapshot for real.
|
|
try:
|
|
return await _tc_orig_restic_backup(middleware, job, cloud_backup, dry_run, rate_limit)
|
|
finally:
|
|
try:
|
|
await _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))
|
|
|
|
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
|
|
|
|
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) before
|
|
# restarting. 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 ! 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 ==="
|