Fix B2 restic URL: use colon separator (b2:bucket:path) for restic 0.16.x

restic 0.16.x changed the B2 URL format to use a colon between bucket and
path (b2:bucket:prefix) instead of a slash. The middlewared URL builder
produces b2:/bucket/path; restic then validates the full string after 'b2:'
as a bucket name, which fails because the slash is not in [a-z0-9-].

Fix the restic.py wrapper to strip the leading slash and replace the first
slash with a colon: b2:/bucket/path -> b2:bucket:path.

Also fix the hasattr bug in sitecustomize.py _patch_b2: hasattr() returns
True for methods inherited from the base class (which raises
NotImplementedError), causing the patch to be silently skipped. Use
'get_restic_config' not in cls.__dict__ instead.
This commit is contained in:
2026-06-16 16:41:26 +00:00
parent c8a0c42762
commit e8b0f961c7
3 changed files with 65 additions and 50 deletions
+2 -2
View File
@@ -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 for providers with no hostname component (`b2:bucket/path` vs the broken `b2:/bucket/path`). | `sitecustomize.py` — Python's standard startup hook; no middleware files are modified on disk |
| **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) |
| **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
@@ -202,7 +202,7 @@ You need three things from the task you created:
export B2_ACCOUNT_ID="your-key-id"
export B2_ACCOUNT_KEY="your-application-key"
export RESTIC_PASSWORD="your-repo-password"
REPO="b2:your-bucket/your-folder"
REPO="b2:your-bucket:your-folder" # restic 0.16.x uses colon, not slash
```
**S3-compatible (AWS S3, Wasabi, Cloudflare R2, MinIO, etc.):**
+28 -18
View File
@@ -239,25 +239,35 @@ def get_restic_config(cloud_backup):
cmd = list(result.cmd)
for i, part in enumerate(cmd):
if part.startswith("--repo=") or part.startswith("--repository="):
prefix, _, url = part.partition("=")
prefix += "="
scheme, sep, rest = url.partition(":")
if sep and rest.startswith("/") and not rest.startswith("//"):
cmd[i] = f"{prefix}{scheme}:{rest[1:]}"
try:
return _dc.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
break
if i and cmd[i - 1] in ("-r", "--repo", "--repository"):
scheme, sep, rest = part.partition(":")
if sep and rest.startswith("/") and not rest.startswith("//"):
cmd[i] = f"{scheme}:{rest[1:]}"
try:
return _dc.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
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
+35 -30
View File
@@ -7,13 +7,14 @@ Hooks two middlewared module imports using the find_spec / exec_module API
middlewared.rclone.remote.b2
Adds get_restic_config() so the native restic B2 backend works.
Restic repo URL: b2:<bucket>/<folder>
Restic repo URL: b2:<bucket>:<folder> (colon separator, restic 0.16.x)
Auth: B2_ACCOUNT_ID, B2_ACCOUNT_KEY
middlewared.plugins.cloud_backup.restic
Fixes the URL builder for providers with no hostname component.
Stock code: f"{rclone_type}:{url}/{remote_path}" → "b2:/bucket/path" (broken)
Patched: "b2:bucket/path" when url == ""
Fixes the URL builder for providers with no hostname component, and
converts the slash separator to a colon for B2 (restic 0.16.x format):
Stock code: f"{rclone_type}:{url}/{remote_path}" → "b2:/bucket/path"
Patched: "b2:bucket:path" (leading slash stripped, / → : for B2)
Both patches are no-ops if the module already provides the functionality
(i.e. a future TrueNAS version adds native support). All errors are caught
@@ -134,7 +135,7 @@ class _Loader:
def _patch_b2(module):
cls = module.B2RcloneRemote
if hasattr(cls, "get_restic_config"):
if "get_restic_config" in cls.__dict__:
# A future TrueNAS version already added native B2 restic support.
_record_status("middlewared.rclone.remote.b2", ok=True,
detail="native support present; patch not needed")
@@ -168,42 +169,46 @@ def _patch_restic(module):
_orig = module.get_restic_config
def get_restic_config(cloud_backup):
# Call the original — it handles transfer_setting, cache, RESTIC_PASSWORD,
# env construction, and everything else we don't own.
# Call the original — it handles cache, RESTIC_PASSWORD, env, etc.
result = _orig(cloud_backup)
# Fix stray leading slash in repo URL: "b2:/bucket" → "b2:bucket".
# 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.
# Covers all flag forms restic accepts (--repository is a documented synonym):
# -r <url> --repo <url> --repo=<url>
# --repository <url> --repository=<url>
cmd = list(result.cmd)
for i, part in enumerate(cmd):
if part.startswith("--repo=") or part.startswith("--repository="):
prefix, _, url = part.partition("=")
prefix += "="
scheme, sep, rest = url.partition(":")
if sep and rest.startswith("/") and not rest.startswith("//"):
cmd[i] = f"{prefix}{scheme}:{rest[1:]}"
try:
return dataclasses.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
break
if i and cmd[i - 1] in ("-r", "--repo", "--repository"):
scheme, sep, rest = part.partition(":")
if sep and rest.startswith("/") and not rest.startswith("//"):
cmd[i] = f"{scheme}:{rest[1:]}"
try:
return dataclasses.replace(result, cmd=cmd)
except TypeError:
return result._replace(cmd=cmd)
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
get_restic_config._truecloud_patched = True
module.get_restic_config = get_restic_config
sys.stderr.write("[truecloud-patch] restic URL fix applied\n")
sys.stderr.write("[truecloud-patch] restic B2 URL fix applied (b2:bucket:path)\n")
_record_status("middlewared.plugins.cloud_backup.restic", ok=True)