Compare commits

..
4 Commits
Author SHA1 Message Date
flan 04ac71deb3 Fix TypeError creating B2 tasks on TrueNAS 24.10: handle both credential schemas
On 24.10 (Electric Eel) credentials["provider"] is the type string with
account/key in credentials["attributes"]; 25.04+ moved them into a
provider dict. The injected get_restic_config only handled the newer
shape and raised TypeError on 24.10 at task creation (#1).

The method now detects the schema and reads credentials from the right
place on both. create_task.py list-credentials and list-tasks use the
same schema-agnostic lookup.
2026-07-06 05:43:09 +00:00
flan 73233865e8 Fix backend patch never loading at boot: schedule deferred middlewared restart
PREINIT initshutdownscripts are executed by middlewared itself
(ix-preinit.service, ordered after ix-zfs pool import), so the running
process had already imported the stock modules when apply.sh patched
them in the overlay — S3/B2 support silently reverted on every reboot
until something restarted middlewared. install.sh masked the bug with
its explicit restart.

apply.sh now detects boot context (parent process is middlewared) and
schedules a single detached restart via a transient systemd unit
(truecloud-mw-restart, After=multi-user.target and ix-postinit.service).
Manual runs never trigger a restart.

create_task.py verify no longer trusts hook_status.json alone: it
compares the middlewared main-process start time (derived from
/proc/<pid>/stat and btime) against patched_at and reports FAIL when
the running process predates the patch.

recover.sh and uninstall.sh cancel a still-queued deferred restart
before their own; docs updated to match the real boot ordering.
2026-07-06 05:42:04 +00:00
flan ee190f558f Fix PREINIT 10-second timeout killing apply.sh before patches land
Registers the boot hook with timeout:120 so TrueNAS gives apply.sh
two minutes instead of the default ten seconds.  Also consolidates
apply.sh Python subprocess count from ~8 to 2, cutting startup
overhead from ~12-16s to ~2-4s.

Bumps all scripts to v0.0.3.
2026-06-22 15:40:54 +00:00
flan c8de9971e7 Print version in all scripts; add Updating section to README 2026-06-19 19:04:48 +00:00
7 changed files with 349 additions and 141 deletions
+71
View File
@@ -1,5 +1,76 @@
# Changelog # Changelog
## v0.0.4 — 2026-07-06
### Fixed
- **Backend patch inactive after every reboot.** PREINIT initshutdownscripts
are executed by middlewared itself (`ix-preinit.service` runs
`midclt call initshutdownscript.execute_init_tasks PREINIT`, ordered after
`ix-zfs.service` pool import). By the time `apply.sh` patched `b2.py` and
`restic.py` in the overlay, the running middlewared had already imported the
stock modules and never re-imports — so S3/B2 support silently reverted on
every reboot until something restarted middlewared. `install.sh` masked the
bug because it restarts middlewared explicitly.
Fix: when `apply.sh` detects it was invoked by middlewared (boot context),
it now schedules a single detached restart via a transient systemd unit
(`truecloud-mw-restart`, ordered after `multi-user.target` and
`ix-postinit.service`) so the patched modules are loaded once boot settles.
The restart is never synchronous — `apply.sh` is a child of middlewared's
own job runner, and later `ix-*` boot units still need midclt. Manual runs
of `apply.sh` never trigger a restart.
- **`TypeError: string indices must be integers` when creating a B2 task on
TrueNAS 24.10 (Electric Eel)** (#1). The credential schema differs between
releases: on 24.10 `credentials["provider"]` is the type string (`"B2"`)
with the account/key in `credentials["attributes"]`, while 25.04+ moved
them into a provider dict. The injected `get_restic_config` only handled
the 25.04+ shape. It now detects the schema and reads the credentials from
the right place on both; `create_task.py list-credentials` and `list-tasks`
got the same treatment.
- **`create_task.py verify` false-positive after reboot.** `verify` trusted
`hook_status.json`, which only records that the files were patched on disk —
not that the running process loaded them. `verify` now also compares the
middlewared main-process start time against `patched_at` and reports FAIL
(with recovery instructions) when the process predates the patch.
### Changed
- README and script comments no longer claim PREINIT runs "before middlewared
starts"; the boot ordering and the deferred restart are now documented.
- `recover.sh` and `uninstall.sh` cancel a still-queued deferred restart
before performing their own, and their re-enable instructions now include
the required `systemctl restart middlewared`.
---
## v0.0.3 — 2026-06-22
### Fixed
- **`patch/apply.sh` silently killed by the 10-second PREINIT timeout.**
TrueNAS PREINIT initshutdownscripts have a 10-second default timeout. The
previous apply.sh ran approximately 8 Python subprocesses (each ~1-2 s), so it
was routinely killed mid-run. Symptoms: patches not applied after reboot, but
re-running `bash apply.sh` manually (no timeout) always succeeded.
Fix: `install.sh` now registers the hook with `"timeout": 120`. Existing
installations are updated to the new timeout on the next `bash install.sh` run.
Additionally, `patch/apply.sh` consolidates its Python subprocess invocations
from ~8 down to 2, reducing startup overhead from ~12-16 s to ~2-4 s — well
within the new 120-second budget.
### Changed
- `find_mw_python` in `apply.sh` no longer spawns a separate Python process to
verify the interpreter can import `middlewared`. Verification is now implicit in
the combined path-discovery subprocess that follows.
---
## v0.0.2 — 2026-06-19 ## v0.0.2 — 2026-06-19
### Fixed ### Fixed
+48 -11
View File
@@ -54,9 +54,9 @@ see [Native support](#if-truenas-adds-native-support) below.
## What is actually patched ## What is actually patched
**Nothing in TrueNAS's persistent database or configuration is modified.** **Nothing in TrueNAS's persistent database or configuration is modified**
On every boot, `patch/apply.sh` runs as a PREINIT script before middlewared (other than the boot-hook entry itself). On every boot, `patch/apply.sh` runs
starts. It mounts a writable as a PREINIT script. It mounts a writable
[overlayfs](https://docs.kernel.org/filesystems/overlayfs.html) over the [overlayfs](https://docs.kernel.org/filesystems/overlayfs.html) over the
relevant directories in `/usr/` (upper layer in `/run` tmpfs), then patches relevant directories in `/usr/` (upper layer in `/run` tmpfs), then patches
`b2.py` and `restic.py` inside that overlay. The overlay is volatile — it `b2.py` and `restic.py` inside that overlay. The overlay is volatile — it
@@ -64,6 +64,14 @@ exists only for the current boot — but the PREINIT script recreates it
automatically on every subsequent boot. Nothing in `/usr/` is written to automatically on every subsequent boot. Nothing in `/usr/` is written to
directly. directly.
PREINIT scripts are executed *by* middlewared, which by then has already
imported the stock modules — so after patching, `apply.sh` schedules a single
detached middlewared restart (transient systemd unit `truecloud-mw-restart`,
ordered after `multi-user.target`) that loads the patched modules once boot
completes. Expect one middlewared restart shortly after every boot; the UI
and API are briefly unavailable while it happens, and running services are
not affected.
| Layer | What changes | Technique | | Layer | What changes | Technique |
|---|---|---| |---|---|---|
| **Backend** | `B2RcloneRemote` gains `get_restic_config()` — skipped automatically if TrueNAS already provides one on the class. `restic.py` URL builder is fixed: strips the stray leading slash and converts the slash separator to a colon (`b2:bucket:path`), which is the format restic 0.16.x expects. URL wrapper is a no-op if the URL is already correctly formed. | File patch applied inside the overlayfs upper layer | | **Backend** | `B2RcloneRemote` gains `get_restic_config()` — skipped automatically if TrueNAS already provides one on the class. `restic.py` URL builder is fixed: strips the stray leading slash and converts the slash separator to a colon (`b2:bucket:path`), which is the format restic 0.16.x expects. URL wrapper is a no-op if the URL is already correctly formed. | File patch applied inside the overlayfs upper layer |
@@ -86,11 +94,14 @@ support and the reason is logged to `apply.log` in your repo root.
TrueNAS SCALE updates replace `/usr/` entirely. The patch survives by keeping TrueNAS SCALE updates replace `/usr/` entirely. The patch survives by keeping
this repository on a **persistent ZFS pool** (your data pool, not `/tmp` or a this repository on a **persistent ZFS pool** (your data pool, not `/tmp` or a
system path) and registering a **PREINIT initshutdownscript** in the TrueNAS system path) and registering a **PREINIT initshutdownscript** in the TrueNAS
database. On every boot, `patch/apply.sh` runs before `middlewared` starts. It database — the one piece of state that survives both reboots and OS updates.
mounts a writable [overlayfs](https://docs.kernel.org/filesystems/overlayfs.html) On every boot, `patch/apply.sh` runs (executed by middlewared after pools are
over the relevant directories (upper layer in `/run`, recreated each boot), then imported), mounts a writable
patches `b2.py` and `restic.py` directly in that overlay and re-patches the UI [overlayfs](https://docs.kernel.org/filesystems/overlayfs.html) over the
bundle. No extra configuration is needed. relevant directories (upper layer in `/run`, recreated each boot), patches
`b2.py` and `restic.py` directly in that overlay, re-patches the UI bundle,
and schedules the one-time deferred middlewared restart that loads the
patched backend. No extra configuration is needed.
--- ---
@@ -120,6 +131,30 @@ call that path on every boot.
Refresh your browser. S3 and B2 credentials now appear in the Refresh your browser. S3 and B2 credentials now appear in the
**Data Protection → TrueCloud Backup → Add** credential dropdown. **Data Protection → TrueCloud Backup → Add** credential dropdown.
## Updating
To update to a new version of the patch:
```bash
cd /mnt/tank/truenas-truecloud-patch
# If install.sh was previously run as root, the .git directory may be owned
# by root. Fix it first, or just pull as root:
sudo git pull # easiest option
# — or —
sudo chown -R $(whoami) .git && git pull
bash install.sh
```
`install.sh` clears any stale kill switch, re-applies the updated patches,
and restarts middlewared. Run `python3 patch/create_task.py verify` afterwards
to confirm the patches loaded successfully.
Check [CHANGELOG.md](CHANGELOG.md) to see what changed between versions.
---
## Creating a task via CLI ## Creating a task via CLI
If the UI still shows only Storj after refreshing (e.g. the JS bundle pattern If the UI still shows only Storj after refreshing (e.g. the JS bundle pattern
@@ -252,6 +287,7 @@ To re-enable the patch once you have investigated:
```bash ```bash
rm /mnt/tank/truenas-truecloud-patch/disabled rm /mnt/tank/truenas-truecloud-patch/disabled
bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
systemctl restart middlewared # manual apply.sh runs never restart for you
``` ```
--- ---
@@ -315,9 +351,10 @@ cat /mnt/tank/truenas-truecloud-patch/apply.log
```bash ```bash
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
``` ```
Reads `hook_status.json` written by `apply.sh` at boot. Reflects whether the Reads `hook_status.json` written by `apply.sh` at boot **and** checks that the
overlay patches to `b2.py` and `restic.py` were applied successfully. Does not running middlewared process started *after* the patches were applied — an
require `--host` or `--api-key`. on-disk patch that middlewared has not loaded yet is reported as FAIL with
instructions. Does not require `--host` or `--api-key`.
**Middlewared log:** **Middlewared log:**
```bash ```bash
+9 -5
View File
@@ -10,12 +10,16 @@
# #
# What this does: # What this does:
# 1. Registers a PREINIT initshutdownscript so patch/apply.sh re-runs on # 1. Registers a PREINIT initshutdownscript so patch/apply.sh re-runs on
# every boot before middlewared starts. # every boot. At boot, apply.sh re-patches the overlay and schedules a
# one-time deferred middlewared restart to load the patched modules
# (PREINIT runs after middlewared starts, so a restart is required).
# 2. Applies the patches immediately (no reboot required). # 2. Applies the patches immediately (no reboot required).
# 3. Restarts middlewared so the backend change takes effect now. # 3. Restarts middlewared so the backend change takes effect now.
set -euo pipefail set -euo pipefail
VERSION="0.0.4"
# The directory containing install.sh is the permanent install location. # The directory containing install.sh is the permanent install location.
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)' _HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
@@ -29,7 +33,7 @@ if [ ! -f "$PATCH_DIR/patch/apply.sh" ]; then
exit 1 exit 1
fi fi
echo "=== TrueNAS TrueCloud Provider Patch — Install ===" echo "=== TrueNAS TrueCloud Provider Patch v${VERSION} — Install ==="
echo "" echo ""
# ── Preflight ───────────────────────────────────────────────────────────────── # ── Preflight ─────────────────────────────────────────────────────────────────
@@ -71,9 +75,9 @@ for s in json.load(sys.stdin):
" 2>/dev/null || true) " 2>/dev/null || true)
if [ -n "$EXISTING_ID" ]; then if [ -n "$EXISTING_ID" ]; then
echo "Already registered (id=$EXISTING_ID). Updating path and enabling ..." echo "Already registered (id=$EXISTING_ID). Updating path, timeout, and enabling ..."
if ! _midclt_out=$(midclt call initshutdownscript.update "$EXISTING_ID" \ if ! _midclt_out=$(midclt call initshutdownscript.update "$EXISTING_ID" \
"{\"enabled\": true, \"script\": \"$PATCH_DIR/patch/apply.sh\"}" 2>&1); then "{\"enabled\": true, \"script\": \"$PATCH_DIR/patch/apply.sh\", \"timeout\": 120}" 2>&1); then
echo "ERROR: Failed to update PREINIT hook (id=$EXISTING_ID)." >&2 echo "ERROR: Failed to update PREINIT hook (id=$EXISTING_ID)." >&2
[ -n "$_midclt_out" ] && echo " midclt: $_midclt_out" >&2 [ -n "$_midclt_out" ] && echo " midclt: $_midclt_out" >&2
echo " To remove the stale entry and retry:" >&2 echo " To remove the stale entry and retry:" >&2
@@ -82,7 +86,7 @@ if [ -n "$EXISTING_ID" ]; then
fi fi
else else
if ! _midclt_out=$(midclt call initshutdownscript.create \ if ! _midclt_out=$(midclt call initshutdownscript.create \
"{\"type\":\"SCRIPT\",\"script\":\"$PATCH_DIR/patch/apply.sh\",\"when\":\"PREINIT\",\"enabled\":true,\"comment\":\"$_HOOK_COMMENT\"}" \ "{\"type\":\"SCRIPT\",\"script\":\"$PATCH_DIR/patch/apply.sh\",\"when\":\"PREINIT\",\"enabled\":true,\"timeout\":120,\"comment\":\"$_HOOK_COMMENT\"}" \
2>&1); then 2>&1); then
echo "ERROR: Failed to register PREINIT hook." >&2 echo "ERROR: Failed to register PREINIT hook." >&2
[ -n "$_midclt_out" ] && echo " midclt: $_midclt_out" >&2 [ -n "$_midclt_out" ] && echo " midclt: $_midclt_out" >&2
+134 -119
View File
@@ -1,15 +1,21 @@
#!/bin/bash #!/bin/bash
# patch/apply.sh — registered as a TrueNAS PREINIT initshutdownscript. # patch/apply.sh — registered as a TrueNAS PREINIT initshutdownscript.
# #
# Runs on every boot BEFORE middlewared starts, so patches land before # PREINIT scripts are executed BY middlewared itself (ix-preinit.service runs
# the first Python process for middlewared is created. # `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 two patches: # TrueNAS updates replace /usr/ entirely; this script re-applies two patches:
# #
# 1. Backend — b2.py and restic.py are patched directly in the overlay. # 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. Angular JS bundle — Widens the TrueCloud Backup credential dropdown # 2. Angular JS bundle — Widens the TrueCloud Backup credential dropdown
# from Storj-only to include S3 and B2. # 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. # Design principle: every step is independently fail-safe.
# A failed patch logs a warning and continues; middlewared always starts. # A failed patch logs a warning and continues; middlewared always starts.
@@ -18,6 +24,7 @@
# Derive PATCH_DIR from this script's location (parent of the patch/ directory). # Derive PATCH_DIR from this script's location (parent of the patch/ directory).
PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)" PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOG="$PATCH_DIR/apply.log" LOG="$PATCH_DIR/apply.log"
VERSION="0.0.4"
# Rotate log at 512 KB to avoid unbounded growth on a system volume. # 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. # Keep two prior generations (.1 and .2) so the last three boots are always available.
@@ -27,7 +34,7 @@ if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 524288 ]; then
fi fi
exec >> "$LOG" 2>&1 exec >> "$LOG" 2>&1
echo "=== $(date -Iseconds) ===" echo "=== $(date -Iseconds) [v${VERSION}] ==="
# Kill switch: if this file exists, skip all patching and exit cleanly. # Kill switch: if this file exists, skip all patching and exit cleanly.
# Recovery: touch "$PATCH_DIR/disabled" (then reboot or restart middlewared). # Recovery: touch "$PATCH_DIR/disabled" (then reboot or restart middlewared).
@@ -40,7 +47,7 @@ fi
# Mounts a writable overlayfs on $1 using /run (tmpfs) for the upper/work dirs # 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 # when the directory is read-only. The overlay is volatile per boot; this
# PREINIT script recreates it on every boot before middlewared starts. # PREINIT script recreates it on every boot.
# Returns 0 if the directory is now writable, 1 if it could not be made so. # Returns 0 if the directory is now writable, 1 if it could not be made so.
_ensure_writable() { _ensure_writable() {
local dir="$1" tag="$2" local dir="$1" tag="$2"
@@ -67,6 +74,8 @@ _ensure_writable() {
# Find the Python interpreter that middlewared actually uses. # Find the Python interpreter that middlewared actually uses.
# On TrueNAS SCALE, /usr/bin/middlewared is usually a Python entry-point script # On TrueNAS SCALE, /usr/bin/middlewared is usually a Python entry-point script
# with a shebang pointing at the right interpreter (system or venv). # 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() { find_mw_python() {
local py="python3" local py="python3"
local shebang="" local shebang=""
@@ -82,47 +91,56 @@ find_mw_python() {
fi fi
fi fi
# Verify the chosen interpreter can actually import middlewared.
# Use >&2 so this message goes to stderr, not captured by $(...) substitution.
if ! "$py" -c "import middlewared" 2>/dev/null; then
echo "WARNING: '$py' cannot import middlewared; falling back to python3" >&2
py="python3"
if ! "$py" -c "import middlewared" 2>/dev/null; then
echo "WARNING: 'python3' also cannot import middlewared; backend patch will be skipped" >&2
fi
fi
echo "$py" echo "$py"
} }
PYTHON=$(find_mw_python) PYTHON=$(find_mw_python)
echo "Using Python: $PYTHON" echo "Using Python: $PYTHON"
# ── Native support check ────────────────────────────────────────────────────── # ── Discover paths + native support check (single Python subprocess) ──────────
# If TrueNAS has shipped native B2 restic support, this patch is no longer # Combines what were previously four separate Python invocations into one to
# needed. Set the kill switch and instruct the user to uninstall cleanly. # avoid repeated interpreter startup overhead under the PREINIT timeout budget.
_tc_info=$("$PYTHON" -c "
import inspect, os, sys
result = {'native': '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
_tc_native=$("$PYTHON" -c "
try: try:
import inspect
import middlewared.rclone.remote.b2 as _b2_mod import middlewared.rclone.remote.b2 as _b2_mod
from middlewared.rclone.remote.b2 import B2RcloneRemote from middlewared.rclone.remote.b2 import B2RcloneRemote
if 'get_restic_config' not in B2RcloneRemote.__dict__: if 'get_restic_config' in B2RcloneRemote.__dict__:
print('no')
else:
src = open(inspect.getfile(_b2_mod), encoding='utf-8', errors='replace').read() src = open(inspect.getfile(_b2_mod), encoding='utf-8', errors='replace').read()
if 'TRUECLOUD_PATCH' in src: if 'TRUECLOUD_PATCH' not in src:
print('no')
else:
# Distinguish a real implementation from a stub that raises NotImplementedError.
try: try:
method_src = inspect.getsource(B2RcloneRemote.get_restic_config) method_src = inspect.getsource(B2RcloneRemote.get_restic_config)
except (OSError, TypeError): except (OSError, TypeError):
method_src = '' method_src = ''
print('no' if 'NotImplementedError' in method_src else 'yes') if 'NotImplementedError' not in method_src:
result['native'] = 'yes'
except Exception: except Exception:
print('no') pass
" 2>/dev/null || echo "no")
print(result['native'])
print(result['site_pkg'])
print(result['mw_dir'])
" 2>/dev/null || printf 'no\n\n\n')
_tc_native=$(printf '%s' "$_tc_info" | sed -n '1p')
SITE_PKG=$(printf '%s' "$_tc_info" | sed -n '2p')
_MW_DIR=$(printf '%s' "$_tc_info" | sed -n '3p')
if [ "$_tc_native" = "yes" ]; then if [ "$_tc_native" = "yes" ]; then
echo "NOTICE: TrueNAS now provides native B2 restic support — truecloud-patch is no longer needed." echo "NOTICE: TrueNAS now provides native B2 restic support — truecloud-patch is no longer needed."
@@ -144,29 +162,6 @@ fi
echo "--- backend patch ---" echo "--- backend patch ---"
# Derive site-packages from where middlewared actually lives.
# getsitepackages()[0] may return the wrong directory; using middlewared.__file__
# ensures we patch files in the directory Python will actually read.
SITE_PKG=$("$PYTHON" -c "
import os
try:
import middlewared
print(os.path.dirname(os.path.dirname(os.path.abspath(middlewared.__file__))))
except ImportError:
import site
print(site.getsitepackages()[0])
" 2>/dev/null || true)
# MW_DIR is the middlewared package directory itself (one level below SITE_PKG).
_MW_DIR=$("$PYTHON" -c "
import os
try:
import middlewared
print(os.path.dirname(os.path.abspath(middlewared.__file__)))
except ImportError:
pass
" 2>/dev/null || true)
_b2_ok=0 _b2_ok=0
_restic_ok=0 _restic_ok=0
@@ -181,49 +176,28 @@ else
_B2_PY="$_MW_DIR/rclone/remote/b2.py" _B2_PY="$_MW_DIR/rclone/remote/b2.py"
_RESTIC_PY="$_MW_DIR/plugins/cloud_backup/restic.py" _RESTIC_PY="$_MW_DIR/plugins/cloud_backup/restic.py"
# ── b2.py ───────────────────────────────────────────────────────────── # ── patch b2.py + restic.py + hook_status.json (single subprocess) ──────
if [ -f "$_B2_PY" ]; then if "$PYTHON" - "$_B2_PY" "$_RESTIC_PY" "$PATCH_DIR/hook_status.json" << 'PYEOF'
if "$PYTHON" - "$_B2_PY" << 'PYEOF' import json, os, sys, time
import sys
BLOCK = """ b2_path, restic_path, status_path = sys.argv[1], sys.argv[2], sys.argv[3]
B2_BLOCK = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh # TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
def _tc_get_restic_config(task): def _tc_get_restic_config(task):
p = task["credentials"]["provider"] 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"]} return "", {"B2_ACCOUNT_ID": p["account"], "B2_ACCOUNT_KEY": p["key"]}
B2RcloneRemote.get_restic_config = staticmethod(_tc_get_restic_config) B2RcloneRemote.get_restic_config = staticmethod(_tc_get_restic_config)
B2RcloneRemote.restic = True B2RcloneRemote.restic = True
""" """
path = sys.argv[1] RESTIC_BLOCK = """
with open(path, encoding="utf-8") as fh:
content = fh.read()
marker = "\n# TRUECLOUD_PATCH"
idx = content.find(marker)
base = content[:idx] if idx != -1 else content
patched = base.rstrip("\n") + "\n" + BLOCK
with open(path, "w", encoding="utf-8") as fh:
fh.write(patched)
PYEOF
then
echo "OK: Patched b2.py → $_B2_PY"
_b2_ok=1
else
echo "WARNING: Failed to patch b2.py"
fi
else
echo "WARNING: b2.py not found at $_B2_PY"
fi
# ── restic.py ─────────────────────────────────────────────────────────
if [ -f "$_RESTIC_PY" ]; then
if "$PYTHON" - "$_RESTIC_PY" << 'PYEOF'
import sys
BLOCK = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh # TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
try: try:
_tc_orig_get_restic_config = get_restic_config _tc_orig_get_restic_config = get_restic_config
@@ -266,56 +240,66 @@ else:
get_restic_config._truecloud_patched = True get_restic_config._truecloud_patched = True
""" """
path = sys.argv[1] def patch_file(path, block):
with open(path, encoding="utf-8") as fh: with open(path, encoding="utf-8") as fh:
content = fh.read() content = fh.read()
marker = "\n# TRUECLOUD_PATCH"
idx = content.find(marker)
base = content[:idx] if idx != -1 else content
with open(path, "w", encoding="utf-8") as fh:
fh.write(base.rstrip("\n") + "\n" + block)
marker = "\n# TRUECLOUD_PATCH" b2_ok = restic_ok = False
idx = content.find(marker)
base = content[:idx] if idx != -1 else content if os.path.exists(b2_path):
patched = base.rstrip("\n") + "\n" + BLOCK 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}")
with open(path, "w", encoding="utf-8") as fh:
fh.write(patched)
PYEOF
then
echo "OK: Patched restic.py → $_RESTIC_PY"
_restic_ok=1
else
echo "WARNING: Failed to patch restic.py"
fi
else
echo "WARNING: restic.py not found at $_RESTIC_PY"
fi
# Write hook_status.json so 'verify' reflects the current patch state.
"$PYTHON" -c "
import json, os, sys, time
b2_ok = sys.argv[1] == '1'
restic_ok = sys.argv[2] == '1'
patches = { patches = {
'middlewared.rclone.remote.b2': { 'middlewared.rclone.remote.b2': {
'ok': b2_ok, 'ok': b2_ok,
'detail': ('patched on disk in overlay at boot' if b2_ok 'detail': 'patched on disk in overlay at boot' if b2_ok else 'b2.py not found or write failed',
else 'b2.py not found or write failed'),
}, },
'middlewared.plugins.cloud_backup.restic': { 'middlewared.plugins.cloud_backup.restic': {
'ok': restic_ok, 'ok': restic_ok,
'detail': ('patched on disk in overlay at boot' if restic_ok 'detail': 'patched on disk in overlay at boot' if restic_ok else 'restic.py not found or write failed',
else 'restic.py not found or write failed'),
}, },
} }
payload = {'patched_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), payload = {'patched_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'patches': patches}
'patches': patches} tmp = status_path + '.tmp'
sf = sys.argv[3]
tmp = sf + '.tmp'
try: try:
with open(tmp, 'w') as f: with open(tmp, 'w') as f:
json.dump(payload, f, indent=2) json.dump(payload, f, indent=2)
os.replace(tmp, sf) os.replace(tmp, status_path)
print('OK: Wrote hook_status.json') print('OK: Wrote hook_status.json')
except OSError as e: except OSError as e:
print(f'WARNING: Could not write hook_status.json: {e}') print(f'WARNING: Could not write hook_status.json: {e}')
" "$_b2_ok" "$_restic_ok" "$PATCH_DIR/hook_status.json" || true
sys.exit(0 if (b2_ok and restic_ok) else 1)
PYEOF
then
_b2_ok=1
_restic_ok=1
else
# Individual results already printed above; exit code 1 means at least one failed.
true
fi
fi fi
# ── Step 2: Angular bundle ──────────────────────────────────────────────────── # ── Step 2: Angular bundle ────────────────────────────────────────────────────
@@ -337,6 +321,37 @@ fi
"$PYTHON" "$PATCH_DIR/patch/patch_ui.py" || echo "WARNING: patch_ui.py exited non-zero; UI dropdown may still show Storj only." "$PYTHON" "$PATCH_DIR/patch/patch_ui.py" || echo "WARNING: patch_ui.py exited non-zero; UI dropdown may still show Storj only."
# ── 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.
echo "--- deferred restart ---"
if ! grep -aq middlewared "/proc/$PPID/cmdline" 2>/dev/null; then
echo "Manual run (parent is not middlewared) — no restart scheduled."
elif [ "$_b2_ok" != "1" ] || [ "$_restic_ok" != "1" ]; then
echo "Backend patch incomplete — 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 \
--property=Type=oneshot \
--property=After=multi-user.target \
--property=After=ix-postinit.service \
systemctl try-restart middlewared; then
echo "OK: Scheduled deferred middlewared restart (unit: truecloud-mw-restart)."
echo " Backend patch becomes active once boot completes."
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 ────────────────────────────────────────────────────────────────────── # ── Done ──────────────────────────────────────────────────────────────────────
echo "=== done ===" echo "=== done ==="
+68 -4
View File
@@ -41,13 +41,18 @@ List existing TrueCloud Backup tasks:
""" """
import argparse import argparse
import calendar
import json import json
import os import os
import ssl import ssl
import subprocess
import sys import sys
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
__version__ = "0.0.4"
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json") _STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")
@@ -84,6 +89,31 @@ def make_client(host, api_key, insecure=False):
# ── Sub-commands ────────────────────────────────────────────────────────────── # ── Sub-commands ──────────────────────────────────────────────────────────────
def _middlewared_start_epoch():
"""Epoch timestamp of the running middlewared main process, or None."""
try:
pid = int(subprocess.run(
["systemctl", "show", "--property=MainPID", "--value", "middlewared"],
capture_output=True, text=True, timeout=10, check=True,
).stdout.strip())
if pid <= 0:
return None
with open(f"/proc/{pid}/stat", encoding="ascii", errors="replace") as fh:
stat = fh.read()
# Field 22 (starttime, in clock ticks since boot); the comm field may
# contain spaces, so split after the closing paren.
start_ticks = float(stat.rsplit(")", 1)[1].split()[19])
# Base on /proc/stat btime, not uptime: starttime ticks count from the
# kernel boot, which uptime does not match inside containers.
with open("/proc/stat", encoding="ascii") as fh:
btime = next(float(line.split()[1]) for line in fh
if line.startswith("btime "))
return btime + start_ticks / os.sysconf("SC_CLK_TCK")
except (OSError, ValueError, IndexError, StopIteration,
subprocess.SubprocessError):
return None
def cmd_verify(): def cmd_verify():
"""Print the hook status written by apply.sh at boot.""" """Print the hook status written by apply.sh at boot."""
if not os.path.exists(_STATUS_FILE): if not os.path.exists(_STATUS_FILE):
@@ -113,14 +143,48 @@ def cmd_verify():
if not ok: if not ok:
all_ok = False all_ok = False
# The disk status alone can false-positive: at boot the files are patched
# while middlewared is already running with the stock modules imported.
# The running process only has the patch if it started AFTER patched_at.
try:
patched_epoch = calendar.timegm(
time.strptime(status.get("patched_at", ""), "%Y-%m-%dT%H:%M:%SZ"))
except ValueError:
patched_epoch = None
mw_start = _middlewared_start_epoch()
proc_stale = False
if patched_epoch is None or mw_start is None:
print(" [?? ] running middlewared process — could not compare start time;")
print(" the results above reflect the on-disk state only")
elif mw_start + 2 < patched_epoch:
proc_stale = True
print(" [FAIL] running middlewared process — started BEFORE the patch was applied,")
print(" so it is running the stock (unpatched) modules")
else:
print(" [OK ] running middlewared process — started after the patch was applied")
print() print()
if all_ok: if all_ok and not proc_stale:
print("All patches installed. Run a test backup to confirm end-to-end.") print("All patches installed. Run a test backup to confirm end-to-end.")
elif all_ok:
print("The patch is on disk but not loaded. Right after boot, the deferred")
print("restart (unit truecloud-mw-restart) may still be pending — re-check in a")
print("minute. Otherwise run: systemctl restart middlewared")
sys.exit(1)
else: else:
print("One or more patches failed to apply.") print("One or more patches failed to apply.")
print(f"Check {os.path.join(_PATCH_DIR, 'apply.log')} and journalctl -u middlewared") print(f"Check {os.path.join(_PATCH_DIR, 'apply.log')} and journalctl -u middlewared")
sys.exit(1) sys.exit(1)
def _provider_type(cred):
"""Provider type string across schemas (<=24.10 plain str, >=25.04 dict)."""
p = (cred or {}).get("provider")
if isinstance(p, dict):
return p.get("type", "?")
return p or "?"
def cmd_list_credentials(client, _args): def cmd_list_credentials(client, _args):
creds = client("GET", "/cloudsync/credentials") creds = client("GET", "/cloudsync/credentials")
if not creds: if not creds:
@@ -129,7 +193,7 @@ def cmd_list_credentials(client, _args):
print(f"{'ID':>4} {'Provider':<14} Name") print(f"{'ID':>4} {'Provider':<14} Name")
print("─" * 55) print("─" * 55)
for c in sorted(creds, key=lambda x: x["id"]): for c in sorted(creds, key=lambda x: x["id"]):
print(f"{c['id']:>4} {c['provider']['type']:<14} {c['name']}") print(f"{c['id']:>4} {_provider_type(c):<14} {c['name']}")
def cmd_list_tasks(client, _args): def cmd_list_tasks(client, _args):
@@ -140,8 +204,7 @@ def cmd_list_tasks(client, _args):
print(f"{'ID':>4} {'Enabled':<8} {'Provider':<14} Name") print(f"{'ID':>4} {'Enabled':<8} {'Provider':<14} Name")
print("─" * 60) print("─" * 60)
for t in sorted(tasks, key=lambda x: x["id"]): for t in sorted(tasks, key=lambda x: x["id"]):
creds = t.get("credentials") or {} ptype = _provider_type(t.get("credentials"))
ptype = (creds.get("provider") or {}).get("type", "?")
enabled = "yes" if t.get("enabled") else "no" enabled = "yes" if t.get("enabled") else "no"
print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t.get('description', '')}") print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t.get('description', '')}")
@@ -194,6 +257,7 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split("Examples")[1] if __doc__ and "Examples" in __doc__ else "", epilog=__doc__.split("Examples")[1] if __doc__ and "Examples" in __doc__ else "",
) )
p.add_argument("--version", "-V", action="version", version=f"truecloud-patch {__version__}")
p.add_argument("--host", default=None, metavar="HOST", p.add_argument("--host", default=None, metavar="HOST",
help="TrueNAS hostname or IP address (required except for verify)") help="TrueNAS hostname or IP address (required except for verify)")
p.add_argument("--api-key", default=None, metavar="KEY", p.add_argument("--api-key", default=None, metavar="KEY",
+11
View File
@@ -15,9 +15,15 @@
# To re-enable the patch after investigating: # To re-enable the patch after investigating:
# rm /mnt/tank/truenas-truecloud-patch/disabled # rm /mnt/tank/truenas-truecloud-patch/disabled
# bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh # bash /mnt/tank/truenas-truecloud-patch/patch/apply.sh
# systemctl restart middlewared
VERSION="0.0.4"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== TrueNAS TrueCloud Provider Patch v${VERSION} — Recover ==="
echo ""
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must be run as root." >&2 echo "ERROR: must be run as root." >&2
exit 1 exit 1
@@ -46,6 +52,10 @@ for _tag in mw ui; do
done done
[ "$_any" -eq 0 ] && echo " No overlays active." [ "$_any" -eq 0 ] && echo " No overlays active."
# Cancel a deferred boot restart if one is still queued — we restart ourselves.
systemctl stop truecloud-mw-restart.service 2>/dev/null
systemctl reset-failed truecloud-mw-restart.service 2>/dev/null
echo "Restarting middlewared ..." echo "Restarting middlewared ..."
if systemctl restart middlewared; then if systemctl restart middlewared; then
echo "" echo ""
@@ -63,3 +73,4 @@ echo ""
echo "To re-enable the patch once you have investigated:" echo "To re-enable the patch once you have investigated:"
echo " rm $PATCH_DIR/disabled" echo " rm $PATCH_DIR/disabled"
echo " bash $PATCH_DIR/patch/apply.sh" echo " bash $PATCH_DIR/patch/apply.sh"
echo " systemctl restart middlewared"
+7 -1
View File
@@ -3,10 +3,12 @@
set -euo pipefail set -euo pipefail
VERSION="0.0.4"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)" PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)' _HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
echo "=== TrueNAS TrueCloud Provider Patch — Uninstall ===" echo "=== TrueNAS TrueCloud Provider Patch v${VERSION} — Uninstall ==="
echo "" echo ""
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
@@ -97,6 +99,10 @@ if [ "$_restore_failed" -eq 1 ]; then
exit 1 exit 1
fi 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 ..." echo "Restarting middlewared ..."
if systemctl restart middlewared; then if systemctl restart middlewared; then
echo "" echo ""