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 += `
-
${t('settings.updateWarning')}
+
`; } el.innerHTML = html; @@ -1390,14 +1391,76 @@ async function loadVersionInfo() { async function triggerUpdate() { if (!confirm(t('settings.confirmUpdate'))) return; const spinner = document.getElementById('update-spinner'); + const btn = document.getElementById('update-trigger-btn'); + const progressText = document.getElementById('update-progress-text'); + const setProgress = (msg) => { + if (!progressText) return; + progressText.classList.remove('d-none'); + progressText.textContent = msg; + }; + const stopUpdateUi = () => { + if (spinner) spinner.classList.add('d-none'); + if (btn) btn.disabled = false; + }; + if (spinner) spinner.classList.remove('d-none'); + if (btn) btn.disabled = true; + setProgress(t('settings.updateStepStarting')); + try { const data = await api('POST', '/settings/update'); showSettingsAlert('success', data.message || t('messages.updateStarted')); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); - if (spinner) spinner.classList.add('d-none'); + stopUpdateUi(); + return; } + + // Phase 1: poll build/pull progress until the container restarts + // (connection drops, which is expected and is our cue to move to phase 2). + const stepLabelKey = { + backup: 'settings.updateStepBackup', + pull: 'settings.updateStepPull', + build: 'settings.updateStepBuild', + restart: 'settings.updateStepRestart', + }; + for (let i = 0; i < 200; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const st = await api('GET', '/settings/update/status'); + if (st.state === 'failed') { + showSettingsAlert('danger', st.message || t('errors.requestFailed')); + stopUpdateUi(); + return; + } + setProgress(t(stepLabelKey[st.step] || 'settings.updateStepStarting') + (st.message ? ` — ${st.message}` : '')); + } catch (err) { + // Connection dropped — the container is very likely mid-restart. Move on. + break; + } + } + + // Phase 2: wait for the app to come back up, then reload version info. + setProgress(t('settings.updateStepReconnecting')); + for (let i = 0; i < 90; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + await api('GET', '/settings/version'); + setProgress(t('settings.updateStepDone')); + stopUpdateUi(); + showSettingsAlert('success', t('settings.updateStepDone')); + await loadVersionInfo(); + return; + } catch (err) { + // still restarting — keep polling + } + } + + // Gave up waiting — surface this instead of spinning forever. + stopUpdateUi(); + setProgress(''); + if (progressText) progressText.classList.add('d-none'); + showSettingsAlert('warning', t('settings.updateStepTimeout')); } // --------------------------------------------------------------------------- diff --git a/static/lang/de.json b/static/lang/de.json index b1bfb37..24b1e16 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -230,6 +230,14 @@ "triggerUpdate": "Update starten", "updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.", "confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).", + "updateStepStarting": "Update wird gestartet …", + "updateStepBackup": "Datenbank wird gesichert …", + "updateStepPull": "Code wird geholt …", + "updateStepBuild": "Docker-Image wird gebaut (kann mehrere Minuten dauern) …", + "updateStepRestart": "Container wird neu gestartet …", + "updateStepReconnecting": "Container startet neu — warte auf Verbindung …", + "updateStepDone": "Update abgeschlossen.", + "updateStepTimeout": "Update läuft länger als erwartet. Bitte Server-Logs prüfen oder die Seite in ein paar Minuten neu laden.", "gitTitle": "Git-Repository Einstellungen", "gitRepoUrl": "Repository URL", "gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.", diff --git a/static/lang/en.json b/static/lang/en.json index 3ed6b73..ae03a28 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -262,6 +262,14 @@ "triggerUpdate": "Start Update", "updateWarning": "The app will be unavailable for ~60 seconds during rebuild.", "confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).", + "updateStepStarting": "Starting update …", + "updateStepBackup": "Backing up database …", + "updateStepPull": "Fetching code …", + "updateStepBuild": "Building Docker image (can take several minutes) …", + "updateStepRestart": "Restarting container …", + "updateStepReconnecting": "Container is restarting — waiting for connection …", + "updateStepDone": "Update complete.", + "updateStepTimeout": "Update is taking longer than expected. Check the server logs or reload this page in a few minutes.", "gitTitle": "Git Repository Settings", "gitRepoUrl": "Repository URL", "gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",