Files
ChuckandClaude Opus 5.5 f841fa36b6 feat(install): updates refresh systemd units; new installs run the newest release (#729)
Updates that move HEAD now install changed systemd units through a root-owned helper (/usr/local/sbin/ledmatrix-refresh-units, two literal sudo lines), with a backup restored on rollback; a refresh that fails part-way puts the old units back. Devices without the new sudo rule keep updating and are told to re-run the installer once. The one-shot installer now checks out the newest vX.Y.Z release (LEDMATRIX_CHANNEL=beta keeps main) and never moves an existing checkout backwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:09:53 -04:00

134 lines
6.7 KiB
Python

"""After an update, bring the installed systemd units in line with the new templates.
An update moves the checkout, and with it systemd/*.service, but systemd runs
the copies in /etc/systemd/system, which used to be written only by the
installer. Settings added to a template (the render-loop watchdog, a memory
limit) therefore never reached a device that was already installed.
Updates now run the root-owned helper /usr/local/sbin/ledmatrix-refresh-units
(scripts/install/ledmatrix_refresh_units.py) through sudo when the rendered
units differ from the installed ones. The services pick the new units up at
the restart that follows the update. The automatic update's rollback runs the
same helper with ``--restore`` (scripts/utils/auto_update_verify.py).
The sudo rule is written by the installer, so a device installed before it
existed cannot run the helper. That is reported, not fatal: the update
itself stands, and the message says to re-run the installer once, the same
remedy as the display's startup "unit drift" warning.
"""
import importlib.util
import logging
import os
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
from pathlib import Path
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
HELPER_SOURCE = PROJECT_ROOT / 'scripts' / 'install' / 'ledmatrix_refresh_units.py'
#: The installed, root-owned copy that sudo is allowed to run.
HELPER_PATH = '/usr/local/sbin/ledmatrix-refresh-units'
SYSTEMD_DIR = '/etc/systemd/system'
TIMEOUT_SECONDS = 90
#: Outcomes (``result['status']``).
CURRENT = 'current' # nothing differs
REFRESHED = 'refreshed' # installed the new units
NEEDS_REINSTALL = 'needs_reinstall' # the helper or its sudo rule is missing
FAILED = 'failed' # the helper ran and refused or failed
SKIPPED = 'skipped' # not a systemd install (dev machine, emulator)
REINSTALL_HINT = ('Run "sudo ./first_time_install.sh" in the LEDMatrix folder once '
'(or "sudo ./scripts/install/install_service.sh" followed by '
'"./scripts/install/configure_web_sudo.sh") so updates can apply them.')
#: sudo's words for "this command line is not allowed without a password".
_SUDO_REFUSED = ('a password is required', 'is not allowed to run', 'no tty present',
'a terminal is required', 'command not found')
def _load_helper(path=HELPER_SOURCE):
spec = importlib.util.spec_from_file_location('ledmatrix_refresh_units', str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def stale_units(systemd_dir=None, helper_source=None):
"""Installed units whose rendering from the checkout differs. Needs no root.
Returns a sorted list, or None when this is not a systemd install.
Raises the helper's RefreshError when a unit cannot be rendered safely.
"""
systemd_dir = systemd_dir or SYSTEMD_DIR
helper_source = helper_source or HELPER_SOURCE
if not os.path.isfile(os.path.join(systemd_dir, 'ledmatrix.service')):
return None
helper = _load_helper(helper_source)
return sorted(helper.Refresher(systemd_dir=systemd_dir).plan())
def _result(status, message, units=()):
return {'status': status, 'message': message, 'units': list(units)}
def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_path=None):
"""Install the units an update changed. Never raises.
Returns ``{'status', 'message', 'units'}``; ``message`` is '' when there
is nothing to tell the user. The defaults are the module's constants,
read at call time (the test suite points SYSTEMD_DIR away from the host).
"""
run = run or subprocess.run
helper_path = helper_path or HELPER_PATH
try:
stale = stale_units(systemd_dir, helper_source)
except Exception as e: # a broken template must not fail the update itself
if type(e).__name__ != 'UnitsUnreadable':
logger.warning("Could not compare the installed systemd units with the new templates: %s", e)
return _result(FAILED, f'The service settings could not be checked: {e}.')
# Units installed mode 0600 (install_service.sh run on its own, before
# it set 0644): only root can compare them, so let the helper decide.
stale = []
unknown = True
else:
unknown = False
if stale is None:
return _result(SKIPPED, '')
if not stale:
return _result(CURRENT, '')
names = ', '.join(stale) or 'the LEDMatrix units'
changes = (f'This update changes service settings ({names}) that are not applied yet. ' if not unknown
else 'Service settings this update may change could not be checked or applied. ')
if not os.path.isfile(helper_path):
logger.warning("Updates cannot install systemd unit changes (%s): %s is not installed; "
"they take effect only after a reinstall. %s", names, helper_path, REINSTALL_HINT)
return _result(NEEDS_REINSTALL, changes + REINSTALL_HINT, stale)
try:
result = run(['sudo', '-n', helper_path], capture_output=True, text=True,
timeout=TIMEOUT_SECONDS)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("Refreshing the systemd units failed: %s", e)
return _result(FAILED, f'Updating the service settings ({names}) failed: {e}.', stale)
if result.returncode == 0:
# The helper says what it did: "units refreshed: a b" or "units: up to date".
done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines()
if line.startswith('units refreshed:')), None)
if not done:
# Nothing replaced, so nothing for a rollback to restore.
if stale:
logger.warning("The unit helper found nothing to change in %s; the installed "
"helper may be older than this version", names)
return _result(CURRENT, '')
logger.info("Refreshed systemd units after the update: %s", ', '.join(done))
return _result(REFRESHED, f'Service settings updated ({", ".join(done)}).', done)
detail = (result.stderr or result.stdout or '').strip()
if any(phrase in detail.lower() for phrase in _SUDO_REFUSED):
logger.warning("Updates cannot install systemd unit changes (%s): no sudo rule for %s; "
"they take effect only after a reinstall. %s", names, helper_path, REINSTALL_HINT)
return _result(NEEDS_REINSTALL, changes + REINSTALL_HINT, stale)
logger.warning("Refreshing the systemd units failed (exit %s): %s", result.returncode, detail)
return _result(FAILED, f'Updating the service settings ({names}) failed: {detail or "unknown error"}.',
stale)