added new systemd update unit
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user