2ded37e190
install.sh ran 'pip install .', which builds the project: pip downloads
setuptools into an isolated build environment. With no internet it fails at
the 'Building the virtual environment' step with
Could not find a version that satisfies the requirement setuptools>=68
(from versions: none)
even though the runtime libraries are already in the wheel directory. The
build backend is only needed to package, never to run.
- Install the runtime deps straight from the bundled wheels with --no-index,
and put src/ on the path via a .pth file, so no build step happens at all.
- Generate the console script with the venv's interpreter as an absolute
shebang. '#\!/usr/bin/env python3' resolves to the *system* python when the
venv is not activated, so the package was not importable from the unit.
- apt-get update/install and 'pip install --upgrade pip' are now best-effort:
all three need the network and none should abort the install.
- Detect a failed 'python3 -m venv' and name python3-venv as the usual cause.
- Sanity-check the install with --version before enabling the service.
- The unit falls back to 'python -m rdm_card_reader' if the console script is
ever absent (no shell in ExecStart, so the installer picks the path).
Reproduced the failure offline, then verified the fix offline as well: a venv
created on a copy of the tree self-provisions from the bundled wheels and runs
from cwd=/ with no PYTHONPATH and no venv activation.
Added tests/test_deploy.py (20 tests) covering the installer and the deploy
artifacts, including a regression test for 'pip install .' that was confirmed
to fail when the bug is reintroduced.
200 lines
6.3 KiB
Python
200 lines
6.3 KiB
Python
"""Tests for the deploy artifacts.
|
|
|
|
These are the files that only ever run on the target device, so a mistake in
|
|
them is found late and painfully. The checks here are the ones that would have
|
|
caught real bugs: the install must not depend on the network, and the service
|
|
must not reference something the installer does not create.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import stat
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT = os.path.dirname(HERE)
|
|
DEPLOY = os.path.join(PROJECT, "deploy")
|
|
|
|
INSTALL_SH = os.path.join(DEPLOY, "install.sh")
|
|
UNIT = os.path.join(DEPLOY, "rdm-card-reader.service")
|
|
RULES = os.path.join(DEPLOY, "99-rdm-reader.rules")
|
|
|
|
|
|
def read(path):
|
|
with open(path, "r") as handle:
|
|
return handle.read()
|
|
|
|
|
|
# -- install.sh -------------------------------------------------------------
|
|
|
|
def test_installer_exists_and_is_executable():
|
|
assert os.path.exists(INSTALL_SH)
|
|
mode = os.stat(INSTALL_SH).st_mode
|
|
assert mode & stat.S_IXUSR, "install.sh should be executable"
|
|
|
|
|
|
def test_installer_does_not_pip_install_the_project():
|
|
"""`pip install .` needs setuptools from the network, so it breaks offline.
|
|
|
|
This is the bug that made installation fail on an air-gapped Pi with
|
|
"Could not find a version that satisfies the requirement setuptools>=68".
|
|
"""
|
|
text = read(INSTALL_SH)
|
|
code = "\n".join(
|
|
line for line in text.splitlines() if not line.strip().startswith("#")
|
|
)
|
|
assert not re.search(r'pip"?\s+install[^\n]*"\$INSTALL_DIR"', code), (
|
|
"install.sh must not 'pip install' the project directory: it requires "
|
|
"downloading setuptools and fails with no network"
|
|
)
|
|
assert not re.search(r"pip\s+install\s+\.\s*$", code, re.M)
|
|
|
|
|
|
def test_installer_installs_runtime_deps_from_bundled_wheels():
|
|
text = read(INSTALL_SH)
|
|
assert "--no-index" in text
|
|
assert "--find-links" in text
|
|
assert "wheels" in text
|
|
|
|
|
|
def test_installer_adds_src_to_path():
|
|
"""Without this the package is not importable at all."""
|
|
text = read(INSTALL_SH)
|
|
assert ".pth" in text
|
|
|
|
|
|
def test_installer_network_steps_are_not_fatal():
|
|
"""A device with no internet must still get a working install."""
|
|
text = read(INSTALL_SH)
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("apt-get"):
|
|
assert "|| " in line or line.rstrip().endswith("\\"), (
|
|
"network-dependent step should not abort the install: " + stripped
|
|
)
|
|
# The pip self-upgrade is best-effort too. The fallback may sit on the
|
|
# following line (line continuation), so join continuations first.
|
|
joined = text.replace("\\\n", " ")
|
|
assert re.search(r"upgrade pip[^\n]*\|\|", joined), (
|
|
"'pip install --upgrade pip' needs the network, so it must not abort "
|
|
"the install; add '|| ...' to it"
|
|
)
|
|
|
|
|
|
def test_installer_copies_the_wheels_with_the_source():
|
|
"""The bundled wheels live under src/, so src/ must be copied."""
|
|
text = read(INSTALL_SH)
|
|
match = re.search(r"for item in ([^\n;]+);", text)
|
|
assert match, "could not find the copy list in install.sh"
|
|
assert "src" in match.group(1)
|
|
|
|
|
|
def test_installer_checks_venv_creation_can_fail_clearly():
|
|
text = read(INSTALL_SH)
|
|
assert "python3-venv" in text, "should mention the usual cause of venv failure"
|
|
|
|
|
|
def test_installer_verifies_the_install_before_enabling_service():
|
|
text = read(INSTALL_SH)
|
|
assert "--version" in text, "installer should sanity-check the install"
|
|
|
|
|
|
def test_installer_copies_launch_doc():
|
|
text = read(INSTALL_SH)
|
|
assert "LAUNCH.md" in text
|
|
|
|
|
|
# -- service unit -----------------------------------------------------------
|
|
|
|
def test_unit_exists():
|
|
assert os.path.exists(UNIT)
|
|
|
|
|
|
def test_unit_restarts_on_failure():
|
|
text = read(UNIT)
|
|
assert "Restart=always" in text
|
|
assert "RestartSec=" in text
|
|
|
|
|
|
def test_unit_runs_as_a_non_root_user_in_dialout():
|
|
text = read(UNIT)
|
|
assert re.search(r"^User=", text, re.M)
|
|
assert re.search(r"^Group=dialout", text, re.M)
|
|
|
|
|
|
def _sections(text):
|
|
"""Split an INI-style unit into {section: body}.
|
|
|
|
Splitting on the literal string "[Service]" is wrong: the file mentions it
|
|
in a comment above the real section header.
|
|
"""
|
|
sections = {}
|
|
current = None
|
|
for line in text.splitlines():
|
|
header = re.match(r"^\[([^\]]+)\]\s*$", line)
|
|
if header:
|
|
current = header.group(1)
|
|
sections[current] = []
|
|
elif current is not None:
|
|
sections[current].append(line)
|
|
return {name: "\n".join(body) for name, body in sections.items()}
|
|
|
|
|
|
def test_unit_start_limits_are_in_the_unit_section():
|
|
"""systemd silently ignores these in [Service]."""
|
|
sections = _sections(read(UNIT))
|
|
assert "Unit" in sections and "Service" in sections
|
|
for key in ("StartLimitIntervalSec", "StartLimitBurst"):
|
|
assert key not in sections["Service"], (
|
|
"{} belongs in [Unit]; systemd ignores it in [Service]".format(key)
|
|
)
|
|
assert "StartLimitBurst" in sections["Unit"]
|
|
|
|
|
|
def test_unit_has_the_expected_sections():
|
|
sections = _sections(read(UNIT))
|
|
assert set(sections) >= {"Unit", "Service", "Install"}
|
|
|
|
|
|
def test_unit_uses_loopback_by_default():
|
|
text = read(UNIT)
|
|
# No --host flag means the CLI default (127.0.0.1) applies.
|
|
assert "--host" not in text
|
|
|
|
|
|
def test_unit_serves_in_login_mode():
|
|
text = read(UNIT)
|
|
assert "--serve" in text
|
|
assert "--mode login" in text
|
|
|
|
|
|
def test_unit_points_at_the_stable_device_alias():
|
|
text = read(UNIT)
|
|
assert "/dev/rdm-reader" in text
|
|
|
|
|
|
# -- udev rule --------------------------------------------------------------
|
|
|
|
def test_udev_rule_creates_the_alias_the_unit_expects():
|
|
text = read(RULES)
|
|
assert 'SYMLINK+="rdm-reader"' in text
|
|
unit = read(UNIT)
|
|
assert "/dev/rdm-reader" in unit, "unit and rule must agree on the alias name"
|
|
|
|
|
|
def test_udev_rule_sets_group_and_mode():
|
|
text = read(RULES)
|
|
assert 'GROUP="dialout"' in text
|
|
assert 'MODE="0660"' in text
|
|
|
|
|
|
def test_udev_rule_only_has_one_active_match():
|
|
"""Multiple active rules would fight over the same alias."""
|
|
active = [
|
|
line
|
|
for line in read(RULES).splitlines()
|
|
if line.strip().startswith('SUBSYSTEM=="tty"')
|
|
]
|
|
assert len(active) == 1, "exactly one rule should be active, found {}".format(
|
|
len(active)
|
|
)
|