mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-20 18:09:05 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf521bdfd8 |
@@ -178,21 +178,11 @@ class PluginHealthTracker:
|
|||||||
)
|
)
|
||||||
return self._health_state[plugin_id]
|
return self._health_state[plugin_id]
|
||||||
|
|
||||||
# Fields the circuit breaker is rebuilt from after a restart. Everything
|
|
||||||
# else in a health record is reporting, read only for display.
|
|
||||||
_DURABLE_FIELDS = ('consecutive_failures', 'circuit_state',
|
|
||||||
'circuit_opened_time', 'half_open_start_time')
|
|
||||||
|
|
||||||
def _durable(self, state: Dict[str, Any]) -> tuple:
|
|
||||||
"""The part of a health record whose loss would change behaviour."""
|
|
||||||
return tuple(state.get(field) for field in self._DURABLE_FIELDS)
|
|
||||||
|
|
||||||
def record_success(self, plugin_id: str) -> None:
|
def record_success(self, plugin_id: str) -> None:
|
||||||
"""Record a successful plugin execution."""
|
"""Record a successful plugin execution."""
|
||||||
state = self.get_health_state(plugin_id)
|
state = self.get_health_state(plugin_id)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
durable_before = self._durable(state)
|
|
||||||
|
|
||||||
# Reset consecutive failures
|
# Reset consecutive failures
|
||||||
state['consecutive_failures'] = 0
|
state['consecutive_failures'] = 0
|
||||||
state['total_successes'] = state.get('total_successes', 0) + 1
|
state['total_successes'] = state.get('total_successes', 0) + 1
|
||||||
@@ -208,20 +198,9 @@ class PluginHealthTracker:
|
|||||||
# Shouldn't happen, but handle it
|
# Shouldn't happen, but handle it
|
||||||
state['circuit_state'] = CircuitState.CLOSED.value
|
state['circuit_state'] = CircuitState.CLOSED.value
|
||||||
state['circuit_opened_time'] = None
|
state['circuit_opened_time'] = None
|
||||||
|
|
||||||
# A healthy plugin reports success every cycle, and in that steady state
|
self._save_health_state(plugin_id, state)
|
||||||
# the only fields changed above are a counter and a timestamp that
|
|
||||||
# nothing reads back after a restart. Persisting them anyway rewrites a
|
|
||||||
# small file per plugin per cycle: on a rig running 24 plugins, a
|
|
||||||
# five-minute sample measured 22 rewrites, about 4.4 a minute or 6,300 a
|
|
||||||
# day. Those land on an SD card, where the cost is an erase-block cycle
|
|
||||||
# rather than the 400 bytes involved, and where wear is what eventually
|
|
||||||
# kills the card.
|
|
||||||
# In-memory state is still updated every time, so the health API and web
|
|
||||||
# UI show exactly what they did before; only the write is skipped.
|
|
||||||
if self._durable(state) != durable_before:
|
|
||||||
self._save_health_state(plugin_id, state)
|
|
||||||
|
|
||||||
def record_failure(self, plugin_id: str, error: Optional[Exception] = None) -> None:
|
def record_failure(self, plugin_id: str, error: Optional[Exception] = None) -> None:
|
||||||
"""Record a failed plugin execution."""
|
"""Record a failed plugin execution."""
|
||||||
state = self.get_health_state(plugin_id)
|
state = self.get_health_state(plugin_id)
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class StartupValidator:
|
|||||||
# Validate plugins if plugin manager is available
|
# Validate plugins if plugin manager is available
|
||||||
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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
"""A healthy plugin must not rewrite its health record every cycle.
|
|
||||||
|
|
||||||
Every successful plugin update called record_success(), which persisted the
|
|
||||||
record unconditionally. In steady state the only fields that had changed were
|
|
||||||
total_successes and last_success_time -- a counter and a timestamp that
|
|
||||||
health_monitor reads for display and that nothing reads back after a restart.
|
|
||||||
|
|
||||||
Measured on a rig running 24 plugins: about 17 health-file rewrites a minute,
|
|
||||||
roughly 25,000 a day. Each is ~400 bytes, but they land on an SD card where
|
|
||||||
the unit of cost is an erase-block cycle, not the byte count, and where wear is
|
|
||||||
what eventually kills the card.
|
|
||||||
|
|
||||||
The circuit breaker still needs its own state to survive a restart, so the
|
|
||||||
write is kept for exactly the fields it is rebuilt from -- and a failure, a
|
|
||||||
circuit opening, or a recovery must still be written the moment it happens.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
|
|
||||||
|
|
||||||
|
|
||||||
class _Cache:
|
|
||||||
"""Counts writes; serves back whatever was last written."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.store = {}
|
|
||||||
self.writes = 0
|
|
||||||
|
|
||||||
def set(self, key, data, ttl=None, **kwargs):
|
|
||||||
self.writes += 1
|
|
||||||
self.store[key] = data
|
|
||||||
|
|
||||||
def get(self, key, max_age=None, memory_ttl=None, **kwargs):
|
|
||||||
return self.store.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tracker():
|
|
||||||
cache = _Cache()
|
|
||||||
t = PluginHealthTracker(cache_manager=cache)
|
|
||||||
return t, cache
|
|
||||||
|
|
||||||
|
|
||||||
def test_steady_state_success_stops_writing(tracker):
|
|
||||||
"""The regression: 100 healthy cycles used to be 100 SD writes."""
|
|
||||||
t, cache = tracker
|
|
||||||
t.record_success("weather")
|
|
||||||
first = cache.writes
|
|
||||||
for _ in range(100):
|
|
||||||
t.record_success("weather")
|
|
||||||
assert cache.writes == first, (
|
|
||||||
f"{cache.writes - first} redundant writes across 100 healthy cycles"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_counters_are_still_accurate_in_memory(tracker):
|
|
||||||
"""Skipping the write must not skip the bookkeeping."""
|
|
||||||
t, _ = tracker
|
|
||||||
for _ in range(10):
|
|
||||||
t.record_success("weather")
|
|
||||||
state = t.get_health_state("weather")
|
|
||||||
assert state["total_successes"] == 10
|
|
||||||
assert state["last_success_time"] is not None
|
|
||||||
assert state["last_success_time"] <= time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failure_is_written_immediately(tracker):
|
|
||||||
t, cache = tracker
|
|
||||||
t.record_success("weather")
|
|
||||||
before = cache.writes
|
|
||||||
t.record_failure("weather", RuntimeError("boom"))
|
|
||||||
assert cache.writes > before, "a failure must reach disk"
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_after_failure_is_written(tracker):
|
|
||||||
"""consecutive_failures returning to 0 is durable state changing."""
|
|
||||||
t, cache = tracker
|
|
||||||
t.record_failure("weather", RuntimeError("boom"))
|
|
||||||
before = cache.writes
|
|
||||||
t.record_success("weather")
|
|
||||||
assert cache.writes > before, "recovery must reach disk"
|
|
||||||
assert t.get_health_state("weather")["consecutive_failures"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_closing_circuit_is_written(tracker):
|
|
||||||
"""Success in half-open closes the circuit -- that must survive a restart."""
|
|
||||||
t, cache = tracker
|
|
||||||
state = t.get_health_state("weather")
|
|
||||||
state["circuit_state"] = CircuitState.HALF_OPEN.value
|
|
||||||
state["half_open_start_time"] = time.time()
|
|
||||||
before = cache.writes
|
|
||||||
t.record_success("weather")
|
|
||||||
assert cache.writes > before, "a circuit transition must reach disk"
|
|
||||||
assert t.get_health_state("weather")["circuit_state"] == CircuitState.CLOSED.value
|
|
||||||
|
|
||||||
|
|
||||||
def test_durable_state_survives_a_restart(tracker):
|
|
||||||
"""What is skipped must genuinely not matter to the breaker."""
|
|
||||||
t, cache = tracker
|
|
||||||
for _ in range(3):
|
|
||||||
t.record_failure("weather", RuntimeError("boom"))
|
|
||||||
for _ in range(50):
|
|
||||||
t.record_success("weather")
|
|
||||||
|
|
||||||
revived = PluginHealthTracker(cache_manager=cache)
|
|
||||||
state = revived.get_health_state("weather")
|
|
||||||
assert state["consecutive_failures"] == 0
|
|
||||||
assert state["circuit_state"] == CircuitState.CLOSED.value
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user