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:
Chuck
2026-10-03 13:09:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 515248b34e
commit f841fa36b6
24 changed files with 1974 additions and 29 deletions
+15
View File
@@ -328,6 +328,21 @@ def _hermetic_control_socket(monkeypatch):
monkeypatch.setenv(SOCKET_PATH_ENV, 'off')
@pytest.fixture(autouse=True)
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
"""Keep updates' systemd unit refresh off the host.
perform_core_update runs web_interface/unit_refresh.py after any update
that moves HEAD, and several tests run the real one against a test clone.
On a device -- or a machine where install_service.sh was tried out -- it
would compare the clone's templates with the real /etc/systemd/system and
run the real sudo helper. Point it at a folder that does not exist: no units
installed, nothing to do. The unit refresh tests pass their own.
"""
from web_interface import unit_refresh
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
@pytest.fixture(autouse=True)
def reset_logging():
"""Reset logging configuration before each test."""
+26
View File
@@ -517,6 +517,32 @@ class TestUpdateIsVerified:
h.updater.run()
assert h.pending['dependency_failures'] == ['requirements.txt']
@pytest.mark.parametrize('unit_refresh, expected', [
({'status': 'refreshed', 'message': '', 'units': ['ledmatrix.service']}, True),
({'status': 'needs_reinstall', 'message': '', 'units': ['ledmatrix.service']}, False),
(None, False),
])
def test_the_health_check_learns_whether_the_update_installed_units(self, tmp_path, unit_refresh,
expected):
"""Its rollback restores the previous units only when this update replaced them."""
repo = Repo(tmp_path)
repo.publish()
h = Harness(tmp_path, repo, core_update=real_pull(repo.device, unit_refresh=unit_refresh))
h.updater.run()
assert h.pending['units_refreshed'] is expected
def test_a_health_check_that_never_starts_also_restores_the_units(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
refreshed = {'status': 'refreshed', 'message': '', 'units': ['ledmatrix.service']}
h = Harness(tmp_path, repo, pickup=False,
core_update=real_pull(repo.device, unit_refresh=refreshed))
h.updater.run()
assert repo.head() == old
assert [a for a in h.sudo if a[-1] == '--restore'] == [
['sudo', '-n', '/usr/local/sbin/ledmatrix-refresh-units', '--restore']]
def test_a_health_check_that_never_starts_means_the_update_is_undone(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
+48
View File
@@ -80,6 +80,8 @@ class FakeHost:
self.nrestarts = 0
self.heartbeat = heartbeat
self.display_started_at = -1000.0 # the pre-update display, long running
self.unit_restores = [] # (argv, commit checked out, restarts so far)
self.restore_ok = True
def broken(self, kind):
if self.running_head is None:
@@ -103,6 +105,10 @@ class FakeHost:
if self.pip:
return self.pip(args, self)
return done(args, rc=0 if self.pip_ok else 1)
if args[:3] == ['sudo', '-n', av.REFRESH_UNITS_PATH]:
self.unit_restores.append((list(args), git(self.repo, 'rev-parse', 'HEAD'),
len(self.restarts)))
return done(args, rc=0 if self.restore_ok else 1)
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
if self.restart_failures:
self.restart_failures -= 1
@@ -405,3 +411,45 @@ def test_the_heartbeat_location_and_freshness_match_the_display():
# window it has to stay healthy for.
assert av.HEARTBEAT_FRESH_SECONDS + av.POLL_SECONDS < av.STABLE_SECONDS
assert av.HEARTBEAT_FRESH_SECONDS > display_watchdog.BEAT_INTERVAL_SECONDS * 2
# -- systemd units the update installed ------------------------------------------
def test_a_rollback_restores_the_units_the_update_installed(tmp_path):
"""The update installed new units (web_interface/unit_refresh.py); the
rollback puts the old ones back before restarting onto the old code."""
code, result, host, head, old, new = check(tmp_path, 'display_down', units_refreshed=True)
assert result['status'] == 'rolled_back' and head == old
assert len(host.unit_restores) == 1
argv, commit, restarts_before = host.unit_restores[0]
assert argv == ['sudo', '-n', av.REFRESH_UNITS_PATH, '--restore']
assert commit == old, 'restored after the code was rolled back'
assert restarts_before == 2, 'restored before the services restart onto the old code'
assert host.restarts[-2:] == [('ledmatrix.service', old), ('ledmatrix-web.service', old)]
assert result['detail'] is None
@pytest.mark.parametrize('pending', [{}, {'units_refreshed': False}])
def test_a_rollback_leaves_units_alone_when_the_update_did_not_change_them(tmp_path, pending):
# {} is what an updater from before this change writes.
code, result, host, head, old, new = check(tmp_path, 'display_down', **pending)
assert result['status'] == 'rolled_back' and host.unit_restores == []
def test_a_healthy_update_keeps_its_new_units(tmp_path):
code, result, host, head, old, new = check(tmp_path, units_refreshed=True)
assert result['status'] == 'success' and host.unit_restores == []
def test_a_failed_unit_restore_is_reported_but_the_rollback_stands(tmp_path):
repo, old, new = updated_repo(tmp_path)
av.write_pending(av.pending_path(repo), {'status': 'pending', 'old_head': old, 'new_head': new,
'display_was_active': True, 'dependency_failures': [],
'units_refreshed': True})
host = FakeHost(repo, new, 'display_down')
host.restore_ok = False
host.verifier().verify()
result = av.read_pending(av.pending_path(repo))
assert result['status'] == 'rolled_back'
assert git(repo, 'rev-parse', 'HEAD') == old
assert 'install_service.sh' in result['detail']
+370
View File
@@ -0,0 +1,370 @@
"""New installs run the newest release; re-running the installer never moves backwards.
#684 made devices update along a channel -- stable follows the newest vX.Y.Z
tag, beta follows main -- but a new install still cloned main's tip, so it ran
unreleased code until the next release caught up with it. The one-shot
installer (scripts/install/one-shot-install.sh, which is where the clone
happens) now checks out the newest release after cloning, unless
LEDMATRIX_CHANNEL=beta. Re-running it on an existing checkout moves a stable
device forward to the newest release only when that release contains its
commit, as update_channel.checkout_release() does, and leaves beta devices
(and stable ones newer than every release) on the fast-forward pull they
always had. first_time_install.sh writes an explicitly chosen channel
(--beta / LEDMATRIX_CHANNEL) into config.json.
These run the installer's own bash, under its strict mode, against real git
repositories.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
ONE_SHOT = ROOT / "scripts" / "install" / "one-shot-install.sh"
INSTALLER = ROOT / "first_time_install.sh"
BEGIN = "# --- release checkout helpers"
END = "# --- end release checkout helpers"
from web_interface import update_channel # noqa: E402
pytestmark = pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="runs the installer's bash under Linux"
)
def helper_block() -> str:
text = ONE_SHOT.read_text(encoding="utf-8")
assert text.count(BEGIN) == 1 and text.count(END) == 1, "helper block markers missing or duplicated"
return text[text.index(BEGIN): text.index(END)]
def git(*args, cwd, env):
result = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True)
assert result.returncode == 0, f"git {' '.join(args)} failed: {result.stderr}"
return result.stdout.strip()
@pytest.fixture
def git_env(tmp_path):
config = tmp_path / "gitconfig"
config.write_text(
"[user]\n\tname = t\n\temail = t@t\n"
"[protocol \"file\"]\n\tallow = always\n"
"[init]\n\tdefaultBranch = main\n"
"[advice]\n\tdetachedHead = false\n",
encoding="utf-8",
)
return {
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": str(tmp_path),
"GIT_CONFIG_GLOBAL": str(config),
"GIT_CONFIG_NOSYSTEM": "1",
}
#: Tags and the commit (index into the history) each points at. The newest
#: release is v3.10.0: 10 > 8 numerically, the rc and the zero-padded tag are
#: not releases, and v3.12 and nightly are not vX.Y.Z at all.
TAGS = {
"v3.7.0": 0, "v3.8.0": 1, "v3.10.0": 2,
"v3.11.0-rc1": 3, "v03.12.0": 3, "v3.12": 3, "nightly": 3,
}
NEWEST = "v3.10.0"
@pytest.fixture
def origin(tmp_path, git_env):
"""A stand-in for GitHub: five commits on main (the last newer than any release)."""
seed = tmp_path / "seed"
seed.mkdir()
git("init", "-q", ".", cwd=seed, env=git_env)
commits = []
for i in range(5):
(seed / "version.txt").write_text(str(i), encoding="utf-8")
git("add", ".", cwd=seed, env=git_env)
git("commit", "-qm", f"c{i}", cwd=seed, env=git_env)
commits.append(git("rev-parse", "HEAD", cwd=seed, env=git_env))
for tag, index in TAGS.items():
git("tag", tag, commits[index], cwd=seed, env=git_env)
bare = tmp_path / "origin.git"
git("clone", "-q", "--bare", str(seed), str(bare), cwd=tmp_path, env=git_env)
return bare, commits, seed
def run_block(snippet, cwd, env, channel=None):
env = dict(env)
if channel is not None:
env["LEDMATRIX_CHANNEL"] = channel
script = (
"set -Eeuo pipefail\n"
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
'print_success() { echo "OK: $*"; }\n'
'print_warning() { echo "W: $*"; }\n'
f"{helper_block()}\n"
f"{snippet}\n"
)
result = subprocess.run(["bash", "-c", script], cwd=cwd, capture_output=True, text=True, env=env)
assert "ERR_TRAP_FIRED" not in result.stderr, result.stdout + result.stderr
return result
def clone(origin, tmp_path, env, name="LEDMatrix"):
bare, _, _ = origin
target = tmp_path / name
git("clone", "-q", str(bare), str(target), cwd=tmp_path, env=env)
return target
def head(repo, env):
return git("rev-parse", "HEAD", cwd=repo, env=env)
def branch(repo, env):
result = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], cwd=repo, env=env,
capture_output=True, text=True)
return result.stdout.strip()
def set_channel(repo, channel):
(repo / "config").mkdir(exist_ok=True)
(repo / "config" / "config.json").write_text(
json.dumps({"auto_update": {"enabled": False, "channel": channel}}), encoding="utf-8")
# -- a fresh install -------------------------------------------------------------
def test_a_fresh_clone_checks_out_the_newest_release(origin, tmp_path, git_env):
_, commits, _ = origin
repo = clone(origin, tmp_path, git_env)
out = run_block("_lm_checkout_release_after_clone", repo, git_env)
assert out.returncode == 0
assert head(repo, git_env) == commits[TAGS[NEWEST]]
assert branch(repo, git_env) == "", "a release is checked out detached, as Update Code does"
assert f"Installing release {NEWEST}" in out.stdout
@pytest.mark.parametrize("channel", ["beta", "BETA", " beta "])
def test_a_fresh_beta_install_stays_on_main(origin, tmp_path, git_env, channel):
_, commits, _ = origin
repo = clone(origin, tmp_path, git_env)
run_block("_lm_checkout_release_after_clone", repo, git_env, channel=channel)
assert head(repo, git_env) == commits[-1] and branch(repo, git_env) == "main"
def test_an_unknown_channel_falls_back_to_stable_and_says_so(origin, tmp_path, git_env):
_, commits, _ = origin
repo = clone(origin, tmp_path, git_env)
out = run_block("_lm_checkout_release_after_clone", repo, git_env, channel="nightly")
assert head(repo, git_env) == commits[TAGS[NEWEST]]
assert "not stable or beta" in out.stdout + out.stderr
def test_a_repository_without_releases_installs_main(tmp_path, git_env):
seed = tmp_path / "seed"
seed.mkdir()
git("init", "-q", ".", cwd=seed, env=git_env)
(seed / "f").write_text("x", encoding="utf-8")
git("add", ".", cwd=seed, env=git_env)
git("commit", "-qm", "only", cwd=seed, env=git_env)
git("tag", "v3.0", cwd=seed, env=git_env) # not a release tag
repo = tmp_path / "LEDMatrix"
git("clone", "-q", str(seed), str(repo), cwd=tmp_path, env=git_env)
out = run_block("_lm_checkout_release_after_clone", repo, git_env)
assert branch(repo, git_env) == "main" and "No release found" in out.stdout
# -- re-running on an existing checkout -----------------------------------------------
def existing(origin, tmp_path, env, at, detached):
"""An installed checkout, at commit index ``at``, on main or detached."""
_, commits, _ = origin
repo = clone(origin, tmp_path, env)
if detached:
git("checkout", "-q", "--detach", commits[at], cwd=repo, env=env)
else:
git("reset", "-q", "--hard", commits[at], cwd=repo, env=env)
return repo
def update(repo, env, channel=None):
out = run_block("if _lm_update_existing_checkout; then echo RESULT=handled; "
"else echo RESULT=pull; fi", repo, env, channel=channel)
return re.search(r"RESULT=(\w+)", out.stdout).group(1), out
def test_a_device_on_an_older_release_moves_to_the_newest(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.7.0"], detached=True)
result, out = update(repo, git_env)
assert result == "handled"
assert head(repo, git_env) == commits[TAGS[NEWEST]]
assert f"Updated to release {NEWEST}" in out.stdout
def test_a_device_already_on_the_newest_release_stays(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS[NEWEST], detached=True)
result, out = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[TAGS[NEWEST]]
assert "Already on the newest release" in out.stdout
def test_a_device_on_main_behind_the_newest_release_moves_to_it(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.8.0"], detached=False)
result, _ = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[TAGS[NEWEST]]
def test_a_device_on_main_newer_than_every_release_is_not_moved_back(origin, tmp_path, git_env):
"""It keeps the fast-forward pull it always had, and waits for a release to contain it."""
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=4, detached=False)
result, _ = update(repo, git_env)
assert result == "pull"
assert head(repo, git_env) == commits[4] and branch(repo, git_env) == "main"
def test_a_detached_device_newer_than_every_release_is_left_alone(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=3, detached=True)
result, out = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[3]
assert "newer than the newest release" in out.stdout
def test_a_higher_version_on_an_older_commit_is_not_a_downgrade(origin, tmp_path, git_env):
"""Newest by version is not newest by history: never move to a tag that does not contain HEAD."""
bare, commits, seed = origin
git("tag", "v9.0.0", commits[0], cwd=seed, env=git_env)
git("push", "-q", str(bare), "v9.0.0", cwd=seed, env=git_env)
repo = existing(origin, tmp_path, git_env, at=TAGS[NEWEST], detached=True)
result, _ = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[TAGS[NEWEST]]
def test_a_new_release_published_since_the_clone_is_fetched(origin, tmp_path, git_env):
bare, commits, seed = origin
repo = existing(origin, tmp_path, git_env, at=TAGS[NEWEST], detached=True)
git("tag", "v3.11.0", commits[4], cwd=seed, env=git_env)
git("push", "-q", str(bare), "v3.11.0", cwd=seed, env=git_env)
result, _ = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[4]
def test_a_beta_device_keeps_its_pull(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.8.0"], detached=False)
set_channel(repo, "beta")
result, _ = update(repo, git_env)
assert result == "pull" and head(repo, git_env) == commits[TAGS["v3.8.0"]]
def test_the_environment_overrides_the_configured_channel(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.8.0"], detached=False)
set_channel(repo, "stable")
result, _ = update(repo, git_env, channel="beta")
assert result == "pull" and head(repo, git_env) == commits[TAGS["v3.8.0"]]
def test_local_edits_that_block_the_move_keep_the_checkout(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.7.0"], detached=True)
(repo / "version.txt").write_text("my edit", encoding="utf-8")
result, out = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[TAGS["v3.7.0"]]
assert (repo / "version.txt").read_text(encoding="utf-8") == "my edit"
assert "Could not move to release" in out.stdout
def test_an_unreachable_origin_keeps_the_checkout(origin, tmp_path, git_env):
_, commits, _ = origin
repo = existing(origin, tmp_path, git_env, at=TAGS["v3.7.0"], detached=True)
git("remote", "set-url", "origin", str(tmp_path / "gone.git"), cwd=repo, env=git_env)
result, out = update(repo, git_env)
assert result == "handled" and head(repo, git_env) == commits[TAGS["v3.7.0"]]
assert "Could not fetch" in out.stdout
# -- same rules as the web interface -------------------------------------------------
NAMES = ["v1.2.3", "v1.10.0", "v1.9.9", "v2.0.0-rc1", "v02.0.0", "v2.0", "v10.0.0", "v9.99.99",
"release-11", "v10.0.0+build", "v0.0.0", "v10.0.1", "v1.2.03", "V11.0.0"]
@pytest.mark.parametrize("subset", [NAMES, NAMES[:4], ["v2.0", "nightly"], NAMES[::-1][:6]])
def test_the_newest_tag_matches_update_channel(tmp_path, git_env, subset):
repo = tmp_path / "tags"
repo.mkdir()
git("init", "-q", ".", cwd=repo, env=git_env)
git("commit", "-q", "--allow-empty", "-m", "x", cwd=repo, env=git_env)
for name in subset:
git("tag", name, cwd=repo, env=git_env)
out = run_block("_lm_newest_release_tag", repo, git_env).stdout.strip()
assert out == (update_channel.newest_release_tag(subset) or "")
# -- wiring --------------------------------------------------------------------------
def test_every_clone_is_followed_by_the_release_checkout():
text = ONE_SHOT.read_text(encoding="utf-8")
clones = [m.start() for m in re.finditer(r'retry git clone "\$REPO_URL" "\$REPO_DIR"\n', text)]
assert clones
for pos in clones:
following = text[pos:].splitlines()[1]
assert '_lm_checkout_release_after_clone' in following, following
def test_the_existing_checkout_is_handled_before_the_old_pull():
text = ONE_SHOT.read_text(encoding="utf-8")
assert re.search(r'if _lm_update_existing_checkout; then\n\s+PULL_SUCCESS=true\n'
r'\s+elif git pull --ff-only origin "\$CURRENT_BRANCH"', text)
def test_the_one_shot_passes_the_channel_to_the_installer():
text = ONE_SHOT.read_text(encoding="utf-8")
assert 'LEDMATRIX_CHANNEL="${LEDMATRIX_CHANNEL:-}"' in text
# -- first_time_install.sh records the chosen channel ----------------------------------
CHANNEL_BEGIN = 'case "$UPDATE_CHANNEL" in'
CHANNEL_END = 'set it from the General tab instead"\n fi\nfi\n'
def channel_block():
text = INSTALLER.read_text(encoding="utf-8")
start = text.index(CHANNEL_BEGIN)
return text[start: text.index(CHANNEL_END, start) + len(CHANNEL_END)]
@pytest.mark.parametrize("auto_update, channel, expected", [
("", "beta", {"enabled": False, "channel": "beta"}),
("", "stable", {"enabled": False, "channel": "stable"}),
("1", "", {"enabled": True, "channel": "stable"}),
("", "", {"enabled": False, "channel": "stable"}), # nothing asked: untouched
("", "nightly", {"enabled": False, "channel": "stable"}), # nonsense: untouched
])
def test_the_installer_writes_only_an_explicit_channel(tmp_path, auto_update, channel, expected):
(tmp_path / "config").mkdir()
config = tmp_path / "config" / "config.json"
config.write_text(json.dumps({"auto_update": {"enabled": False, "channel": "stable"}, "x": 1}))
script = (f'set -Eeuo pipefail\nPROJECT_ROOT_DIR="{tmp_path}"\nAUTO_UPDATE="{auto_update}"\n'
f'UPDATE_CHANNEL="{channel}"\n{channel_block()}')
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
data = json.loads(config.read_text())
assert data["auto_update"] == expected and data["x"] == 1
def test_the_installer_accepts_beta_as_a_flag_and_from_the_environment():
text = INSTALLER.read_text(encoding="utf-8")
assert re.search(r"^\s*--beta\) UPDATE_CHANNEL=beta ;;", text, re.M)
assert 'UPDATE_CHANNEL=$(printf \'%s\' "${LEDMATRIX_CHANNEL:-}"' in text
assert "LEDMATRIX_CHANNEL=stable|beta" in text, "documented in --help"
+525
View File
@@ -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 == []
+34
View File
@@ -82,3 +82,37 @@ class TestRestartIsRequestedWhenCodeChanged:
data = _pull(client)
assert data['status'] == 'error'
assert data['restart_required'] is False
class TestUnitsAreRefreshedWithTheCode:
"""New code may bring new systemd unit settings; installing them is part of the update."""
@pytest.fixture
def refresh(self, monkeypatch):
from web_interface import unit_refresh
calls = []
def fake():
calls.append(True)
return {'status': 'refreshed', 'message': 'Service settings updated (ledmatrix.service).',
'units': ['ledmatrix.service']}
monkeypatch.setattr(unit_refresh, 'refresh_after_update', fake)
return calls
def test_an_update_that_moved_head_refreshes_the_units(self, client, refresh):
with patch.object(mod.subprocess, 'run', _git(['aaa111', 'bbb222'])):
data = _pull(client)
assert refresh == [True]
assert data['unit_refresh']['status'] == 'refreshed'
assert 'Service settings updated' in data['message']
def test_nothing_new_touches_no_units(self, client, refresh):
with patch.object(mod.subprocess, 'run',
_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
data = _pull(client)
assert refresh == [] and data['unit_refresh'] is None
def test_a_failed_pull_touches_no_units(self, client, refresh):
with patch.object(mod.subprocess, 'run', _git(['aaa111'], pull_rc=1)):
data = _pull(client)
assert refresh == [] and data['unit_refresh'] is None
+6 -1
View File
@@ -54,6 +54,10 @@ EXPECTED_GRANTS = frozenset({
("NOPASSWD:", "$SYSTEMCTL_PATH restart ledmatrix-web.service"),
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *"),
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *"),
# The unit refresh helper (scripts/install/ledmatrix_refresh_units.py),
# with no arguments (`""`; RULE below drops the closing quote) or --restore.
("NOPASSWD:", '$LEDMATRIX_REFRESH_UNITS_PATH "'),
("NOPASSWD:", "$LEDMATRIX_REFRESH_UNITS_PATH --restore"),
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix.service *"),
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix *"),
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -t ledmatrix *"),
@@ -145,7 +149,8 @@ def test_installer_call_renders_the_expected_rules(installer, tmp_path):
rendered.add((m.group(2), m.group(3)))
subst = {"$SYSTEMCTL_PATH": "/x/systemctl", "$BASH_PATH": "/x/bash",
"$REBOOT_PATH": "/x/reboot", "$POWEROFF_PATH": "/x/poweroff",
"$JOURNALCTL_PATH": "/x/journalctl", "$PROJECT_ROOT": "/srv/led root"}
"$JOURNALCTL_PATH": "/x/journalctl", "$PROJECT_ROOT": "/srv/led root",
"$LEDMATRIX_REFRESH_UNITS_PATH": "/usr/local/sbin/ledmatrix-refresh-units"}
expected = set()
for tags, command in EXPECTED_GRANTS:
for var, value in subst.items():