Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5988af6a3 |
+1
-1
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="NetBird MSP Appliance",
|
title="NetBird MSP Appliance",
|
||||||
description="Multi-tenant NetBird management platform for MSPs",
|
description="Multi-tenant NetBird management platform for MSPs",
|
||||||
version="1.1.1",
|
version="1.1.2",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
|
|||||||
+39
-10
@@ -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.
|
(singleton row with id=1) and is editable via the Web UI settings page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -358,13 +359,15 @@ async def trigger_update(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
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.
|
Only admin users may trigger an update.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ok, message, and backup path.
|
|
||||||
"""
|
"""
|
||||||
if getattr(current_user, "role", "admin") != "admin":
|
if getattr(current_user, "role", "admin") != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -383,11 +386,37 @@ async def trigger_update(
|
|||||||
detail="git_repo_url is not configured in settings.",
|
detail="git_repo_url is not configured in settings.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = update_service.trigger_update(config, DATABASE_PATH)
|
current_status = update_service.get_update_status()
|
||||||
if not result.get("ok"):
|
if current_status.get("state") == "running":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=result.get("message", "Update failed."),
|
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)
|
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()
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ SERVICE_NAME = "netbird-msp-appliance"
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _get_compose_project_name() -> str:
|
||||||
"""Detect the compose project name from the running container's labels.
|
"""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.
|
Dict with ok (bool), message, backup path, and pulled_branch.
|
||||||
"""
|
"""
|
||||||
# 1. Backup database before any changes
|
# 1. Backup database before any changes
|
||||||
|
_set_status("running", "backup", "Datenbank wird gesichert …")
|
||||||
try:
|
try:
|
||||||
backup_path = backup_database(db_path)
|
backup_path = backup_database(db_path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Database backup failed: %s", 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}
|
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
|
||||||
|
|
||||||
# 2. Build git pull command (embed token in URL if provided)
|
# 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]
|
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
|
||||||
|
|
||||||
# 3. Git pull (synchronous — must complete before rebuild)
|
# 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).
|
# 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.
|
# The .git dir may be owned by the host user after manual operations.
|
||||||
try:
|
try:
|
||||||
@@ -270,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
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}
|
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
|
||||||
except Exception as exc:
|
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}
|
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
stderr = result.stderr.strip()[:500]
|
stderr = result.stderr.strip()[:500]
|
||||||
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
|
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
|
||||||
|
_set_status("failed", "pull", f"git pull failed: {stderr}")
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"git pull failed: {stderr}",
|
"message": f"git pull failed: {stderr}",
|
||||||
@@ -348,6 +380,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
]
|
]
|
||||||
logger.info("Phase A: building new image …")
|
logger.info("Phase A: building new image …")
|
||||||
|
_set_status("running", "build", "Docker-Image wird gebaut (kann mehrere Minuten dauern) …")
|
||||||
try:
|
try:
|
||||||
build_result = subprocess.run(
|
build_result = subprocess.run(
|
||||||
build_cmd,
|
build_cmd,
|
||||||
@@ -360,15 +393,18 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
f.write(build_result.stderr)
|
f.write(build_result.stderr)
|
||||||
if build_result.returncode != 0:
|
if build_result.returncode != 0:
|
||||||
logger.error("Image build failed: %s", build_result.stderr[:500])
|
logger.error("Image build failed: %s", build_result.stderr[:500])
|
||||||
|
_set_status("failed", "build", f"Image build failed: {build_result.stderr[:300]}")
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"Image build failed: {build_result.stderr[:300]}",
|
"message": f"Image build failed: {build_result.stderr[:300]}",
|
||||||
"backup": backup_path,
|
"backup": backup_path,
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
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}
|
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
|
||||||
|
|
||||||
logger.info("Phase A complete — image built successfully.")
|
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.
|
# Phase B — swap the container using a helper container.
|
||||||
# When compose recreates our container, ALL processes inside die (PID namespace
|
# 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")
|
raise ValueError("Could not find /app-source mount")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to discover host source path: %s", 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}
|
return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
logger.info("Host source directory: %s", host_source_dir)
|
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:
|
if result.returncode != 0:
|
||||||
logger.error("Failed to start updater container: %s", result.stderr.strip())
|
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 {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
|
"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.")
|
logger.info("Phase B: updater container started — this container will restart in ~5s.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to launch updater: %s", 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 {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+65
-2
@@ -1374,11 +1374,12 @@ async function loadVersionInfo() {
|
|||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
html += `<div class="mt-3">
|
html += `<div class="mt-3">
|
||||||
<button class="btn btn-warning" onclick="triggerUpdate()">
|
<button class="btn btn-warning" id="update-trigger-btn" onclick="triggerUpdate()">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
|
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
|
||||||
</button>
|
</button>
|
||||||
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
|
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
|
||||||
|
<div class="small mt-2 d-none" id="update-progress-text"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
@@ -1390,14 +1391,76 @@ async function loadVersionInfo() {
|
|||||||
async function triggerUpdate() {
|
async function triggerUpdate() {
|
||||||
if (!confirm(t('settings.confirmUpdate'))) return;
|
if (!confirm(t('settings.confirmUpdate'))) return;
|
||||||
const spinner = document.getElementById('update-spinner');
|
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 (spinner) spinner.classList.remove('d-none');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setProgress(t('settings.updateStepStarting'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api('POST', '/settings/update');
|
const data = await api('POST', '/settings/update');
|
||||||
showSettingsAlert('success', data.message || t('messages.updateStarted'));
|
showSettingsAlert('success', data.message || t('messages.updateStarted'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -230,6 +230,14 @@
|
|||||||
"triggerUpdate": "Update starten",
|
"triggerUpdate": "Update starten",
|
||||||
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
|
"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).",
|
"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",
|
"gitTitle": "Git-Repository Einstellungen",
|
||||||
"gitRepoUrl": "Repository URL",
|
"gitRepoUrl": "Repository URL",
|
||||||
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
|
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
|
||||||
|
|||||||
@@ -262,6 +262,14 @@
|
|||||||
"triggerUpdate": "Start Update",
|
"triggerUpdate": "Start Update",
|
||||||
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
|
"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).",
|
"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",
|
"gitTitle": "Git Repository Settings",
|
||||||
"gitRepoUrl": "Repository URL",
|
"gitRepoUrl": "Repository URL",
|
||||||
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
|
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
|
||||||
|
|||||||
Reference in New Issue
Block a user