added new systemd update unit

This commit is contained in:
2026-04-02 12:54:32 -05:00
parent ad688a1d29
commit 08492cef94
3 changed files with 155 additions and 82 deletions

View File

@@ -7,7 +7,6 @@ import json
import os import os
import socket import socket
import subprocess import subprocess
import threading
import urllib.request import urllib.request
from typing import AsyncIterator from typing import AsyncIterator
@@ -26,15 +25,13 @@ FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
FLAKE_INPUT_NAME = "Sovran_Systems" FLAKE_INPUT_NAME = "Sovran_Systems"
GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" 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_COMMAND = [
"reboot", "reboot",
] ]
UPDATE_COMMAND = [
"bash", "-c",
"cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y",
]
CATEGORY_ORDER = [ CATEGORY_ORDER = [
("infrastructure", "Infrastructure"), ("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"), ("bitcoin-base", "Bitcoin Base"),
@@ -163,6 +160,42 @@ def _get_external_ip() -> str:
return "unavailable" 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 ─────────────────────────────────────────────────────── # ── Routes ───────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@@ -292,36 +325,48 @@ async def api_reboot():
@app.post("/api/updates/run") @app.post("/api/updates/run")
async def api_updates_run(): async def api_updates_run():
async def event_stream() -> AsyncIterator[str]: """Kick off the detached update systemd unit."""
yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n" loop = asyncio.get_event_loop()
yield "data: \n\n"
process = await asyncio.create_subprocess_exec( # Check if already running
*UPDATE_COMMAND, running = await loop.run_in_executor(None, _update_is_active)
stdout=asyncio.subprocess.PIPE, if running:
stderr=asyncio.subprocess.STDOUT, return {"ok": True, "status": "already_running"}
)
assert process.stdout is not None # Clear the old log
try: try:
async for raw_line in process.stdout: open(UPDATE_LOG, "w").close()
line = raw_line.decode(errors="replace").rstrip("\n") except OSError:
# SSE requires data: prefix; escape newlines within a line pass
yield f"data: {line}\n\n"
except Exception:
yield "data: [stream error: output read interrupted]\n\n"
await process.wait() # Reset the failed state (if any) and start the unit
if process.returncode == 0: await asyncio.create_subprocess_exec(
yield "event: done\ndata: success\n\n" "systemctl", "reset-failed", UPDATE_UNIT,
else: stdout=asyncio.subprocess.DEVNULL,
yield f"event: error\ndata: exit code {process.returncode}\n\n" stderr=asyncio.subprocess.DEVNULL,
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
) )
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,
}

View File

@@ -4,6 +4,7 @@
const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_SERVICES = 5000; // 5 s
const POLL_INTERVAL_UPDATES = 1800000; // 30 min const POLL_INTERVAL_UPDATES = 1800000; // 30 min
const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart 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 = [ const CATEGORY_ORDER = [
"infrastructure", "infrastructure",
@@ -24,6 +25,8 @@ let _servicesCache = [];
let _categoryLabels = {}; let _categoryLabels = {};
let _updateSource = null; let _updateSource = null;
let _updateLog = ""; let _updateLog = "";
let _updatePollTimer = null;
let _updateLogOffset = 0;
// ── DOM refs ────────────────────────────────────────────────────── // ── DOM refs ──────────────────────────────────────────────────────
@@ -261,6 +264,7 @@ async function checkUpdates() {
function openUpdateModal() { function openUpdateModal() {
if (!$modal) return; if (!$modal) return;
_updateLog = ""; _updateLog = "";
_updateLogOffset = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalStatus) $modalStatus.textContent = "Updating…";
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
@@ -269,69 +273,81 @@ function openUpdateModal() {
if ($btnCloseModal) { $btnCloseModal.disabled = true; } if ($btnCloseModal) { $btnCloseModal.disabled = true; }
$modal.classList.add("open"); $modal.classList.add("open");
startUpdateStream(); startUpdate();
} }
function closeUpdateModal() { function closeUpdateModal() {
if (!$modal) return; if (!$modal) return;
$modal.classList.remove("open"); $modal.classList.remove("open");
if (_updateSource) { stopUpdatePoll();
_updateSource.close();
_updateSource = null;
}
} }
function appendLog(text) { function appendLog(text) {
_updateLog += text + "\n"; if (!text) return;
_updateLog += text;
if ($modalLog) { if ($modalLog) {
$modalLog.textContent += text + "\n"; $modalLog.textContent += text;
$modalLog.scrollTop = $modalLog.scrollHeight; $modalLog.scrollTop = $modalLog.scrollHeight;
} }
} }
function startUpdateStream() { function startUpdate() {
// Trigger the update via POST first, then listen via SSE appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y\n\n");
fetch("/api/updates/run", { method: "POST" }).then(response => {
if (!response.ok || !response.body) { // Trigger the systemd unit via POST
const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`; fetch("/api/updates/run", { method: "POST" })
appendLog(`[Error: failed to start update — ${detail}]`); .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); 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(); // Check if finished
const decoder = new TextDecoder(); if (!data.running) {
let buffer = ""; stopUpdatePoll();
if (data.result === "success") {
function read() { onUpdateDone(true);
reader.read().then(({ done, value }) => { } else {
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}]`);
onUpdateDone(false); onUpdateDone(false);
}); }
} }
read(); } catch (err) {
}).catch(err => { // Server may be restarting during nixos-rebuild switch — keep polling
appendLog(`[Request error: ${err}]`); console.warn("Update poll failed (server may be restarting):", err);
onUpdateDone(false); }
});
} }
function onUpdateDone(success) { function onUpdateDone(success) {

View File

@@ -129,6 +129,18 @@ 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 ───────────────────────────────────── # ── Open firewall port ─────────────────────────────────────
networking.firewall.allowedTCPPorts = [ 8937 ]; networking.firewall.allowedTCPPorts = [ 8937 ];
}; };