fix: install plugin/base requirements as root so ledmatrix.service can see them

ledmatrix-web.service runs as a non-root user, so "Reinstall plugin
requirements" installed packages into that user's ~/.local site-packages.
ledmatrix.service (the actual display, which loads and runs plugin code)
runs as root and can't see another user's user-site packages, so plugins
with dependencies not already present system-wide would silently fail at
runtime with ModuleNotFoundError even after a "successful" reinstall.
Reproduced and fixed live against a real device (weather plugin's astral
dependency, used for moon-phase data): confirmed the exact failure
("No module named 'astral'" on every almanac cycle) and confirmed it's
gone after this fix.

Adds scripts/fix_perms/safe_pip_install.sh, a root-owned wrapper (mirroring
the existing safe_plugin_rm.sh pattern) that validates the target is
requirements.txt at the project root or under plugin-repos/ or plugins/
before running pip install as root. configure_web_sudo.sh provisions a
narrowly-scoped sudoers rule for it. api_v3.py's install_base_requirements
and install_plugin_requirements actions now use it via `sudo -n`, falling
back to today's current-user-only install (with an explanatory note) if
the wrapper isn't set up yet, so existing installs don't regress.

Also uses --ignore-installed in the wrapper: root's site-packages often has
apt/dpkg-managed copies of common libraries (requests, etc.) with no pip
RECORD file, which pip refuses to upgrade in place and aborts the *entire*
requirements.txt install over — discovered this while testing the fix live,
since a plugin's other already-satisfied-for-the-web-user dependencies had
never actually been attempted as root before.

Also fixes a pre-existing bug in configure_web_sudo.sh where the
display_controller.py/start_display.sh/stop_display.sh sudoers entries used
PROJECT_DIR (scripts/install/, where this script lives) instead of
PROJECT_ROOT (where those files actually live) — visible as the script's
own "File access test" self-check failing. Verified fixed live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-04 09:46:47 -04:00
co-authored by Claude Sonnet 5
parent c6ce332d49
commit 8de706323a
3 changed files with 140 additions and 13 deletions
+38 -8
View File
@@ -43,6 +43,42 @@ def _truncate_output(stdout: str, stderr: str) -> str:
return combined
def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess:
"""Install a requirements.txt file, preferring the vetted sudo wrapper so
the packages are visible to root-run ledmatrix.service not just to
whichever non-root user runs this web process. Falls back to installing
for the current process only if the wrapper isn't set up yet (i.e. the
admin hasn't run scripts/install/configure_web_sudo.sh since upgrading),
so the button still does *something* useful rather than hard-failing.
"""
wrapper = PROJECT_ROOT / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
if wrapper.exists():
result = subprocess.run(
['sudo', '-n', str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT)
)
if result.returncode == 0:
return result
note = (
f"[Root install unavailable ({result.stderr.strip() or 'sudo denied'}); "
"installed for the web service's user only. Packages may not be "
"visible to ledmatrix.service if it runs as a different user — "
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
)
else:
note = (
"[safe_pip_install.sh not found; installed for the web service's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
result = subprocess.run(
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT)
)
result.stdout = note + (result.stdout or '')
return result
def _scrub_git_remote_url(url: str) -> str:
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
try:
@@ -1671,10 +1707,7 @@ def execute_system_action():
req_file = PROJECT_ROOT / 'requirements.txt'
if not req_file.exists():
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
result = subprocess.run(
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
)
result = _pip_install_requirements(req_file, timeout=120)
return jsonify({
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
@@ -1695,10 +1728,7 @@ def execute_system_action():
req = p / 'requirements.txt'
if p.is_dir() and req.exists():
try:
r = subprocess.run(
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
capture_output=True, text=True, timeout=60
)
r = _pip_install_requirements(req, timeout=60)
results.append({
'plugin': p.name,
'ok': r.returncode == 0,