middlewared's alert.load() imports every file in alert/source/ with NO try/except:
def load(self):
for module in load_modules(.../alert/source):
for cls in load_classes(module, AlertSource, (ThreadedAlertSource,)):
...
and it runs during setup. A module that raises on import therefore takes
middlewared's startup down with it -- exactly the class of failure this project
exists to avoid.
apply.sh now COMPILES the substituted alert source and refuses to write it if it
does not parse. An uninstalled alert is a missing convenience; a broken one is a
broken box.
@PATCH_DIR@ is also substituted with repr() rather than raw, so a repository path
containing a quote or backslash yields a valid Python literal instead of a syntax
error in the installed module.
The alert source no longer mutates sys.path. It loaded tools/release_notes.py via
sys.path.insert(0, ...), which shadows the stdlib for that interpreter -- and
ThreadedAlertSource runs in middlewared's thread pool, so mutating sys.path is a
race. It now loads by file path with importlib.
New tests guard every import-time failure mode: the module compiles, apply.sh
compiles before writing, awkward paths (quotes, backslashes) still produce valid
modules, nothing but imports/constants/classes runs at module scope, every
AlertClass name ends in "AlertClass" (AlertClassMeta raises NameError otherwise),
the alert text placeholders match the args passed, and no git command that writes
to .git is ever used.
152 tests, ruff and shellcheck -S style clean.
227 lines
7.7 KiB
Python
227 lines
7.7 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 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)
|
|
_GITHUB_RE = re.compile(r"github\.com[:/]([^/]+)/([^/.]+)")
|
|
|
|
_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 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):
|
|
return subprocess.run(
|
|
["git", "-C", PATCH_DIR, *args],
|
|
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 _remote_changelog(self, tag):
|
|
"""CHANGELOG.md at `tag`, over HTTPS. None if it cannot be read."""
|
|
try:
|
|
remote = self._git("remote", "get-url", "origin").strip()
|
|
except Exception:
|
|
return None
|
|
|
|
m = _GITHUB_RE.search(remote)
|
|
if not m:
|
|
return None # not a GitHub remote; skip classification
|
|
|
|
url = (
|
|
f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/"
|
|
f"{tag}/CHANGELOG.md"
|
|
)
|
|
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
|