fixed updater

This commit is contained in:
2026-04-02 13:42:24 -05:00
parent a66e8e736f
commit bb7db0693a
4 changed files with 45 additions and 103 deletions

View File

@@ -24,9 +24,9 @@ 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"
UPDATE_LOG = "/var/log/sovran-hub-update.log" UPDATE_STATUS = "/var/log/sovran-hub-update.status"
UPDATE_LOCK = "/run/sovran-hub-update.lock" UPDATE_UNIT = "sovran-hub-update.service"
REBOOT_COMMAND = ["reboot"] REBOOT_COMMAND = ["reboot"]
@@ -156,51 +156,15 @@ def _get_external_ip() -> str:
return "unavailable" return "unavailable"
# ── Update unit helpers ────────────────────────────────────────── # ── Update helpers (file-based, no systemctl) ────────────────────
def _update_is_active() -> bool: def _read_update_status() -> str:
"""Return True if the update unit is currently running.""" """Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
r = subprocess.run(
["systemctl", "is-active", "--quiet", UPDATE_UNIT],
capture_output=True,
)
return r.returncode == 0
def _update_result() -> str:
"""Return 'success', 'failed', or 'unknown'."""
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 "unknown"
def _update_lock_exists() -> bool:
"""Check if the file-based update lock exists (survives server restart)."""
return os.path.exists(UPDATE_LOCK)
def _create_update_lock():
"""Create the lock file to indicate an update is in progress."""
try: try:
with open(UPDATE_LOCK, "w") as f: with open(UPDATE_STATUS, "r") as f:
f.write(str(os.getpid())) return f.read().strip()
except OSError:
pass
def _remove_update_lock():
"""Remove the lock file."""
try:
os.unlink(UPDATE_LOCK)
except FileNotFoundError: except FileNotFoundError:
pass return "IDLE"
def _read_log(offset: int = 0) -> tuple[str, int]: def _read_log(offset: int = 0) -> tuple[str, int]:
@@ -208,10 +172,9 @@ def _read_log(offset: int = 0) -> tuple[str, int]:
Returns (new_text, new_offset).""" Returns (new_text, new_offset)."""
try: try:
with open(UPDATE_LOG, "rb") as f: with open(UPDATE_LOG, "rb") as f:
f.seek(0, 2) # seek to end f.seek(0, 2)
size = f.tell() size = f.tell()
if offset > size: if offset > size:
# Log was truncated (new run), start over
offset = 0 offset = 0
f.seek(offset) f.seek(offset)
chunk = f.read() chunk = f.read()
@@ -351,8 +314,8 @@ async def api_updates_run():
"""Kick off the detached update systemd unit.""" """Kick off the detached update systemd unit."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
running = await loop.run_in_executor(None, _update_is_active) status = await loop.run_in_executor(None, _read_update_status)
if running: if status == "RUNNING":
return {"ok": True, "status": "already_running"} return {"ok": True, "status": "already_running"}
# Reset failed state if any # Reset failed state if any
@@ -362,9 +325,6 @@ async def api_updates_run():
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
# Create a file-based lock that survives server restarts
_create_update_lock()
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"systemctl", "start", "--no-block", UPDATE_UNIT, "systemctl", "start", "--no-block", UPDATE_UNIT,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
@@ -377,38 +337,17 @@ async def api_updates_run():
@app.get("/api/updates/status") @app.get("/api/updates/status")
async def api_updates_status(offset: int = 0): async def api_updates_status(offset: int = 0):
"""Poll endpoint: returns running state, result, and new log content.""" """Poll endpoint: reads status file + log file. No systemctl needed."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
active = await loop.run_in_executor(None, _update_is_active) status = await loop.run_in_executor(None, _read_update_status)
result = await loop.run_in_executor(None, _update_result)
lock_exists = _update_lock_exists()
new_log, new_offset = await loop.run_in_executor(None, _read_log, offset) new_log, new_offset = await loop.run_in_executor(None, _read_log, offset)
# If the unit is active, it's definitely still running running = (status == "RUNNING")
if active: result = "pending" if running else status.lower()
return {
"running": True,
"result": "pending",
"log": new_log,
"offset": new_offset,
}
# If the lock file exists but the unit is not active, the update
# finished (or the server just restarted after nixos-rebuild switch).
# The lock file persists across server restarts because it's on disk.
if lock_exists:
_remove_update_lock()
return {
"running": False,
"result": result,
"log": new_log,
"offset": new_offset,
}
# No lock, not active — nothing happening
return { return {
"running": False, "running": running,
"result": result, "result": result,
"log": new_log, "log": new_log,
"offset": new_offset, "offset": new_offset,

View File

@@ -72,7 +72,7 @@ async function apiFetch(path, options = {}) {
return res.json(); return res.json();
} }
// ── Render: initial build ────────────────────────<EFBFBD><EFBFBD>──────────────── // ── Render: initial build ────────────────────────────────────────
function buildTiles(services, categoryLabels) { function buildTiles(services, categoryLabels) {
_servicesCache = services; _servicesCache = services;
@@ -341,24 +341,28 @@ async function pollUpdateStatus() {
if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalStatus) $modalStatus.textContent = "Updating…";
} }
// Append any new log content // Append new log content
if (data.log) { if (data.log) {
appendLog(data.log); appendLog(data.log);
} }
_updateLogOffset = data.offset; _updateLogOffset = data.offset;
// Check if finished // RUNNING → keep polling
if (!data.running) { if (data.running) {
_updateFinished = true; return;
stopUpdatePoll(); }
if (data.result === "success") {
onUpdateDone(true); // Finished — check result
} else { _updateFinished = true;
onUpdateDone(false); stopUpdatePoll();
}
if (data.result === "success") {
onUpdateDone(true);
} else {
onUpdateDone(false);
} }
} catch (err) { } catch (err) {
// Server is likely restarting during nixos-rebuild switch — keep polling // Server is restarting during nixos-rebuild switch — keep polling
if (!_serverWasDown) { if (!_serverWasDown) {
_serverWasDown = true; _serverWasDown = true;
appendLog("\n[Server restarting — waiting for it to come back…]\n"); appendLog("\n[Server restarting — waiting for it to come back…]\n");

View File

@@ -473,14 +473,14 @@ button:disabled {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
/* Reboot button: green */ /* Reboot button */
.btn-reboot { #btn-reboot {
background-color: var(--green); background-color: #2ec27e !important;
color: #fff; color: #fff !important;
} }
.btn-reboot:hover:not(:disabled) { #btn-reboot:hover:not(:disabled) {
background-color: #27ae6e; background-color: #27ae6e !important;
} }
.btn-save { .btn-save {

View File

@@ -28,7 +28,7 @@ let
{ name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; }
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; }
] ]
# ── Self-Hosted Apps ────────────────<EFBFBD><EFBFBD>────────────────────── # ── Self-Hosted Apps ──────────────────────────────────────
++ [ ++ [
{ name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; }
{ name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; }
@@ -58,18 +58,15 @@ let
export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH"
LOG="/var/log/sovran-hub-update.log" LOG="/var/log/sovran-hub-update.log"
LOCK="/run/sovran-hub-update.lock" STATUS="/var/log/sovran-hub-update.status"
# Create lock file (survives server restarts, cleared on reboot since /run is tmpfs) # Mark as RUNNING
echo $$ > "$LOCK" echo "RUNNING" > "$STATUS"
# Truncate the log and redirect ALL output (stdout + stderr) into it # Truncate the log and redirect ALL output (stdout + stderr) into it
: > "$LOG" : > "$LOG"
exec > >(tee -a "$LOG") 2>&1 exec > >(tee -a "$LOG") 2>&1
# Ensure lock is removed on exit (success or failure)
trap 'rm -f "$LOCK"' EXIT
echo "" echo ""
echo " Sovran_SystemsOS Update $(date)" echo " Sovran_SystemsOS Update $(date)"
echo "" echo ""
@@ -105,10 +102,12 @@ let
echo "" echo ""
echo " Update completed successfully" echo " Update completed successfully"
echo "" echo ""
echo "SUCCESS" > "$STATUS"
else else
echo "" echo ""
echo " Update failed see errors above" echo " Update failed see errors above"
echo "" echo ""
echo "FAILED" > "$STATUS"
fi fi
exit "$RC" exit "$RC"