mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 02:48:15 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8591e4ac |
@@ -63,9 +63,6 @@ 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:
|
||||||
@@ -77,71 +74,6 @@ 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,133 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""A check that could not run must not be reported as "up to date".
|
||||||
|
|
||||||
|
check-update returned update_available=False whenever git failed. The banner
|
||||||
|
is the only route to the update button, so a checkout git refuses to touch
|
||||||
|
looked exactly like a current one -- permanently, and with nothing for the
|
||||||
|
user to act on. The usual cause is an install performed as root, after which
|
||||||
|
every git command fails with "detected dubious ownership".
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
|
||||||
|
DUBIOUS = ("fatal: detected dubious ownership in repository at "
|
||||||
|
"'/home/pi/LEDMatrix'\nTo add an exception for this directory, call:\n"
|
||||||
|
"\tgit config --global --add safe.directory /home/pi/LEDMatrix\n")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
mod._update_check_cache['result'] = None
|
||||||
|
mod._update_check_cache['ts'] = 0
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fails(stderr: bytes):
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
if args[:2] == ['git', 'fetch']:
|
||||||
|
return subprocess.CompletedProcess(args, 1, stdout=b'', stderr=stderr)
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
||||||
|
return fake_run
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailedCheckIsNotSilence:
|
||||||
|
def test_dubious_ownership_is_reported_not_swallowed(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['check_failed'] is True, (
|
||||||
|
"a git failure was reported as a successful 'no update' check")
|
||||||
|
assert data['update_available'] is False
|
||||||
|
|
||||||
|
def test_the_message_tells_the_user_what_to_do(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert 'chown' in data['error'], (
|
||||||
|
"dubious ownership is unactionable without the fix command")
|
||||||
|
assert 'root' in data['error']
|
||||||
|
|
||||||
|
def test_an_ordinary_git_failure_still_surfaces(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_fetch_fails(b'fatal: some other git problem\n')):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['check_failed'] is True
|
||||||
|
assert 'some other git problem' in data['error']
|
||||||
|
|
||||||
|
def test_offline_reads_as_offline(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_fetch_fails(b'fatal: could not resolve host: github.com\n')):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert 'Could not reach GitHub' in data['error']
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuccessPathUnchanged:
|
||||||
|
def test_up_to_date_carries_no_failure_flag(self, client):
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
if args[:2] == ['git', 'fetch']:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout=b'', stderr=b'')
|
||||||
|
if args[:2] == ['git', 'rev-parse']:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='abc123\n', stderr='')
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='0\n', stderr='')
|
||||||
|
with patch.object(mod.subprocess, 'run', fake_run):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['update_available'] is False
|
||||||
|
assert not data.get('check_failed'), "a healthy check must not look like a failure"
|
||||||
@@ -1821,6 +1821,33 @@ def get_system_version():
|
|||||||
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
||||||
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
||||||
|
|
||||||
|
def _update_check_failed(detail: str) -> Dict[str, Any]:
|
||||||
|
"""A check that could not run is not the same as being up to date.
|
||||||
|
|
||||||
|
Reporting update_available=False on a git failure hides the banner, and
|
||||||
|
the banner is the only route to the update button -- so a checkout git
|
||||||
|
refuses to touch looks exactly like a current one, permanently. The most
|
||||||
|
common cause is an install performed as root: git then reports "dubious
|
||||||
|
ownership" and every command fails, including the fetch here.
|
||||||
|
"""
|
||||||
|
return {'update_available': False, 'remote_sha': 'unknown',
|
||||||
|
'commits_behind': 0, 'check_failed': True, 'error': detail}
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_git_failure(stderr: str) -> str:
|
||||||
|
"""Turn git's stderr into something the user can act on."""
|
||||||
|
text = (stderr or '').strip()
|
||||||
|
if 'dubious ownership' in text or 'detected dubious ownership' in text:
|
||||||
|
return ("This checkout is owned by a different user than the one "
|
||||||
|
"running the web interface, so git refuses to use it. It is "
|
||||||
|
"usually the result of installing as root. Fix the ownership "
|
||||||
|
"and the update will work: sudo chown -R $USER:$USER "
|
||||||
|
+ str(PROJECT_ROOT))
|
||||||
|
if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower():
|
||||||
|
return "Could not reach GitHub to check for updates."
|
||||||
|
return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed")
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/system/check-update', methods=['GET'])
|
@api_v3.route('/system/check-update', methods=['GET'])
|
||||||
def check_for_update():
|
def check_for_update():
|
||||||
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||||
@@ -1836,12 +1863,13 @@ def check_for_update():
|
|||||||
capture_output=True, timeout=10, cwd=cwd,
|
capture_output=True, timeout=10, cwd=cwd,
|
||||||
)
|
)
|
||||||
if fetch_result.returncode != 0:
|
if fetch_result.returncode != 0:
|
||||||
|
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
||||||
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||||
fetch_result.returncode,
|
fetch_result.returncode, stderr)
|
||||||
fetch_result.stderr.decode(errors='replace').strip())
|
failed = _update_check_failed(_describe_git_failure(stderr))
|
||||||
_update_check_cache['result'] = _safe
|
_update_check_cache['result'] = failed
|
||||||
_update_check_cache['ts'] = now
|
_update_check_cache['ts'] = now
|
||||||
return jsonify(_safe)
|
return jsonify(failed)
|
||||||
local = subprocess.run(
|
local = subprocess.run(
|
||||||
['git', 'rev-parse', 'HEAD'],
|
['git', 'rev-parse', 'HEAD'],
|
||||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
@@ -1869,7 +1897,8 @@ def check_for_update():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("check-update failed: %s", e)
|
logger.warning("check-update failed: %s", e)
|
||||||
return jsonify(_safe)
|
return jsonify(_update_check_failed(
|
||||||
|
"Could not check for updates; see logs for details."))
|
||||||
|
|
||||||
@api_v3.route('/system/action', methods=['POST'])
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
|
|||||||
@@ -1107,15 +1107,29 @@
|
|||||||
fetch('/api/v3/system/check-update')
|
fetch('/api/v3/system/check-update')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
var banner = document.getElementById('update-banner');
|
||||||
|
var btn = document.getElementById('update-banner-btn');
|
||||||
|
if (data.check_failed) {
|
||||||
|
// A check that could not run is not the same as being up
|
||||||
|
// to date. Hiding the banner here made a checkout git
|
||||||
|
// refuses to touch look permanently current, with no
|
||||||
|
// route to the update button and nothing to act on.
|
||||||
|
document.getElementById('update-banner-text').textContent =
|
||||||
|
data.error || 'Could not check for updates.';
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
banner.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||||||
var n = data.commits_behind || 0;
|
var n = data.commits_behind || 0;
|
||||||
var msg = 'A new LEDMatrix update is available';
|
var msg = 'A new LEDMatrix update is available';
|
||||||
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||||
document.getElementById('update-banner-text').textContent = msg;
|
document.getElementById('update-banner-text').textContent = msg;
|
||||||
document.getElementById('update-banner').style.display = '';
|
banner.style.display = '';
|
||||||
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('update-banner').style.display = 'none';
|
banner.style.display = 'none';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
|
|||||||
Reference in New Issue
Block a user