Files
truenas-truecloud-patch/patch/sitecustomize.py
T
flan 49bd775d42 Fix five quality findings from adversarial code review
sitecustomize.py — _install():
- Scope hook to middlewared service process only via sys.argv[0] check;
  previously any tool in the same venv (midclt, debug scripts) would also
  get its imports of the two target modules intercepted.
- Exec-chain a displaced sitecustomize.py: if apply.sh backed up a
  pre-existing sitecustomize.py to .pre-truecloud-patch, run it in a
  sandboxed namespace before installing our hook so any startup code
  (path additions, codec registrations) still takes effect.

sitecustomize.py — _b2_restic_config():
- Validate expected credential fields ("account", "key") before accessing
  them; raise a named KeyError listing what is missing and what is present
  so a schema change produces an attributable error at backup time rather
  than a bare KeyError with no indication this patch is involved.

sitecustomize.py — get_restic_config wrapper:
- Extend URL fix to cover all three flag forms restic accepts:
    -r <url>      (existing)
    --repo <url>  (long two-element form, now checked)
    --repo=<url>  (long single-element form, now handled)
  Without this, a restic CLI change from -r to --repo would silently make
  the fix a no-op while verify still reported the patch as OK.

patch_ui.py — find_bundle():
- Remove UnicodeDecodeError from except clause; errors="replace" in the
  open() call means the exception can never be raised, and its presence
  suggested the error parameter was not understood.
2026-06-15 03:37:26 +00:00

265 lines
10 KiB
Python

