From 9c1da237de24a773f106876f3c0a2f4cfba68ca8 Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Mon, 6 Apr 2026 23:07:24 -0400 Subject: [PATCH 1/2] feat: in-app update checker and sidecar updater daemon Adds a self-contained update system that notifies admins when a newer version is available on GHCR and performs the pull/restart entirely within the docker-compose stack via a sidecar container. - update-checker.ts: GHCR OCI tag polling, semver comparison, request/ status file helpers, and clearUpdateStatus for post-update reset - misc-routes.ts: /api/update/check, /api/update/start, /api/update/status, /api/update/reset endpoints (admin-only) - settings.html + settings.js: Software Update section with check, progress log, and auto-reset on completion; fixes infinite reload loop on done state - docker/updater.Dockerfile: minimal docker:27-cli + bash + jq sidecar image - docker-compose.yml: overlord-updater sidecar service; mounts docker socket and data volume; passes PORT and other env vars through so compose-within- compose resolves the correct healthcheck URL - scripts/overlord-updater.sh: poll daemon that pulls new image and recreates overlord-server only (not the whole stack, to avoid self-termination) - scripts/overlord-updater.service: systemd unit for bare-metal deployments Co-Authored-By: Claude Sonnet 4.6 --- Overlord-Server/public/assets/settings.js | 220 ++++++++++++++++++ Overlord-Server/public/settings.html | 70 ++++++ .../src/server/routes/misc-routes.ts | 104 +++++++++ .../src/server/update-checker.test.ts | 46 ++++ Overlord-Server/src/server/update-checker.ts | 204 ++++++++++++++++ docker-compose.yml | 25 +- docker/updater.Dockerfile | 11 + scripts/overlord-updater.service | 29 +++ scripts/overlord-updater.sh | 171 ++++++++++++++ scripts/update-overlord.sh | 21 ++ 10 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 Overlord-Server/src/server/update-checker.test.ts create mode 100644 Overlord-Server/src/server/update-checker.ts create mode 100644 docker/updater.Dockerfile create mode 100644 scripts/overlord-updater.service create mode 100755 scripts/overlord-updater.sh create mode 100755 scripts/update-overlord.sh diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index a1b9cf4..11aa08b 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -62,6 +62,22 @@ const appearancePermissionNote = document.getElementById("appearance-permission- const appearanceSaveBtn = document.getElementById("appearance-save-btn"); const appearanceCustomCssInput = document.getElementById("appearance-custom-css"); +const updateSection = document.getElementById("update-section"); +const updateVersionLabel = document.getElementById("update-version-label"); +const updateStatusText = document.getElementById("update-status-text"); +const updateCheckBtn = document.getElementById("update-check-btn"); +const updateAvailablePanel = document.getElementById("update-available-panel"); +const updateNewVersion = document.getElementById("update-new-version"); +const updateApplyBtn = document.getElementById("update-apply-btn"); +const updateUpToDatePanel = document.getElementById("update-up-to-date-panel"); +const updateProgressPanel = document.getElementById("update-progress-panel"); +const updateProgressTitle = document.getElementById("update-progress-title"); +const updateProgressBar = document.getElementById("update-progress-bar"); +const updateProgressMessage = document.getElementById("update-progress-message"); +const updateProgressLog = document.getElementById("update-progress-log"); +const updateErrorPanel = document.getElementById("update-error-panel"); +const updateErrorText = document.getElementById("update-error-text"); + const exportImportSection = document.getElementById("export-import-section"); const exportSettingsBtn = document.getElementById("export-settings-btn"); const importSettingsFile = document.getElementById("import-settings-file"); @@ -827,6 +843,209 @@ async function wipeOfflineClients() { } } +// ---- In-app update ---- + +let latestUpdateCheck = null; +let updatePollTimer = null; + +function hideAllUpdatePanels() { + [updateAvailablePanel, updateUpToDatePanel, updateProgressPanel, updateErrorPanel].forEach( + (el) => el && el.classList.add("hidden"), + ); +} + +async function checkForUpdates() { + if (!updateCheckBtn) return; + updateCheckBtn.disabled = true; + updateCheckBtn.innerHTML = 'Checking...'; + hideAllUpdatePanels(); + + try { + const res = await fetch("/api/update/check", { credentials: "include" }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = data.error || "Update check failed."; + updateErrorPanel.classList.remove("hidden"); + } + return; + } + + latestUpdateCheck = data; + if (updateVersionLabel) updateVersionLabel.textContent = data.currentVersion || "-"; + if (updateStatusText) updateStatusText.textContent = `Last checked: ${new Date().toLocaleTimeString()}`; + + if (data.updateAvailable) { + if (updateNewVersion) updateNewVersion.textContent = data.latestVersion; + if (updateAvailablePanel) updateAvailablePanel.classList.remove("hidden"); + } else { + if (updateUpToDatePanel) updateUpToDatePanel.classList.remove("hidden"); + } + } catch (err) { + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = `Network error: ${err.message || err}`; + updateErrorPanel.classList.remove("hidden"); + } + } finally { + updateCheckBtn.disabled = false; + updateCheckBtn.innerHTML = 'Check for Updates'; + } +} + +async function applyUpdate() { + if (!latestUpdateCheck?.latestVersion) return; + if (!confirm(`Update Overlord to version ${latestUpdateCheck.latestVersion}?\n\nThe server will restart during the update. You may briefly lose connection.`)) return; + + if (updateApplyBtn) { + updateApplyBtn.disabled = true; + updateApplyBtn.innerHTML = 'Requesting...'; + } + + try { + const res = await fetch("/api/update/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ targetVersion: latestUpdateCheck.latestVersion }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = data.error || "Failed to start update."; + updateErrorPanel.classList.remove("hidden"); + } + return; + } + + hideAllUpdatePanels(); + if (updateProgressPanel) updateProgressPanel.classList.remove("hidden"); + if (updateProgressMessage) updateProgressMessage.textContent = "Waiting for host updater to pick up the request..."; + if (updateProgressBar) updateProgressBar.style.width = "5%"; + startUpdatePolling(); + } catch (err) { + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = `Network error: ${err.message || err}`; + updateErrorPanel.classList.remove("hidden"); + } + } finally { + if (updateApplyBtn) { + updateApplyBtn.disabled = false; + updateApplyBtn.innerHTML = 'Apply Update'; + } + } +} + +function startUpdatePolling() { + if (updatePollTimer) clearInterval(updatePollTimer); + updatePollTimer = setInterval(pollUpdateStatus, 3000); + pollUpdateStatus(); +} + +async function pollUpdateStatus() { + try { + const res = await fetch("/api/update/status", { credentials: "include" }); + if (!res.ok) return; + const status = await res.json().catch(() => null); + if (!status) return; + + if (status.state === "idle" && status.updatedAt === 0) return; + + hideAllUpdatePanels(); + if (updateProgressPanel) updateProgressPanel.classList.remove("hidden"); + + if (updateProgressBar) updateProgressBar.style.width = `${status.progress || 0}%`; + if (updateProgressMessage) updateProgressMessage.textContent = status.message || ""; + + const stateLabels = { + pending: "Update pending...", + pulling: "Pulling new image from registry...", + restarting: "Restarting container...", + done: "Update complete!", + error: "Update failed", + }; + if (updateProgressTitle) { + updateProgressTitle.textContent = stateLabels[status.state] || `State: ${status.state}`; + } + + if (status.log && status.log.length && updateProgressLog) { + updateProgressLog.classList.remove("hidden"); + updateProgressLog.textContent = status.log.join("\n"); + updateProgressLog.scrollTop = updateProgressLog.scrollHeight; + } + + if (status.state === "done") { + if (updatePollTimer) clearInterval(updatePollTimer); + await fetch("/api/update/reset", { method: "POST", credentials: "include" }).catch(() => {}); + hideAllUpdatePanels(); + const toast = document.createElement("p"); + toast.className = "text-sm text-emerald-400 flex items-center gap-2"; + toast.innerHTML = 'Update complete!'; + updateSection?.prepend(toast); + setTimeout(() => toast.remove(), 4000); + } + + if (status.state === "error") { + if (updatePollTimer) clearInterval(updatePollTimer); + hideAllUpdatePanels(); + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = status.message || "Update failed. Check logs."; + updateErrorPanel.classList.remove("hidden"); + } + } + } catch { + // Server may be restarting - keep polling + } +} + +async function initUpdateSection() { + if (!isAdmin(currentUser?.role) || !updateSection) return; + updateSection.classList.remove("hidden"); + + // Fetch current version + try { + const res = await fetch("/api/version", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + if (updateVersionLabel) updateVersionLabel.textContent = data.version || "-"; + } + } catch {} + + // Check if an update is already in progress or recently completed + try { + const res = await fetch("/api/update/status", { credentials: "include" }); + if (res.ok) { + const status = await res.json(); + if (status.state === "done") { + // Clear the status file so future page loads start fresh + await fetch("/api/update/reset", { method: "POST", credentials: "include" }).catch(() => {}); + // Briefly show a success toast then restore the normal check-for-updates UI + hideAllUpdatePanels(); + const toast = document.createElement("p"); + toast.className = "text-sm text-emerald-400 flex items-center gap-2"; + toast.innerHTML = 'Last update completed successfully.'; + updateSection?.prepend(toast); + setTimeout(() => toast.remove(), 4000); + } else if (status.state === "error") { + hideAllUpdatePanels(); + if (updateErrorPanel && updateErrorText) { + updateErrorText.textContent = status.message || "Last update failed."; + updateErrorPanel.classList.remove("hidden"); + } + } else if (status.state && status.state !== "idle") { + // In-progress — resume live polling + hideAllUpdatePanels(); + if (updateProgressPanel) updateProgressPanel.classList.remove("hidden"); + startUpdatePolling(); + } + } + } catch {} + + if (updateCheckBtn) updateCheckBtn.addEventListener("click", checkForUpdates); + if (updateApplyBtn) updateApplyBtn.addEventListener("click", applyUpdate); +} + async function init() { try { await loadCurrentUser(); @@ -840,6 +1059,7 @@ async function init() { wipeOfflineSection.classList.remove("hidden"); } + await initUpdateSection(); await loadSecurityPolicy(); await loadTlsSettings(); await loadAppearanceSettings(); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index b4a109c..bbecf34 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -55,6 +55,76 @@

News & Updates

+ +