mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 09:48:06 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86dc5914b |
@@ -8,7 +8,6 @@ files that need to be accessible by both root service and web user.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -17,25 +16,6 @@ from typing import Optional
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
|
||||||
# own error output can be logged/displayed without echoing back a private
|
|
||||||
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
|
||||||
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
|
||||||
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_url_credentials(text: Optional[str]) -> str:
|
|
||||||
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
|
||||||
|
|
||||||
Safe to call on any subprocess output destined for logs: it only ever
|
|
||||||
shortens/replaces the credential substring, never changes the presence
|
|
||||||
or absence of the specific fixed phrases callers check for
|
|
||||||
(e.g. "a password is required"), so it can't affect control flow.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text or ""
|
|
||||||
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
|
||||||
|
|
||||||
# System directories that should never have their permissions modified
|
# System directories that should never have their permissions modified
|
||||||
# These directories have special system-level permissions that must be preserved
|
# These directories have special system-level permissions that must be preserved
|
||||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||||
@@ -358,13 +338,6 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
|||||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||||
)
|
)
|
||||||
# Redact immediately: pip can echo a private index URL's embedded
|
|
||||||
# basic-auth credentials back in its own error/progress output
|
|
||||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
|
||||||
# the fixed-phrase "denied" check below -- those phrases never
|
|
||||||
# overlap with URL syntax.
|
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
|
||||||
result.stdout = _redact_url_credentials(result.stdout)
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return result
|
return result
|
||||||
# Distinguish "sudo rejected this exact command line" (worth
|
# Distinguish "sudo rejected this exact command line" (worth
|
||||||
@@ -375,24 +348,16 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
|||||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||||
)
|
)
|
||||||
if not denied:
|
if not denied:
|
||||||
# Deliberately don't interpolate req_file or the pip output here:
|
|
||||||
# this log line is scanner-visible, and a static analyzer can't
|
|
||||||
# tell "already redacted above" from "still raw" just by looking
|
|
||||||
# at this call in isolation. The full (redacted) text is still
|
|
||||||
# available to callers via the returned CompletedProcess.
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Root pip install failed (rc=%s); see the returned "
|
"Root pip install failed (rc=%s) for %s: %s",
|
||||||
"CompletedProcess.stderr for details.",
|
result.returncode, req_file, result.stderr.strip()[:500],
|
||||||
result.returncode,
|
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
|
||||||
# this log line, only in the returned note/CompletedProcess.
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Root pip install wrapper denied via sudo for all candidates; "
|
"Root pip install wrapper denied via sudo for %s; falling back to "
|
||||||
"falling back to user-level install. See the returned "
|
"user-level install: %s",
|
||||||
"CompletedProcess.stderr for details."
|
req_file, result.stderr.strip()[:500] if result else "no bash candidates found",
|
||||||
)
|
)
|
||||||
note = (
|
note = (
|
||||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
||||||
@@ -402,7 +367,8 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"safe_pip_install.sh not found; falling back to user-level install."
|
"safe_pip_install.sh not found; falling back to user-level install for %s",
|
||||||
|
req_file,
|
||||||
)
|
)
|
||||||
note = (
|
note = (
|
||||||
"[safe_pip_install.sh not found; installed for the current process's "
|
"[safe_pip_install.sh not found; installed for the current process's "
|
||||||
@@ -420,7 +386,6 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
|||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||||
)
|
)
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
result.stdout = note + (result.stdout or "")
|
||||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -93,27 +93,6 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
|
||||||
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
|
|
||||||
|
|
||||||
Used as a containment check for a directory name derived from untrusted
|
|
||||||
input (a manifest-declared plugin id, an externally-supplied plugin
|
|
||||||
path): the returned value always comes from enumerating trusted_dir
|
|
||||||
itself via os.scandir(), so a caller that builds a path by joining
|
|
||||||
trusted_dir with this return value is joining against a name the
|
|
||||||
filesystem produced under a trusted root -- not the caller's original
|
|
||||||
string, which could otherwise smuggle a traversal sequence through.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with os.scandir(trusted_dir) as entries:
|
|
||||||
for entry in entries:
|
|
||||||
if entry.name == name and entry.is_dir():
|
|
||||||
return entry.name
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader:
|
class PluginLoader:
|
||||||
"""Handles plugin module loading and class instantiation."""
|
"""Handles plugin module loading and class instantiation."""
|
||||||
|
|
||||||
@@ -228,7 +207,7 @@ class PluginLoader:
|
|||||||
self,
|
self,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugins_dir: Path,
|
plugins_dir: Optional[Path] = None,
|
||||||
timeout: int = 300
|
timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -237,12 +216,7 @@ class PluginLoader:
|
|||||||
Args:
|
Args:
|
||||||
plugin_dir: Plugin directory path
|
plugin_dir: Plugin directory path
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
plugins_dir: Trusted base plugins directory for path containment check.
|
plugins_dir: Trusted base plugins directory for path containment check
|
||||||
Required (not optional) so every caller reconstructs the plugin
|
|
||||||
path through the sanitiser below rather than trusting plugin_dir
|
|
||||||
directly -- CodeQL's path-injection query (and a malicious
|
|
||||||
manifest/plugin_id in practice) can't tell a legitimate
|
|
||||||
plugin_dir from one crafted to traverse outside plugins_dir.
|
|
||||||
timeout: Installation timeout in seconds
|
timeout: Installation timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -254,24 +228,31 @@ class PluginLoader:
|
|||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
|
||||||
requested_name = os.path.basename(plugin_dir_real)
|
|
||||||
|
|
||||||
# Match the requested directory against an entry actually enumerated
|
if plugins_dir is not None:
|
||||||
# from the trusted plugins_dir, and build the path from that entry --
|
# Reconstruct the plugin path from a trusted base + a sanitised
|
||||||
# not from requested_name. A name that came out of os.scandir() on a
|
# directory name. os.path.basename() is CodeQL's recognised
|
||||||
# trusted root carries no taint regardless of what the caller asked
|
# py/path-injection sanitiser: it strips all directory components
|
||||||
# for, so this is a real containment guarantee (an allowlist check
|
# so the result cannot contain traversal sequences. Joining it
|
||||||
# against a trusted source), not a string-sanitisation of untrusted
|
# with the resolved, trusted plugins_dir produces a path that
|
||||||
# input that a static analyzer has to trust blindly.
|
# CodeQL considers untainted.
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
if matched_name is None:
|
safe_dir_name = os.path.basename(plugin_dir_real)
|
||||||
self.logger.error(
|
if not safe_dir_name:
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
||||||
)
|
return False
|
||||||
return False
|
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
||||||
|
if not os.path.isdir(safe_plugin_dir):
|
||||||
|
self.logger.error(
|
||||||
|
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
safe_plugin_dir = plugin_dir_real
|
||||||
|
if not os.path.isdir(safe_plugin_dir):
|
||||||
|
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||||
|
return False
|
||||||
|
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
if not os.path.isfile(requirements_file):
|
||||||
@@ -717,14 +698,6 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
# Install dependencies if needed
|
# Install dependencies if needed
|
||||||
if install_deps:
|
if install_deps:
|
||||||
if plugins_dir is None:
|
|
||||||
raise PluginError(
|
|
||||||
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
|
|
||||||
"(needed for path containment; pass install_deps=False if the caller "
|
|
||||||
"doesn't have a trusted plugins directory to supply)",
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
context={'plugin_dir': str(plugin_dir)},
|
|
||||||
)
|
|
||||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||||
raise PluginError(
|
raise PluginError(
|
||||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import types
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
@@ -744,18 +743,8 @@ class PluginManager:
|
|||||||
# If resource monitor exists, wrap the call
|
# If resource monitor exists, wrap the call
|
||||||
def monitored_update():
|
def monitored_update():
|
||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||||
# SimpleNamespace stores `update` as an *instance*
|
|
||||||
# attribute, so attribute lookup returns the plain
|
|
||||||
# function object as-is. A dynamically-built class
|
|
||||||
# (`type(..., {'update': monitored_update})`) instead
|
|
||||||
# stores it as a *class* attribute, which the
|
|
||||||
# descriptor protocol turns into a bound method on
|
|
||||||
# access -- silently prepending the instance as an
|
|
||||||
# implicit first argument to a function that takes
|
|
||||||
# none, raising "monitored_update() takes 0
|
|
||||||
# positional arguments but 1 was given" on every call.
|
|
||||||
success = self.plugin_executor.execute_update(
|
success = self.plugin_executor.execute_update(
|
||||||
types.SimpleNamespace(update=monitored_update),
|
type('obj', (object,), {'update': monitored_update})(),
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import logging
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
||||||
from src.plugin_system.plugin_loader import (
|
from src.plugin_system.plugin_loader import requirements_has_real_deps, requirements_are_satisfied
|
||||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
@@ -142,28 +140,9 @@ class PluginStoreManager:
|
|||||||
# then get the result from the warm cache (double-checked locking).
|
# then get the result from the warm cache (double-checked locking).
|
||||||
self._registry_fetch_lock = threading.Lock()
|
self._registry_fetch_lock = threading.Lock()
|
||||||
|
|
||||||
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
|
||||||
# Flask with threaded=True, so two overlapping requests for the
|
|
||||||
# same plugin_id (double-click, two browser tabs) would otherwise
|
|
||||||
# both rename the same directory aside — one succeeds, and the
|
|
||||||
# loser can end up renaming the winner's in-progress install aside
|
|
||||||
# mid-download, stealing its own rollback safety net. Keyed by
|
|
||||||
# plugin_id so unrelated plugins still update concurrently.
|
|
||||||
self._reinstall_locks: Dict[str, threading.Lock] = {}
|
|
||||||
self._reinstall_locks_guard = threading.Lock()
|
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock:
|
|
||||||
"""Lazily create (or fetch) the per-plugin reinstall lock."""
|
|
||||||
with self._reinstall_locks_guard:
|
|
||||||
lock = self._reinstall_locks.get(plugin_id)
|
|
||||||
if lock is None:
|
|
||||||
lock = threading.Lock()
|
|
||||||
self._reinstall_locks[plugin_id] = lock
|
|
||||||
return lock
|
|
||||||
|
|
||||||
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
||||||
cache_timeout: int, payload: Any) -> None:
|
cache_timeout: int, payload: Any) -> None:
|
||||||
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
||||||
@@ -1928,25 +1907,7 @@ class PluginStoreManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if successful or no requirements file
|
True if successful or no requirements file
|
||||||
"""
|
"""
|
||||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
requirements_file = plugin_path / "requirements.txt"
|
||||||
# an entry actually enumerated from it, rather than trusting
|
|
||||||
# plugin_path directly -- callers ultimately derive it from a
|
|
||||||
# plugin-supplied manifest "id" field (see install_plugin_from_url),
|
|
||||||
# so without this a malicious manifest could point requirements_file
|
|
||||||
# outside plugins_dir. find_trusted_subdir()'s return value always
|
|
||||||
# comes from os.scandir() on the trusted root, so building the path
|
|
||||||
# from it (not from the caller's string) is a real containment
|
|
||||||
# guarantee, matching the pattern in PluginLoader.install_dependencies().
|
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_path))
|
|
||||||
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
|
|
||||||
requested_name = os.path.basename(plugin_dir_real)
|
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
|
||||||
if matched_name is None:
|
|
||||||
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
|
||||||
return False
|
|
||||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
|
||||||
|
|
||||||
requirements_file = safe_plugin_path / "requirements.txt"
|
|
||||||
|
|
||||||
if not requirements_file.exists():
|
if not requirements_file.exists():
|
||||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
||||||
@@ -2282,74 +2243,6 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
|
||||||
"""Replace an installed plugin with a fresh install, atomically.
|
|
||||||
|
|
||||||
The old install is renamed aside (not deleted) until the new install
|
|
||||||
succeeds, then removed; on ANY install failure the old directory is
|
|
||||||
restored. This is the difference between a failed update and a
|
|
||||||
destroyed plugin: the previous delete-then-install flow permanently
|
|
||||||
removed plugins whenever the download failed mid-update (seen in the
|
|
||||||
field during the monorepo migration on a Pi with broken DNS — every
|
|
||||||
old-remote plugin was deleted and none could be re-downloaded).
|
|
||||||
|
|
||||||
The aside name embeds '.standalone-backup-' so plugin discovery
|
|
||||||
(plugin_manager._scan_directory_for_plugins) ignores it even though
|
|
||||||
it still contains a manifest.json.
|
|
||||||
|
|
||||||
Held for the whole operation under a per-plugin_id lock: two
|
|
||||||
overlapping requests for the same plugin (double-click, two
|
|
||||||
browser tabs — the web UI runs Flask with threaded=True) must not
|
|
||||||
interleave their renames, or the second could steal the first's
|
|
||||||
rollback safety net mid-install. Other plugin_ids are unaffected.
|
|
||||||
"""
|
|
||||||
with self._get_reinstall_lock(plugin_id):
|
|
||||||
backup_path = plugin_path.with_name(
|
|
||||||
f"{plugin_path.name}.standalone-backup-migrating")
|
|
||||||
# A stale aside from a previous crash would block the rename
|
|
||||||
if backup_path.exists():
|
|
||||||
if not self._safe_remove_directory(backup_path):
|
|
||||||
self.logger.error(
|
|
||||||
f"Could not clear stale backup for {plugin_id} at "
|
|
||||||
f"{backup_path}; leaving old install in place")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
plugin_path.rename(backup_path)
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Could not set aside old plugin directory for {plugin_id}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
installed = self.install_plugin(plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
|
|
||||||
installed = False
|
|
||||||
|
|
||||||
if installed:
|
|
||||||
if not self._safe_remove_directory(backup_path):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Update of {plugin_id} succeeded but the old backup "
|
|
||||||
f"at {backup_path} could not be removed; it will be "
|
|
||||||
f"cleared on the next update")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Install failed (bad network, registry error...) — put the old
|
|
||||||
# version back so the user still has a working plugin.
|
|
||||||
self.logger.error(
|
|
||||||
f"Reinstall of {plugin_id} failed; restoring previous version")
|
|
||||||
try:
|
|
||||||
if plugin_path.exists():
|
|
||||||
# partial download debris from the failed install
|
|
||||||
self._safe_remove_directory(plugin_path)
|
|
||||||
backup_path.rename(plugin_path)
|
|
||||||
self.logger.info(f"Restored previous install of {plugin_id}")
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
|
|
||||||
f"The previous install is preserved there — rename it back manually.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update_plugin(self, plugin_id: str) -> bool:
|
def update_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update a plugin to the latest commit on its upstream branch.
|
Update a plugin to the latest commit on its upstream branch.
|
||||||
@@ -2412,7 +2305,10 @@ class PluginStoreManager:
|
|||||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||||
f"Reinstalling from registry to migrate to new source."
|
f"Reinstalling from registry to migrate to new source."
|
||||||
)
|
)
|
||||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
if not self._safe_remove_directory(plugin_path):
|
||||||
|
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
|
||||||
|
return False
|
||||||
|
return self.install_plugin(resolved_id)
|
||||||
|
|
||||||
# Check if already up to date
|
# Check if already up to date
|
||||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||||
@@ -2716,11 +2612,11 @@ class PluginStoreManager:
|
|||||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||||
|
|
||||||
# Reinstall with the old version kept aside until the new
|
# Remove directory and reinstall fresh
|
||||||
# download succeeds — this is the path every routine store
|
if not self._safe_remove_directory(plugin_path):
|
||||||
# update takes, and a mid-update network failure must not
|
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||||
# destroy the user's plugin.
|
return False
|
||||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
return self.install_plugin(registry_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
@@ -454,18 +454,6 @@ class VisualTestDisplayManager:
|
|||||||
"""Check if display is currently scrolling."""
|
"""Check if display is currently scrolling."""
|
||||||
return self._scrolling_state['is_scrolling']
|
return self._scrolling_state['is_scrolling']
|
||||||
|
|
||||||
def process_deferred_updates(self):
|
|
||||||
"""Process any deferred updates (no-op for testing).
|
|
||||||
|
|
||||||
Several ticker-style plugins (news, odds-ticker, leaderboard,
|
|
||||||
stock-news, stocks) call this unconditionally between
|
|
||||||
set_scrolling_state() and their scroll-position update, mirroring the
|
|
||||||
real display_manager's deferred-update queue. This double has no such
|
|
||||||
queue, so there is nothing to process — the no-op just lets those
|
|
||||||
plugins render under the harness instead of raising AttributeError.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Utility methods
|
# Utility methods
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -172,16 +172,6 @@ class TestVisualDisplayManager:
|
|||||||
vdm.set_scrolling_state(False)
|
vdm.set_scrolling_state(False)
|
||||||
assert vdm.is_currently_scrolling() is False
|
assert vdm.is_currently_scrolling() is False
|
||||||
|
|
||||||
def test_process_deferred_updates_is_noop(self):
|
|
||||||
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
|
|
||||||
# stocks) call this unconditionally alongside set_scrolling_state();
|
|
||||||
# it must exist and be harmless so those plugins render under the
|
|
||||||
# harness instead of raising AttributeError.
|
|
||||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
|
||||||
vdm.set_scrolling_state(True)
|
|
||||||
vdm.process_deferred_updates() # should not raise
|
|
||||||
assert vdm.is_currently_scrolling() is True
|
|
||||||
|
|
||||||
def test_format_date_with_ordinal(self):
|
def test_format_date_with_ordinal(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src.common.permission_utils's URL-credential redaction.
|
|
||||||
|
|
||||||
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
|
|
||||||
install_requirements_file() must never let a private index URL's embedded
|
|
||||||
user:pass@ credentials reach logs or its returned CompletedProcess, since
|
|
||||||
pip can echo that URL back verbatim in its own stderr/stdout on failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
|
|
||||||
|
|
||||||
|
|
||||||
class TestRedactUrlCredentials:
|
|
||||||
def test_redacts_embedded_basic_auth(self):
|
|
||||||
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
|
|
||||||
redacted = _redact_url_credentials(text)
|
|
||||||
assert "s3cr3t" not in redacted
|
|
||||||
assert "alice" not in redacted
|
|
||||||
assert "https://***:***@pypi.example.com/simple/" in redacted
|
|
||||||
|
|
||||||
def test_leaves_credential_free_text_unchanged(self):
|
|
||||||
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
|
|
||||||
assert _redact_url_credentials(text) == text
|
|
||||||
|
|
||||||
def test_handles_none_and_empty(self):
|
|
||||||
assert _redact_url_credentials(None) == ""
|
|
||||||
assert _redact_url_credentials("") == ""
|
|
||||||
|
|
||||||
def test_does_not_touch_denied_check_phrases(self):
|
|
||||||
"""The fixed phrases install_requirements_file greps for must survive
|
|
||||||
redaction untouched -- they don't overlap with URL syntax, but this
|
|
||||||
pins that assumption so a regex change can't silently break it."""
|
|
||||||
text = "sudo: a password is required"
|
|
||||||
assert _redact_url_credentials(text) == text
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallRequirementsFileRedaction:
|
|
||||||
@patch('src.common.permission_utils.subprocess.run')
|
|
||||||
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
|
|
||||||
"""safe_pip_install.sh exists in this repo, so install_requirements_file
|
|
||||||
takes the sudo-wrapper branch; a failing result must come back
|
|
||||||
with any embedded index-URL credentials already redacted."""
|
|
||||||
req_file = tmp_path / "requirements.txt"
|
|
||||||
req_file.write_text("requests\n")
|
|
||||||
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=1,
|
|
||||||
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
|
|
||||||
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_requirements_file(req_file, timeout=5)
|
|
||||||
|
|
||||||
assert "hunter2" not in result.stdout
|
|
||||||
assert "hunter2" not in result.stderr
|
|
||||||
assert "https://***:***@pypi.internal" in result.stdout
|
|
||||||
assert "https://***:***@pypi.internal" in result.stderr
|
|
||||||
|
|
||||||
@patch('src.common.permission_utils.subprocess.run')
|
|
||||||
@patch('src.common.permission_utils.Path.exists', return_value=False)
|
|
||||||
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
|
|
||||||
"""No safe_pip_install.sh wrapper -> falls straight to the
|
|
||||||
sys.executable pip fallback (the second subprocess.run call site);
|
|
||||||
its result must come back redacted too, independent of the wrapper
|
|
||||||
branch's own redaction above."""
|
|
||||||
req_file = tmp_path / "requirements.txt"
|
|
||||||
req_file.write_text("requests\n")
|
|
||||||
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=1,
|
|
||||||
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
|
|
||||||
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_requirements_file(req_file, timeout=5)
|
|
||||||
|
|
||||||
assert "swordfish" not in result.stdout
|
|
||||||
assert "swordfish" not in result.stderr
|
|
||||||
assert "https://***:***@pypi.internal" in result.stdout
|
|
||||||
assert "https://***:***@pypi.internal" in result.stderr
|
|
||||||
@@ -193,7 +193,7 @@ class TestPluginLoader:
|
|||||||
|
|
||||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_subprocess.assert_called_once()
|
mock_subprocess.assert_called_once()
|
||||||
@@ -204,7 +204,7 @@ class TestPluginLoader:
|
|||||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||||
plugin_dir.mkdir()
|
plugin_dir.mkdir()
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_subprocess.assert_not_called()
|
mock_subprocess.assert_not_called()
|
||||||
@@ -219,7 +219,7 @@ class TestPluginLoader:
|
|||||||
|
|
||||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ class TestPluginLoader:
|
|||||||
retry_attempt = MagicMock(returncode=0, stderr="")
|
retry_attempt = MagicMock(returncode=0, stderr="")
|
||||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_subprocess.call_count == 2
|
assert mock_subprocess.call_count == 2
|
||||||
@@ -271,7 +271,7 @@ class TestPluginLoader:
|
|||||||
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
|
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
|
||||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_subprocess.call_count == 2
|
assert mock_subprocess.call_count == 2
|
||||||
@@ -298,7 +298,7 @@ class TestPluginLoader:
|
|||||||
subprocess.TimeoutExpired(cmd="pip", timeout=300),
|
subprocess.TimeoutExpired(cmd="pip", timeout=300),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_subprocess.call_count == 2
|
assert mock_subprocess.call_count == 2
|
||||||
@@ -311,34 +311,7 @@ class TestPluginLoader:
|
|||||||
requirements_file = plugin_dir / "requirements.txt"
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
requirements_file.write_text("pytest>=1.0\n")
|
requirements_file.write_text("pytest>=1.0\n")
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_subprocess.assert_not_called()
|
mock_subprocess.assert_not_called()
|
||||||
|
|
||||||
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
|
|
||||||
"""plugins_dir is a required argument, not an optional trust-me flag --
|
|
||||||
calling without it must fail loudly (TypeError) rather than silently
|
|
||||||
falling back to trusting plugin_dir unchecked."""
|
|
||||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
|
||||||
plugin_dir.mkdir()
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
def test_install_dependencies_rejects_path_outside_plugins_dir(
|
|
||||||
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
|
|
||||||
):
|
|
||||||
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
|
|
||||||
manifest-derived id crafted to traverse elsewhere) must be rejected
|
|
||||||
rather than read from -- this is the path-injection containment
|
|
||||||
check CodeQL flagged as missing."""
|
|
||||||
outside_dir = tmp_path / "outside"
|
|
||||||
outside_dir.mkdir()
|
|
||||||
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
|
|
||||||
|
|
||||||
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
mock_subprocess.assert_not_called()
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
from src.plugin_system.plugin_state import PluginState
|
from src.plugin_system.plugin_state import PluginState
|
||||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
|
||||||
|
|
||||||
class TestPluginManager:
|
class TestPluginManager:
|
||||||
"""Test PluginManager functionality."""
|
"""Test PluginManager functionality."""
|
||||||
@@ -79,54 +78,6 @@ class TestPluginManager:
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
||||||
|
|
||||||
def test_run_scheduled_updates_calls_update_with_resource_monitor(
|
|
||||||
self, mock_config_manager, mock_display_manager, mock_cache_manager
|
|
||||||
):
|
|
||||||
"""Regression test: run_scheduled_updates() must actually call a
|
|
||||||
plugin's update() when self.resource_monitor is set (as it is in
|
|
||||||
every real deployment -- display_controller.py and web_interface/
|
|
||||||
app.py both assign a real PluginResourceMonitor after construction).
|
|
||||||
|
|
||||||
Previously, the resource_monitor branch wrapped the call in a
|
|
||||||
function stored as a *class* attribute on a dynamically-built type
|
|
||||||
(`type('obj', (object,), {'update': monitored_update})()`), which
|
|
||||||
the descriptor protocol turns into a bound method on access --
|
|
||||||
silently passing the synthetic instance as an implicit first
|
|
||||||
argument to monitored_update(), which takes none. Every plugin's
|
|
||||||
scheduled update failed with "monitored_update() takes 0 positional
|
|
||||||
arguments but 1 was given" and was silently swallowed into a
|
|
||||||
circuit-breaker retry loop that never succeeded, so plugin data
|
|
||||||
(scores, odds, etc.) never refreshed.
|
|
||||||
"""
|
|
||||||
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
|
||||||
pm = PluginManager(
|
|
||||||
plugins_dir="plugins",
|
|
||||||
config_manager=mock_config_manager,
|
|
||||||
display_manager=mock_display_manager,
|
|
||||||
cache_manager=mock_cache_manager
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_instance = MagicMock()
|
|
||||||
plugin_instance.enabled = True
|
|
||||||
plugin_instance.update = MagicMock()
|
|
||||||
|
|
||||||
pm.plugins["test_plugin"] = plugin_instance
|
|
||||||
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
|
|
||||||
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
|
|
||||||
# Plain MagicMock, not the mock_cache_manager fixture: this test
|
|
||||||
# is about run_scheduled_updates() actually invoking update()
|
|
||||||
# through the resource-monitor wrapper, not about
|
|
||||||
# PluginResourceMonitor's own cache-backed metrics persistence
|
|
||||||
# (which calls cache_manager.get(..., memory_ttl=...) --
|
|
||||||
# a kwarg the fixture's mock_get() doesn't accept).
|
|
||||||
pm.resource_monitor = PluginResourceMonitor(MagicMock())
|
|
||||||
|
|
||||||
pm.run_scheduled_updates(current_time=time.time())
|
|
||||||
|
|
||||||
plugin_instance.update.assert_called_once()
|
|
||||||
assert "test_plugin" in pm.plugin_last_update
|
|
||||||
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginLoader:
|
class TestPluginLoader:
|
||||||
"""Test PluginLoader functionality."""
|
"""Test PluginLoader functionality."""
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Tests for atomic plugin updates (store_manager._reinstall_with_rollback).
|
|
||||||
|
|
||||||
Regression for a field data-loss incident: update_plugin's reinstall paths
|
|
||||||
(monorepo migration AND routine archive updates) deleted the installed
|
|
||||||
plugin BEFORE downloading its replacement — a mid-update network failure
|
|
||||||
permanently destroyed the plugin. Seen live: a Pi with broken DNS lost 12
|
|
||||||
plugins from one update pass.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from src.plugin_system.store_manager import PluginStoreManager # noqa: E402
|
|
||||||
|
|
||||||
PLUGIN_ID = "rollback-test-plugin"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path):
|
|
||||||
mgr = PluginStoreManager(plugins_dir=str(tmp_path))
|
|
||||||
plugin_dir = tmp_path / PLUGIN_ID
|
|
||||||
plugin_dir.mkdir()
|
|
||||||
(plugin_dir / "manifest.json").write_text(json.dumps(
|
|
||||||
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "1.0.0"}))
|
|
||||||
(plugin_dir / "manager.py").write_text("# old version marker\n")
|
|
||||||
return mgr, plugin_dir
|
|
||||||
|
|
||||||
|
|
||||||
class TestReinstallWithRollback:
|
|
||||||
def test_failed_install_restores_old_version(self, store):
|
|
||||||
"""The whole point: a failed download must leave the old install."""
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
with patch.object(mgr, "install_plugin", return_value=False):
|
|
||||||
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
|
||||||
assert ok is False
|
|
||||||
assert plugin_dir.exists()
|
|
||||||
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
|
||||||
# no aside debris left behind
|
|
||||||
leftovers = [p for p in plugin_dir.parent.iterdir()
|
|
||||||
if "standalone-backup" in p.name]
|
|
||||||
assert leftovers == []
|
|
||||||
|
|
||||||
def test_install_exception_restores_old_version(self, store):
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
with patch.object(mgr, "install_plugin",
|
|
||||||
side_effect=RuntimeError("network down")):
|
|
||||||
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
|
||||||
assert ok is False
|
|
||||||
assert plugin_dir.exists()
|
|
||||||
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
|
||||||
|
|
||||||
def test_successful_install_removes_aside(self, store):
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
|
|
||||||
def fake_install(plugin_id):
|
|
||||||
new_dir = plugin_dir # same path, new content
|
|
||||||
new_dir.mkdir(exist_ok=True)
|
|
||||||
(new_dir / "manager.py").write_text("# new version\n")
|
|
||||||
(new_dir / "manifest.json").write_text(json.dumps(
|
|
||||||
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
|
|
||||||
return True
|
|
||||||
|
|
||||||
with patch.object(mgr, "install_plugin", side_effect=fake_install):
|
|
||||||
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
|
||||||
assert ok is True
|
|
||||||
assert "new version" in (plugin_dir / "manager.py").read_text()
|
|
||||||
leftovers = [p for p in plugin_dir.parent.iterdir()
|
|
||||||
if "standalone-backup" in p.name]
|
|
||||||
assert leftovers == []
|
|
||||||
|
|
||||||
def test_partial_download_debris_is_replaced_by_old_version(self, store):
|
|
||||||
"""A failed install that left a partial directory must still roll back."""
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
|
|
||||||
def fake_partial_install(plugin_id):
|
|
||||||
plugin_dir.mkdir(exist_ok=True)
|
|
||||||
(plugin_dir / "half-downloaded.tmp").write_text("junk")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with patch.object(mgr, "install_plugin", side_effect=fake_partial_install):
|
|
||||||
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
|
||||||
assert ok is False
|
|
||||||
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
|
||||||
assert not (plugin_dir / "half-downloaded.tmp").exists()
|
|
||||||
|
|
||||||
def test_stale_aside_from_previous_crash_is_cleared(self, store):
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
|
|
||||||
stale.mkdir()
|
|
||||||
(stale / "old.txt").write_text("stale")
|
|
||||||
with patch.object(mgr, "install_plugin", return_value=False) as mock_install:
|
|
||||||
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
|
||||||
# The reinstall itself still fails (mocked) and the old install is
|
|
||||||
# restored, but the stale aside must not have survived — otherwise
|
|
||||||
# it would have blocked this run's own rename (or a future one).
|
|
||||||
assert not stale.exists()
|
|
||||||
mock_install.assert_called_once_with(PLUGIN_ID)
|
|
||||||
assert ok is False
|
|
||||||
assert plugin_dir.exists()
|
|
||||||
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
|
||||||
|
|
||||||
def test_concurrent_updates_for_same_plugin_are_serialized(self, store):
|
|
||||||
"""Two overlapping requests for the same plugin_id (double-click,
|
|
||||||
two browser tabs — the web UI runs Flask with threaded=True) must
|
|
||||||
not interleave: the loser must wait for the winner to finish
|
|
||||||
rather than renaming the winner's in-progress install aside and
|
|
||||||
stealing its rollback safety net."""
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
|
|
||||||
active = 0
|
|
||||||
max_active = 0
|
|
||||||
guard = threading.Lock()
|
|
||||||
|
|
||||||
def fake_install(plugin_id):
|
|
||||||
nonlocal active, max_active
|
|
||||||
with guard:
|
|
||||||
active += 1
|
|
||||||
max_active = max(max_active, active)
|
|
||||||
time.sleep(0.05)
|
|
||||||
plugin_dir.mkdir(exist_ok=True)
|
|
||||||
(plugin_dir / "manager.py").write_text("# new version\n")
|
|
||||||
(plugin_dir / "manifest.json").write_text(json.dumps(
|
|
||||||
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
|
|
||||||
with guard:
|
|
||||||
active -= 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
results.append(mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir))
|
|
||||||
|
|
||||||
with patch.object(mgr, "install_plugin", side_effect=fake_install):
|
|
||||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join(timeout=5)
|
|
||||||
|
|
||||||
assert max_active == 1, "install_plugin ran concurrently for the same plugin_id"
|
|
||||||
assert results == [True, True]
|
|
||||||
assert plugin_dir.exists()
|
|
||||||
assert "new version" in (plugin_dir / "manager.py").read_text()
|
|
||||||
leftovers = [p for p in plugin_dir.parent.iterdir()
|
|
||||||
if "standalone-backup" in p.name]
|
|
||||||
assert leftovers == []
|
|
||||||
|
|
||||||
def test_aside_name_is_invisible_to_discovery(self, store, tmp_path):
|
|
||||||
"""The aside still contains a manifest.json — discovery must skip it
|
|
||||||
(relies on the existing '.standalone-backup-' exclusion)."""
|
|
||||||
mgr, plugin_dir = store
|
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
|
||||||
aside = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
|
|
||||||
plugin_dir.rename(aside)
|
|
||||||
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
|
||||||
display_manager=None, cache_manager=None)
|
|
||||||
found = pm._scan_directory_for_plugins(Path(tmp_path))
|
|
||||||
assert PLUGIN_ID not in found
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
Reference in New Issue
Block a user