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>
This commit is contained in:
Chuck
2026-09-14 18:41:37 -04:00
co-authored by Claude Opus 5
parent 388d1e7de3
commit abddcc85d8
7 changed files with 123 additions and 23 deletions
+35 -3
View File
@@ -43,8 +43,8 @@ class FakeSystemctl:
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
def project(tmp_path, user='hdpi', workdir=None):
root = tmp_path / 'LEDMatrix'
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)
@@ -83,7 +83,7 @@ def test_installs_both_units_for_the_web_user(tmp_path):
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'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
@@ -169,6 +169,38 @@ def test_not_a_systemd_host_is_left_alone(tmp_path):
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