v0.5.1: the update alert could have broken middlewared at startup

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.
This commit is contained in:
flan
2026-07-13 16:52:23 +00:00
parent 4e0814028c
commit bf6d37e621
9 changed files with 200 additions and 14 deletions
+21 -6
View File
@@ -20,11 +20,11 @@ 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 sys
import urllib.request
from middlewared.alert.base import (
@@ -102,11 +102,10 @@ class TrueCloudPatchUpdateAlertSource(ThreadedAlertSource):
if not latest:
return None
sys.path.insert(0, os.path.join(PATCH_DIR, "tools"))
try:
from release_notes import significance, version_tuple
finally:
sys.path.pop(0)
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
@@ -136,6 +135,22 @@ class TrueCloudPatchUpdateAlertSource(ThreadedAlertSource):
)
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:
+14 -3
View File
@@ -32,7 +32,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.5.0"
VERSION="0.5.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.
@@ -602,15 +602,26 @@ else:
try:
with open(alert_src, encoding='utf-8') as fh:
_body = fh.read()
# PATCH_DIR is baked in: the alert source must find the repo it belongs to.
# repr() so ANY path becomes a valid Python literal -- a directory
# containing a quote or backslash would otherwise produce a syntax error.
_body = _body.replace('"@PATCH_DIR@"', repr(_patch_dir))
# COMPILE BEFORE WRITING. middlewared's alert.load() imports every file in
# alert/source/ with NO try/except, and it runs at startup -- a module that
# raises on import takes middlewared's setup down with it. An uninstalled
# alert is a missing convenience; a broken one is a broken box.
compile(_body, alert_dst, 'exec')
with open(alert_dst, 'w', encoding='utf-8') as fh:
fh.write(_body.replace('@PATCH_DIR@', _patch_dir))
fh.write(_body)
alert_ok = True
alert_detail = 'update alert installed'
print(f'OK: Installed update alert → {alert_dst}')
except Exception as e:
alert_detail = f'not applied: {e}'
print(f'WARNING: could not install update alert: {e}')
print('WARNING: no update alert; everything else is unaffected.')
patches = {
'providers': {
+1 -1
View File
@@ -52,7 +52,7 @@ import subprocess
import sys
import time
__version__ = "0.5.0"
__version__ = "0.5.1"
_PATCH_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_STATUS_FILE = os.path.join(_PATCH_DIR, "hook_status.json")