mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
fix: dependency installation gaps in Plugin Store and first-time install (#385)
* fix: install plugin dependencies through root-visible installer in Plugin Store install_plugin/update_plugin (store_manager.py) installed requirements.txt with a bare `pip3` off PATH, bypassing the root-visible installer added in #380 for the "Reinstall Plugin Deps" button. Two bugs stacked: (1) `pip3` can resolve to a different Python install than the one that actually runs ledmatrix.service, and (2) even when it resolves correctly, ledmatrix-web runs as a non-root user so the package lands in that user's local site-packages, invisible to root-run ledmatrix.service. Either way the install reports success and writes the .dependencies_installed hash marker, so plugin_loader's own (correct) install-on-load path skips reinstalling — leaving the dependency permanently missing until a user finds and clicks the separate "Reinstall Plugin Deps" tool. This is why users kept hitting "No module named 'astral'" for the weather plugin even after installing it from the Store. Extracts the sudo-wrapper-then-fallback install logic from api_v3.py's _pip_install_requirements into src/common/permission_utils.py as install_requirements_file, and routes store_manager.py's dependency installation through it so the automatic Store install/update path now matches the manual "Reinstall Plugin Deps" path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx * chore: suppress Codacy false-positive on subprocess.run in install_requirements_file Codacy's generic subprocess-security rule (Bandit B603 equivalent) flagged the pip/sudo subprocess.run calls in install_requirements_file for lacking a "static string argument" — the standard pattern-based flag for any subprocess.run() call with a variable in its argv list. Both calls use list-form argv (no shell=True, so no shell-injection surface), and the only dynamic value is req_file, a Path built internally by callers rather than raw external input; safe_pip_install.sh independently re-validates it before installing anything as root. Suppresses with inline `# nosec B603` comments matching this codebase's existing convention (see permission_utils.py's own PROTECTED_SYSTEM_DIRECTORIES, display_manager.py, sync_manager.py, etc.). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx * fix: first-time install script fails on apt-managed requests package web_interface/requirements.txt and requirements.txt both pin requests>=2.33.0,<3.0.0, but Raspberry Pi OS ships an apt-managed python3-requests with no pip RECORD file. Upgrading it via plain `pip install` aborts with "uninstall-no-record-file" because pip refuses to uninstall a package it has no record of, in place — which is exactly the "Some web interface dependencies failed to install" warning first-time install hits. scripts/install_dependencies_apt.py and scripts/fix_perms/safe_pip_install.sh already work around this with --ignore-installed (lets pip lay the new version down in /usr/local, shadowing the apt copy, instead of trying to remove it first). first_time_install.sh's own direct pip invocations — the per-package requirements.txt loop, the web_interface/requirements.txt install, and the requirements_web_v2.txt fallback — didn't have it. Adds --ignore-installed to all three so first-time install no longer fails on this well-known apt/pip conflict. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx * chore: add nosemgrep to subprocess.run calls Codacy still flagged The prior # nosec B603 comments suppressed Bandit's check but Codacy's semgrep-based rule ("subprocess function 'run' without a static string") kept flagging the same two lines as a critical security issue even after that fix landed. install_dependencies_apt.py's _run() already needed both tags together (# nosec B603 B607 ... # nosemgrep) for the identical subprocess.run pattern, so apply the same double suppression here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx * fix: add --ignore-installed to install_requirements_file fallback path CodeRabbit review caught this (confirming a gap already flagged in conversation): the non-sudo fallback pip install in install_requirements_file was missing --ignore-installed, unlike the sudo-wrapper branch and safe_pip_install.sh. Without it, the same apt/pip RECORD-file conflict this PR fixes elsewhere (first_time_install.sh, install_dependencies_apt.py) could still hit installs that fall back to this path (e.g. a plugin's requirements.txt on a host where safe_pip_install.sh isn't set up yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from src.web_interface.validators import (
|
||||
validate_file_upload
|
||||
)
|
||||
from src.error_aggregator import get_error_aggregator
|
||||
from src.common.permission_utils import install_requirements_file
|
||||
|
||||
_SUDO = shutil.which('sudo')
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
@@ -50,83 +51,12 @@ def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.Comple
|
||||
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.
|
||||
|
||||
Thin wrapper around the shared implementation in permission_utils so the
|
||||
Plugin Store's own dependency installation (store_manager.py) follows the
|
||||
exact same root-visible install path instead of a divergent one.
|
||||
"""
|
||||
wrapper = PROJECT_ROOT / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
|
||||
if wrapper.exists():
|
||||
# Must invoke via an explicit `bash <path>` — matching both the
|
||||
# sudoers rule configure_web_sudo.sh provisions ($BASH_PATH
|
||||
# $SAFE_PIP_INSTALL_PATH *) and the existing safe_plugin_rm.sh call
|
||||
# in src/common/permission_utils.py. Calling the script path directly
|
||||
# (relying on its shebang) makes sudo check a different command line
|
||||
# than what's actually allowlisted, so `sudo -n` denies it on any
|
||||
# install that only has the specific rules this script provisions —
|
||||
# it only appeared to work in prior testing because that device also
|
||||
# had a broader, non-standard NOPASSWD: ALL grant.
|
||||
#
|
||||
# $BASH_PATH is resolved once at setup time (configure_web_sudo.sh's
|
||||
# `command -v bash`) and baked into the static sudoers file as a
|
||||
# literal path; sudo requires an exact string match against that, so
|
||||
# if this process's own PATH resolves bash somewhere else, the
|
||||
# sudoers rule won't match here either. Try the standard Debian/
|
||||
# Raspberry Pi OS locations first, then this process's own
|
||||
# resolution, so a divergence in just one of them doesn't break this.
|
||||
bash_candidates = []
|
||||
for candidate in ('/usr/bin/bash', '/bin/bash', shutil.which('bash')):
|
||||
if candidate and candidate not in bash_candidates:
|
||||
bash_candidates.append(candidate)
|
||||
|
||||
result = None
|
||||
for bash_path in bash_candidates:
|
||||
result = subprocess.run(
|
||||
['sudo', '-n', bash_path, str(wrapper), str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Best-effort distinction between "sudo rejected this exact
|
||||
# command line" (no matching NOPASSWD rule for this bash path —
|
||||
# worth trying the next candidate) and "sudo ran it but the
|
||||
# wrapper/pip itself failed" (a real error — stop and surface it
|
||||
# rather than uselessly retrying other bash paths or doubling up
|
||||
# with a redundant non-root install attempt).
|
||||
denied = any(
|
||||
phrase in result.stderr
|
||||
for phrase in ('a password is required', 'is not allowed to run', 'no tty present')
|
||||
)
|
||||
if not denied:
|
||||
logger.warning(
|
||||
"[Pip Install] Root install failed (rc=%s) for %s: %s",
|
||||
result.returncode, req_file, result.stderr.strip()[:500],
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(
|
||||
"[Pip Install] Root wrapper denied via sudo for %s; falling back "
|
||||
"to user-level install: %s",
|
||||
req_file, result.stderr.strip()[:500] if result else 'no bash candidates found',
|
||||
)
|
||||
note = (
|
||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') 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:
|
||||
logger.warning(
|
||||
"[Pip Install] safe_pip_install.sh not found; falling back to user-level install for %s",
|
||||
req_file,
|
||||
)
|
||||
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
|
||||
return install_requirements_file(req_file, timeout=timeout)
|
||||
|
||||
|
||||
def _scrub_git_remote_url(url: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user