Compare commits

..
2 Commits
Author SHA1 Message Date
flan 4ded8cff3d Fix deferred restart racing boot: wait for boot to settle before restarting middlewared
The truecloud-mw-restart unit relied on After=multi-user.target /
After=ix-postinit.service, but systemd ordering cannot see middlewared's
internal boot work. On 25.10.4 the restart fired two seconds into
ix-reporting's reporting.start_service call and before the docker/apps
startup task ran, killing both for the whole boot: all apps down
(docker.status FAILED), no dashboard stats, SMB backend uninitialized.

The unit now runs patch/wait_restart.sh: drain the systemd boot job
queue (is-system-running --wait), poll docker.status until the state
machine leaves its transitional states, short grace period, then
try-restart. No Type=oneshot — a oneshot's start job sits in the very
queue the script waits on and would deadlock on itself. All waits are
bounded and fail open.
2026-07-09 17:21:07 +00:00
flan da1be97377 create_task.py: migrate REST /api/v2.0 -> midclt (removed in TrueNAS 26.04)
The tool created cloud_backup tasks via POST /api/v2.0/cloud_backup, which is deprecated
and removed in TrueNAS 26.04. It now calls the middleware directly with midclt
(cloudsync.credentials.query / cloud_backup.query / cloud_backup.create), so it runs on
the TrueNAS host with no host address or API key. --host/--api-key/--insecure are kept
accepted-but-ignored for compatibility. Dropped the ssl/urllib HTTP client. v0.2.0.
2026-07-08 15:09:38 -04:00
6 changed files with 187 additions and 83 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## v0.2.1 — 2026-07-09
### Fixed
- **Deferred restart raced the rest of boot, leaving all apps and dashboard
stats down.** The `truecloud-mw-restart` unit introduced in v0.0.4 relied
on systemd ordering (`After=multi-user.target`, `After=ix-postinit.service`),
which cannot see middlewared's *internal* boot work. Observed on 25.10.4:
the restart fired two seconds into `ix-reporting.service`'s
`midclt call reporting.start_service` and before the docker/apps startup
task (created on middlewared's system-ready event) had run. Both were
killed, and nothing retries them until the next boot — every app stayed
down (`docker.status` FAILED, the apps dataset never mounted), netdata
never started (no dashboard hardware stats), and the SMB middleware
backend was left uninitialized.
The transient unit now runs `patch/wait_restart.sh` instead of restarting
directly: it waits for the systemd boot job queue to drain
(`systemctl is-system-running --wait`, covering in-flight `ix-*` oneshots
such as ix-reporting), then polls `midclt call docker.status` until the
docker state machine leaves its transitional states, then allows a short
grace period for middleware-internal tasks with no queryable state before
issuing `systemctl try-restart middlewared`. The unit no longer sets
`Type=oneshot` — a oneshot's start job stays in the very queue the script
waits on and would deadlock on itself. All waits are bounded and fail
open: worst case the restart still happens, just later.
Recovery on a boot that already hit this (without rebooting):
`midclt call reporting.start_service` and
`midclt call docker.state.start_service true`.
## v0.2.0 — 2026-07-08
### Changed
- **`create_task.py` now uses the TrueNAS middleware via `midclt` instead of the
deprecated `/api/v2.0` REST API**, which is removed in TrueNAS 26.04. Practical
effects:
- Run the script **on the TrueNAS host** — it uses the local middleware socket, so
it no longer needs a host address or API key.
- `--host`, `--api-key`, and `--insecure` are accepted but **ignored** (a deprecation
note is printed); they will be removed in a future release.
- `list-credentials` → `cloudsync.credentials.query`, `list-tasks` →
`cloud_backup.query`, `create` → `cloud_backup.create`.
- Dropped the `ssl`/`urllib` HTTP client; no TLS certificate handling is needed anymore.
## v0.1.0 — 2026-07-08
### Added
+25 -19
View File
@@ -66,9 +66,11 @@ directly.
PREINIT scripts are executed *by* middlewared, which by then has already
imported the stock modules — so after patching, `apply.sh` schedules a single
detached middlewared restart (transient systemd unit `truecloud-mw-restart`,
ordered after `multi-user.target`) that loads the patched modules once boot
completes. Expect one middlewared restart shortly after every boot; the UI
detached middlewared restart (transient systemd unit `truecloud-mw-restart`
running `patch/wait_restart.sh`) that loads the patched modules once boot has
*actually* settled: the script waits for the systemd boot job queue to drain
and for the docker/apps state machine to reach a terminal state before
restarting. Expect one middlewared restart shortly after every boot; the UI
and API are briefly unavailable while it happens, and running services are
not affected.
@@ -113,10 +115,15 @@ Two different things must survive two different events:
imported the stock modules in step 1 and never re-imports, so the on-disk
patch alone is not enough. `apply.sh` detects it was invoked by middlewared
and creates a transient systemd unit (`truecloud-mw-restart`, via
`systemd-run --no-block`, ordered after `multi-user.target`) — detached and
deferred so it cannot disrupt the remainder of the boot sequence.
5. **Once boot completes, middlewared restarts once** and imports the patched
modules from the overlay. S3/B2 backup support is now active until the next
`systemd-run --no-block`) running `patch/wait_restart.sh` — detached so it
cannot disrupt the remainder of the boot sequence.
5. **Once boot has settled, middlewared restarts once** and imports the
patched modules from the overlay. `wait_restart.sh` holds the restart until
the systemd boot job queue has drained (so in-flight `ix-*` units like
`ix-reporting` finish first) *and* middlewared's docker/apps startup has
reached a terminal state — plain unit ordering cannot see either, and
restarting middlewared while they run kills apps and dashboard reporting
for the whole boot. S3/B2 backup support is then active until the next
reboot, when the cycle repeats.
What you will observe: one middlewared restart shortly after every boot (a
@@ -187,18 +194,18 @@ Check [CHANGELOG.md](CHANGELOG.md) to see what changed between versions.
## Creating a task via CLI
If the UI still shows only Storj after refreshing (e.g. the JS bundle pattern
changed in a new TrueNAS version), create tasks directly via the REST API:
changed in a new TrueNAS version), create tasks directly. Run this **on the
TrueNAS host** — it talks to the local middleware via `midclt`, so it needs no
host address or API key:
```bash
# Replace /mnt/tank/truenas-truecloud-patch with your clone path
# List your cloud credentials to find the right ID
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py \
--host 192.168.1.1 --api-key <key> list-credentials
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py list-credentials
# Create a task with a B2 credential (id=3)
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py \
--host 192.168.1.1 --api-key <key> create \
python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py create \
--name "tank-to-b2" \
--path /mnt/tank/data \
--credential 3 \
@@ -213,7 +220,8 @@ python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py \
> which re-fetches all repo metadata from the provider every run — glacially slow
> on large repos. Point it at a writable dir on a pool with free space.
Get an API key from **System → API Keys → Add**.
> Versions ≤ 0.1.0 used the `/api/v2.0` REST API with `--host`/`--api-key`; those
> flags are now accepted-but-ignored (REST is removed in TrueNAS 26.04).
---
@@ -433,9 +441,7 @@ grep -c 'STORJ_IX.*S3.*B2' \
| grep -v ':0'
```
**`create_task.py` SSL error connecting to TrueNAS**
`create_task.py` talks to the **TrueNAS API**, not your S3 endpoint, and
verifies its TLS certificate. If your NAS uses a self-signed certificate,
pass `--insecure` — but be aware this disables certificate verification for
the API call that transmits your TrueNAS API key. Adding your NAS certificate
to your system's trust store is safer.
**`create_task.py` — "midclt not found" or permission errors**
`create_task.py` now talks to the local middleware via `midclt`, so run it **on
the TrueNAS host** (not remotely) as a user with middleware access (root). There
is no HTTPS/API-key call anymore, so there is no TLS certificate to configure.
Binary file not shown.
+12 -6
View File
@@ -24,7 +24,7 @@
# Derive PATCH_DIR from this script's location (parent of the patch/ directory).
PATCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOG="$PATCH_DIR/apply.log"
VERSION="0.0.4"
VERSION="0.2.1"
# Rotate log at 512 KB to avoid unbounded growth on a system volume.
# Keep two prior generations (.1 and .2) so the last three boots are always available.
@@ -329,6 +329,14 @@ fi
# ix-* boot units still need midclt to answer.
# Boot context is detected by the parent process being middlewared; manual
# runs (install.sh, recovery) never trigger a restart.
#
# The unit runs wait_restart.sh, which blocks until boot has actually
# settled (systemd job queue drained, docker/apps state terminal) before
# restarting. systemd ordering alone (After=multi-user.target, ≤ v0.0.4)
# fired while ix-reporting and the docker/apps startup were still in flight
# and killed both — apps and dashboard stats stayed down until the next
# boot. No Type=oneshot: a oneshot's start job would hold the boot queue
# open against the `is-system-running --wait` inside the script.
echo "--- deferred restart ---"
@@ -340,12 +348,10 @@ else
# A failed unit from an earlier attempt this boot would block systemd-run.
systemctl reset-failed truecloud-mw-restart.service 2>/dev/null
if systemd-run --no-block --collect --unit=truecloud-mw-restart \
--property=Type=oneshot \
--property=After=multi-user.target \
--property=After=ix-postinit.service \
systemctl try-restart middlewared; then
/bin/bash "$PATCH_DIR/patch/wait_restart.sh"; then
echo "OK: Scheduled deferred middlewared restart (unit: truecloud-mw-restart)."
echo " Backend patch becomes active once boot completes."
echo " It waits for boot to fully settle (apps started, reporting up),"
echo " then restarts middlewared so the backend patch actually loads."
else
echo "WARNING: Could not schedule deferred restart — backend patch is on disk but NOT loaded."
echo " Activate manually: systemctl restart middlewared"
+46 -55
View File
@@ -3,22 +3,24 @@
create_task.py — create TrueNAS TrueCloud Backup tasks with S3 or B2 credentials.
The TrueNAS UI normally restricts the credential dropdown to Storj only.
This script bypasses that restriction by calling the REST API directly.
This script bypasses that restriction by talking to the TrueNAS middleware
directly via `midclt` (the /api/v2.0 REST API is removed in TrueNAS 26.04).
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.
Run this ON the TrueNAS host — it uses the local middleware socket via `midclt`,
so no host address or API key is needed.
Examples
--------
List available cloud credentials:
python3 create_task.py --host 192.168.1.1 --api-key <key> list-credentials
python3 create_task.py 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 \\
python3 create_task.py create \\
--name "tank-to-b2" \\
--path /mnt/tank/data \\
--credential 3 \\
@@ -28,7 +30,7 @@ Create a task backed by a B2 credential (id=3):
--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 \\
python3 create_task.py create \\
--name "tank-to-wasabi" \\
--path /mnt/tank/data \\
--credential 5 \\
@@ -37,54 +39,44 @@ Create a task using an S3-compatible credential (Wasabi, R2, etc.):
--password "restic-repo-password"
List existing TrueCloud Backup tasks:
python3 create_task.py --host 192.168.1.1 --api-key <key> list-tasks
python3 create_task.py list-tasks
"""
import argparse
import calendar
import json
import os
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
__version__ = "0.1.0"
__version__ = "0.2.0"
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")
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}",
"Content-Type": "application/json",
}
ctx = ssl.create_default_context()
if insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def call(method, path, body=None):
url = base + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
def midclt_call(method, *args):
"""Call a middleware method locally via `midclt`, the supported JSON-RPC transport
that replaces the deprecated /api/v2.0 REST API (removed in TrueNAS 26.04). Must run
on the TrueNAS host. Each arg is JSON-encoded (a dict for create; none for queries).
Exits with a clear message on failure."""
cmd = ["midclt", "call", method] + [json.dumps(a) for a in args]
try:
with urllib.request.urlopen(req, context=ctx) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
print(f"HTTP {exc.code} {exc.reason}: {detail}", file=sys.stderr)
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print("ERROR: `midclt` not found — run this script ON the TrueNAS host.",
file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as exc:
print(f"Connection error: {exc.reason}", file=sys.stderr)
except subprocess.SubprocessError as exc:
print(f"ERROR: midclt call failed: {exc}", file=sys.stderr)
sys.exit(1)
return call
if proc.returncode != 0:
print(f"ERROR: midclt {method}: {(proc.stderr or proc.stdout).strip()}",
file=sys.stderr)
sys.exit(1)
out = proc.stdout.strip()
return json.loads(out) if out else None
# ── Sub-commands ──────────────────────────────────────────────────────────────
@@ -185,8 +177,8 @@ def _provider_type(cred):
return p or "?"
def cmd_list_credentials(client, _args):
creds = client("GET", "/cloudsync/credentials")
def cmd_list_credentials(_args):
creds = midclt_call("cloudsync.credentials.query")
if not creds:
print("No cloud credentials configured.")
return
@@ -196,8 +188,8 @@ def cmd_list_credentials(client, _args):
print(f"{c['id']:>4} {_provider_type(c):<14} {c['name']}")
def cmd_list_tasks(client, _args):
tasks = client("GET", "/cloud_backup")
def cmd_list_tasks(_args):
tasks = midclt_call("cloud_backup.query")
if not tasks:
print("No TrueCloud Backup tasks configured.")
return
@@ -209,7 +201,7 @@ def cmd_list_tasks(client, _args):
print(f"{t['id']:>4} {enabled:<8} {ptype:<14} {t.get('description', '')}")
def cmd_create(client, args):
def cmd_create(args):
parts = args.schedule.split()
if len(parts) != 5:
print(
@@ -253,7 +245,7 @@ def cmd_create(client, args):
file=sys.stderr,
)
result = client("POST", "/cloud_backup", body)
result = midclt_call("cloud_backup.create", body)
try:
print(f"Created task id={result['id']} name={result['description']!r}")
except (KeyError, TypeError):
@@ -269,14 +261,12 @@ def main():
epilog=__doc__.split("Examples")[1] if __doc__ and "Examples" in __doc__ else "",
)
p.add_argument("--version", "-V", action="version", version=f"truecloud-patch {__version__}")
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). "
"WARNING: exposes your API key to network interception. "
"Prefer adding your cert to the trust store instead.")
# Deprecated & ignored: the tool now uses the local middleware via `midclt` (the
# /api/v2.0 REST API is removed in TrueNAS 26.04), so it must run ON the TrueNAS
# host and needs no host/API key. Kept accepted-but-ignored for compatibility.
p.add_argument("--host", default=None, help=argparse.SUPPRESS)
p.add_argument("--api-key", default=None, help=argparse.SUPPRESS)
p.add_argument("--insecure", action="store_true", help=argparse.SUPPRESS)
sub = p.add_subparsers(dest="cmd", required=True)
@@ -323,16 +313,17 @@ def main():
cmd_verify()
return
if not args.host or not args.api_key:
p.error("--host and --api-key are required for this command")
if args.host or args.api_key or args.insecure:
print("NOTE: --host/--api-key/--insecure are deprecated and ignored; this tool "
"now uses the local middleware (midclt) and must run on the TrueNAS host.",
file=sys.stderr)
client = make_client(args.host, args.api_key, args.insecure)
if args.cmd == "list-credentials":
cmd_list_credentials(client, args)
cmd_list_credentials(args)
elif args.cmd == "list-tasks":
cmd_list_tasks(client, args)
cmd_list_tasks(args)
elif args.cmd == "create":
cmd_create(client, args)
cmd_create(args)
if __name__ == "__main__":
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# patch/wait_restart.sh — payload of the transient `truecloud-mw-restart`
# unit that apply.sh schedules in boot context (Step 3).
#
# Why not restart middlewared directly from the unit: systemd ordering
# (`After=multi-user.target`, used up to v0.0.4) cannot see middlewared's
# *internal* boot work. When the boot targets are reached, two things are
# typically still in flight inside middlewared:
#
# - ix-reporting.service's `midclt call reporting.start_service` (netdata,
# which feeds the dashboard hardware stats), and
# - the docker/apps startup task middlewared creates on its own
# system-ready event (`docker.state.start_service`).
#
# Restarting middlewared while those run kills them, and nothing retries
# them until the next boot: every app stays down (`docker.status` FAILED),
# the dashboard shows no stats, and middleware-internal service state (e.g.
# the SMB backend) is left uninitialized. Observed on 25.10.4 with v0.0.4.
#
# So this script waits for both layers to settle before restarting. Every
# wait is bounded and fails open: worst case the restart still happens, just
# later — a restart on a settled system is harmless (docker, apps and
# netdata are independent processes; only the middleware API blips).
#
# NOTE: the unit must NOT be Type=oneshot. A oneshot's start job stays in
# the systemd job queue until the process exits, and `is-system-running
# --wait` below waits for that same queue to drain — the unit would deadlock
# on itself until the timeout. apply.sh schedules this with the default
# service type, whose start job completes at fork.
# 1. systemd layer: wait for the boot job queue to drain. This covers every
# ix-* oneshot still activating, including ix-reporting's in-flight midclt
# call. The exit code is irrelevant — a "degraded" boot (any unrelated
# failed unit) is still a finished boot. The timeout only guards against
# a boot that never settles (e.g. a unit stuck on a network wait).
timeout 900 systemctl is-system-running --wait > /dev/null 2>&1
# 2. middlewared layer: poll the docker state machine until it leaves the
# transitional states (PENDING/INITIALIZING/STOPPING/MIGRATING — see
# middlewared/plugins/docker/state_utils.py). An empty answer means
# midclt could not respond at all; keep waiting. Cap at 10 minutes.
for _ in $(seq 1 120); do
_status=$(midclt call docker.status 2>/dev/null \
| grep -oE '"status": "[A-Z_]+"' | cut -d'"' -f4)
case "$_status" in
RUNNING|STOPPED|UNCONFIGURED|FAILED|MIGRATION_FAILED) break ;;
esac
sleep 5
done
# 3. Grace period for middleware-internal ready-event tasks that expose no
# queryable state (smb.configure and friends). Bounded insurance.
sleep 30
exec systemctl try-restart middlewared