Audit fixes: a compat verdict must never be able to brick a working box
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 12s
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 38s

The audit found the new machinery could do more harm than the bugs it prevents.

- apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT
  kill switch, cleared only by install.sh, never by update.sh -- for the
  incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go
  quiet, so the switch would fire and the release that fixed 26 could never
  re-enable itself. Retirement and incompatibility now take different exits.
- A network blip, a re-export, or a conditional def all read as BROKEN. Each is
  now 'unknown', which changes nothing, rather than evidence strong enough to
  disable a module.
- 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded
  the guard AND reshaped the functions rendered as good news.
- compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the
  documented way to check a live box lied on every patched machine.
- The signature check was a name-subset test. It passed reorders, kw-only
  conversions, and added required params -- and it had already passed a real bug:
  restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested
  backups have been raising TypeError on those releases the whole time. The
  wrapper now forwards *args/**kwargs.
- release.sh --promote was unreachable: it died if the tag existed, the gate died
  if it did not. The tests hid it by always tagging first.
This commit is contained in:
2026-07-13 17:58:15 +00:00
parent f927773f81
commit ea090c7f72
10 changed files with 773 additions and 100 deletions
+82 -11
View File
@@ -227,6 +227,17 @@ fi
_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
@@ -244,7 +255,19 @@ except Exception:
raise SystemExit(0)
def verdict(r):
return 'broken' if (not r['ok'] and not r['native']) else 'ok'
# 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:
@@ -260,6 +283,7 @@ 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')
@@ -273,20 +297,63 @@ if [ "$_NESTED_ENABLED" = "1" ] && [ "$_tc_native_nested" != "yes" ]; then
_nested_needed=1
fi
if [ "$_tc_compat_providers" = "broken" ]; then
# 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_ENABLED" = "1" ]; then
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
@@ -301,12 +368,7 @@ if [ "$_providers_needed" = "0" ] && [ "$_nested_needed" = "0" ]; then
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
_tc_unmount_overlays
echo "=== done ==="
exit 0
fi
@@ -524,12 +586,21 @@ except ImportError:
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):
async def restic_backup(middleware, job, cloud_backup, *args, **kwargs):
# *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 here 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 the snapshot for real.
try:
return await _tc_orig_restic_backup(middleware, job, cloud_backup, dry_run, rate_limit)
return await _tc_orig_restic_backup(middleware, job, cloud_backup, *args, **kwargs)
finally:
try:
await _tc_nested.cleanup_task(