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