mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-21 02:19:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf521bdfd8 |
@@ -63,6 +63,9 @@ class StartupValidator:
|
|||||||
if self.plugin_manager:
|
if self.plugin_manager:
|
||||||
self._validate_plugins()
|
self._validate_plugins()
|
||||||
|
|
||||||
|
# Warn when the running systemd unit no longer matches the repo's
|
||||||
|
self._validate_systemd_units()
|
||||||
|
|
||||||
is_valid = len(self.errors) == 0
|
is_valid = len(self.errors) == 0
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
@@ -74,6 +77,71 @@ class StartupValidator:
|
|||||||
|
|
||||||
return (is_valid, self.errors.copy(), self.warnings.copy())
|
return (is_valid, self.errors.copy(), self.warnings.copy())
|
||||||
|
|
||||||
|
#: Units this project installs, and where each is installed to.
|
||||||
|
_UNITS = (
|
||||||
|
("systemd/ledmatrix.service", "/etc/systemd/system/ledmatrix.service"),
|
||||||
|
("systemd/ledmatrix-web.service", "/etc/systemd/system/ledmatrix-web.service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_systemd_units(self) -> None:
|
||||||
|
"""Warn when an installed unit has drifted from the repo's template.
|
||||||
|
|
||||||
|
Nothing re-applies these after the first install. `git pull` -- which is
|
||||||
|
what the web UI's update button runs -- brings a new template into the
|
||||||
|
checkout, but nothing copies it to /etc/systemd/system and nothing runs
|
||||||
|
`systemctl daemon-reload`, so the unit that actually runs is whatever
|
||||||
|
first_time_install.sh wrote on day one.
|
||||||
|
|
||||||
|
That makes every hardening added to a unit inert on existing installs.
|
||||||
|
Measured on one rig: the installed unit was thirteen days older than the
|
||||||
|
repo's and differed in content, so a MemoryMax the repo had specified
|
||||||
|
was not being enforced at all -- `systemctl show` reported
|
||||||
|
MemoryMax=infinity.
|
||||||
|
|
||||||
|
A warning rather than an error, and certainly not a silent rewrite:
|
||||||
|
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.
|
||||||
|
The remedy is to re-run scripts/install/install_service.sh.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project_root = Path(__file__).resolve().parent.parent
|
||||||
|
for template_rel, installed_path in self._UNITS:
|
||||||
|
template = project_root / template_rel
|
||||||
|
installed = Path(installed_path)
|
||||||
|
if not template.is_file() or not installed.is_file():
|
||||||
|
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:
|
||||||
|
actual = installed.read_text(encoding="utf-8")
|
||||||
|
except PermissionError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._unit_body(expected) != self._unit_body(actual):
|
||||||
|
self.warnings.append(
|
||||||
|
f"{installed.name} differs from {template_rel}; the "
|
||||||
|
"installed unit is not refreshed by an update, so "
|
||||||
|
"settings added to the template are not in effect. "
|
||||||
|
"Re-run scripts/install/install_service.sh to apply them."
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.debug("Could not compare systemd units: %s", e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unit_body(text: str) -> str:
|
||||||
|
"""A unit's meaningful lines: no comments, no blanks, no ordering noise."""
|
||||||
|
lines = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(sorted(lines))
|
||||||
|
|
||||||
def _validate_config(self) -> None:
|
def _validate_config(self) -> None:
|
||||||
"""Validate configuration files."""
|
"""Validate configuration files."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""An installed unit that no longer matches the repo's must be reported.
|
||||||
|
|
||||||
|
Nothing re-applies systemd units after the first install. `git pull` -- what
|
||||||
|
the web UI's update button runs -- brings a new template into the checkout, but
|
||||||
|
no code in web_interface/ or src/ copies it to /etc/systemd/system or runs
|
||||||
|
`systemctl daemon-reload`. The unit that actually runs is whatever
|
||||||
|
first_time_install.sh wrote on day one.
|
||||||
|
|
||||||
|
So every hardening added to a unit is inert on existing installs. Measured on a
|
||||||
|
live rig: the installed unit was dated 2026-08-06 and the repo's 2026-08-19,
|
||||||
|
and they differed -- with the result that a MemoryMax=85% present in the repo's
|
||||||
|
template was not being enforced at all. `systemctl show` reported
|
||||||
|
MemoryMax=infinity.
|
||||||
|
|
||||||
|
This is a warning, not an error, and deliberately not a silent rewrite:
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.startup_validator import StartupValidator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def validator():
|
||||||
|
v = StartupValidator(config_manager=MagicMock())
|
||||||
|
v.logger = logging.getLogger("test")
|
||||||
|
v.warnings = []
|
||||||
|
v.errors = []
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_matching_unit_produces_no_warning(validator, tmp_path):
|
||||||
|
"""The installed unit, substituted exactly as the installer would."""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
installed = tmp_path / "ledmatrix.service"
|
||||||
|
installed.write_text(
|
||||||
|
template.read_text(encoding="utf-8")
|
||||||
|
.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
.replace("__USER__", "root"),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert not validator.warnings, f"a matching unit warned: {validator.warnings}"
|
||||||
|
assert not validator.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_comments_and_blank_lines_are_not_drift():
|
||||||
|
"""Otherwise every comment the repo adds would look like a changed unit."""
|
||||||
|
a = "[Service]\n# explains a setting\nExecStart=/x\nRestart=always\n"
|
||||||
|
b = "[Service]\nExecStart=/x\n\nRestart=always\n"
|
||||||
|
assert StartupValidator._unit_body(a) == StartupValidator._unit_body(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_changed_directive_is_drift():
|
||||||
|
a = "[Service]\nExecStart=/x\nMemoryMax=85%\n"
|
||||||
|
b = "[Service]\nExecStart=/x\n"
|
||||||
|
assert StartupValidator._unit_body(a) != StartupValidator._unit_body(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reordered_directives_are_not_drift():
|
||||||
|
"""systemd does not care about order within a section, so neither should this."""
|
||||||
|
a = "[Service]\nExecStart=/x\nRestart=always\n"
|
||||||
|
b = "[Service]\nRestart=always\nExecStart=/x\n"
|
||||||
|
assert StartupValidator._unit_body(a) == StartupValidator._unit_body(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cosmetic_differences_do_not_warn(validator, tmp_path):
|
||||||
|
"""Through the real comparison, not the helper.
|
||||||
|
|
||||||
|
The repo's template carries explanatory comments the installed copy may not
|
||||||
|
have, and the installer does not preserve ordering or blank lines. If those
|
||||||
|
counted as drift, every boot would warn and the warning would be ignored.
|
||||||
|
Asserting this on _unit_body alone would not catch a comparison that stopped
|
||||||
|
calling it -- which is exactly what a careless edit does.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
substituted = (template.read_text(encoding="utf-8")
|
||||||
|
.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
.replace("__USER__", "root"))
|
||||||
|
# Same directives, stripped of comments and blank lines and reordered.
|
||||||
|
directives = sorted(line.strip() for line in substituted.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith("#"))
|
||||||
|
installed = tmp_path / "ledmatrix.service"
|
||||||
|
installed.write_text("\n".join(reversed(directives)) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert not validator.warnings, (
|
||||||
|
f"cosmetic-only difference reported as drift: {validator.warnings}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_is_reported_as_a_warning(validator, tmp_path):
|
||||||
|
"""The whole point: a real difference must surface, and only as a warning."""
|
||||||
|
installed = tmp_path / "ledmatrix.service"
|
||||||
|
installed.write_text("[Service]\nExecStart=/usr/bin/python3 /x/run.py\n")
|
||||||
|
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
|
||||||
|
assert validator.warnings, "a differing unit produced no warning"
|
||||||
|
assert "install_service.sh" in validator.warnings[0], (
|
||||||
|
"the warning does not tell the user how to fix it")
|
||||||
|
assert not validator.errors, "drift must not be fatal at startup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_installed_unit_is_silent(validator, tmp_path):
|
||||||
|
"""Development checkouts have no /etc/systemd unit; that is not drift."""
|
||||||
|
validator._UNITS = (("systemd/ledmatrix.service", str(tmp_path / "absent.service")),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert not validator.warnings
|
||||||
|
assert not validator.errors
|
||||||
@@ -194,46 +194,15 @@ class TestSavePluginConfig:
|
|||||||
|
|
||||||
def test_secret_count_message_counts_top_level_keys(self, env):
|
def test_secret_count_message_counts_top_level_keys(self, env):
|
||||||
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
|
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
|
||||||
# the separated secrets dict. Here that is 1: the posted accounts
|
# the separated secrets dict. Here that is 2: the posted accounts
|
||||||
# array, whose item tokens all count as ONE key.
|
# array (all its item tokens count as ONE key) plus the schema's
|
||||||
#
|
# api_key default ("") that merge_with_defaults adds before
|
||||||
# It was 2 before blank secrets were dropped, the second being the
|
# separation.
|
||||||
# schema's api_key default (""), which merge_with_defaults adds to
|
|
||||||
# every save. Counting it was the visible edge of a real bug: that
|
|
||||||
# injected blank was merged over the stored api_key, so saving any
|
|
||||||
# unrelated field destroyed the credential. See
|
|
||||||
# test_an_unrelated_edit_does_not_erase_a_stored_secret.
|
|
||||||
resp = self._save(env, {
|
resp = self._save(env, {
|
||||||
"accounts": [{"name": "a", "token": "t"}],
|
"accounts": [{"name": "a", "token": "t"}],
|
||||||
})
|
})
|
||||||
message = resp.get_json()["message"]
|
message = resp.get_json()["message"]
|
||||||
assert "(1 secret field(s) saved to config_secrets.json)" in message
|
assert "(2 secret field(s) saved to config_secrets.json)" in message
|
||||||
|
|
||||||
def test_an_unrelated_edit_does_not_erase_a_stored_secret(self, env):
|
|
||||||
"""Editing one field must not wipe the plugin's API key.
|
|
||||||
|
|
||||||
The config form renders secrets masked, so the browser posts them
|
|
||||||
back blank; merge_with_defaults injects a blank api_key even when
|
|
||||||
the client omits it entirely. Either way a "" reached the secrets
|
|
||||||
file and deep_merge wrote it over the stored credential.
|
|
||||||
"""
|
|
||||||
assert self._save(env, {"api_key": "REAL-KEY-0123456789",
|
|
||||||
"city": "Austin"}).status_code == 200
|
|
||||||
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
|
|
||||||
"REAL-KEY-0123456789"
|
|
||||||
|
|
||||||
# the user changes the city; the masked api_key rides along blank
|
|
||||||
assert self._save(env, {"api_key": "", "city": "Dallas"}).status_code == 200
|
|
||||||
|
|
||||||
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
|
|
||||||
"REAL-KEY-0123456789", "an unrelated edit destroyed the API key"
|
|
||||||
assert env.fresh_load()[PLUGIN_ID]["city"] == "Dallas"
|
|
||||||
|
|
||||||
def test_a_secret_can_still_be_changed(self, env):
|
|
||||||
"""Dropping blanks must not stop a real new value from being saved."""
|
|
||||||
self._save(env, {"api_key": "first-key"})
|
|
||||||
self._save(env, {"api_key": "second-key"})
|
|
||||||
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == "second-key"
|
|
||||||
|
|
||||||
def test_resave_replaces_stored_secrets_list_wholesale(self, env):
|
def test_resave_replaces_stored_secrets_list_wholesale(self, env):
|
||||||
# Characterized: api_v3's deep_merge intentionally replaces lists,
|
# Characterized: api_v3's deep_merge intentionally replaces lists,
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Import new infrastructure
|
# Import new infrastructure
|
||||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
from src.web_interface.secret_helpers import (find_secret_fields, remove_empty_secrets,
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
separate_secrets)
|
|
||||||
from src.web_interface.error_handler import describe_exception, redact_text
|
from src.web_interface.error_handler import describe_exception, redact_text
|
||||||
from src.plugin_system.operation_types import OperationType
|
from src.plugin_system.operation_types import OperationType
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
@@ -1217,11 +1216,6 @@ def save_main_config():
|
|||||||
|
|
||||||
# Separate secrets from regular config (same logic as save_plugin_config)
|
# Separate secrets from regular config (same logic as save_plugin_config)
|
||||||
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
|
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
|
||||||
# The config form renders secrets masked, so every save posts
|
|
||||||
# them back blank. Without this the blank is merged over the
|
|
||||||
# stored value and the credential is destroyed by the act of
|
|
||||||
# changing an unrelated setting. A blank means "unchanged".
|
|
||||||
secrets_config = remove_empty_secrets(secrets_config)
|
|
||||||
|
|
||||||
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config
|
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config
|
||||||
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
||||||
@@ -5605,11 +5599,6 @@ def save_plugin_config():
|
|||||||
# Separate secrets from regular config (handles nested configs and
|
# Separate secrets from regular config (handles nested configs and
|
||||||
# array-item secrets — see src/web_interface/secret_helpers.py)
|
# array-item secrets — see src/web_interface/secret_helpers.py)
|
||||||
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
|
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
|
||||||
# The config form renders secrets masked, so every save posts
|
|
||||||
# them back blank. Without this the blank is merged over the
|
|
||||||
# stored value and the credential is destroyed by the act of
|
|
||||||
# changing an unrelated setting. A blank means "unchanged".
|
|
||||||
secrets_config = remove_empty_secrets(secrets_config)
|
|
||||||
|
|
||||||
# Get current configs
|
# Get current configs
|
||||||
current_config = api_v3.config_manager.load_config()
|
current_config = api_v3.config_manager.load_config()
|
||||||
|
|||||||
Reference in New Issue
Block a user