From 08492cef947c3c4e726cef632d7fad5f6da53cdb Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 12:54:32 -0500 Subject: [PATCH] added new systemd update unit --- app/sovran_systemsos_web/server.py | 115 +++++++++++++++++-------- app/sovran_systemsos_web/static/app.js | 108 +++++++++++++---------- modules/core/sovran-hub.nix | 14 ++- 3 files changed, 155 insertions(+), 82 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index ccf6cab..b454b0a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -7,7 +7,6 @@ import json import os import socket import subprocess -import threading import urllib.request from typing import AsyncIterator @@ -26,15 +25,13 @@ FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" +UPDATE_UNIT = "sovran-hub-update.service" +UPDATE_LOG = "/var/log/sovran-hub-update.log" + REBOOT_COMMAND = [ "reboot", ] -UPDATE_COMMAND = [ - "bash", "-c", - "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", -] - CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -163,6 +160,42 @@ def _get_external_ip() -> str: return "unavailable" +# ── Update unit helpers ────────────────────────────────────────── + +def _update_is_active() -> bool: + """Return True if the update unit is currently running.""" + r = subprocess.run( + ["systemctl", "is-active", "--quiet", UPDATE_UNIT], + capture_output=True, + ) + return r.returncode == 0 + + +def _update_result() -> str: + """Return 'success', 'failed', or 'inactive'.""" + r = subprocess.run( + ["systemctl", "show", "-p", "Result", "--value", UPDATE_UNIT], + capture_output=True, text=True, + ) + val = r.stdout.strip() + if val == "success": + return "success" + elif val: + return "failed" + return "inactive" + + +def _read_update_log(offset: int = 0) -> tuple[str, int]: + """Read update log from offset. Return (new_text, new_offset).""" + try: + with open(UPDATE_LOG, "r") as f: + f.seek(offset) + text = f.read() + return text, f.tell() + except FileNotFoundError: + return "", 0 + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -292,36 +325,48 @@ async def api_reboot(): @app.post("/api/updates/run") async def api_updates_run(): - async def event_stream() -> AsyncIterator[str]: - yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n" - yield "data: \n\n" + """Kick off the detached update systemd unit.""" + loop = asyncio.get_event_loop() - process = await asyncio.create_subprocess_exec( - *UPDATE_COMMAND, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) + # Check if already running + running = await loop.run_in_executor(None, _update_is_active) + if running: + return {"ok": True, "status": "already_running"} - assert process.stdout is not None - try: - async for raw_line in process.stdout: - line = raw_line.decode(errors="replace").rstrip("\n") - # SSE requires data: prefix; escape newlines within a line - yield f"data: {line}\n\n" - except Exception: - yield "data: [stream error: output read interrupted]\n\n" + # Clear the old log + try: + open(UPDATE_LOG, "w").close() + except OSError: + pass - await process.wait() - if process.returncode == 0: - yield "event: done\ndata: success\n\n" - else: - yield f"event: error\ndata: exit code {process.returncode}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, + # Reset the failed state (if any) and start the unit + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", UPDATE_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", UPDATE_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "started"} + + +@app.get("/api/updates/status") +async def api_updates_status(offset: int = 0): + """Poll endpoint: returns running state, result, and new log lines.""" + loop = asyncio.get_event_loop() + + running = await loop.run_in_executor(None, _update_is_active) + result = await loop.run_in_executor(None, _update_result) + new_log, new_offset = await loop.run_in_executor(None, _read_update_log, offset) + + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 79e88bd..f1b2e3e 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,6 +4,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart +const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const CATEGORY_ORDER = [ "infrastructure", @@ -24,6 +25,8 @@ let _servicesCache = []; let _categoryLabels = {}; let _updateSource = null; let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; // ── DOM refs ────────────────────────────────────────────────────── @@ -261,6 +264,7 @@ async function checkUpdates() { function openUpdateModal() { if (!$modal) return; _updateLog = ""; + _updateLogOffset = 0; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -269,69 +273,81 @@ function openUpdateModal() { if ($btnCloseModal) { $btnCloseModal.disabled = true; } $modal.classList.add("open"); - startUpdateStream(); + startUpdate(); } function closeUpdateModal() { if (!$modal) return; $modal.classList.remove("open"); - if (_updateSource) { - _updateSource.close(); - _updateSource = null; - } + stopUpdatePoll(); } function appendLog(text) { - _updateLog += text + "\n"; + if (!text) return; + _updateLog += text; if ($modalLog) { - $modalLog.textContent += text + "\n"; + $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } } -function startUpdateStream() { - // Trigger the update via POST first, then listen via SSE - fetch("/api/updates/run", { method: "POST" }).then(response => { - if (!response.ok || !response.body) { - const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`; - appendLog(`[Error: failed to start update — ${detail}]`); +function startUpdate() { + appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y\n\n"); + + // Trigger the systemd unit via POST + fetch("/api/updates/run", { method: "POST" }) + .then(response => { + if (!response.ok) { + return response.text().then(t => { throw new Error(t); }); + } + return response.json(); + }) + .then(data => { + // Start polling for status + log lines + startUpdatePoll(); + }) + .catch(err => { + appendLog(`[Error: failed to start update — ${err}]\n`); onUpdateDone(false); - return; + }); +} + +function startUpdatePoll() { + // Poll immediately, then on interval + pollUpdateStatus(); + _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); +} + +function stopUpdatePoll() { + if (_updatePollTimer) { + clearInterval(_updatePollTimer); + _updatePollTimer = null; + } +} + +async function pollUpdateStatus() { + try { + const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); + + // Append new log text + if (data.log) { + appendLog(data.log); } + _updateLogOffset = data.offset; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - function read() { - reader.read().then(({ done, value }) => { - if (done) { - onUpdateDone(true); - return; - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop(); // keep incomplete line - for (const line of lines) { - if (line.startsWith("data: ")) { - appendLog(line.slice(6)); - } else if (line.startsWith("event: done")) { - // success event will follow in data: - } else if (line.startsWith("event: error")) { - // error event will follow in data: - } - } - read(); - }).catch(err => { - appendLog(`[Stream error: ${err}]`); + // Check if finished + if (!data.running) { + stopUpdatePoll(); + if (data.result === "success") { + onUpdateDone(true); + } else { onUpdateDone(false); - }); + } } - read(); - }).catch(err => { - appendLog(`[Request error: ${err}]`); - onUpdateDone(false); - }); + } catch (err) { + // Server may be restarting during nixos-rebuild switch — keep polling + console.warn("Update poll failed (server may be restarting):", err); + } } function onUpdateDone(success) { @@ -405,4 +421,4 @@ async function init() { setInterval(checkUpdates, POLL_INTERVAL_UPDATES); } -document.addEventListener("DOMContentLoaded", init); +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index ead8070..8f403e1 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -129,7 +129,19 @@ in }; }; + # ── System update as a detached oneshot ───────────────────── + systemd.services.sovran-hub-update = { + description = "Sovran_SystemsOS System Update"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.bash}/bin/bash -c 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'"; + StandardOutput = "file:/var/log/sovran-hub-update.log"; + StandardError = "file:/var/log/sovran-hub-update.log"; + }; + path = [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak ]; + }; + # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} +} \ No newline at end of file