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 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
# Clear the old log
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"
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,
}

View File

@@ -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}]`);
onUpdateDone(false);
return;
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);
});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function startUpdatePoll() {
// Poll immediately, then on interval
pollUpdateStatus();
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
}
function read() {
reader.read().then(({ done, value }) => {
if (done) {
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;
// Check if finished
if (!data.running) {
stopUpdatePoll();
if (data.result === "success") {
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}]`);
} 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) {

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 ─────────────────────────────────────
networking.firewall.allowedTCPPorts = [ 8937 ];
};