diff --git a/README.md b/README.md index 2135d3e..8b60b08 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/patch/apply.sh b/patch/apply.sh index d07c393..bef1a96 100755 --- a/patch/apply.sh +++ b/patch/apply.sh @@ -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] diff --git a/patch/sitecustomize.py b/patch/sitecustomize.py index b649abf..adcd268 100644 --- a/patch/sitecustomize.py +++ b/patch/sitecustomize.py @@ -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