Fix package manager: better error handling, debug endpoint, robust PATH
- Rewrote _apt helper as _run_cmd that never raises (returns rc/stdout/stderr) - Handle stderr warnings from apt-cache gracefully (common on Armbian) - Catch FileNotFoundError if binary missing despite PATH fix - Show actual backend error message in frontend instead of generic "Search failed" - Add /api/packages/debug endpoint for troubleshooting apt-cache issues - Add logging throughout for server-side diagnostics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ec9726db4
commit
c3e127421b
2 changed files with 105 additions and 55 deletions
|
|
@ -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 asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -11,18 +12,19 @@ from pydantic import BaseModel
|
||||||
from backend.auth import get_current_user
|
from backend.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/packages", tags=["packages"])
|
router = APIRouter(prefix="/api/packages", tags=["packages"])
|
||||||
|
log = logging.getLogger("atlus.packages")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Guard
|
# Guard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _find_apt():
|
def _find_apt():
|
||||||
"""Find apt-cache binary, checking common paths if PATH is restricted."""
|
"""Find apt binary, checking common paths if PATH is restricted."""
|
||||||
found = shutil.which("apt-cache")
|
for name in ("apt-cache", "apt"):
|
||||||
if found:
|
if shutil.which(name):
|
||||||
return True
|
return True
|
||||||
# systemd services may have a minimal PATH — check directly
|
# 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):
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
@ -55,52 +57,35 @@ def _safe_env():
|
||||||
env = {**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}
|
env = {**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}
|
||||||
# Ensure standard paths are included (systemd may use minimal PATH)
|
# Ensure standard paths are included (systemd may use minimal PATH)
|
||||||
path = env.get("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:
|
if p not in path:
|
||||||
path = p + ":" + path
|
path = p + ":" + path
|
||||||
env["PATH"] = path
|
env["PATH"] = path
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
async def _apt(*args: str, timeout: float = 30) -> str:
|
async def _run_cmd(cmd: list[str], timeout: float = 30) -> tuple[int, str, str]:
|
||||||
"""Run an apt/dpkg command and return stdout."""
|
"""Run a command, return (returncode, stdout, stderr). Never raises."""
|
||||||
_require_apt()
|
_require_apt()
|
||||||
cmd = list(args)
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=_safe_env(),
|
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:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
raise HTTPException(504, "Package operation timed out")
|
return 1, "", "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 proc.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -121,14 +106,26 @@ async def search_packages(
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Search for packages matching a query."""
|
"""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 = []
|
results = []
|
||||||
for line in output.strip().splitlines():
|
for line in stdout.strip().splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
parts = line.split(" - ", 1)
|
parts = line.split(" - ", 1)
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
results.append({"name": parts[0].strip(), "summary": parts[1].strip()})
|
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:
|
if len(results) >= 100:
|
||||||
break
|
break
|
||||||
return results
|
return results
|
||||||
|
|
@ -143,13 +140,16 @@ async def package_info(
|
||||||
name = _validate_name(name)
|
name = _validate_name(name)
|
||||||
|
|
||||||
# Get package info from apt-cache
|
# 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
|
# Parse key-value fields
|
||||||
info = {}
|
info = {}
|
||||||
current_key = None
|
current_key = None
|
||||||
current_val = ""
|
current_val = ""
|
||||||
for line in output.splitlines():
|
for line in stdout.splitlines():
|
||||||
if line.startswith(" "):
|
if line.startswith(" "):
|
||||||
# Continuation of previous field
|
# Continuation of previous field
|
||||||
current_val += "\n" + line.strip()
|
current_val += "\n" + line.strip()
|
||||||
|
|
@ -166,17 +166,17 @@ async def package_info(
|
||||||
if current_key:
|
if current_key:
|
||||||
info[current_key] = current_val
|
info[current_key] = current_val
|
||||||
|
|
||||||
# Check installed status
|
# Check installed status via dpkg-query
|
||||||
rc, dpkg_out, _ = await _apt_nofail(
|
rc2, dpkg_out, _ = await _run_cmd(
|
||||||
"dpkg-query", "-W", "-f=${Status}\t${Version}\n", name
|
["dpkg-query", "-W", "-f=${Status}\t${Version}\n", name]
|
||||||
)
|
)
|
||||||
installed_version = None
|
installed_version = None
|
||||||
is_installed = False
|
is_installed = False
|
||||||
if rc == 0 and dpkg_out.strip():
|
if rc2 == 0 and dpkg_out.strip():
|
||||||
parts = dpkg_out.strip().split("\t")
|
parts = dpkg_out.strip().split("\t")
|
||||||
if len(parts) >= 2 and "install ok installed" in parts[0]:
|
if len(parts) >= 2 and "install ok installed" in parts[0]:
|
||||||
is_installed = True
|
is_installed = True
|
||||||
installed_version = parts[1]
|
installed_version = parts[1].strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": info.get("Package", name),
|
"name": info.get("Package", name),
|
||||||
|
|
@ -201,8 +201,12 @@ async def install_package(
|
||||||
):
|
):
|
||||||
"""Install a package via apt-get."""
|
"""Install a package via apt-get."""
|
||||||
name = _validate_name(req.name)
|
name = _validate_name(req.name)
|
||||||
output = await _apt("apt-get", "install", "-y", name, timeout=300)
|
rc, stdout, stderr = await _run_cmd(["apt-get", "install", "-y", name], timeout=300)
|
||||||
return {"success": True, "package": name, "output": output[-500:]}
|
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")
|
@router.post("/remove")
|
||||||
|
|
@ -212,12 +216,55 @@ async def remove_package(
|
||||||
):
|
):
|
||||||
"""Remove a package via apt-get."""
|
"""Remove a package via apt-get."""
|
||||||
name = _validate_name(req.name)
|
name = _validate_name(req.name)
|
||||||
output = await _apt("apt-get", "remove", "-y", name, timeout=120)
|
rc, stdout, stderr = await _run_cmd(["apt-get", "remove", "-y", name], timeout=120)
|
||||||
return {"success": True, "package": name, "output": output[-500:]}
|
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")
|
@router.post("/update-cache")
|
||||||
async def update_cache(_user: str = Depends(get_current_user)):
|
async def update_cache(_user: str = Depends(get_current_user)):
|
||||||
"""Run apt-get update to refresh package lists."""
|
"""Run apt-get update to refresh package lists."""
|
||||||
output = await _apt("apt-get", "update", timeout=120)
|
rc, stdout, stderr = await _run_cmd(["apt-get", "update"], timeout=120)
|
||||||
return {"success": True, "output": output[-500:]}
|
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
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@
|
||||||
listEl.innerHTML = '<div class="pkg-empty">Package manager not available on this system</div>';
|
listEl.innerHTML = '<div class="pkg-empty">Package manager not available on this system</div>';
|
||||||
return;
|
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();
|
const results = await res.json();
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue