Eliminate URL-fix duplication; document native-support behaviour

URL-fix logic now lives once in sitecustomize._tc_fix_restic_cmd.
apply.sh's restic.py BLOCK delegates to it instead of repeating
the ~40-line implementation.

Additional safety: the BLOCK now guards its get_restic_config
reference with try/except NameError, so a future TrueNAS that
restructures restic.py won't cause an import error.

README: updated Backend table and disclaimer to reflect graceful
degradation; added "If TrueNAS adds native support" section that
covers all five upgrade scenarios (safe pass-through, base-class
shadowing risk, schema-change risk, etc.).
This commit is contained in:
2026-06-16 17:11:08 +00:00
parent e8b0f961c7
commit 8c0ffab166
3 changed files with 75 additions and 78 deletions
+24 -3
View File
@@ -46,8 +46,8 @@ By installing this patch you accept the following:
- **No warranty.** This software is provided as-is. See the LICENSE file.
If TrueNAS ever adds native support for additional providers in TrueCloud
Backup, uninstall this patch immediately.
If TrueNAS adds native B2 or S3 support to TrueCloud Backup, the patch
detects it and degrades gracefully — see [Native support](#if-truenas-adds-native-support) below.
---
@@ -59,7 +59,7 @@ on every update) and are therefore re-applied automatically on every boot.
| Layer | What changes | Technique |
|---|---|---|
| **Backend** | `B2RcloneRemote` gains `get_restic_config()`. `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. | Direct file patch in the overlay (primary) + `sitecustomize.py` import hook (belt-and-suspenders) |
| **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. | Direct file patch in the overlay (primary, delegates URL logic to `sitecustomize.py`) + `sitecustomize.py` import hook (belt-and-suspenders) |
| **UI** | The Angular bundle's `filterByProviders` binding is widened from `["STORJ_IX"]` to `["STORJ_IX","S3","B2"]` | In-place text replacement in the compiled JS chunk; original is backed up |
Both changes are **fail-safe**: if a patch cannot be applied (e.g. TrueNAS
@@ -265,6 +265,27 @@ restic -r "$REPO" ls latest
---
## If TrueNAS adds native support
When a TrueNAS update ships native B2 or S3 support in TrueCloud Backup, the
patch handles each component as follows:
| Component | What happens | Action needed |
|---|---|---|
| **B2 `get_restic_config`** added directly to `B2RcloneRemote` | `__dict__` guard detects it; our method is **not attached** | None — native version used automatically |
| **restic.py URL builder** fixed to emit `b2:bucket:path` directly | Our wrapper sees no `/` to fix; it becomes a **no-op** | None — correct URL passes through unchanged |
| **`get_restic_config` moved** out of `restic.py` entirely | `NameError` guard in the patched file catches it; wrapper silently does nothing | None — but run `verify` to confirm state |
| **B2 credential schema changed** (e.g. `provider["account"]` renamed) | Our B2 config function raises `KeyError`; backup task fails | Uninstall or update the patch |
| **B2 `get_restic_config`** added to a **base class** (not `B2RcloneRemote`) | `__dict__` check misses it; our method is attached and **shadows** the native one | Uninstall the patch |
**Recommended check after any TrueNAS update that adds TrueCloud provider
support**: run `python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify`
and attempt a B2 backup. If both pass, the patch is coexisting correctly. If
the backup fails with a credential or URL error that worked before the update,
uninstall the patch — TrueNAS has shipped a conflicting implementation.
---
## After a TrueNAS update
1. Check the log: `cat /mnt/tank/truenas-truecloud-patch/apply.log | tail -30`
+17 -39
View File
@@ -231,46 +231,24 @@ import sys
BLOCK = """
# TRUECLOUD_PATCH — added by truenas-truecloud-patch/patch/apply.sh
_tc_orig_get_restic_config = get_restic_config
# URL fix logic lives in sitecustomize._tc_fix_restic_cmd (single source of truth).
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)
try:
import sitecustomize as _sc
return _sc._tc_fix_restic_cmd(result, _dc)
except Exception as _e:
import sys as _sys
_sys.stderr.write(f"[truecloud-patch] restic URL fix failed: {_e}\n")
return result
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
# Strip stray leading slash: b2:/bucket -> b2:bucket
if rest.startswith("/") and not rest.startswith("//"):
rest = rest[1:]
changed = True
# restic 0.16.x B2 uses colon to separate bucket from path:
# b2:bucket:prefix (not b2:bucket/prefix)
# middlewared builds the slash form; fix the separator.
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
get_restic_config._truecloud_patched = True
"""
path = sys.argv[1]
+34 -36
View File
@@ -132,6 +132,39 @@ class _Loader:
# ── Patch functions ───────────────────────────────────────────────────────────
def _tc_fix_restic_cmd(result, dataclasses):
"""Fix the restic repo URL: b2:/bucket/path → b2:bucket:path (restic 0.16.x)."""
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 dataclasses.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
break
return result
def _patch_b2(module):
cls = module.B2RcloneRemote
@@ -169,42 +202,7 @@ def _patch_restic(module):
_orig = module.get_restic_config
def get_restic_config(cloud_backup):
# Call the original — it handles cache, RESTIC_PASSWORD, env, etc.
result = _orig(cloud_backup)
# Fix the repo URL in the restic command.
# Stock middlewared builds: b2:/bucket/path
# restic 0.16.x B2 expects: b2:bucket:path (colon separator, no leading slash)
# "scheme://path" (Storj) must not be touched.
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 dataclasses.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
break
return result
return _tc_fix_restic_cmd(_orig(cloud_backup), dataclasses)
get_restic_config._truecloud_patched = True
module.get_restic_config = get_restic_config