Notification for required packages install prompt added.
This commit is contained in:
parent
23e4906d08
commit
983857bd5b
3 changed files with 222 additions and 1 deletions
|
|
@ -3,9 +3,11 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.config import BASE_DIR
|
||||
|
|
@ -110,6 +112,83 @@ async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
|
|||
return proc.returncode, stdout.decode().strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Package dependency detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_apt_packages(install_sh_content: str) -> set[str]:
|
||||
"""Extract package names from apt-get install lines in install.sh."""
|
||||
packages = set()
|
||||
# Match lines with apt-get install (possibly multi-line with backslash continuations)
|
||||
# First join backslash-continued lines
|
||||
joined = install_sh_content.replace("\\\n", " ")
|
||||
for line in joined.split("\n"):
|
||||
line = line.strip()
|
||||
if "apt-get install" not in line:
|
||||
continue
|
||||
# Remove everything before the first package name
|
||||
# apt-get install [-y] [-qq] [--no-install-recommends] package1 package2 ...
|
||||
# Strip the apt-get install part and flags
|
||||
after_install = re.split(r"apt-get\s+install\s+", line, maxsplit=1)
|
||||
if len(after_install) < 2:
|
||||
continue
|
||||
tokens = after_install[1].split()
|
||||
for tok in tokens:
|
||||
# Skip flags and redirections
|
||||
if tok.startswith("-") or tok.startswith(">") or tok in ("2>&1",):
|
||||
continue
|
||||
# Stop at pipe, semicolon, redirection
|
||||
if tok in ("|", ";", "&&", "||") or tok.startswith(">"):
|
||||
break
|
||||
# Valid package name
|
||||
if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.+\-]+$", tok):
|
||||
packages.add(tok)
|
||||
return packages
|
||||
|
||||
|
||||
async def _get_installed_packages(packages: set[str]) -> set[str]:
|
||||
"""Check which packages from the set are already installed via dpkg."""
|
||||
if not packages:
|
||||
return set()
|
||||
installed = set()
|
||||
for pkg in packages:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"dpkg", "-s", pkg,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_safe_env(),
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
# Verify it's actually installed (Status: install ok installed)
|
||||
output = stdout.decode()
|
||||
if "Status:" in output and "installed" in output:
|
||||
installed.add(pkg)
|
||||
return installed
|
||||
|
||||
|
||||
async def _detect_new_packages(remote_ref: str) -> list[str]:
|
||||
"""Compare remote install.sh against locally installed packages.
|
||||
Returns list of package names that the update requires but aren't installed."""
|
||||
try:
|
||||
# Get install.sh content from the remote ref
|
||||
rc, remote_install_sh = await _git_nofail("show", f"{remote_ref}:install.sh", timeout=10)
|
||||
if rc != 0:
|
||||
return []
|
||||
remote_packages = _parse_apt_packages(remote_install_sh)
|
||||
if not remote_packages:
|
||||
return []
|
||||
installed = await _get_installed_packages(remote_packages)
|
||||
missing = sorted(remote_packages - installed)
|
||||
return missing
|
||||
except Exception as e:
|
||||
log.debug("Package detection failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -153,11 +232,64 @@ async def check_for_updates(_user: str = Depends(get_current_user)):
|
|||
rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}")
|
||||
behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0
|
||||
|
||||
# Detect new system packages needed by the update
|
||||
new_packages = []
|
||||
if behind_count > 0:
|
||||
new_packages = await _detect_new_packages(remote_hash)
|
||||
|
||||
return {
|
||||
"available": behind_count > 0,
|
||||
"local_hash": local_hash[:8],
|
||||
"remote_hash": remote_hash[:8],
|
||||
"behind_count": behind_count,
|
||||
"new_packages": new_packages,
|
||||
}
|
||||
|
||||
|
||||
class InstallDepsRequest(BaseModel):
|
||||
packages: list[str]
|
||||
|
||||
|
||||
@router.post("/install-deps")
|
||||
async def install_deps(req: InstallDepsRequest, _user: str = Depends(get_current_user)):
|
||||
"""Install system packages via apt-get. Used before applying updates."""
|
||||
if not req.packages:
|
||||
return {"success": True, "message": "No packages to install", "output": ""}
|
||||
|
||||
# Validate package names — only allow safe characters
|
||||
for pkg in req.packages:
|
||||
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.+\-]+$", pkg):
|
||||
raise HTTPException(400, f"Invalid package name: {pkg}")
|
||||
|
||||
apt_bin = shutil.which("apt-get") or "/usr/bin/apt-get"
|
||||
cmd = [apt_bin, "install", "-y"] + req.packages
|
||||
log.info("Installing system packages: %s", " ".join(req.packages))
|
||||
|
||||
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=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise HTTPException(504, "Package installation timed out")
|
||||
|
||||
output = stdout.decode().strip()
|
||||
err_output = stderr.decode().strip()
|
||||
|
||||
if proc.returncode != 0:
|
||||
log.warning("apt-get install failed (rc=%d): %s", proc.returncode, err_output)
|
||||
raise HTTPException(500, f"Package installation failed: {err_output[-500:]}")
|
||||
|
||||
log.info("System packages installed successfully")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Installed {len(req.packages)} package(s)",
|
||||
"output": output[-500:],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -306,3 +306,48 @@
|
|||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* New packages needed by update */
|
||||
.update-packages {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--status-amber) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--status-amber) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.update-packages-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--status-amber);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.update-packages-list {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.update-packages-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.update-packages-toggle input[type="checkbox"] {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -497,6 +497,23 @@
|
|||
}
|
||||
|
||||
function showUpdateAvailable(panel, data) {
|
||||
const pkgs = data.new_packages || [];
|
||||
const hasPkgs = pkgs.length > 0;
|
||||
|
||||
let pkgHtml = '';
|
||||
if (hasPkgs) {
|
||||
pkgHtml = `
|
||||
<div class="update-packages">
|
||||
<div class="update-packages-title">New system packages needed:</div>
|
||||
<div class="update-packages-list">${pkgs.join(', ')}</div>
|
||||
<label class="update-packages-toggle">
|
||||
<input type="checkbox" class="update-install-pkgs-cb" checked>
|
||||
<span>Install packages with update</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="update-status update-available">
|
||||
<span class="update-status-dot available"></span>
|
||||
|
|
@ -506,13 +523,40 @@
|
|||
</div>
|
||||
<button class="update-install-btn" title="Install update">Update</button>
|
||||
</div>
|
||||
${pkgHtml}
|
||||
`;
|
||||
|
||||
panel.querySelector('.update-install-btn').addEventListener('click', async (e) => {
|
||||
const btn = e.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating…';
|
||||
|
||||
const installPkgs = hasPkgs && panel.querySelector('.update-install-pkgs-cb')?.checked;
|
||||
|
||||
// Step 1: Install system packages if needed
|
||||
if (installPkgs) {
|
||||
btn.textContent = 'Installing packages…';
|
||||
try {
|
||||
const depRes = await Atlus.apiFetch('/api/updates/install-deps', {
|
||||
method: 'POST',
|
||||
body: { packages: pkgs },
|
||||
});
|
||||
if (!depRes.ok) {
|
||||
const err = await depRes.json().catch(() => ({}));
|
||||
btn.textContent = 'Pkg install failed';
|
||||
btn.disabled = false;
|
||||
console.error('Package install failed:', err);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
btn.textContent = 'Pkg install failed';
|
||||
btn.disabled = false;
|
||||
console.error('Package install error:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Apply the update (git pull + pip + restart)
|
||||
btn.textContent = 'Updating…';
|
||||
try {
|
||||
const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue