Patching at PREINIT and restarting minutes later is only sound while the patched files are still on the live path when middlewared re-imports them, and PREINIT cannot guarantee that. The overlay sits inside /usr, so anything that remounts that hierarchy detaches it — a systemd-sysext merge/refresh from another PREINIT hook, or middlewared's own docker.configure_nvidia at runtime. Init scripts run sequentially in id order, so a hook registered after this one always wins, and reordering them would not help because docker.configure_nvidia fires long after PREINIT is done. Observed on 25.10.6: the overlay was mounted at 16:41:56, a sysext refresh unmerged and remerged /usr four seconds later, and the deferred restart at 16:47:24 loaded stock modules. Every B2 cloud_backup task then failed with NotImplementedError for nineteen hours across four scheduled runs while apply.log and hook_status.json both reported the patch active. wait_restart.sh now re-applies immediately before restarting — after boot has settled, which is also after every sysext merge and docker nvidia configuration — verifies the marker is on the live path, restarts, and verifies again, retrying once. It is no longer exec'd, so something can run after the restart to find out what it loaded. apply.sh records the resolved middlewared directory in .mw_dir for that check, and honours TRUECLOUD_REAPPLY so the re-apply pass does not schedule a second restart. _ensure_writable treated "one of our overlays is listed here" as "already done", but it only reaches that check when the directory is not writable, and a live overlay of ours always is — a shadowed overlay was indistinguishable from a healthy one. It is now detached and re-mounted, reusing the upperdir so files patched earlier in the boot survive, with a fresh workdir and a retry on a private one, since overlayfs refuses a workdir a detached mount still holds. Add a CRITICAL hourly alert for the case none of this can prevent: the patch being on disk but not in the running process. apply.log can only report the first. The alert asks the second question from inside middlewared, where the patch's own stamps make it exact, and checks both halves since either can go missing alone. It stays quiet when the kill switch is set or the providers module has been retired as native, and is not muted by update_alerts_disabled. wait_restart.sh also logs to apply.log now: journald retention on a busy box is easily shorter than the interval between reboots, and the boot that caused this had already rotated away by the time it was investigated.
361 lines
13 KiB
Python
361 lines
13 KiB
Python
"""TrueNAS alert: a truecloud-patch update is available.
|
|
|
|
Installed by patch/apply.sh into middlewared/alert/source/, where middlewared
|
|
discovers and polls it natively — no cron job, no systemd timer.
|
|
|
|
@PATCH_DIR@ is substituted at install time.
|
|
|
|
Two rules govern this file:
|
|
|
|
1. **It must never break middlewared.** It runs inside the alert framework on a
|
|
timer. Every failure path returns None (no alert) rather than raising.
|
|
|
|
2. **It must not nag.** A release whose CHANGELOG only has a "### Docs" section
|
|
changed no code, and nobody wants an alert because a README was reworded. The
|
|
CHANGELOG's own section headings are the signal — see tools/release_notes.py.
|
|
|
|
It also never writes to the repository. `git ls-remote` is read-only and the
|
|
CHANGELOG is fetched over HTTPS, so this cannot leave root-owned objects in .git
|
|
the way a `git fetch` from middlewared (running as root) would.
|
|
"""
|
|
|
|
import datetime
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import urllib.request
|
|
|
|
from middlewared.alert.base import (
|
|
Alert,
|
|
AlertCategory,
|
|
AlertClass,
|
|
AlertLevel,
|
|
ThreadedAlertSource,
|
|
)
|
|
from middlewared.alert.schedule import IntervalSchedule
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PATCH_DIR = "@PATCH_DIR@"
|
|
DISABLED_MARKER = os.path.join(PATCH_DIR, "update_alerts_disabled")
|
|
|
|
_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
|
|
_VERSION_RE = re.compile(r'^VERSION="([^"]+)"', re.M)
|
|
|
|
#: owner/repo out of any of:
|
|
#: git@github.com:sudolulo/repo.git
|
|
#: https://github.com/sudolulo/repo.git
|
|
#: ssh://git@git.arch.fyi:55214/flan/repo.git
|
|
#: https://git.arch.fyi/flan/repo.git
|
|
#: The SSH port is deliberately not captured: it is not the web port.
|
|
_REMOTE_RE = re.compile(
|
|
r"^(?:\w+://)?(?:[^@/]+@)?([^:/]+)(?::\d+)?[:/]([^/]+)/([^/]+?)(?:\.git)?/?$"
|
|
)
|
|
|
|
_TIMEOUT = 20
|
|
|
|
|
|
class TrueCloudPatchUpdateAlertClass(AlertClass):
|
|
category = AlertCategory.SYSTEM
|
|
level = AlertLevel.INFO
|
|
title = "truecloud-patch update available"
|
|
text = (
|
|
"truecloud-patch %(current)s is installed; %(latest)s is available.%(summary)s "
|
|
"Update with: bash %(dir)s/update.sh"
|
|
)
|
|
|
|
|
|
class TrueCloudPatchSecurityUpdateAlertClass(AlertClass):
|
|
category = AlertCategory.SYSTEM
|
|
level = AlertLevel.WARNING
|
|
title = "truecloud-patch security update available"
|
|
text = (
|
|
"truecloud-patch %(current)s is installed; %(latest)s contains a SECURITY "
|
|
"fix.%(summary)s Update with: bash %(dir)s/update.sh"
|
|
)
|
|
|
|
|
|
class TrueCloudPatchNotLoadedAlertClass(AlertClass):
|
|
category = AlertCategory.SYSTEM
|
|
level = AlertLevel.CRITICAL
|
|
title = "truecloud-patch is installed but NOT loaded"
|
|
text = (
|
|
"truecloud-patch patched middlewared on disk, but this middlewared is "
|
|
"running the STOCK cloud_backup modules -- B2 and S3 TrueCloud Backup "
|
|
"tasks will fail with NotImplementedError. Something remounted /usr "
|
|
"after the patch was applied (a systemd-sysext merge, or "
|
|
"docker.configure_nvidia), detaching the patch overlay. Re-apply with: "
|
|
"bash %(dir)s/install.sh"
|
|
)
|
|
|
|
|
|
class TrueCloudPatchNotLoadedAlertSource(ThreadedAlertSource):
|
|
"""Does the middlewared running this check actually have the patch in it?
|
|
|
|
This is the one question apply.log cannot answer. apply.sh reports what it
|
|
wrote to disk; whether the restart that followed imported those files is a
|
|
separate fact, and on 2026-08-19 the two disagreed silently for nineteen
|
|
hours while every B2 backup task failed. Asking from inside the process is
|
|
exact -- the patch stamps the objects it replaces, so a missing stamp means
|
|
this interpreter imported stock code.
|
|
|
|
Deliberately NOT silenced by the update-alert marker: that mutes release
|
|
notifications, not a broken backup path. Only the patch's own kill switch
|
|
(the `disabled` file, meaning the operator turned the patch off) stops it.
|
|
"""
|
|
|
|
schedule = IntervalSchedule(datetime.timedelta(hours=1))
|
|
run_on_backup_node = False
|
|
|
|
def check_sync(self):
|
|
try:
|
|
return self._check()
|
|
except Exception:
|
|
# An alert source must never take middlewared down with it.
|
|
logger.debug("truecloud-patch loaded check failed", exc_info=True)
|
|
return None
|
|
|
|
# -- internals ------------------------------------------------------------
|
|
|
|
def _check(self):
|
|
if os.path.exists(os.path.join(PATCH_DIR, "disabled")):
|
|
return None
|
|
|
|
# Only the providers module puts B2/S3 on the restic path. If it was
|
|
# never applied here, or TrueNAS went native and it was retired, then
|
|
# "not loaded" is the correct state and not a fault.
|
|
status = self._hook_status()
|
|
if not status:
|
|
return None
|
|
providers = status.get("patches", {}).get("providers", {})
|
|
if not providers.get("active"):
|
|
return None
|
|
|
|
if self._providers_loaded():
|
|
return None
|
|
|
|
return Alert(
|
|
TrueCloudPatchNotLoadedAlertClass,
|
|
{"dir": PATCH_DIR},
|
|
key=None,
|
|
)
|
|
|
|
def _hook_status(self):
|
|
try:
|
|
with open(os.path.join(PATCH_DIR, "hook_status.json")) as f:
|
|
return json.load(f)
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
def _providers_loaded(self):
|
|
"""True when THIS interpreter holds the patched provider objects.
|
|
|
|
Two independent stamps, because the two halves are written separately
|
|
and either can be missing on its own:
|
|
|
|
* restic.py -- apply.sh sets `_truecloud_patched` on the wrapper it
|
|
installs over `get_restic_config`.
|
|
* b2.py -- apply.sh binds a B2-specific `get_restic_config` onto
|
|
`B2RcloneRemote`. Comparing it against the base implementation is
|
|
exact and survives renames of the patch's own helper.
|
|
"""
|
|
try:
|
|
from middlewared.plugins.cloud_backup.restic import get_restic_config
|
|
except Exception:
|
|
return False
|
|
if not getattr(get_restic_config, "_truecloud_patched", False):
|
|
return False
|
|
|
|
try:
|
|
from middlewared.rclone.base import BaseRcloneRemote
|
|
from middlewared.rclone.remote.b2 import B2RcloneRemote
|
|
except Exception:
|
|
return False
|
|
base = getattr(BaseRcloneRemote, "get_restic_config", None)
|
|
b2 = getattr(B2RcloneRemote, "get_restic_config", None)
|
|
return b2 is not None and b2 is not base
|
|
|
|
|
|
class TrueCloudPatchUpdateAlertSource(ThreadedAlertSource):
|
|
schedule = IntervalSchedule(datetime.timedelta(hours=24))
|
|
run_on_backup_node = False
|
|
|
|
def check_sync(self):
|
|
try:
|
|
return self._check()
|
|
except Exception:
|
|
# An alert source must never take middlewared down with it.
|
|
logger.debug("truecloud-patch update check failed", exc_info=True)
|
|
return None
|
|
|
|
# ── internals ────────────────────────────────────────────────────────────
|
|
|
|
def _git(self, *args):
|
|
# List form, never shell=True, and every `args` value is a literal from
|
|
# this file -- nothing user-supplied reaches the command line. The partial
|
|
# `git` path is moot: this runs as root inside middlewared, so anyone who
|
|
# can poison PATH already has root.
|
|
return subprocess.run( # noqa: S603
|
|
["git", "-C", PATCH_DIR, *args], # noqa: S607
|
|
capture_output=True, text=True, timeout=_TIMEOUT, check=True,
|
|
).stdout
|
|
|
|
def _check(self):
|
|
if os.path.exists(DISABLED_MARKER):
|
|
return None
|
|
if not os.path.isdir(os.path.join(PATCH_DIR, ".git")):
|
|
return None
|
|
|
|
current = self._installed_version()
|
|
if not current:
|
|
return None
|
|
|
|
latest = self._latest_release_tag()
|
|
if not latest:
|
|
return None
|
|
|
|
rn = self._release_notes()
|
|
if rn is None:
|
|
return None
|
|
significance, version_tuple = rn.significance, rn.version_tuple
|
|
|
|
if version_tuple(latest) <= version_tuple(current):
|
|
return None
|
|
|
|
level, versions, summary = self._classify(
|
|
current, latest, significance
|
|
)
|
|
|
|
# Documentation-only releases are not worth an alert. This is the whole
|
|
# point: nobody should get a notification because a README was reworded.
|
|
if level == "docs":
|
|
logger.debug(
|
|
"truecloud-patch %s -> %s is documentation-only; not alerting",
|
|
current, latest,
|
|
)
|
|
return None
|
|
|
|
args = {
|
|
"current": f"v{current}",
|
|
"latest": latest,
|
|
"summary": summary,
|
|
"dir": PATCH_DIR,
|
|
}
|
|
klass = (
|
|
TrueCloudPatchSecurityUpdateAlertClass if level == "security"
|
|
else TrueCloudPatchUpdateAlertClass
|
|
)
|
|
return Alert(klass, args, key=[current, latest])
|
|
|
|
def _release_notes(self):
|
|
"""Load tools/release_notes.py by path.
|
|
|
|
NOT via sys.path: prepending would shadow the stdlib for this interpreter,
|
|
and this runs in middlewared's thread pool, so mutating sys.path is a race.
|
|
"""
|
|
path = os.path.join(PATCH_DIR, "tools", "release_notes.py")
|
|
try:
|
|
spec = importlib.util.spec_from_file_location("_tc_release_notes", path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
except Exception:
|
|
logger.debug("could not load release_notes", exc_info=True)
|
|
return None
|
|
|
|
def _installed_version(self):
|
|
"""The version of the patch actually checked out here."""
|
|
try:
|
|
with open(os.path.join(PATCH_DIR, "patch", "apply.sh"), encoding="utf-8") as fh:
|
|
m = _VERSION_RE.search(fh.read())
|
|
except OSError:
|
|
return None
|
|
return m.group(1) if m else None
|
|
|
|
def _latest_release_tag(self):
|
|
"""Newest plain vX.Y.Z tag on the remote. Read-only: no .git writes.
|
|
|
|
Pre-release tags (-rc, -beta) are excluded: git's version sort ranks
|
|
v0.5.0-rc1 above v0.5.0, so including them would advertise a release
|
|
candidate as the latest stable.
|
|
"""
|
|
try:
|
|
out = self._git("ls-remote", "--tags", "--refs", "origin")
|
|
except Exception:
|
|
return None
|
|
|
|
tags = []
|
|
for line in out.splitlines():
|
|
parts = line.split("refs/tags/")
|
|
if len(parts) == 2 and _TAG_RE.match(parts[1].strip()):
|
|
tags.append(parts[1].strip())
|
|
if not tags:
|
|
return None
|
|
|
|
return max(tags, key=lambda t: tuple(int(x) for x in t.lstrip("v").split(".")))
|
|
|
|
def _classify(self, current, latest, significance):
|
|
"""(level, versions, one-line summary). Falls back to alerting."""
|
|
text = self._remote_changelog(latest)
|
|
if text is None:
|
|
# Cannot tell whether it matters. Alert rather than risk hiding a
|
|
# security fix -- but say that we could not tell.
|
|
return "notable", [], " (could not read the changelog)"
|
|
|
|
level, versions, headings = significance(text, current, latest)
|
|
if level == "docs":
|
|
return level, versions, ""
|
|
|
|
seen, ordered = set(), []
|
|
for h in headings:
|
|
if h not in seen:
|
|
seen.add(h)
|
|
ordered.append(h.capitalize())
|
|
detail = ", ".join(ordered)
|
|
return level, versions, f" Changes: {detail}." if detail else ""
|
|
|
|
def _changelog_url(self, tag):
|
|
"""Where to read CHANGELOG.md at `tag`, derived from the origin remote.
|
|
|
|
Forge-agnostic on purpose. This project is canonically hosted on Gitea and
|
|
mirrored to GitHub, and hard-coding either one has a nastier failure than it
|
|
looks: when the changelog cannot be read, _classify() falls back to
|
|
"notable" and alerts ANYWAY, because the alternative is silently hiding a
|
|
security fix. So a stale URL does not disable the alert -- it makes the
|
|
alert fire on every release including documentation-only ones, which is
|
|
precisely the nagging this whole mechanism exists to prevent.
|
|
"""
|
|
try:
|
|
remote = self._git("remote", "get-url", "origin").strip()
|
|
except Exception:
|
|
return None
|
|
|
|
m = _REMOTE_RE.match(remote)
|
|
if not m:
|
|
return None
|
|
|
|
host, owner, repo = m.group(1), m.group(2), m.group(3)
|
|
|
|
if host.endswith("github.com"):
|
|
return f"https://raw.githubusercontent.com/{owner}/{repo}/{tag}/CHANGELOG.md"
|
|
|
|
# Gitea and Forgejo both serve /{owner}/{repo}/raw/tag/{tag}/{path} over the
|
|
# web port, which is not the SSH port the remote may name.
|
|
return f"https://{host}/{owner}/{repo}/raw/tag/{tag}/CHANGELOG.md"
|
|
|
|
def _remote_changelog(self, tag):
|
|
"""CHANGELOG.md at `tag`, over HTTPS. None if it cannot be read."""
|
|
url = self._changelog_url(tag)
|
|
if not url:
|
|
return None
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310
|
|
if resp.status != 200:
|
|
return None
|
|
return resp.read().decode("utf-8", "replace")
|
|
except Exception:
|
|
return None
|