"""
TrueCloud provider patch — sitecustomize.py
Installed into Python site-packages on every boot by apply.sh.
Hooks two middlewared module imports using the find_spec / exec_module API
(required for Python 3.12+, which ships with TrueNAS SCALE 25.x / Debian 13):
middlewared.rclone.remote.b2
Adds get_restic_config() so the native restic B2 backend works.
Restic repo URL: b2:<bucket>/<folder>
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 == ""
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
and written to stderr so middlewared always starts regardless of patch state.
"""
import sys
# ── Import hook ───────────────────────────────────────────────────────────────
class _Finder:
"""
find_spec-based meta path finder (Python 3.4+, required for 3.12+).
Intercepts specific module imports, loads them normally, then patches.
"""
_targets = frozenset({
"middlewared.rclone.remote.b2",
"middlewared.plugins.cloud_backup.restic",
})
def __init__(self):
self._loading = set() # guards against re-entrant imports
self._done = set() # modules already patched
def find_spec(self, fullname, path, target=None): # noqa: ARG002
if (
fullname in self._targets
and fullname not in self._done
and fullname not in self._loading
):
import importlib.machinery
import importlib.util
# Find the real file spec HERE, before Python adds the module to
# sys.modules. If we deferred this to exec_module, find_spec would
# short-circuit via sys.modules[fullname].__spec__ (our own spec) and
# exec_module would call itself recursively forever.
self._loading.add(fullname)
try:
real_spec = importlib.util.find_spec(fullname)
finally:
self._loading.discard(fullname)
if real_spec is None:
return None # module doesn't exist; don't intercept
return importlib.machinery.ModuleSpec(
fullname,
_Loader(self, fullname, real_spec),
origin=real_spec.origin,
is_package=real_spec.submodule_search_locations is not None,
)
return None
def _mark_done(self, fullname):
self._done.add(fullname)
if self._done >= self._targets:
try:
sys.meta_path.remove(self)
except ValueError:
pass
class _Loader:
def __init__(self, finder, fullname, real_spec):
self._finder = finder
self._fullname = fullname
self._real_spec = real_spec
def create_module(self, spec): # noqa: ARG002
return None # use Python's default module creation
def exec_module(self, module):
fullname = self._fullname
real_spec = self._real_spec
try:
real_spec.loader.exec_module(module)
# Fix module metadata so it looks like a normal import.
module.__spec__ = real_spec
module.__loader__ = real_spec.loader
if real_spec.origin:
module.__file__ = real_spec.origin
finally:
# Mark done even on failure so a broken module doesn't cause
# infinite retry loops on subsequent import attempts.
self._finder._mark_done(fullname)
# exec_module succeeded — apply our patch.
# Must stay in sync with _Finder._targets.
try:
if fullname == "middlewared.rclone.remote.b2":
_patch_b2(module)
elif fullname == "middlewared.plugins.cloud_backup.restic":
_patch_restic(module)
except Exception as exc:
sys.stderr.write(
f"[truecloud-patch] patch failed for {fullname}: {exc}\n"
)
_record_status(fullname, ok=False, detail=str(exc))
# ── Patch functions ───────────────────────────────────────────────────────────
def _patch_b2(module):
cls = module.B2RcloneRemote
if hasattr(cls, "get_restic_config"):
# 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")
return
def _b2_restic_config(task):
p = task["credentials"]["provider"]
missing = [f for f in ("account", "key") if f not in p]
if missing:
raise KeyError(
f"truecloud-patch: B2 provider missing field(s) {missing!r}; "
f"schema may have changed. Present: {sorted(p)!r}"
)
return "", {"B2_ACCOUNT_ID": p["account"], "B2_ACCOUNT_KEY": p["key"]}
cls.get_restic_config = staticmethod(_b2_restic_config)
cls.restic = True
sys.stderr.write("[truecloud-patch] B2 restic support enabled\n")
_record_status("middlewared.rclone.remote.b2", ok=True)
def _patch_restic(module):
if getattr(module.get_restic_config, "_truecloud_patched", False):
_record_status("middlewared.plugins.cloud_backup.restic", ok=True,
detail="already patched in this process")
return
import dataclasses
_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.
result = _orig(cloud_backup)
# Scan the built command for the repo argument and fix the URL if it
# has a stray leading slash: "b2:/bucket/path" → "b2:bucket/path".
# "scheme://path" is intentional (Storj) and must not be touched.
# Covers all three flag forms restic accepts:
# -r <url> (short, two-element)
# --repo <url> (long, two-element)
# --repo=<url> (long, single-element)
cmd = list(result.cmd)
for i, part in enumerate(cmd):
if part.startswith("--repo="):
url = part[len("--repo="):]
scheme, sep, rest = url.partition(":")
if sep and rest.startswith("/") and not rest.startswith("//"):
cmd[i] = f"--repo={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"):
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)
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")
_record_status("middlewared.plugins.cloud_backup.restic", ok=True)
_STATUS_FILE = "/data/truecloud-patch/hook_status.json"
_hook_status: dict = {}
def _record_status(fullname: str, ok: bool, detail: str = "") -> None:
import json
import os
import time
if fullname in _hook_status:
return # idempotent: first call wins
_hook_status[fullname] = {"ok": ok, "detail": detail}
if len(_hook_status) < len(_Finder._targets):
return # wait for all patches; _Finder._targets is the canonical count
payload = {
"patched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"patches": _hook_status,
}
try:
tmp = _STATUS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
os.replace(tmp, _STATUS_FILE) # atomic on POSIX
except OSError:
pass # non-fatal — status file is informational only
# ── Entry point ───────────────────────────────────────────────────────────────
def _install():
import os
if os.path.exists("/data/truecloud-patch/disabled"):
return # kill switch
# Scope to the middlewared service process only — not midclt, debug scripts,
# or other tools that happen to share the same venv.
_argv0 = (sys.argv or [""])[0]
if os.path.basename(_argv0) != "middlewared":
return
import importlib.util
if importlib.util.find_spec("middlewared") is None:
return
# If apply.sh displaced an existing sitecustomize.py, exec it first so any
# iX-provided startup code (path additions, codec registrations, etc.) still
# runs. We use a separate namespace so it cannot shadow our globals.
_self = globals().get("__file__", "")
if _self:
_pre = _self + ".pre-truecloud-patch"
if os.path.isfile(_pre):
try:
import builtins
with open(_pre, encoding="utf-8") as _fh:
exec( # noqa: S102
compile(_fh.read(), _pre, "exec"),
{"__builtins__": builtins, "__file__": _pre,
"__name__": "sitecustomize"},
)
except Exception:
pass
sys.meta_path.append(_Finder())
try:
_install()
except Exception:
pass # never raise from sitecustomize.py — it would prevent Python from starting