diff --git a/backend/routers/packages.py b/backend/routers/packages.py index 28654bd..d3eb91e 100644 --- a/backend/routers/packages.py +++ b/backend/routers/packages.py @@ -1,6 +1,7 @@ -"""Package management — apt-get / dpkg wrapper for Debian-based systems.""" +"""Package management — apt / dpkg wrapper for Debian-based systems.""" import asyncio +import logging import os import re import shutil @@ -11,18 +12,19 @@ from pydantic import BaseModel from backend.auth import get_current_user router = APIRouter(prefix="/api/packages", tags=["packages"]) +log = logging.getLogger("atlus.packages") # --------------------------------------------------------------------------- # Guard # --------------------------------------------------------------------------- def _find_apt(): - """Find apt-cache binary, checking common paths if PATH is restricted.""" - found = shutil.which("apt-cache") - if found: - return True + """Find apt binary, checking common paths if PATH is restricted.""" + for name in ("apt-cache", "apt"): + if shutil.which(name): + return True # systemd services may have a minimal PATH — check directly - for p in ("/usr/bin/apt-cache", "/usr/sbin/apt-cache"): + for p in ("/usr/bin/apt-cache", "/usr/bin/apt"): if os.path.isfile(p) and os.access(p, os.X_OK): return True return False @@ -55,52 +57,35 @@ def _safe_env(): env = {**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"} # Ensure standard paths are included (systemd may use minimal PATH) path = env.get("PATH", "") - for p in ("/usr/bin", "/usr/sbin", "/bin", "/sbin"): + for p in ("/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin"): if p not in path: path = p + ":" + path env["PATH"] = path return env -async def _apt(*args: str, timeout: float = 30) -> str: - """Run an apt/dpkg command and return stdout.""" +async def _run_cmd(cmd: list[str], timeout: float = 30) -> tuple[int, str, str]: + """Run a command, return (returncode, stdout, stderr). Never raises.""" _require_apt() - cmd = list(args) - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_safe_env(), - ) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + except FileNotFoundError as e: + log.error("Command not found: %s — %s", cmd[0], e) + return 127, "", f"Command not found: {cmd[0]}" + try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() - raise HTTPException(504, "Package operation timed out") - if proc.returncode != 0: - msg = stderr.decode().strip() or stdout.decode().strip() - raise HTTPException(500, f"Package error: {msg}") - return stdout.decode() + return 1, "", "Operation timed out" - -async def _apt_nofail(*args: str, timeout: float = 30) -> tuple[int, str, str]: - """Run an apt/dpkg command, return (returncode, stdout, stderr) without raising.""" - _require_apt() - cmd = list(args) - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_safe_env(), - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - return 1, "", "Timed out" - return proc.returncode, stdout.decode(), stderr.decode() + return proc.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace") # --------------------------------------------------------------------------- @@ -121,14 +106,26 @@ async def search_packages( _user: str = Depends(get_current_user), ): """Search for packages matching a query.""" - output = await _apt("apt-cache", "search", q) + rc, stdout, stderr = await _run_cmd(["apt-cache", "search", q]) + + # apt-cache search returns 0 even with no results — stderr warnings are normal + if rc != 0: + log.warning("apt-cache search failed (rc=%d): %s", rc, stderr.strip()) + raise HTTPException(500, f"Package search failed: {stderr.strip()[:200]}") + + if stderr.strip(): + log.debug("apt-cache search stderr (non-fatal): %s", stderr.strip()[:200]) + results = [] - for line in output.strip().splitlines(): + for line in stdout.strip().splitlines(): if not line.strip(): continue parts = line.split(" - ", 1) if len(parts) == 2: results.append({"name": parts[0].strip(), "summary": parts[1].strip()}) + elif parts[0].strip(): + # Some lines may not have " - " separator + results.append({"name": parts[0].strip(), "summary": ""}) if len(results) >= 100: break return results @@ -143,13 +140,16 @@ async def package_info( name = _validate_name(name) # Get package info from apt-cache - output = await _apt("apt-cache", "show", name) + rc, stdout, stderr = await _run_cmd(["apt-cache", "show", name]) + if rc != 0: + log.warning("apt-cache show %s failed (rc=%d): %s", name, rc, stderr.strip()) + raise HTTPException(404, f"Package not found: {name}") # Parse key-value fields info = {} current_key = None current_val = "" - for line in output.splitlines(): + for line in stdout.splitlines(): if line.startswith(" "): # Continuation of previous field current_val += "\n" + line.strip() @@ -166,17 +166,17 @@ async def package_info( if current_key: info[current_key] = current_val - # Check installed status - rc, dpkg_out, _ = await _apt_nofail( - "dpkg-query", "-W", "-f=${Status}\t${Version}\n", name + # Check installed status via dpkg-query + rc2, dpkg_out, _ = await _run_cmd( + ["dpkg-query", "-W", "-f=${Status}\t${Version}\n", name] ) installed_version = None is_installed = False - if rc == 0 and dpkg_out.strip(): + if rc2 == 0 and dpkg_out.strip(): parts = dpkg_out.strip().split("\t") if len(parts) >= 2 and "install ok installed" in parts[0]: is_installed = True - installed_version = parts[1] + installed_version = parts[1].strip() return { "name": info.get("Package", name), @@ -201,8 +201,12 @@ async def install_package( ): """Install a package via apt-get.""" name = _validate_name(req.name) - output = await _apt("apt-get", "install", "-y", name, timeout=300) - return {"success": True, "package": name, "output": output[-500:]} + rc, stdout, stderr = await _run_cmd(["apt-get", "install", "-y", name], timeout=300) + if rc != 0: + msg = stderr.strip() or stdout.strip() + log.warning("apt-get install %s failed (rc=%d): %s", name, rc, msg[:200]) + raise HTTPException(500, f"Install failed: {msg[:300]}") + return {"success": True, "package": name, "output": stdout[-500:]} @router.post("/remove") @@ -212,12 +216,55 @@ async def remove_package( ): """Remove a package via apt-get.""" name = _validate_name(req.name) - output = await _apt("apt-get", "remove", "-y", name, timeout=120) - return {"success": True, "package": name, "output": output[-500:]} + rc, stdout, stderr = await _run_cmd(["apt-get", "remove", "-y", name], timeout=120) + if rc != 0: + msg = stderr.strip() or stdout.strip() + log.warning("apt-get remove %s failed (rc=%d): %s", name, rc, msg[:200]) + raise HTTPException(500, f"Remove failed: {msg[:300]}") + return {"success": True, "package": name, "output": stdout[-500:]} @router.post("/update-cache") async def update_cache(_user: str = Depends(get_current_user)): """Run apt-get update to refresh package lists.""" - output = await _apt("apt-get", "update", timeout=120) - return {"success": True, "output": output[-500:]} + rc, stdout, stderr = await _run_cmd(["apt-get", "update"], timeout=120) + if rc != 0: + msg = stderr.strip() or stdout.strip() + log.warning("apt-get update failed (rc=%d): %s", rc, msg[:200]) + raise HTTPException(500, f"Cache update failed: {msg[:300]}") + return {"success": True, "output": stdout[-500:]} + + +@router.get("/debug") +async def debug_apt(_user: str = Depends(get_current_user)): + """Debug endpoint — test apt-cache availability.""" + results = { + "has_apt": _HAS_APT, + "which_apt_cache": shutil.which("apt-cache"), + "which_apt": shutil.which("apt"), + "which_dpkg": shutil.which("dpkg-query"), + "path": os.environ.get("PATH", ""), + "safe_path": _safe_env().get("PATH", ""), + } + + # Try running apt-cache directly + for cmd_path in ["/usr/bin/apt-cache", "apt-cache"]: + try: + proc = await asyncio.create_subprocess_exec( + cmd_path, "search", "bash", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + lines = stdout.decode(errors="replace").strip().splitlines() + results[f"test_{cmd_path}"] = { + "rc": proc.returncode, + "stdout_lines": len(lines), + "first_line": lines[0] if lines else "", + "stderr": stderr.decode(errors="replace").strip()[:200], + } + except Exception as e: + results[f"test_{cmd_path}"] = {"error": str(e)} + + return results diff --git a/frontend/js/apps/packages.js b/frontend/js/apps/packages.js index d5b8fd6..7fbf25f 100644 --- a/frontend/js/apps/packages.js +++ b/frontend/js/apps/packages.js @@ -25,7 +25,10 @@ listEl.innerHTML = '
Package manager not available on this system
'; return; } - if (!res.ok) throw new Error('Search failed'); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` })); + throw new Error(err.detail || `Search failed (${res.status})`); + } const results = await res.json(); if (results.length === 0) {