Files
LEDMatrix/test/test_auto_update_setup.py
T
ChuckandClaude Opus 5 869e36fb2f feat(web): weekly automatic updates with health check and rollback (#581)
* feat(web): weekly automatic updates with health check and rollback

A General-tab toggle (off by default) checks for and installs LEDMatrix and
plugin updates once a week, overnight in the configured timezone.

- Pre-update checks skip (and report) instead of forcing: local edits or
  commits, merge/live rebase, no upstream, low disk, missing health check, or
  a version that was already rolled back. An abandoned rebase (HEAD back on a
  branch) is cleared, since it would otherwise block every pull.
- The pull reuses the Update Code path (now perform_core_update(), which
  reports dependency install failures as data).
- ledmatrix-update-verify.service, started via a .path unit from a request
  file, restarts the services from its own cgroup, requires them to come up
  and stay up, and otherwise resets to the previous commit and reinstalls the
  previous requirements. It runs a copy of the checker taken before the pull.
- No SSH needed: switching the toggle on restarts the display service, which
  (as root) installs the two units from the repo templates for the web user.
  first_time_install.sh installs them too and takes --enable-auto-update /
  LEDMATRIX_AUTO_UPDATE (passed through by one-shot-install.sh).
- Plugins update after the code passes its check; failures, blocks and
  rollbacks raise an Overview banner and show under the toggle.

Tested end to end on a Pi: web-UI setup, a good update, a broken web service
and a broken display (both rolled back), a blocked local edit, and an
abandoned rebase found on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(auto-update): address static-analysis findings

- Replace the subprocess.CompletedProcess the verifier fabricated for a
  command that could not start with a plain namedtuple; nothing is executed
  there, but the scanner flags any CompletedProcess built from variables.
- Mark the subprocess imports with the repo's standard B404 annotation (all
  calls are list-form argv, no shell).
- Mark the rollback-failed message as not SQL (B608 matched its wording).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): CI failures on Linux

- Keep the setup result when chown fails. CI runs as a non-root user, where
  chown to the web user raises; that discarded the result file, so the
  General tab would never learn whether setup worked. Regression test added.
- Register the two new /api/v3/system/auto-update routes in the URL map
  snapshot.
- Use utility classes app.css defines (space-y-1, hover:text-red-600).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): address review feedback

- Health check: a failed restart command no longer lets the check run
  against the still-running old process; it counts as a failure (and after a
  rollback, as a failed rollback). An unreadable restart count is never
  treated as stable, since a crash loop looks healthy between attempts.
- Installer writes the auto_update setting to a temp file and swaps it in,
  keeping mode and owner, so a running config watcher never reads a
  truncated config.json.
- Verify unit quotes its command-line paths (install folders with spaces);
  setup refuses folder names systemd would reinterpret (%, quotes,
  backslashes, control characters) and says so on the General tab.
- The auto-update status route no longer returns exception text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): keep error detail in the status route's 500

test_web_error_detail requires every 5xx handler to log the traceback and
return describe_exception(e), which redacts credentials, so failures are
diagnosable from the web UI. Dropping it for CodeQL broke that policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): dismiss route rejects non-object JSON with 400

A JSON array or scalar body made `.get('alert_id')` raise, returning 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): let the app-wide handler answer status-route errors

