mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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:
@@ -0,0 +1,525 @@
|
||||
"""Updates refresh the installed systemd units: the root helper and its callers.
|
||||
|
||||
An update moved the checkout, and with it systemd/*.service, but systemd runs
|
||||
the copies in /etc/systemd/system, which only the installer wrote. So unit
|
||||
settings added after a device was installed (#687's render-loop watchdog)
|
||||
never reached it. scripts/install/ledmatrix_refresh_units.py, installed
|
||||
root-owned as /usr/local/sbin/ledmatrix-refresh-units and granted to the web
|
||||
user by exact command line, now installs changed units after an update, and
|
||||
puts the previous ones back when the automatic update rolls back.
|
||||
|
||||
The helper runs as root on input the web user can edit (the templates), so
|
||||
most of these are about what it refuses.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
'ledmatrix_refresh_units', ROOT / 'scripts' / 'install' / 'ledmatrix_refresh_units.py')
|
||||
ru = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ru)
|
||||
|
||||
from web_interface import unit_refresh # noqa: E402
|
||||
|
||||
try:
|
||||
import pwd
|
||||
# The web side checks the account exists, so use one that does.
|
||||
WEB_USER = pwd.getpwuid(os.getuid()).pw_name
|
||||
except ImportError: # Windows
|
||||
WEB_USER = 'ledpi'
|
||||
|
||||
# An account a hostile template switches the web unit to. Root, unless the
|
||||
# tests themselves run as root: then root *is* the web user and switching to
|
||||
# it changes nothing, so use another account.
|
||||
OTHER_USER = 'root' if WEB_USER != 'root' else 'nobody'
|
||||
|
||||
|
||||
class Host:
|
||||
"""A project checkout, an /etc/systemd/system, and a fake systemctl."""
|
||||
|
||||
def __init__(self, tmp_path, web_user=WEB_USER):
|
||||
self.project = tmp_path / 'LEDMatrix'
|
||||
shutil.copytree(ROOT / 'systemd', self.project / 'systemd')
|
||||
self.systemd = tmp_path / 'etc-systemd-system'
|
||||
self.systemd.mkdir()
|
||||
self.backup = tmp_path / 'var-lib-ledmatrix' / 'unit-backup'
|
||||
self.web_user = web_user
|
||||
self.calls = []
|
||||
self.path_active = True
|
||||
self.root = True
|
||||
for name in ru.UNITS:
|
||||
self.install(name)
|
||||
|
||||
def template(self, name):
|
||||
return (self.project / 'systemd' / name).read_text(encoding='utf-8')
|
||||
|
||||
def set_template(self, name, text):
|
||||
(self.project / 'systemd' / name).write_text(text, encoding='utf-8', newline='\n')
|
||||
|
||||
def rendered(self, name, text=None):
|
||||
user = 'root' if name == ru.DISPLAY_UNIT else self.web_user
|
||||
return ru.render(text if text is not None else self.template(name), str(self.project), user)
|
||||
|
||||
def install(self, name, text=None):
|
||||
(self.systemd / name).write_text(self.rendered(name, text), encoding='utf-8', newline='\n')
|
||||
|
||||
def installed(self, name):
|
||||
path = self.systemd / name
|
||||
return path.read_text(encoding='utf-8') if path.exists() else None
|
||||
|
||||
def run(self, args, **kwargs):
|
||||
self.calls.append(list(args))
|
||||
if args[:2] == ['systemctl', 'is-active']:
|
||||
out = 'active\n' if self.path_active else 'inactive\n'
|
||||
return subprocess.CompletedProcess(args, 0, stdout=out, stderr='')
|
||||
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
||||
|
||||
def refresher(self, log=None):
|
||||
return ru.Refresher(systemd_dir=str(self.systemd), backup_dir=str(self.backup), run=self.run,
|
||||
is_root=lambda: self.root, user_exists=lambda user: True,
|
||||
log=log or (lambda m: None))
|
||||
|
||||
@property
|
||||
def reloads(self):
|
||||
return self.calls.count(['systemctl', 'daemon-reload'])
|
||||
|
||||
|
||||
def watchdog_added(text):
|
||||
"""The kind of change #687 made: a new directive in [Service]."""
|
||||
return text.replace('[Service]\n', '[Service]\nWatchdogSec=60\n', 1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(tmp_path):
|
||||
return Host(tmp_path)
|
||||
|
||||
|
||||
# -- refresh ------------------------------------------------------------------
|
||||
|
||||
def test_units_that_match_are_left_alone(host):
|
||||
assert host.refresher().refresh() == []
|
||||
assert host.reloads == 0
|
||||
|
||||
|
||||
def test_a_changed_template_is_installed_and_systemd_reloaded(host):
|
||||
old = host.installed(ru.DISPLAY_UNIT)
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
|
||||
assert host.refresher().refresh() == [ru.DISPLAY_UNIT]
|
||||
assert 'WatchdogSec=60' in host.installed(ru.DISPLAY_UNIT)
|
||||
assert host.installed(ru.DISPLAY_UNIT) == host.rendered(ru.DISPLAY_UNIT)
|
||||
assert host.reloads == 1
|
||||
# Only the unit that changed is replaced, and the one it replaced is kept.
|
||||
assert (host.backup / ru.DISPLAY_UNIT).read_text(encoding='utf-8') == old
|
||||
assert json.loads((host.backup / ru.MANIFEST).read_text()) == {'units': [ru.DISPLAY_UNIT]}
|
||||
|
||||
|
||||
def test_the_web_unit_keeps_the_web_users_account(host):
|
||||
host.set_template(ru.WEB_UNIT, watchdog_added(host.template(ru.WEB_UNIT)))
|
||||
host.refresher().refresh()
|
||||
assert ru.directive_values(host.installed(ru.WEB_UNIT), 'User') == [WEB_USER]
|
||||
assert ru.directive_values(host.installed(ru.DISPLAY_UNIT), 'User') == ['root']
|
||||
|
||||
|
||||
def test_comment_only_changes_are_not_a_refresh(host):
|
||||
host.set_template(ru.DISPLAY_UNIT, '# a new comment\n\n' + host.template(ru.DISPLAY_UNIT))
|
||||
assert host.refresher().refresh() == []
|
||||
assert host.reloads == 0
|
||||
|
||||
|
||||
def test_a_unit_that_was_never_installed_is_not_installed(host):
|
||||
(host.systemd / ru.VERIFY_SERVICE).unlink()
|
||||
(host.systemd / ru.VERIFY_PATH).unlink()
|
||||
host.set_template(ru.VERIFY_SERVICE, watchdog_added(host.template(ru.VERIFY_SERVICE)))
|
||||
host.refresher().refresh()
|
||||
assert host.installed(ru.VERIFY_SERVICE) is None
|
||||
|
||||
|
||||
def test_a_changed_path_unit_is_restarted_so_it_watches_the_new_path(host):
|
||||
host.set_template(ru.VERIFY_PATH, host.template(ru.VERIFY_PATH).replace(
|
||||
'[Path]\n', '[Path]\nMakeDirectory=yes\n'))
|
||||
host.refresher().refresh()
|
||||
assert ['systemctl', 'restart', ru.VERIFY_PATH] in host.calls
|
||||
|
||||
|
||||
def test_it_must_run_as_root(host):
|
||||
host.root = False
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
with pytest.raises(ru.RefreshError, match='root'):
|
||||
host.refresher().refresh()
|
||||
assert 'WatchdogSec=60' not in host.installed(ru.DISPLAY_UNIT)
|
||||
|
||||
|
||||
def _failing_reload(host):
|
||||
real = host.run
|
||||
def run(args, **kwargs):
|
||||
if args == ['systemctl', 'daemon-reload'] and host.reloads == 0:
|
||||
host.calls.append(list(args))
|
||||
return subprocess.CompletedProcess(args, 1, stdout='', stderr='boom')
|
||||
return real(args, **kwargs)
|
||||
return run
|
||||
|
||||
|
||||
def test_a_failed_daemon_reload_puts_the_old_units_back(host):
|
||||
# The web side reports this as a failure and records no units_refreshed,
|
||||
# so a rollback would not --restore: the helper must undo it itself.
|
||||
old = host.installed(ru.DISPLAY_UNIT)
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
host.run = _failing_reload(host)
|
||||
|
||||
with pytest.raises(ru.RefreshError):
|
||||
host.refresher().refresh()
|
||||
assert host.installed(ru.DISPLAY_UNIT) == old
|
||||
assert host.reloads == 2 # the failed one, then one after putting it back
|
||||
assert not (host.backup / ru.MANIFEST).exists()
|
||||
|
||||
|
||||
def test_a_failed_write_puts_back_the_units_already_written(host, monkeypatch):
|
||||
old = {name: host.installed(name) for name in (ru.DISPLAY_UNIT, ru.WEB_UNIT)}
|
||||
for name in old:
|
||||
host.set_template(name, watchdog_added(host.template(name)))
|
||||
refresher = host.refresher()
|
||||
real_write = refresher._write_unit
|
||||
writes = []
|
||||
|
||||
def write(name, text):
|
||||
writes.append(name)
|
||||
if len(writes) == 2:
|
||||
raise OSError('disk full')
|
||||
real_write(name, text)
|
||||
monkeypatch.setattr(refresher, '_write_unit', write)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
refresher.refresh()
|
||||
first = writes[0]
|
||||
assert host.installed(first) == old[first]
|
||||
assert all(host.installed(name) == old[name] for name in old)
|
||||
|
||||
# -- what it refuses ------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('unit, edit', [
|
||||
# The web interface's unit switched to root by a template edit.
|
||||
(ru.WEB_UNIT, lambda t: t.replace('User=__USER__', f'User={OTHER_USER}')),
|
||||
# The display's unit switched to another account.
|
||||
(ru.DISPLAY_UNIT, lambda t: t.replace('User=root', 'User=nobody')),
|
||||
# A second User= line.
|
||||
(ru.VERIFY_SERVICE, lambda t: t.replace('[Service]\n', '[Service]\nUser=root\n', 1)),
|
||||
# Run from somewhere else.
|
||||
(ru.DISPLAY_UNIT, lambda t: t.replace('WorkingDirectory=__PROJECT_ROOT_DIR__', 'WorkingDirectory=/tmp')),
|
||||
# A second User= written with spaces, which systemd accepts (last one wins).
|
||||
# Placed in [Service] (before [Install]), where it is not a layout problem.
|
||||
(ru.WEB_UNIT, lambda t: t.replace('\n[Install]', 'User = root\n\n[Install]')),
|
||||
# The web user's User= moved to [Unit], where systemd ignores it (so root).
|
||||
(ru.WEB_UNIT, lambda t: t.replace('User=__USER__\n', '').replace('[Unit]\n', '[Unit]\nUser=__USER__\n')),
|
||||
# The User= line hidden inside a continued line, where systemd does not see it.
|
||||
(ru.WEB_UNIT, lambda t: t.replace('User=__USER__\n', '').replace(
|
||||
'Description=LED Matrix Web Interface Service\n',
|
||||
'Description=LED Matrix Web Interface Service \\\\\nUser=__USER__\n')),
|
||||
# A path unit that starts something else.
|
||||
(ru.VERIFY_PATH, lambda t: t.replace('Unit=ledmatrix-update-verify.service', 'Unit=ledmatrix.service')),
|
||||
])
|
||||
def test_a_template_that_changes_who_or_where_is_refused_and_nothing_changes(host, unit, edit):
|
||||
before = {name: host.installed(name) for name in ru.UNITS}
|
||||
# A legitimate change alongside, which must not go in either.
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
host.set_template(unit, edit(host.template(unit)))
|
||||
with pytest.raises(ru.RefreshError):
|
||||
host.refresher().refresh()
|
||||
assert {name: host.installed(name) for name in ru.UNITS} == before
|
||||
assert host.reloads == 0
|
||||
|
||||
|
||||
def test_the_project_folder_comes_from_the_installed_unit_not_the_caller(host, tmp_path):
|
||||
# The installed display unit names the project; a WorkingDirectory that
|
||||
# is not an existing absolute folder is refused before any template is read.
|
||||
host.install(ru.DISPLAY_UNIT, host.template(ru.DISPLAY_UNIT).replace(
|
||||
'WorkingDirectory=__PROJECT_ROOT_DIR__', 'WorkingDirectory=relative/path'))
|
||||
with pytest.raises(ru.RefreshError, match='cannot be used'):
|
||||
host.refresher().plan()
|
||||
|
||||
|
||||
def test_without_the_display_unit_installed_nothing_is_done(host):
|
||||
(host.systemd / ru.DISPLAY_UNIT).unlink()
|
||||
with pytest.raises(ru.RefreshError, match='not installed'):
|
||||
host.refresher().plan()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, 'O_NOFOLLOW'), reason='POSIX only')
|
||||
def test_a_template_symlink_is_not_followed(host, tmp_path):
|
||||
secret = tmp_path / 'secret'
|
||||
secret.write_text(host.template(ru.DISPLAY_UNIT) + 'Environment=SECRET=1\n', encoding='utf-8')
|
||||
target = host.project / 'systemd' / ru.DISPLAY_UNIT
|
||||
target.unlink()
|
||||
target.symlink_to(secret)
|
||||
with pytest.raises(ru.RefreshError):
|
||||
host.refresher().plan()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, 'O_NOFOLLOW'), reason='POSIX only')
|
||||
def test_a_symlinked_systemd_folder_is_not_followed(host, tmp_path):
|
||||
elsewhere = tmp_path / 'elsewhere'
|
||||
shutil.move(str(host.project / 'systemd'), str(elsewhere))
|
||||
(host.project / 'systemd').symlink_to(elsewhere, target_is_directory=True)
|
||||
with pytest.raises(ru.RefreshError):
|
||||
host.refresher().plan()
|
||||
|
||||
|
||||
def test_an_oversized_template_is_refused(host):
|
||||
host.set_template(ru.DISPLAY_UNIT, host.template(ru.DISPLAY_UNIT) + '#' * (ru.MAX_TEMPLATE_BYTES + 1))
|
||||
with pytest.raises(ru.RefreshError, match='larger'):
|
||||
host.refresher().plan()
|
||||
|
||||
|
||||
def test_main_leaves_the_callers_environment_alone(host):
|
||||
"""main() runs in-process in these tests; pinning PATH belongs to the installed program."""
|
||||
before = os.environ.get('PATH')
|
||||
ru.main(['ledmatrix-refresh-units', '--check'], refresher=host.refresher())
|
||||
assert os.environ.get('PATH') == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('argv', [
|
||||
['--restore', 'x'], ['--refresh'], ['/etc/passwd'], ['--check', '--restore'], ['']])
|
||||
def test_any_other_command_line_is_refused(argv):
|
||||
class Boom:
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError('must not run')
|
||||
assert ru.main(['ledmatrix-refresh-units', *argv], refresher=Boom()) == ru.EXIT_USAGE
|
||||
|
||||
|
||||
def test_main_reports_a_refusal_as_a_failure(host, capsys):
|
||||
host.set_template(ru.WEB_UNIT, host.template(ru.WEB_UNIT).replace('User=__USER__', f'User={OTHER_USER}'))
|
||||
assert ru.main(['ledmatrix-refresh-units'], refresher=host.refresher()) == ru.EXIT_FAILED
|
||||
assert 'refusing' in capsys.readouterr().err
|
||||
|
||||
|
||||
# -- restore ------------------------------------------------------------------
|
||||
|
||||
def test_restore_puts_back_exactly_what_the_refresh_replaced(host):
|
||||
# A hand-edited installed unit: the rollback must give back this file,
|
||||
# not a rendering of the old template.
|
||||
hand_edited = host.installed(ru.DISPLAY_UNIT) + '# edited by hand\n'
|
||||
(host.systemd / ru.DISPLAY_UNIT).write_text(hand_edited, encoding='utf-8', newline='\n')
|
||||
untouched = host.installed(ru.WEB_UNIT)
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
host.refresher().refresh()
|
||||
|
||||
assert host.refresher().restore() == [ru.DISPLAY_UNIT]
|
||||
assert host.installed(ru.DISPLAY_UNIT) == hand_edited
|
||||
assert host.installed(ru.WEB_UNIT) == untouched
|
||||
assert host.reloads == 2
|
||||
assert not (host.backup / ru.MANIFEST).exists(), 'a second restore must not repeat it'
|
||||
assert host.refresher().restore() == []
|
||||
|
||||
|
||||
def test_restore_after_an_update_that_changed_no_units_restores_nothing(host):
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
host.refresher().refresh() # an earlier update...
|
||||
newer = host.installed(ru.DISPLAY_UNIT)
|
||||
host.refresher().refresh() # ...then one that changed no units
|
||||
assert host.refresher().restore() == []
|
||||
assert host.installed(ru.DISPLAY_UNIT) == newer
|
||||
|
||||
|
||||
def test_restore_must_run_as_root(host):
|
||||
host.root = False
|
||||
with pytest.raises(ru.RefreshError, match='root'):
|
||||
host.refresher().restore()
|
||||
|
||||
|
||||
# -- the real templates and install_service.sh ----------------------------------
|
||||
|
||||
def test_every_shipped_template_passes_the_helpers_checks(tmp_path):
|
||||
host = Host(tmp_path)
|
||||
for name in ru.UNITS:
|
||||
host.set_template(name, watchdog_added(host.template(name)) if name.endswith('.service')
|
||||
else host.template(name))
|
||||
assert host.refresher().refresh() == sorted(n for n in ru.UNITS if n.endswith('.service'))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not sys.platform.startswith('linux'), reason='runs sed as install_service.sh does')
|
||||
def test_rendering_matches_install_service_sh(tmp_path):
|
||||
"""Same text as the installer's sed, including characters sed treats specially."""
|
||||
lib = ROOT / 'scripts' / 'install' / 'lib_systemd_render.sh'
|
||||
for project in ('/home/pi/LEDMatrix', '/opt/led matrix&co'):
|
||||
for name in ru.UNITS:
|
||||
user = 'root' if name == ru.DISPLAY_UNIT else 'pi'
|
||||
script = (f'source "{lib}"; R=$(sed_escape_replacement "$1"); U=$(sed_escape_replacement "$2"); '
|
||||
f'sed "s|__PROJECT_ROOT_DIR__|$R|g; s|__USER__|$U|g" "$3"')
|
||||
out = subprocess.run(['bash', '-c', script, 'x', project, user, str(ROOT / 'systemd' / name)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
template = (ROOT / 'systemd' / name).read_text(encoding='utf-8')
|
||||
assert ru.render(template, project, user) == out, name
|
||||
|
||||
|
||||
def test_install_service_installs_the_helper_root_owned_at_the_granted_path():
|
||||
text = (ROOT / 'scripts' / 'install' / 'install_service.sh').read_text(encoding='utf-8')
|
||||
assert 'scripts/install/ledmatrix_refresh_units.py' in text
|
||||
m = re.search(r'install -D -o root -g root -m 0755 "\$REFRESH_UNITS_SRC" "\$REFRESH_UNITS_DEST"', text)
|
||||
assert m, 'install_service.sh must install the helper root:root 0755'
|
||||
assert f'REFRESH_UNITS_DEST={ru.INSTALLED_PATH}' in text
|
||||
|
||||
|
||||
def test_every_caller_names_the_same_helper_path():
|
||||
lib = (ROOT / 'scripts' / 'install' / 'lib_sudoers.sh').read_text(encoding='utf-8')
|
||||
verifier = (ROOT / 'scripts' / 'utils' / 'auto_update_verify.py').read_text(encoding='utf-8')
|
||||
assert f'LEDMATRIX_REFRESH_UNITS_PATH={ru.INSTALLED_PATH}' in lib
|
||||
assert unit_refresh.HELPER_PATH == ru.INSTALLED_PATH
|
||||
assert f"REFRESH_UNITS_PATH = '{ru.INSTALLED_PATH}'" in verifier
|
||||
assert ru.INSTALLED_PATH.startswith('/usr/local/sbin/'), 'must live outside the user-owned checkout'
|
||||
|
||||
|
||||
def test_the_helper_imports_nothing_from_the_checkout():
|
||||
source = (ROOT / 'scripts' / 'install' / 'ledmatrix_refresh_units.py').read_text(encoding='utf-8')
|
||||
imports = re.findall(r'^\s*(?:from|import)\s+([\w.]+)', source, re.M)
|
||||
assert not [m for m in imports if m.split('.')[0] in ('src', 'web_interface', 'scripts')]
|
||||
assert source.startswith('#!/usr/bin/python3 -I\n'), 'isolated mode: no PYTHON* env, no user site'
|
||||
|
||||
|
||||
# -- the web interface's side (web_interface/unit_refresh.py) ---------------------
|
||||
|
||||
class Sudo:
|
||||
"""sudo: refuses (``rc``/``stderr``), or runs the real helper as root against ``host``."""
|
||||
|
||||
def __init__(self, host=None, rc=0, stderr=''):
|
||||
self.host, self.rc, self.stderr, self.calls = host, rc, stderr, []
|
||||
self.as_root = False
|
||||
|
||||
def __call__(self, args, **kwargs):
|
||||
self.calls.append(list(args))
|
||||
if self.rc or self.host is None:
|
||||
return subprocess.CompletedProcess(args, self.rc, stdout='', stderr=self.stderr)
|
||||
lines = []
|
||||
self.as_root = True
|
||||
try:
|
||||
rc = ru.main(['ledmatrix-refresh-units', *args[3:]], refresher=self.host.refresher(lines.append))
|
||||
finally:
|
||||
self.as_root = False
|
||||
return subprocess.CompletedProcess(args, rc, stdout='\n'.join(lines) + '\n', stderr='')
|
||||
|
||||
|
||||
def _web(host, tmp_path, sudo, helper_installed=True):
|
||||
helper = tmp_path / 'usr-local-sbin' / 'ledmatrix-refresh-units'
|
||||
if helper_installed:
|
||||
helper.parent.mkdir(exist_ok=True)
|
||||
helper.write_text('#!/bin/true\n')
|
||||
return unit_refresh.refresh_after_update(run=sudo, systemd_dir=str(host.systemd),
|
||||
helper_path=str(helper))
|
||||
|
||||
|
||||
def _stale(host):
|
||||
# The web side compares the installed units with the checkout's templates.
|
||||
host.set_template(ru.DISPLAY_UNIT, watchdog_added(host.template(ru.DISPLAY_UNIT)))
|
||||
|
||||
|
||||
def test_web_side_does_nothing_when_the_units_match(host, tmp_path):
|
||||
sudo = Sudo(host)
|
||||
result = _web(host, tmp_path, sudo)
|
||||
assert result['status'] == unit_refresh.CURRENT and sudo.calls == []
|
||||
assert result['message'] == ''
|
||||
|
||||
|
||||
def test_web_side_runs_the_helper_through_sudo_with_no_arguments(host, tmp_path):
|
||||
_stale(host)
|
||||
sudo = Sudo(host)
|
||||
result = _web(host, tmp_path, sudo)
|
||||
assert result['status'] == unit_refresh.REFRESHED
|
||||
assert result['units'] == [ru.DISPLAY_UNIT]
|
||||
assert len(sudo.calls) == 1 and sudo.calls[0][:2] == ['sudo', '-n'] and len(sudo.calls[0]) == 3
|
||||
assert 'ledmatrix.service' in result['message']
|
||||
assert 'WatchdogSec=60' in host.installed(ru.DISPLAY_UNIT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unreadable(monkeypatch, host):
|
||||
"""Installed units only root can read (install_service.sh used to leave them 0600)."""
|
||||
real = ru._read_installed
|
||||
sudo = Sudo(host)
|
||||
|
||||
def read(systemd_dir, name):
|
||||
if not sudo.as_root:
|
||||
raise ru.UnitsUnreadable(f'cannot read the installed {name}: Permission denied')
|
||||
return real(systemd_dir, name)
|
||||
monkeypatch.setattr(ru, '_read_installed', read)
|
||||
monkeypatch.setattr(unit_refresh, '_load_helper', lambda path=None: ru)
|
||||
return sudo
|
||||
|
||||
|
||||
def test_web_side_lets_the_helper_decide_when_it_cannot_read_the_units(host, tmp_path, unreadable):
|
||||
_stale(host)
|
||||
result = _web(host, tmp_path, unreadable)
|
||||
assert len(unreadable.calls) == 1
|
||||
assert result['status'] == unit_refresh.REFRESHED and result['units'] == [ru.DISPLAY_UNIT]
|
||||
|
||||
|
||||
def test_web_side_unreadable_and_already_current_is_current(host, tmp_path, unreadable):
|
||||
result = _web(host, tmp_path, unreadable)
|
||||
assert result['status'] == unit_refresh.CURRENT and result['message'] == ''
|
||||
|
||||
|
||||
def test_web_side_unreadable_without_the_rule_asks_for_a_reinstall(host, tmp_path, unreadable, caplog):
|
||||
unreadable.rc, unreadable.stderr = 1, 'sudo: a password is required'
|
||||
result = _web(host, tmp_path, unreadable)
|
||||
assert result['status'] == unit_refresh.NEEDS_REINSTALL
|
||||
assert 'could not be checked' in result['message']
|
||||
|
||||
|
||||
def test_web_side_trusts_the_helper_about_what_changed(host, tmp_path):
|
||||
"""An older installed helper that renders differently changed nothing: nothing to roll back."""
|
||||
_stale(host)
|
||||
|
||||
def older_helper(args, **kwargs):
|
||||
return subprocess.CompletedProcess(args, 0, stdout='units: up to date\n', stderr='')
|
||||
result = _web(host, tmp_path, older_helper)
|
||||
assert result['status'] == unit_refresh.CURRENT
|
||||
|
||||
|
||||
def test_web_side_without_the_helper_asks_for_a_reinstall(host, tmp_path, caplog):
|
||||
_stale(host)
|
||||
sudo = Sudo()
|
||||
result = _web(host, tmp_path, sudo, helper_installed=False)
|
||||
assert result['status'] == unit_refresh.NEEDS_REINSTALL and sudo.calls == []
|
||||
assert 'first_time_install.sh' in result['message']
|
||||
assert 'reinstall' in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stderr', [
|
||||
'sudo: a password is required',
|
||||
'Sorry, user ledpi is not allowed to run \'/usr/local/sbin/ledmatrix-refresh-units\' as root on ledpi.',
|
||||
])
|
||||
def test_web_side_without_the_sudo_rule_asks_for_a_reinstall(host, tmp_path, stderr, caplog):
|
||||
_stale(host)
|
||||
result = _web(host, tmp_path, Sudo(rc=1, stderr=stderr))
|
||||
assert result['status'] == unit_refresh.NEEDS_REINSTALL
|
||||
assert 'configure_web_sudo.sh' in result['message']
|
||||
assert 'no sudo rule' in caplog.text
|
||||
|
||||
|
||||
def test_web_side_reports_a_helper_refusal_as_a_failure(host, tmp_path):
|
||||
_stale(host)
|
||||
result = _web(host, tmp_path, Sudo(rc=1, stderr='ledmatrix-refresh-units: systemd/x refusing'))
|
||||
assert result['status'] == unit_refresh.FAILED
|
||||
assert 'refusing' in result['message']
|
||||
|
||||
|
||||
def test_web_side_on_a_machine_without_the_units_does_nothing(tmp_path):
|
||||
sudo = Sudo()
|
||||
result = unit_refresh.refresh_after_update(run=sudo, systemd_dir=str(tmp_path))
|
||||
assert result['status'] == unit_refresh.SKIPPED and sudo.calls == []
|
||||
|
||||
|
||||
def test_web_side_never_raises_on_a_broken_template(host, tmp_path):
|
||||
host.set_template(ru.WEB_UNIT, host.template(ru.WEB_UNIT).replace('User=__USER__', f'User={OTHER_USER}'))
|
||||
sudo = Sudo()
|
||||
result = _web(host, tmp_path, sudo)
|
||||
assert result['status'] == unit_refresh.FAILED and sudo.calls == []
|
||||
Reference in New Issue
Block a user