diff --git a/app/main.py b/app/main.py index 39869b4..baff13a 100644 --- a/app/main.py +++ b/app/main.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) app = FastAPI( title="NetBird MSP Appliance", description="Multi-tenant NetBird management platform for MSPs", - version="1.1.1", + version="1.1.2", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", diff --git a/app/routers/settings.py b/app/routers/settings.py index 412c5e4..f80ade6 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -4,6 +4,7 @@ There is no .env file. Every setting lives in the ``system_config`` table (singleton row with id=1) and is editable via the Web UI settings page. """ +import asyncio import logging import os import shutil @@ -358,13 +359,15 @@ async def trigger_update( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """Backup the database, git pull the latest code, and rebuild the container. + """Kick off backup + git pull + container rebuild in the background. + + Returns immediately — the actual work (which can take several minutes, + especially the ``--no-cache`` image build) runs in a background thread so + it doesn't block this request or any other user's requests while it + runs. Progress can be polled via GET /settings/update/status until the + container restarts with the new version. - The rebuild is fire-and-forget — the app will restart in ~60 seconds. Only admin users may trigger an update. - - Returns: - Dict with ok, message, and backup path. """ if getattr(current_user, "role", "admin") != "admin": raise HTTPException( @@ -383,11 +386,37 @@ async def trigger_update( detail="git_repo_url is not configured in settings.", ) - result = update_service.trigger_update(config, DATABASE_PATH) - if not result.get("ok"): + current_status = update_service.get_update_status() + if current_status.get("state") == "running": raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=result.get("message", "Update failed."), + status_code=status.HTTP_409_CONFLICT, + detail="An update is already in progress.", ) + + # Snapshot the only fields trigger_update() needs — avoids passing a + # SQLAlchemy instance into a background thread after this request's + # session may already be closed. + class _ConfigSnapshot: + git_repo_url = config.git_repo_url + git_branch = config.git_branch + git_token = config.git_token + + asyncio.create_task(asyncio.to_thread(update_service.trigger_update, _ConfigSnapshot(), DATABASE_PATH)) logger.info("Update triggered by %s.", current_user.username) - return result + return { + "ok": True, + "message": "Update gestartet. Dies kann mehrere Minuten dauern — Fortschritt via Status sichtbar.", + } + + +@router.get("/update/status") +async def update_status( + current_user: User = Depends(get_current_user), +): + """Return progress of the currently running (or last) update. + + Note: once the container restarts mid-update, this endpoint stops + responding for a few seconds — that itself is a signal the swap is + happening. The frontend falls back to polling for the app coming back up. + """ + return update_service.get_update_status() diff --git a/app/services/update_service.py b/app/services/update_service.py index ed59b06..bc2e13b 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -20,6 +20,32 @@ SERVICE_NAME = "netbird-msp-appliance" logger = logging.getLogger(__name__) +# In-memory progress tracker for the currently running (or last) update. +# The container gets replaced mid-update, so this deliberately does NOT need +# to survive a restart — the frontend detects completion by polling until the +# app comes back up and reports a new version, not by reading a final status +# here. It exists so the UI can show *something* other than a frozen spinner +# while the backup/pull/build steps are in progress. +_update_status: dict[str, Any] = { + "state": "idle", # idle | running | failed + "step": "", + "message": "", + "started_at": None, +} + + +def get_update_status() -> dict[str, Any]: + """Return a snapshot of the current update progress.""" + return dict(_update_status) + + +def _set_status(state: str, step: str, message: str = "") -> None: + _update_status["state"] = state + _update_status["step"] = step + _update_status["message"] = message + if step == "backup": + _update_status["started_at"] = datetime.utcnow().isoformat() + def _get_compose_project_name() -> str: """Detect the compose project name from the running container's labels. @@ -233,10 +259,12 @@ def trigger_update(config: Any, db_path: str) -> dict: Dict with ok (bool), message, backup path, and pulled_branch. """ # 1. Backup database before any changes + _set_status("running", "backup", "Datenbank wird gesichert …") try: backup_path = backup_database(db_path) except Exception as exc: logger.error("Database backup failed: %s", exc) + _set_status("failed", "backup", f"Database backup failed: {exc}") return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None} # 2. Build git pull command (embed token in URL if provided) @@ -252,6 +280,7 @@ def trigger_update(config: Any, db_path: str) -> dict: pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch] # 3. Git pull (synchronous — must complete before rebuild) + _set_status("running", "pull", f"Code wird von Branch '{branch}' geholt …") # Ensure .git directory is owned by the process user (root inside container). # The .git dir may be owned by the host user after manual operations. try: @@ -270,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict: timeout=120, ) except subprocess.TimeoutExpired: + _set_status("failed", "pull", "git pull timed out after 120s.") return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path} except Exception as exc: + _set_status("failed", "pull", f"git pull error: {exc}") return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path} if result.returncode != 0: stderr = result.stderr.strip()[:500] logger.error("git pull failed (exit %d): %s", result.returncode, stderr) + _set_status("failed", "pull", f"git pull failed: {stderr}") return { "ok": False, "message": f"git pull failed: {stderr}", @@ -348,6 +380,7 @@ def trigger_update(config: Any, db_path: str) -> dict: SERVICE_NAME, ] logger.info("Phase A: building new image …") + _set_status("running", "build", "Docker-Image wird gebaut (kann mehrere Minuten dauern) …") try: build_result = subprocess.run( build_cmd, @@ -360,15 +393,18 @@ def trigger_update(config: Any, db_path: str) -> dict: f.write(build_result.stderr) if build_result.returncode != 0: logger.error("Image build failed: %s", build_result.stderr[:500]) + _set_status("failed", "build", f"Image build failed: {build_result.stderr[:300]}") return { "ok": False, "message": f"Image build failed: {build_result.stderr[:300]}", "backup": backup_path, } except subprocess.TimeoutExpired: + _set_status("failed", "build", "Image build timed out after 600s.") return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path} logger.info("Phase A complete — image built successfully.") + _set_status("running", "restart", "Container wird neu gestartet …") # Phase B — swap the container using a helper container. # When compose recreates our container, ALL processes inside die (PID namespace @@ -388,6 +424,7 @@ def trigger_update(config: Any, db_path: str) -> dict: raise ValueError("Could not find /app-source mount") except Exception as exc: logger.error("Failed to discover host source path: %s", exc) + _set_status("failed", "restart", f"Could not find host source path: {exc}") return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path} logger.info("Host source directory: %s", host_source_dir) @@ -426,6 +463,10 @@ def trigger_update(config: Any, db_path: str) -> dict: ) if result.returncode != 0: logger.error("Failed to start updater container: %s", result.stderr.strip()) + _set_status( + "failed", "restart", + f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}", + ) return { "ok": False, "message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}", @@ -434,6 +475,7 @@ def trigger_update(config: Any, db_path: str) -> dict: logger.info("Phase B: updater container started — this container will restart in ~5s.") except Exception as exc: logger.error("Failed to launch updater: %s", exc) + _set_status("failed", "restart", f"Updater launch failed: {exc}") return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path} return { diff --git a/static/js/app.js b/static/js/app.js index cff05fc..8fe7f12 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1374,11 +1374,12 @@ async function loadVersionInfo() { if (needsUpdate) { html += `