Opt-in ------ Nested-dataset snapshot support changes how backups read their source data, so it is now off by default and gated behind a marker file: install.sh --enable-nested-snapshots install.sh --disable-nested-snapshots With neither flag install.sh preserves the current setting, so a routine `git pull && bash install.sh` can never silently flip it. When disabled, apply.sh skips the patch entirely and the stock guard remains. uninstall.sh tears down staging mounts and removes the marker. Snapshot lifecycle ------------------ zfs.snapshot.delete defaults to recursive=False and stock restic_backup() calls it with no options. Stock is safe only because its validation means recursive is never True in the field. Enabling nested datasets makes recursive snapshots real: the parent then has one child snapshot per descendant dataset (160+ on an Apps pool), so stock's delete would orphan every child on EVERY successful run. The patch now owns the lifecycle end to end: - delete_snapshot_tree() sweeps the parent and all children, and is idempotent against stock's finally winning the race once our mounts are released - on a staging failure the tree is deleted here, because sync.py never completes `snapshot, local_path = await create_snapshot(...)` and so its finally deletes nothing at all - the snapshot is recorded in a sidecar file before anything is mounted, so a middlewared restart mid-backup cannot orphan it - a crashed run's snapshot tree is reclaimed on the next run instead of being overwritten and leaked Silent-omission fix ------------------- The dataset list is now enumerated AFTER the snapshot. Read beforehand it could miss a dataset created in the gap, which the recursive snapshot would capture but the staging plan would not -- silently omitting its data. Read afterwards, an unsnapshotted dataset trips the staging check and fails the run loudly. Also from the audit ------------------- - plan_staging scopes by dataset name, so skipped-dataset warnings no longer include every mountpoint-less dataset on the box, which buried the ones that matter - staging_root_for rejects "." / ".." components that would escape the staging base, and resolves STAGING_BASE at call time rather than freezing it into a default argument - uninstall.sh no longer `rm -rf`s a tree that may still contain live bind mounts, and unmounts by path depth rather than string length - apply_plan takes an injectable isdir; verify_staged drops an unused parameter - pin the shellcheck action instead of tracking @master 61 tests, ruff and shellcheck clean.
163 lines
5.7 KiB
Bash
Executable File
163 lines
5.7 KiB
Bash
Executable File
#!/bin/bash
|
|
# uninstall.sh — remove all traces of truecloud-patch from a TrueNAS box.
|
|
|
|
set -euo pipefail
|
|
|
|
VERSION="0.3.0"
|
|
|
|
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
|
|
|
|
echo "=== TrueNAS TrueCloud Provider Patch v${VERSION} — Uninstall ==="
|
|
echo ""
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo "ERROR: must be run as root." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v midclt &>/dev/null; then
|
|
echo "ERROR: midclt not found. Run this script on TrueNAS SCALE." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Remove PREINIT hook ───────────────────────────────────────────────────────
|
|
|
|
echo "Removing PREINIT boot hook ..."
|
|
|
|
IDS=$(midclt call initshutdownscript.query '[]' | \
|
|
python3 -c "
|
|
import sys, json
|
|
for s in json.load(sys.stdin):
|
|
if s.get('comment') == '$_HOOK_COMMENT':
|
|
print(s['id'])
|
|
" 2>/dev/null || true)
|
|
|
|
if [ -n "$IDS" ]; then
|
|
for id in $IDS; do
|
|
if midclt call initshutdownscript.delete "$id" > /dev/null; then
|
|
echo " Removed initshutdownscript id=$id"
|
|
else
|
|
echo " WARNING: could not delete id=$id (already gone?)"
|
|
fi
|
|
done
|
|
else
|
|
echo " No entry found (already removed or never installed)."
|
|
fi
|
|
echo ""
|
|
|
|
# ── Restore UI bundle ─────────────────────────────────────────────────────────
|
|
|
|
echo "Restoring UI bundle backup ..."
|
|
|
|
RESTORED=0
|
|
_restore_failed=0
|
|
while IFS= read -r backup; do
|
|
original="${backup%.pre-truecloud-patch}"
|
|
if mv "$backup" "$original"; then
|
|
echo " Restored: $original"
|
|
RESTORED=1
|
|
else
|
|
echo " WARNING: Could not restore $original — backup left at $backup"
|
|
_restore_failed=1
|
|
fi
|
|
# Keep these paths in sync with WEBUI_CANDIDATES in patch/patch_ui.py
|
|
done < <(find /usr/share/truenas /usr/share/truenas-ui /var/www/truenas \
|
|
-name "*.js.pre-truecloud-patch" 2>/dev/null)
|
|
|
|
if [ "$RESTORED" -eq 0 ]; then
|
|
echo " No backup files found."
|
|
echo " On an immutable OS the UI patch is volatile and already gone after reboot."
|
|
fi
|
|
echo ""
|
|
|
|
# ── Unmount overlays ──────────────────────────────────────────────────────────
|
|
|
|
echo "Unmounting truecloud overlays (if any) ..."
|
|
_ov_found=0
|
|
for _tag in mw ui; do
|
|
if mount | grep -qF "truecloud-${_tag} on "; then
|
|
_ov_mnt=$(mount | grep "truecloud-${_tag} on " | awk '{print $3}' | head -1)
|
|
if umount "$_ov_mnt" 2>/dev/null; then
|
|
echo " Unmounted: $_ov_mnt"
|
|
else
|
|
echo " WARNING: Could not unmount overlay on $_ov_mnt"
|
|
fi
|
|
_ov_found=1
|
|
fi
|
|
done
|
|
if [ "$_ov_found" -eq 0 ]; then
|
|
echo " None active."
|
|
fi
|
|
echo ""
|
|
|
|
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
|
|
# These bind mounts pin their ZFS snapshots, so they must go before anything
|
|
# tries to destroy those snapshots. Deepest first.
|
|
|
|
echo "Unmounting nested-snapshot staging trees (if any) ..."
|
|
_stage_found=0
|
|
_stage_failed=0
|
|
# Deepest FIRST, by path depth (slash count) — not string length, which would
|
|
# let a long shallow path jump ahead of a short deep one and leave a child
|
|
# mounted (and its ZFS snapshot pinned).
|
|
while IFS= read -r _mp; do
|
|
[ -n "$_mp" ] || continue
|
|
if umount "$_mp" 2>/dev/null || umount -l "$_mp" 2>/dev/null; then
|
|
echo " Unmounted: $_mp"
|
|
else
|
|
echo " WARNING: Could not unmount $_mp"
|
|
_stage_failed=1
|
|
fi
|
|
_stage_found=1
|
|
done < <(awk '$2 == "/run/truecloud-nested" || index($2, "/run/truecloud-nested/") == 1 {
|
|
n = gsub(/\//, "/", $2); print n, $2
|
|
}' /proc/self/mounts 2>/dev/null | sort -rn | cut -d' ' -f2-)
|
|
|
|
if [ "$_stage_found" -eq 0 ]; then
|
|
echo " None active."
|
|
fi
|
|
|
|
# NEVER `rm -rf` here: if an unmount failed, that would recurse *through* a live
|
|
# bind mount into the ZFS snapshot behind it. Remove empty directories only.
|
|
if [ "$_stage_failed" -eq 0 ]; then
|
|
find /run/truecloud-nested -depth -type d -exec rmdir {} + 2>/dev/null || true
|
|
rm -f /run/truecloud-nested/*.snapshot 2>/dev/null || true
|
|
rmdir /run/truecloud-nested 2>/dev/null || true
|
|
else
|
|
echo " WARNING: staging mounts remain; leaving /run/truecloud-nested in place."
|
|
echo " Unmount them manually, then remove the directory."
|
|
fi
|
|
|
|
# The opt-in marker lives in the repo dir; remove it so a later re-install
|
|
# starts from the safe default (feature off).
|
|
if [ -f "$PATCH_DIR/nested_snapshots_enabled" ]; then
|
|
rm -f "$PATCH_DIR/nested_snapshots_enabled"
|
|
echo " Removed nested-snapshot opt-in marker."
|
|
fi
|
|
echo ""
|
|
|
|
if [ "$_restore_failed" -eq 1 ]; then
|
|
echo ""
|
|
echo "ERROR: One or more UI bundle backups could not be restored." >&2
|
|
echo " $PATCH_DIR has been left intact (recover.sh and patch files are safe)." >&2
|
|
echo " Restore the backup(s) manually, then re-run uninstall.sh." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Cancel a deferred boot restart if one is still queued — we restart ourselves.
|
|
systemctl stop truecloud-mw-restart.service 2>/dev/null || true
|
|
systemctl reset-failed truecloud-mw-restart.service 2>/dev/null || true
|
|
|
|
echo "Restarting middlewared ..."
|
|
if systemctl restart middlewared; then
|
|
echo ""
|
|
echo "Uninstall complete. Refresh your browser to see the restored UI."
|
|
else
|
|
echo ""
|
|
echo "WARNING: middlewared did not start cleanly after uninstall."
|
|
echo "Check the system log for details:"
|
|
echo " journalctl -u middlewared -n 50"
|
|
exit 1
|
|
fi
|