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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.auth import get_current_user
|
from backend.auth import get_current_user
|
||||||
from backend.config import BASE_DIR
|
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()
|
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
|
# 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}")
|
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
|
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 {
|
return {
|
||||||
"available": behind_count > 0,
|
"available": behind_count > 0,
|
||||||
"local_hash": local_hash[:8],
|
"local_hash": local_hash[:8],
|
||||||
"remote_hash": remote_hash[:8],
|
"remote_hash": remote_hash[:8],
|
||||||
"behind_count": behind_count,
|
"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;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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) {
|
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 = `
|
panel.innerHTML = `
|
||||||
<div class="update-status update-available">
|
<div class="update-status update-available">
|
||||||
<span class="update-status-dot available"></span>
|
<span class="update-status-dot available"></span>
|
||||||
|
|
@ -506,13 +523,40 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="update-install-btn" title="Install update">Update</button>
|
<button class="update-install-btn" title="Install update">Update</button>
|
||||||
</div>
|
</div>
|
||||||
|
${pkgHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
panel.querySelector('.update-install-btn').addEventListener('click', async (e) => {
|
panel.querySelector('.update-install-btn').addEventListener('click', async (e) => {
|
||||||
const btn = e.target;
|
const btn = e.target;
|
||||||
btn.disabled = true;
|
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 {
|
try {
|
||||||
const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' });
|
const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue