mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(install): render the systemd units from their templates, not from heredocs (#547)
* fix(install): render the systemd units from their templates, not from heredocs The installers carried their own inline copies of units that also exist as templates under systemd/, and the copies drifted. install_service.sh renders ledmatrix.service from the template correctly, then wrote ledmatrix-web.service from a heredoc that predated it -- missing Wants=network-online.target, RestartSec=10, SyslogIdentifier, CacheDirectory, CacheDirectoryMode and Environment=USE_THREADING=1. install_web_service.sh had a third copy, and install_wifi_monitor.sh a fourth, that one already differing from its template (syslog where the template says journal). startup_validator.py compares the installed unit against the template, so a rig installed this way warned on every boot -- and the remedy the warning names, "re-run scripts/install/install_service.sh", reinstalled the same stale copy. The warning could never clear. Reproduced on a live rig running exactly that unit. All three installers now render systemd/*.service through the same placeholder substitution. The template gains a __USER__ placeholder rather than hardcoding User=root, because the web interface runs as whoever installed it. That last point was a second, independent cause of a permanent warning: the validator substituted a fixed "root", so any non-root install reported drift forever. It now reads User= from the installed unit -- an install-time decision, not something the template dictates -- and compares everything else strictly. first_time_install.sh already reads the installed User= the same way. Tests cover a non-root web unit not warning, a genuinely changed directive in that unit still warning, the User= fallback, and a grep-based guard that no installer under scripts/install/ contains an inline unit body. That guard is what found the install_wifi_monitor.sh copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(install): escape sed replacements, use mktemp, and make render failures fatal Address CodeRabbit findings on install_service.sh, install_web_service.sh and install_wifi_monitor.sh: - Values interpolated into each script's sed expression (project root path, username) were not escaped, so a value containing &, \ or the | delimiter would corrupt the rendered systemd unit. Add a shared sed_escape_replacement() helper in the new scripts/install/lib_systemd_render.sh (sourced by all three scripts) and apply it to every sed replacement. - install_service.sh rendered the main and web units to the predictable path /tmp/ledmatrix.service.tmp before installing them -- a symlink/TOCTOU race (CWE-377). Use mktemp for both, with a trap to clean up on exit. - install_service.sh treated a missing template as a mere warning and then checked only whether a unit already existed at the destination before enabling/starting it, so a render failure could silently fall back to enabling a stale, previously-installed unit. Both unit blocks now exit non-zero on a missing template or a failed render. Also rename the ambiguous loop variable `l` to `line` in test/test_systemd_unit_drift.py (Ruff E741); ruff isn't wired into any CI workflow in this repo today, so this isn't currently CI-blocking, but the rename is trivial and correct regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 * test(install): cover sed_escape_replacement against sed-special characters CodeRabbit asked for regression coverage using a project path containing an ampersand; the earlier commits on this branch already fixed the escaping, mktemp usage, and enable/start-on-fatal-render-failure findings, and the l->line rename was already applied -- this closes the one remaining gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,20 +16,39 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Create a temporary service file for the main display with the correct paths
|
# Render the main display unit from its template. The display service runs as
|
||||||
# Assuming ledmatrix.service template exists and uses /home/ledpi as a placeholder for user home
|
# root (it needs GPIO), so __USER__ is always root here -- unlike the web unit
|
||||||
|
# below, which runs as whoever installed it.
|
||||||
|
#
|
||||||
|
# A missing template or a failed render is fatal: falling through would leave
|
||||||
|
# whatever unit already sits at /etc/systemd/system/ledmatrix.service (from a
|
||||||
|
# previous install) untouched, and the enable/start step below would then
|
||||||
|
# silently reuse that stale unit instead of the one this run was asked to
|
||||||
|
# install.
|
||||||
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
||||||
sed "s|/home/ledpi|$USER_HOME|g; s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > /tmp/ledmatrix.service.tmp
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
MAIN_UNIT_TMP=$(mktemp)
|
||||||
|
trap 'rm -f "$MAIN_UNIT_TMP"' EXIT
|
||||||
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > "$MAIN_UNIT_TMP"; then
|
||||||
|
echo "ERROR: failed to render ledmatrix.service from its template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Copy the service file to the systemd directory
|
# Copy the service file to the systemd directory
|
||||||
sudo cp /tmp/ledmatrix.service.tmp /etc/systemd/system/ledmatrix.service
|
sudo cp "$MAIN_UNIT_TMP" /etc/systemd/system/ledmatrix.service
|
||||||
# Clean up
|
# Clean up
|
||||||
rm /tmp/ledmatrix.service.tmp
|
rm -f "$MAIN_UNIT_TMP"
|
||||||
|
trap - EXIT
|
||||||
else
|
else
|
||||||
echo "WARNING: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service. Main display service not configured."
|
echo "ERROR: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -48,42 +67,49 @@ fi
|
|||||||
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
||||||
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
||||||
|
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# Rendered from systemd/ledmatrix-web.service, the same template
|
||||||
[Unit]
|
# install_web_service.sh uses. This was an inline heredoc until it drifted from
|
||||||
Description=LED Matrix Web Interface (Conditional Start)
|
# the template: it had lost Wants=network-online.target, RestartSec,
|
||||||
After=network.target
|
# SyslogIdentifier, CacheDirectory and Environment=USE_THREADING. Because
|
||||||
# Wants=ledmatrix.service
|
# src/startup_validator.py compares the installed unit against the template,
|
||||||
# After=network.target ledmatrix.service
|
# every boot warned "re-run install_service.sh" -- and doing so reinstalled the
|
||||||
|
# same stale copy, so the warning could never clear.
|
||||||
[Service]
|
#
|
||||||
Type=simple
|
# As with the main unit above, a missing template or a failed render is
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
# fatal -- otherwise the enable/start check below would fall back to
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
# whatever unit (possibly stale) already exists at the destination path.
|
||||||
StandardOutput=journal
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||||
StandardError=journal
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
User=${ACTUAL_USER}
|
WEB_UNIT_TMP=$(mktemp)
|
||||||
Restart=on-failure
|
trap 'rm -f "$WEB_UNIT_TMP"' EXIT
|
||||||
# Environment="PYTHONUNBUFFERED=1"
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" > "$WEB_UNIT_TMP"; then
|
||||||
[Install]
|
echo "ERROR: failed to render ledmatrix-web.service from its template." >&2
|
||||||
WantedBy=multi-user.target
|
exit 1
|
||||||
EOF
|
fi
|
||||||
)
|
sudo cp "$WEB_UNIT_TMP" /etc/systemd/system/ledmatrix-web.service
|
||||||
|
rm -f "$WEB_UNIT_TMP"
|
||||||
# Write the new service file
|
trap - EXIT
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-web.service > /dev/null
|
else
|
||||||
|
echo "ERROR: ledmatrix-web.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix-web.service." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Reloading systemd daemon for web service..."
|
echo "Reloading systemd daemon for web service..."
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||||
sudo systemctl enable ledmatrix-web.service
|
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||||
|
sudo systemctl enable ledmatrix-web.service
|
||||||
|
|
||||||
echo "Starting ledmatrix-web.service..."
|
echo "Starting ledmatrix-web.service..."
|
||||||
sudo systemctl start ledmatrix-web.service
|
sudo systemctl start ledmatrix-web.service
|
||||||
|
|
||||||
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
||||||
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
||||||
|
else
|
||||||
|
echo "Skipping enable/start for ledmatrix-web.service as it was not configured."
|
||||||
|
fi
|
||||||
# === End of LEDMatrix Web Interface service ===
|
# === End of LEDMatrix Web Interface service ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ fi
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing for user: $ACTUAL_USER"
|
echo "Installing for user: $ACTUAL_USER"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
@@ -26,37 +29,22 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate the service file dynamically with the correct paths
|
# Render the unit from systemd/ledmatrix-web.service. That template is the
|
||||||
echo "Generating service file with dynamic paths..."
|
# only description of the unit; this script used to carry its own heredoc copy,
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# and install_service.sh a third, which is how the installed unit on real rigs
|
||||||
[Unit]
|
# ended up missing RestartSec, SyslogIdentifier and CacheDirectory while
|
||||||
Description=LED Matrix Web Interface Service
|
# src/startup_validator.py warned about drift on every boot.
|
||||||
After=network-online.target
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"
|
||||||
Wants=network-online.target
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${ACTUAL_USER}
|
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
|
||||||
Environment=USE_THREADING=1
|
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-web
|
|
||||||
# Automatically create and manage cache directory
|
|
||||||
CacheDirectory=ledmatrix
|
|
||||||
CacheDirectoryMode=0775
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the service file to systemd directory
|
|
||||||
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" > /etc/systemd/system/ledmatrix-web.service
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
|
||||||
|
|
||||||
# Ensure cache directory exists with proper permissions
|
# Ensure cache directory exists with proper permissions
|
||||||
# This is a fallback for older systemd versions that don't support CacheDirectory
|
# This is a fallback for older systemd versions that don't support CacheDirectory
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
@@ -64,30 +67,19 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
|||||||
echo "✓ Package installation completed"
|
echo "✓ Package installation completed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create service file with correct paths
|
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||||
|
# inlining a second copy here. The copy this replaced had already drifted --
|
||||||
|
# it wrote StandardOutput/StandardError=syslog where the template says journal.
|
||||||
echo ""
|
echo ""
|
||||||
echo "Creating systemd service file..."
|
echo "Creating systemd service file..."
|
||||||
SERVICE_FILE_CONTENT=$(cat <<EOF
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-wifi-monitor.service"
|
||||||
[Unit]
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
Description=LED Matrix WiFi Monitor Daemon
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
After=network.target
|
exit 1
|
||||||
Wants=network.target
|
fi
|
||||||
|
|
||||||
[Service]
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
Type=simple
|
SERVICE_FILE_CONTENT=$(sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$TEMPLATE")
|
||||||
User=root
|
|
||||||
WorkingDirectory=$PROJECT_ROOT_DIR
|
|
||||||
ExecStart=/usr/bin/python3 $PROJECT_ROOT_DIR/scripts/utils/wifi_monitor_daemon.py --interval 30
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Shared helper for rendering systemd unit templates via sed.
|
||||||
|
#
|
||||||
|
# Sourced by install_service.sh, install_web_service.sh and
|
||||||
|
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
||||||
|
# way instead of carrying three copies of the same fix.
|
||||||
|
|
||||||
|
# sed_escape_replacement VALUE
|
||||||
|
#
|
||||||
|
# Print VALUE escaped for safe use as the replacement side of `sed
|
||||||
|
# s|pattern|replacement|`. Every one of these scripts builds its sed
|
||||||
|
# expression by interpolating a shell variable (a path, a username, ...)
|
||||||
|
# straight into the replacement text. sed gives three characters special
|
||||||
|
# meaning there: backslash (escape character), & (whole match) and the
|
||||||
|
# delimiter itself (here `|`). A value containing any of them -- e.g. a
|
||||||
|
# username or path with an `&`, a literal backslash, or a `|` -- would
|
||||||
|
# otherwise corrupt the rendered unit file instead of being substituted
|
||||||
|
# literally. Escape the backslash first so the later escapes aren't
|
||||||
|
# double-escaped.
|
||||||
|
sed_escape_replacement() {
|
||||||
|
local value="$1"
|
||||||
|
value="${value//\\/\\\\}"
|
||||||
|
value="${value//&/\\&}"
|
||||||
|
value="${value//|/\\|}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
@@ -111,17 +111,25 @@ class StartupValidator:
|
|||||||
if not template.is_file() or not installed.is_file():
|
if not template.is_file() or not installed.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# The template carries placeholders the installer substitutes,
|
|
||||||
# so compare the substituted form rather than the raw file.
|
|
||||||
expected = template.read_text(encoding="utf-8")
|
|
||||||
expected = expected.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
|
||||||
expected = expected.replace("__USER__", "root")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actual = installed.read_text(encoding="utf-8")
|
actual = installed.read_text(encoding="utf-8")
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# The template carries placeholders the installer substitutes,
|
||||||
|
# so compare the substituted form rather than the raw file.
|
||||||
|
expected = template.read_text(encoding="utf-8")
|
||||||
|
expected = expected.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
# User= is an install-time decision, not something the template
|
||||||
|
# dictates: the installers write whoever ran them, which on a
|
||||||
|
# non-root install is never "root". Substituting a fixed "root"
|
||||||
|
# here reported drift on every such install, permanently -- and
|
||||||
|
# re-running the installer, which is what the warning tells you
|
||||||
|
# to do, could not clear it. Taking the installed unit's own
|
||||||
|
# value keeps the comparison on the directives the template
|
||||||
|
# actually controls.
|
||||||
|
expected = expected.replace("__USER__", self._installed_user(actual))
|
||||||
|
|
||||||
if self._unit_body(expected) != self._unit_body(actual):
|
if self._unit_body(expected) != self._unit_body(actual):
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
f"{installed.name} differs from {template_rel}; the "
|
f"{installed.name} differs from {template_rel}; the "
|
||||||
@@ -132,6 +140,19 @@ class StartupValidator:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.logger.debug("Could not compare systemd units: %s", e)
|
self.logger.debug("Could not compare systemd units: %s", e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _installed_user(unit_text: str) -> str:
|
||||||
|
"""The installed unit's ``User=``, or "root" when it does not set one.
|
||||||
|
|
||||||
|
systemd itself defaults to root for a system unit with no User=, so that
|
||||||
|
is the right fallback rather than an empty string.
|
||||||
|
"""
|
||||||
|
for line in unit_text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("User="):
|
||||||
|
return stripped.split("=", 1)[1].strip()
|
||||||
|
return "root"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unit_body(text: str) -> str:
|
def _unit_body(text: str) -> str:
|
||||||
"""A unit's meaningful lines, in order: no comments, no blanks.
|
"""A unit's meaningful lines, in order: no comments, no blanks.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
# This is a template file. The actual service file is generated dynamically
|
# This is a template. Do not install it directly -- run
|
||||||
# by install_web_service.sh with the correct paths based on where the project
|
# scripts/install/install_web_service.sh (or install_service.sh, which
|
||||||
# is installed. Do not use this file directly - use install_web_service.sh instead.
|
# renders this same file). Both substitute:
|
||||||
|
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
|
||||||
|
# __USER__ -> the user the web interface runs as
|
||||||
#
|
#
|
||||||
# Template - paths will be replaced dynamically:
|
# This file is the only description of the unit. It used to be duplicated as
|
||||||
# __PROJECT_ROOT_DIR__ will be replaced with the actual project directory
|
# an inline heredoc in both installers, which drifted apart; src/startup_validator.py
|
||||||
|
# compares the installed unit against this template, so a second copy means a
|
||||||
|
# permanent, unclearable drift warning on every boot.
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LED Matrix Web Interface Service
|
Description=LED Matrix Web Interface Service
|
||||||
@@ -12,7 +16,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=__USER__
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
Environment=USE_THREADING=1
|
Environment=USE_THREADING=1
|
||||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/start_web_conditionally.py
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/start_web_conditionally.py
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ editing files under /etc and restarting services is the installer's job, not
|
|||||||
something a display process should do to a machine while it boots.
|
something a display process should do to a machine while it boots.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -150,3 +152,122 @@ def test_a_missing_installed_unit_is_silent(validator, tmp_path):
|
|||||||
validator._validate_systemd_units()
|
validator._validate_systemd_units()
|
||||||
assert not validator.warnings
|
assert not validator.warnings
|
||||||
assert not validator.errors
|
assert not validator.errors
|
||||||
|
|
||||||
|
|
||||||
|
# --- the web unit: one template, three copies, and a warning that never cleared ---
|
||||||
|
#
|
||||||
|
# install_service.sh and install_web_service.sh each carried their own inline
|
||||||
|
# heredoc of ledmatrix-web.service. install_service.sh's had drifted -- no
|
||||||
|
# Wants=network-online.target, RestartSec, SyslogIdentifier or CacheDirectory --
|
||||||
|
# and that is what was installed on real rigs. The validator correctly reported
|
||||||
|
# the drift and told the user to re-run install_service.sh, which reinstalled the
|
||||||
|
# same stale copy, so the warning could never clear. Separately, the template
|
||||||
|
# hardcoded User=root while the installers write whoever ran them, so even the
|
||||||
|
# *correct* installer produced a permanent warning on any non-root install.
|
||||||
|
|
||||||
|
|
||||||
|
def _render(template_text, project_root, user):
|
||||||
|
"""Exactly what the installers' sed does."""
|
||||||
|
return (template_text
|
||||||
|
.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
.replace("__USER__", user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_unit_installed_as_a_non_root_user_is_not_drift(validator, tmp_path):
|
||||||
|
"""The web interface runs as whoever installed it, not as root.
|
||||||
|
|
||||||
|
This is the case that warned forever: nothing the user could do would make
|
||||||
|
an installed `User=pi` match a template that said `User=root`.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix-web.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
installed = tmp_path / "ledmatrix-web.service"
|
||||||
|
installed.write_text(
|
||||||
|
_render(template.read_text(encoding="utf-8"), project_root, "hdpi"),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert not validator.warnings, (
|
||||||
|
f"a correctly installed non-root web unit warned: {validator.warnings}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_unit_still_reports_a_real_changed_directive(validator, tmp_path):
|
||||||
|
"""Ignoring User= must not make the check blind to everything else."""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix-web.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
rendered = _render(template.read_text(encoding="utf-8"), project_root, "hdpi")
|
||||||
|
# Drop RestartSec -- one of the directives the stale heredoc was missing.
|
||||||
|
stale = "\n".join(line for line in rendered.splitlines() if not line.startswith("RestartSec="))
|
||||||
|
installed = tmp_path / "ledmatrix-web.service"
|
||||||
|
installed.write_text(stale + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert validator.warnings, "a web unit missing RestartSec= produced no warning"
|
||||||
|
assert not validator.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_user_falls_back_to_root():
|
||||||
|
"""systemd defaults a system unit with no User= to root, so we must too."""
|
||||||
|
assert StartupValidator._installed_user("[Service]\nExecStart=/x\n") == "root"
|
||||||
|
assert StartupValidator._installed_user("[Service]\nUser=pi\n") == "pi"
|
||||||
|
assert StartupValidator._installed_user("[Service]\n User=hdpi \n") == "hdpi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sed_escape_replacement_preserves_special_characters():
|
||||||
|
"""A project path or username containing sed-special characters must render literally.
|
||||||
|
|
||||||
|
The installers build their sed expression by interpolating a shell
|
||||||
|
variable into the replacement side of `sed s|pattern|replacement|`.
|
||||||
|
Unescaped, sed treats `&` as "insert the whole match" and `\\` as an
|
||||||
|
escape character, so a path like `/opt/led&matrix` would corrupt the
|
||||||
|
rendered unit instead of being substituted as-is. lib_systemd_render.sh's
|
||||||
|
sed_escape_replacement exists to prevent exactly that.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
helper = project_root / "scripts" / "install" / "lib_systemd_render.sh"
|
||||||
|
if not helper.is_file():
|
||||||
|
pytest.skip("install helper not present")
|
||||||
|
|
||||||
|
value = "/opt/led&matrix\\pi|two"
|
||||||
|
escape_cmd = f'source {shlex.quote(str(helper))}; sed_escape_replacement {shlex.quote(value)}'
|
||||||
|
escaped = subprocess.run(
|
||||||
|
["bash", "-c", escape_cmd], capture_output=True, text=True, check=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
rendered = subprocess.run(
|
||||||
|
["sed", f"s|__X__|{escaped}|g"],
|
||||||
|
input="path=__X__\n", capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
assert rendered == f"path={value}\n", (
|
||||||
|
"a sed-special character in the replacement was not preserved literally")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_installer_carries_its_own_copy_of_a_unit():
|
||||||
|
"""The regression guard.
|
||||||
|
|
||||||
|
Both installers used to inline the unit as a heredoc, and the two copies
|
||||||
|
drifted from the template and from each other. A unit body in a shell script
|
||||||
|
is the bug, so assert there isn't one rather than asserting the current
|
||||||
|
contents match -- matching contents is exactly what silently stops being
|
||||||
|
true.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
offenders = []
|
||||||
|
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
|
||||||
|
text = script.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if "[Unit]" in text and "Description=" in text:
|
||||||
|
offenders.append(script.name)
|
||||||
|
assert not offenders, (
|
||||||
|
f"{offenders} contain an inline systemd unit; render "
|
||||||
|
f"systemd/*.service instead so there is one source of truth")
|
||||||
|
|||||||
Reference in New Issue
Block a user