diff --git a/README.md b/README.md index c602cda..0ad824d 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,11 @@ cat /data/truecloud-patch/apply.log **Verify backend patch is loaded** (while middlewared is running): ```bash -python3 -c " -from middlewared.rclone.remote.b2 import B2RcloneRemote -print('B2 restic support:', hasattr(B2RcloneRemote, 'get_restic_config')) -" +python3 /data/truecloud-patch/create_task.py verify ``` +This reads `/data/truecloud-patch/hook_status.json`, written by the import hook +at middlewared startup. It shows which patches applied and any failure details. +Does not require `--host` or `--api-key`. **Verify the UI patch** (should print your TrueNAS version): ```bash diff --git a/patch/create_task.py b/patch/create_task.py index a2d25d4..f9937a8 100755 --- a/patch/create_task.py +++ b/patch/create_task.py @@ -42,11 +42,14 @@ List existing TrueCloud Backup tasks: import argparse import json +import os import ssl import sys import urllib.error import urllib.request +_STATUS_FILE = "/data/truecloud-patch/hook_status.json" + def make_client(host, api_key, insecure=False): """Return a callable that makes authenticated REST API calls.""" @@ -80,6 +83,37 @@ def make_client(host, api_key, insecure=False): # ── Sub-commands ────────────────────────────────────────────────────────────── +def cmd_verify(_client, _args): + """Print the hook status written by sitecustomize.py at middlewared startup.""" + if not os.path.exists(_STATUS_FILE): + print("No hook status file found.") + print("Either the patch has never loaded (middlewared not yet restarted") + print("after install) or the status file was deleted.") + print(f" Expected: {_STATUS_FILE}") + sys.exit(1) + + with open(_STATUS_FILE, encoding="utf-8") as fh: + status = json.load(fh) + + print(f"Hook status (recorded at {status.get('patched_at', 'unknown')})") + print() + all_ok = True + for module, info in status.get("patches", {}).items(): + ok = info.get("ok", False) + label = "OK " if ok else "FAIL" + detail = f" — {info['detail']}" if info.get("detail") else "" + print(f" [{label}] {module}{detail}") + if not ok: + all_ok = False + + print() + if all_ok: + print("All patches active. B2 and S3 backups should work.") + else: + print("One or more patches failed to apply.") + print("Check /data/truecloud-patch/apply.log and journalctl -u middlewared") + sys.exit(1) + def cmd_list_credentials(client, _args): creds = client("GET", "/cloudsync/credentials") if not creds: @@ -149,15 +183,16 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("Examples")[1] if "Examples" in __doc__ else "", ) - p.add_argument("--host", required=True, metavar="HOST", - help="TrueNAS hostname or IP address") - p.add_argument("--api-key", required=True, metavar="KEY", - help="TrueNAS API key (System → API Keys)") + p.add_argument("--host", default=None, metavar="HOST", + help="TrueNAS hostname or IP address (required except for verify)") + p.add_argument("--api-key", default=None, metavar="KEY", + help="TrueNAS API key — System → API Keys (required except for verify)") p.add_argument("--insecure", action="store_true", help="Skip TLS certificate verification (self-signed certs)") sub = p.add_subparsers(dest="cmd", required=True) + sub.add_parser("verify", help="Check that the backend hook loaded correctly") sub.add_parser("list-credentials", help="List configured cloud credentials") sub.add_parser("list-tasks", help="List TrueCloud Backup tasks") @@ -193,11 +228,19 @@ def main(): client = make_client(args.host, args.api_key, args.insecure) dispatch = { + "verify": cmd_verify, "list-credentials": cmd_list_credentials, "list-tasks": cmd_list_tasks, "create": cmd_create, } - dispatch[args.cmd](client, args) + + if args.cmd == "verify": + cmd_verify(None, args) + else: + if not args.host or not args.api_key: + p.error("--host and --api-key are required for this command") + client = make_client(args.host, args.api_key, args.insecure) + dispatch[args.cmd](client, args) if __name__ == "__main__": diff --git a/patch/sitecustomize.py b/patch/sitecustomize.py index 8c8651d..7122667 100644 --- a/patch/sitecustomize.py +++ b/patch/sitecustomize.py @@ -111,6 +111,7 @@ class _Loader: sys.stderr.write( f"[truecloud-patch] patch failed for {fullname}: {exc}\n" ) + _record_status(fullname, ok=False, detail=str(exc)) # ── Patch functions ─────────────────────────────────────────────────────────── @@ -132,44 +133,47 @@ def _patch_b2(module): cls.get_restic_config = get_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): return - # ResticConfig is safe to capture now (it's a dataclass defined in the module). - # REMOTES and get_remote_path are imported lazily inside the function so that - # module layout changes in future middlewared versions fail at call time - # (during an actual backup job) rather than silently at patch time. - _ResticConfig = module.ResticConfig + import dataclasses + import re + + _orig = module.get_restic_config + + # Matches "scheme:/path" — the broken form the stock URL builder produces + # when a provider has no hostname component (url == ""). + # Does NOT match "scheme://path" (Storj and similar legitimately use ://). + _broken_url = re.compile(r'^(\w[\w+.-]*):/(?!/)(.+)$') def get_restic_config(cloud_backup): - from middlewared.plugins.cloud.path import get_remote_path - from middlewared.plugins.cloud.remotes import REMOTES + # Call the original — it handles transfer_setting, cache, RESTIC_PASSWORD, + # env construction, and everything else we don't own. + result = _orig(cloud_backup) - remote = REMOTES[cloud_backup["credentials"]["provider"]["type"]] - remote_path = get_remote_path(remote, cloud_backup["attributes"]) - url, env = remote.get_restic_config(cloud_backup) - - if cloud_backup["cache_path"]: - cache = ["--cache-dir", cloud_backup["cache_path"]] - else: - cache = ["--no-cache"] - - # Stock code produces "b2:/bucket/path" when url == "" (double-slash). - repo = ( - f"{remote.rclone_type}:{url}/{remote_path}" - if url - else f"{remote.rclone_type}:{remote_path}" - ) - cmd = ["restic"] + cache + ["--json", "-r", repo] - env["RESTIC_PASSWORD"] = cloud_backup["password"] - return _ResticConfig(cmd, env) + # Scan the built command for the -r argument and fix the URL if + # it contains a stray leading slash: "b2:/bucket/path" → "b2:bucket/path". + cmd = list(result.cmd) + for i, part in enumerate(cmd): + if i and cmd[i - 1] == "-r": + m = _broken_url.match(part) + if m: + cmd[i] = f"{m.group(1)}:{m.group(2)}" + # dataclasses.replace passes all other fields through, so + # new ResticConfig fields added in future TrueNAS versions + # are preserved automatically. + return dataclasses.replace(result, cmd=cmd) + break # -r arg present and already correct + return result # URL was fine; return original unchanged 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) _PATCHES = { @@ -177,6 +181,31 @@ _PATCHES = { "middlewared.plugins.cloud_backup.restic": _patch_restic, } +_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 + + _hook_status[fullname] = {"ok": ok, "detail": detail} + if len(_hook_status) < len(_PATCHES): + return # wait until all patches have reported + + 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 ───────────────────────────────────────────────────────────────