mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
This commit is contained in:
@@ -603,6 +603,9 @@ class AutoUpdater:
|
||||
'release': info.get('release') if new_head == info.get('upstream_head') else None,
|
||||
'display_was_active': display_was_active,
|
||||
'dependency_failures': list(core.get('dependency_failures') or []),
|
||||
# The update installed new systemd units: a rollback puts the
|
||||
# previous ones back (ledmatrix-refresh-units --restore).
|
||||
'units_refreshed': (core.get('unit_refresh') or {}).get('status') == 'refreshed',
|
||||
'created_at': self.clock(),
|
||||
}
|
||||
|
||||
|
||||
@@ -415,6 +415,7 @@ def _perform_core_update_locked(stash_local_changes=True):
|
||||
# date" is a success too, and prompting for a restart then would
|
||||
# train users to ignore the prompt.
|
||||
code_changed = False
|
||||
unit_result = None
|
||||
# Requirement files whose install failed. The automatic updater refuses
|
||||
# to restart onto code whose dependencies did not install.
|
||||
dependency_failures = []
|
||||
@@ -537,6 +538,14 @@ def _perform_core_update_locked(stash_local_changes=True):
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
# The new code may come with new systemd unit settings (systemd/*).
|
||||
# Install them now, so the restart that follows runs under them; the
|
||||
# automatic update's rollback puts the old ones back.
|
||||
if code_changed:
|
||||
from web_interface import unit_refresh
|
||||
unit_result = unit_refresh.refresh_after_update()
|
||||
if unit_result['message']:
|
||||
pull_message += " " + unit_result['message']
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
# Show git's own first line: "check logs" leaves the user with
|
||||
@@ -556,6 +565,8 @@ def _perform_core_update_locked(stash_local_changes=True):
|
||||
'restart_required': bool(result.returncode == 0 and code_changed),
|
||||
'dependency_failures': dependency_failures,
|
||||
'channel': channel.channel,
|
||||
# web_interface/unit_refresh.py's result, or None when no code changed.
|
||||
'unit_refresh': unit_result,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user