CodeQL (py/stack-trace-exposure, #709) flagged the route's own except,
which returned describe_exception(e). web_interface/app.py's error handler
already logs the traceback and returns the same redacted detail for any
unhandled exception, so the local copy is removed: same response, no new
exception-to-response flow, and test_web_error_detail's policy still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:58:57 -04:00

215 lines
9.0 KiB
Python

"""Installing the automatic-update health check from the display service.
src/auto_update_setup.py is how a user who never opens a terminal gets updates
with a safety net: it runs as root inside the display service and writes
systemd units. So it has to install exactly the right thing when asked, do
nothing when not asked, refuse templates that would run as anyone but the web
user, and say why whenever it could not finish.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from src import auto_update_setup as aus # noqa: E402
ON = {'auto_update': {'enabled': True}}
class FakeSystemctl:
def __init__(self, fail=(), activates=True):
self.calls = []
self.fail = set(fail)
self.activates = activates
self.active = False
def __call__(self, args, **kwargs):
assert args[0] == 'systemctl', args
self.calls.append(args[1:])
verb = args[1]
if verb == 'is-active':
return subprocess.CompletedProcess(args, 0 if self.active else 3,
stdout='active\n' if self.active else 'inactive\n', stderr='')
if verb in self.fail:
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'{verb} refused')
if verb in ('restart', 'enable') and self.activates and (verb == 'restart' or '--now' in args):
self.active = True
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
def project(tmp_path, user='hdpi', workdir=None, name='LEDMatrix'):
root = tmp_path / name
(root / 'systemd').mkdir(parents=True)
for name in aus.UNITS:
shutil.copy(ROOT / 'systemd' / name, root / 'systemd' / name)
etc = tmp_path / 'etc'
etc.mkdir()
(etc / aus.WEB_UNIT).write_text(
f'[Service]\nUser={user}\nWorkingDirectory={workdir or root}\n', encoding='utf-8')
return root, etc
def setup(root, etc, systemctl, root_user=True):
return aus.UpdateHelperSetup(project_root=root, systemd_dir=etc, run=systemctl,
is_root=lambda: root_user,
lookup_ids=lambda user: None if user == 'ghost' else (1000, 1000),
clock=lambda: 1234.0)
def result(root):
return json.loads((root / aus.RESULT_REL).read_text())
def test_does_nothing_while_updates_are_off(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, etc, systemctl).ensure({'auto_update': {'enabled': False}}) is None
assert systemctl.calls == []
assert not (etc / aus.SERVICE_UNIT).exists()
assert not (root / aus.RESULT_REL).exists()
def test_installs_both_units_for_the_web_user(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'installed' and result(root)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert 'User=hdpi' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
assert f'PathExists={root}/data/auto_update_verify.request' in (etc / aus.PATH_UNIT).read_text()
assert '__' not in service
assert ['daemon-reload'] in systemctl.calls
assert ['enable', aus.PATH_UNIT] in systemctl.calls
assert systemctl.active
def test_second_start_changes_nothing(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
first = result(root)
systemctl.calls.clear()
setup(root, etc, systemctl).ensure(ON)
assert systemctl.calls == [['is-active', aus.PATH_UNIT], ['is-active', aus.PATH_UNIT]]
assert result(root) == first, "an unchanged setup must not rewrite its result every boot"
def test_a_changed_template_is_reinstalled(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text() + '\n# newer\n')
systemctl.calls.clear()
assert setup(root, etc, systemctl).ensure(ON)['message'] == 'Installed the update health check.'
assert (etc / aus.SERVICE_UNIT).read_text().endswith('# newer\n')
assert ['daemon-reload'] in systemctl.calls
def test_a_template_that_would_not_run_as_the_web_user_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text().replace('User=__USER__', 'User=root'))
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'refusing' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
assert systemctl.calls == []
def test_a_path_unit_that_starts_something_else_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.PATH_UNIT
template.write_text(template.read_text().replace('Unit=ledmatrix-update-verify.service', 'Unit=other.service'))
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'failed' and not (etc / aus.PATH_UNIT).exists()
@pytest.mark.parametrize('case, expected', [
('not_root', 'not running as root'),
('no_web_unit', 'not installed'),
('other_folder', 'runs from'),
('bad_user', 'not a usable account'),
])
def test_reports_why_it_could_not_install(tmp_path, case, expected):
root, etc = project(tmp_path, user='ghost' if case == 'bad_user' else 'hdpi',
workdir=tmp_path / 'elsewhere' if case == 'other_folder' else None)
if case == 'no_web_unit':
(etc / aus.WEB_UNIT).unlink()
out = setup(root, etc, FakeSystemctl(), root_user=case != 'not_root').ensure(ON)
assert out['status'] == 'failed' and expected in out['message']
assert result(root)['message'] == out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
def test_a_path_unit_that_will_not_start_is_a_failure(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(activates=False)).ensure(ON)
assert out['status'] == 'failed' and 'did not start' in out['message']
def test_systemctl_errors_are_reported(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(fail={'daemon-reload'})).ensure(ON)
assert out['status'] == 'failed' and 'daemon-reload refused' in out['message']
def test_not_a_systemd_host_is_left_alone(tmp_path):
root, _ = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, tmp_path / 'no-etc', systemctl).ensure(ON) is None
assert systemctl.calls == []
def test_a_folder_with_spaces_is_quoted_in_the_commands(tmp_path):
root, etc = project(tmp_path, name='LED Matrix')
assert setup(root, etc, FakeSystemctl()).ensure(ON)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert f'ExecStartPre=/bin/rm -f "{root}/data/auto_update_verify.request"' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
def test_a_folder_name_systemd_would_reinterpret_is_refused(tmp_path):
root, etc = project(tmp_path, name='LED%Matrix')
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'systemd cannot use' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists() and systemctl.calls == []
def test_installer_sets_the_toggle_without_a_half_written_config(tmp_path):
"""first_time_install.sh may run while the display service watches
config.json; the file must be replaced whole, never truncated in place."""
import re
text = (ROOT / 'first_time_install.sh').read_text(encoding='utf-8').replace('\r\n', '\n')
script = next(s for s in re.findall(r"<<'PY'\n(.*?)\nPY\n", text, re.S) if 'auto_update' in s)
assert 'os.replace(' in script
config = tmp_path / 'config.json'
config.write_text(json.dumps({'timezone': 'UTC', 'auto_update': {'enabled': False}}))
run = subprocess.run([sys.executable, '-', str(config), '1'], input=script, text=True,
capture_output=True)
assert run.returncode == 0, run.stderr
assert json.loads(config.read_text()) == {'timezone': 'UTC', 'auto_update': {'enabled': True}}
assert [p.name for p in tmp_path.iterdir()] == ['config.json'], "a temp file was left behind"
def test_the_result_is_kept_when_it_cannot_be_given_to_the_web_user(tmp_path, monkeypatch):
"""Caught by CI, which runs as a non-root Linux user: chown needs root, and
a failed chown used to discard the result, so the General tab never
learned whether setup worked."""
def refuse(*args):
raise PermissionError('Operation not permitted')
monkeypatch.setattr(aus.os, 'chown', refuse, raising=False)
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'installed'
assert result(root) == out