Harden for Python 3.12+, add disclaimer, audit for robustness

- sitecustomize.py: replace deprecated find_module/load_module with
  find_spec/exec_module (required for Python 3.12+ / TrueNAS SCALE 25.x)
- apply.sh: remove set -e (PREINIT must not fail catastrophically);
  detect middlewared's actual Python binary instead of assuming python3;
  log rotation to avoid unbounded growth; independent failure per step
- patch_ui.py: detect multiple bundle matches; include TrueNAS version
  in pattern-not-found warning; better MARKER specificity
- uninstall.sh: mirror Python detection logic from apply.sh
- README: lead with Storj $5→$50 price context; prominent unsupported
  disclaimer; Python version compatibility matrix; post-update checklist
- Add MIT LICENSE
This commit is contained in:
2026-06-15 02:13:24 +00:00
parent 21b9333324
commit 0a54bcba9d
8 changed files with 559 additions and 211 deletions
+74 -47
View File
@@ -2,34 +2,42 @@
"""
create_task.py — create TrueNAS TrueCloud Backup tasks with S3 or B2 credentials.
The TrueNAS UI restricts the credential dropdown to Storj only. This script
talks directly to the REST API so you can use any compatible credential.
The TrueNAS UI normally restricts the credential dropdown to Storj only.
This script bypasses that restriction by calling the REST API directly.
Requires: an API key from TrueNAS UI → System → API Keys.
Compatible providers (after the truecloud-patch backend patch is applied):
S3 — any S3-compatible endpoint (AWS, Wasabi, Cloudflare R2, MinIO, …)
B2 — Backblaze B2 native API
STORJ_IX — Storj (unchanged, always worked)
Requires a TrueNAS API key: UI → System → API Keys → Add.
Examples
--------
List available cloud credentials:
python3 create_task.py --host 192.168.1.1 --api-key <key> list-credentials
python3 create_task.py --host 192.168.1.1 --api-key <key> list-credentials
Create a task backed by a B2 credential (id=3):
python3 create_task.py --host 192.168.1.1 --api-key <key> create \\
--name "tank-to-b2" \\
--path /mnt/tank/data \\
--credential 3 \\
--bucket my-bucket \\
--folder backups/tank \\
--password "restic-repo-password" \\
--keep-last 14
python3 create_task.py --host 192.168.1.1 --api-key <key> create \\
--name "tank-to-b2" \\
--path /mnt/tank/data \\
--credential 3 \\
--bucket my-bucket \\
--folder backups/tank \\
--password "restic-repo-password" \\
--keep-last 14
Create a task using an S3-compatible credential (Wasabi, R2, etc.):
python3 create_task.py --host 192.168.1.1 --api-key <key> create \\
--name "tank-to-wasabi" \\
--path /mnt/tank/data \\
--credential 5 \\
--bucket my-bucket \\
--folder backups \\
--password "restic-repo-password"
python3 create_task.py --host 192.168.1.1 --api-key <key> create \\
--name "tank-to-wasabi" \\
--path /mnt/tank/data \\
--credential 5 \\
--bucket my-bucket \\
--folder backups \\
--password "restic-repo-password"
List existing TrueCloud Backup tasks:
python3 create_task.py --host 192.168.1.1 --api-key <key> list-tasks
"""
import argparse
@@ -41,6 +49,7 @@ import urllib.request
def make_client(host, api_key, insecure=False):
"""Return a callable that makes authenticated REST API calls."""
base = f"https://{host}/api/v2.0"
headers = {
"Authorization": f"Bearer {api_key}",
@@ -59,7 +68,7 @@ def make_client(host, api_key, insecure=False):
with urllib.request.urlopen(req, context=ctx) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()
detail = exc.read().decode(errors="replace")
print(f"HTTP {exc.code} {exc.reason}: {detail}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as exc:
@@ -69,13 +78,15 @@ def make_client(host, api_key, insecure=False):
return call
# ── Sub-commands ──────────────────────────────────────────────────────────────
def cmd_list_credentials(client, _args):
creds = client("GET", "/cloudsync/credentials")
if not creds:
print("No cloud credentials configured.")
return
print(f"{'ID':>4} {'Provider':<14} Name")
print("─" * 50)
print("─" * 55)
for c in sorted(creds, key=lambda x: x["id"]):
print(f"{c['id']:>4} {c['provider']['type']:<14} {c['name']}")
@@ -86,17 +97,20 @@ def cmd_list_tasks(client, _args):
print("No TrueCloud Backup tasks configured.")
return
print(f"{'ID':>4} {'Enabled':<8} {'Provider':<14} Name")
print("─" * 55)
print("─" * 60)
for t in sorted(tasks, key=lambda x: x["id"]):
ptype = t["credentials"]["provider"]["type"] if t.get("credentials") else "?"
ptype = (t.get("credentials") or {}).get("provider", {}).get("type", "?")
enabled = "yes" if t.get("enabled") else "no"
print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t['description']}")
print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t.get('description', '')}")
def cmd_create(client, args):
parts = args.schedule.split()
if len(parts) != 5:
print("--schedule must be a 5-field cron expression, e.g. '0 2 * * *'", file=sys.stderr)
print(
"ERROR: --schedule must be a 5-field cron expression, e.g. '0 2 * * *'",
file=sys.stderr,
)
sys.exit(1)
minute, hour, dom, month, dow = parts
@@ -127,39 +141,52 @@ def cmd_create(client, args):
print(f"Created task id={result['id']} name={result['description']!r}")
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
p = argparse.ArgumentParser(
description="Manage TrueNAS TrueCloud Backup tasks (S3/B2/Storj)",
description="Manage TrueNAS TrueCloud Backup tasks (S3 / B2 / Storj)",
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")
p.add_argument("--api-key", required=True, metavar="KEY", help="TrueNAS API key")
p.add_argument("--insecure", action="store_true", help="Skip TLS certificate verification")
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("--insecure", action="store_true",
help="Skip TLS certificate verification (self-signed certs)")
sub = p.add_subparsers(dest="cmd", required=True)
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")
c = sub.add_parser("create", help="Create a TrueCloud Backup task")
c.add_argument("--name", required=True, help="Task description shown in the UI")
c.add_argument("--path", required=True, help="Local dataset path, e.g. /mnt/tank/data")
c.add_argument("--credential", required=True, type=int, metavar="ID",
help="Cloud credential ID (from list-credentials)")
c.add_argument("--bucket", required=True, help="Bucket or container name")
c.add_argument("--folder", default="", help="Folder path within the bucket (default: root)")
c.add_argument("--password", required=True, help="Restic repository encryption password")
c.add_argument("--keep-last", type=int, default=14, metavar="N",
c = sub.add_parser("create", help="Create a new TrueCloud Backup task")
c.add_argument("--name", required=True,
help="Task description shown in the UI")
c.add_argument("--path", required=True,
help="Local dataset path (e.g. /mnt/tank/data)")
c.add_argument("--credential", required=True, type=int, metavar="ID",
help="Cloud credential ID — get it from list-credentials")
c.add_argument("--bucket", required=True,
help="Bucket (S3) or container (B2) name")
c.add_argument("--folder", default="",
help="Path within the bucket (default: root)")
c.add_argument("--password", required=True,
help="Restic repository encryption password (choose a strong one)")
c.add_argument("--keep-last", type=int, default=14, metavar="N",
help="Snapshots to retain after each run (default: 14)")
c.add_argument("--schedule", default="0 2 * * *",
c.add_argument("--schedule", default="0 2 * * *",
help="Cron schedule (default: '0 2 * * *' — daily at 02:00)")
c.add_argument("--transfer-setting",
choices=["DEFAULT", "PERFORMANCE", "FAST_STORAGE"], default="DEFAULT")
c.add_argument("--snapshot", action="store_true",
help="Create a ZFS snapshot before each backup")
choices=["DEFAULT", "PERFORMANCE", "FAST_STORAGE"],
default="DEFAULT",
help="Pack-size / concurrency preset (default: DEFAULT)")
c.add_argument("--snapshot", action="store_true",
help="Create a ZFS snapshot before each backup run")
c.add_argument("--absolute-paths", action="store_true",
help="Preserve absolute paths inside the restic repo")
c.add_argument("--disabled", action="store_true",
help="Preserve absolute paths inside the restic repository")
c.add_argument("--disabled", action="store_true",
help="Create the task in a disabled state")
args = p.parse_args()
@@ -167,8 +194,8 @@ def main():
dispatch = {
"list-credentials": cmd_list_credentials,
"list-tasks": cmd_list_tasks,
"create": cmd_create,
"list-tasks": cmd_list_tasks,
"create": cmd_create,
}
dispatch[args.cmd](client, args)