Wrap original get_restic_config instead of replacing it; add hook status file
sitecustomize.py: _patch_restic no longer reimplements get_restic_config. It now wraps the original: calls _orig(cloud_backup) to get a ResticConfig, then post-processes only the -r argument to fix "b2:/bucket" → "b2:bucket" when the URL contains a stray leading slash (the stock bug for empty-hostname providers). Uses dataclasses.replace() to build the corrected result so new ResticConfig fields added in future TrueNAS versions pass through unchanged. This eliminates the transfer_setting gap, env dict mutation, and frozen-copy drift that would occur over time. Also adds a status file mechanism: sitecustomize.py writes /data/truecloud-patch/hook_status.json atomically after both patches have reported success or failure. This gives a machine-readable signal that the hook fired correctly — without requiring log scraping. create_task.py: new "verify" subcommand reads the status file and prints a human-readable summary. Does not require --host or --api-key. --host and --api-key are now optional at the parser level and validated only for subcommands that actually need an API connection. README: update troubleshooting to use "create_task.py verify" instead of the manual Python introspection one-liner.
This commit is contained in:
@@ -206,11 +206,11 @@ cat /data/truecloud-patch/apply.log
|
|||||||
|
|
||||||
**Verify backend patch is loaded** (while middlewared is running):
|
**Verify backend patch is loaded** (while middlewared is running):
|
||||||
```bash
|
```bash
|
||||||
python3 -c "
|
python3 /data/truecloud-patch/create_task.py verify
|
||||||
from middlewared.rclone.remote.b2 import B2RcloneRemote
|
|
||||||
print('B2 restic support:', hasattr(B2RcloneRemote, 'get_restic_config'))
|
|
||||||
"
|
|
||||||
```
|
```
|
||||||
|
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):
|
**Verify the UI patch** (should print your TrueNAS version):
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+47
-4
@@ -42,11 +42,14 @@ List existing TrueCloud Backup tasks:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
_STATUS_FILE = "/data/truecloud-patch/hook_status.json"
|
||||||
|
|
||||||
|
|
||||||
def make_client(host, api_key, insecure=False):
|
def make_client(host, api_key, insecure=False):
|
||||||
"""Return a callable that makes authenticated REST API calls."""
|
"""Return a callable that makes authenticated REST API calls."""
|
||||||
@@ -80,6 +83,37 @@ def make_client(host, api_key, insecure=False):
|
|||||||
|
|
||||||
# ── Sub-commands ──────────────────────────────────────────────────────────────
|
# ── 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):
|
def cmd_list_credentials(client, _args):
|
||||||
creds = client("GET", "/cloudsync/credentials")
|
creds = client("GET", "/cloudsync/credentials")
|
||||||
if not creds:
|
if not creds:
|
||||||
@@ -149,15 +183,16 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=__doc__.split("Examples")[1] if "Examples" in __doc__ else "",
|
epilog=__doc__.split("Examples")[1] if "Examples" in __doc__ else "",
|
||||||
)
|
)
|
||||||
p.add_argument("--host", required=True, metavar="HOST",
|
p.add_argument("--host", default=None, metavar="HOST",
|
||||||
help="TrueNAS hostname or IP address")
|
help="TrueNAS hostname or IP address (required except for verify)")
|
||||||
p.add_argument("--api-key", required=True, metavar="KEY",
|
p.add_argument("--api-key", default=None, metavar="KEY",
|
||||||
help="TrueNAS API key (System → API Keys)")
|
help="TrueNAS API key — System → API Keys (required except for verify)")
|
||||||
p.add_argument("--insecure", action="store_true",
|
p.add_argument("--insecure", action="store_true",
|
||||||
help="Skip TLS certificate verification (self-signed certs)")
|
help="Skip TLS certificate verification (self-signed certs)")
|
||||||
|
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
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-credentials", help="List configured cloud credentials")
|
||||||
sub.add_parser("list-tasks", help="List TrueCloud Backup tasks")
|
sub.add_parser("list-tasks", help="List TrueCloud Backup tasks")
|
||||||
|
|
||||||
@@ -193,10 +228,18 @@ def main():
|
|||||||
client = make_client(args.host, args.api_key, args.insecure)
|
client = make_client(args.host, args.api_key, args.insecure)
|
||||||
|
|
||||||
dispatch = {
|
dispatch = {
|
||||||
|
"verify": cmd_verify,
|
||||||
"list-credentials": cmd_list_credentials,
|
"list-credentials": cmd_list_credentials,
|
||||||
"list-tasks": cmd_list_tasks,
|
"list-tasks": cmd_list_tasks,
|
||||||
"create": cmd_create,
|
"create": cmd_create,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
dispatch[args.cmd](client, args)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+54
-25
@@ -111,6 +111,7 @@ class _Loader:
|
|||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"[truecloud-patch] patch failed for {fullname}: {exc}\n"
|
f"[truecloud-patch] patch failed for {fullname}: {exc}\n"
|
||||||
)
|
)
|
||||||
|
_record_status(fullname, ok=False, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
# ── Patch functions ───────────────────────────────────────────────────────────
|
# ── Patch functions ───────────────────────────────────────────────────────────
|
||||||
@@ -132,44 +133,47 @@ def _patch_b2(module):
|
|||||||
cls.get_restic_config = get_restic_config
|
cls.get_restic_config = get_restic_config
|
||||||
cls.restic = True
|
cls.restic = True
|
||||||
sys.stderr.write("[truecloud-patch] B2 restic support enabled\n")
|
sys.stderr.write("[truecloud-patch] B2 restic support enabled\n")
|
||||||
|
_record_status("middlewared.rclone.remote.b2", ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _patch_restic(module):
|
def _patch_restic(module):
|
||||||
if getattr(module.get_restic_config, "_truecloud_patched", False):
|
if getattr(module.get_restic_config, "_truecloud_patched", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
# ResticConfig is safe to capture now (it's a dataclass defined in the module).
|
import dataclasses
|
||||||
# REMOTES and get_remote_path are imported lazily inside the function so that
|
import re
|
||||||
# module layout changes in future middlewared versions fail at call time
|
|
||||||
# (during an actual backup job) rather than silently at patch time.
|
_orig = module.get_restic_config
|
||||||
_ResticConfig = module.ResticConfig
|
|
||||||
|
# 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):
|
def get_restic_config(cloud_backup):
|
||||||
from middlewared.plugins.cloud.path import get_remote_path
|
# Call the original — it handles transfer_setting, cache, RESTIC_PASSWORD,
|
||||||
from middlewared.plugins.cloud.remotes import REMOTES
|
# env construction, and everything else we don't own.
|
||||||
|
result = _orig(cloud_backup)
|
||||||
|
|
||||||
remote = REMOTES[cloud_backup["credentials"]["provider"]["type"]]
|
# Scan the built command for the -r <repo> argument and fix the URL if
|
||||||
remote_path = get_remote_path(remote, cloud_backup["attributes"])
|
# it contains a stray leading slash: "b2:/bucket/path" → "b2:bucket/path".
|
||||||
url, env = remote.get_restic_config(cloud_backup)
|
cmd = list(result.cmd)
|
||||||
|
for i, part in enumerate(cmd):
|
||||||
if cloud_backup["cache_path"]:
|
if i and cmd[i - 1] == "-r":
|
||||||
cache = ["--cache-dir", cloud_backup["cache_path"]]
|
m = _broken_url.match(part)
|
||||||
else:
|
if m:
|
||||||
cache = ["--no-cache"]
|
cmd[i] = f"{m.group(1)}:{m.group(2)}"
|
||||||
|
# dataclasses.replace passes all other fields through, so
|
||||||
# Stock code produces "b2:/bucket/path" when url == "" (double-slash).
|
# new ResticConfig fields added in future TrueNAS versions
|
||||||
repo = (
|
# are preserved automatically.
|
||||||
f"{remote.rclone_type}:{url}/{remote_path}"
|
return dataclasses.replace(result, cmd=cmd)
|
||||||
if url
|
break # -r arg present and already correct
|
||||||
else f"{remote.rclone_type}:{remote_path}"
|
return result # URL was fine; return original unchanged
|
||||||
)
|
|
||||||
cmd = ["restic"] + cache + ["--json", "-r", repo]
|
|
||||||
env["RESTIC_PASSWORD"] = cloud_backup["password"]
|
|
||||||
return _ResticConfig(cmd, env)
|
|
||||||
|
|
||||||
get_restic_config._truecloud_patched = True
|
get_restic_config._truecloud_patched = True
|
||||||
module.get_restic_config = get_restic_config
|
module.get_restic_config = get_restic_config
|
||||||
sys.stderr.write("[truecloud-patch] restic URL fix applied\n")
|
sys.stderr.write("[truecloud-patch] restic URL fix applied\n")
|
||||||
|
_record_status("middlewared.plugins.cloud_backup.restic", ok=True)
|
||||||
|
|
||||||
|
|
||||||
_PATCHES = {
|
_PATCHES = {
|
||||||
@@ -177,6 +181,31 @@ _PATCHES = {
|
|||||||
"middlewared.plugins.cloud_backup.restic": _patch_restic,
|
"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 ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user