6 Commits

4 changed files with 107 additions and 16 deletions

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json, os, subprocess, socket, shutil, pathlib, datetime, tarfile import json, os, subprocess, socket, shutil, pathlib, datetime, tarfile, sys, argparse
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import re import re
import urllib.request import urllib.request
import hashlib import hashlib
import fcntl import fcntl
from functools import partial
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = 4000 PORT = 4000
@@ -40,6 +41,7 @@ DEFAULT_MANIFEST_URL = os.environ.get(
"PIKIT_MANIFEST_URL", "PIKIT_MANIFEST_URL",
"https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json", "https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json",
) )
AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")
WEB_ROOT = pathlib.Path("/var/www/pikit-web") WEB_ROOT = pathlib.Path("/var/www/pikit-web")
API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py") API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
BACKUP_ROOT = pathlib.Path("/var/backups/pikit") BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
@@ -526,7 +528,10 @@ def save_update_state(state: dict):
def fetch_manifest(url: str = None): def fetch_manifest(url: str = None):
target = url or DEFAULT_MANIFEST_URL target = url or DEFAULT_MANIFEST_URL
resp = urllib.request.urlopen(target, timeout=10) req = urllib.request.Request(target)
if AUTH_TOKEN:
req.add_header("Authorization", f"token {AUTH_TOKEN}")
resp = urllib.request.urlopen(req, timeout=10)
data = resp.read() data = resp.read()
manifest = json.loads(data.decode()) manifest = json.loads(data.decode())
return manifest return manifest
@@ -534,7 +539,10 @@ def fetch_manifest(url: str = None):
def download_file(url: str, dest: pathlib.Path): def download_file(url: str, dest: pathlib.Path):
ensure_dir(dest.parent) ensure_dir(dest.parent)
with urllib.request.urlopen(url, timeout=30) as resp, dest.open("wb") as f: req = urllib.request.Request(url)
if AUTH_TOKEN:
req.add_header("Authorization", f"token {AUTH_TOKEN}")
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f) shutil.copyfileobj(resp, f)
return dest return dest
@@ -591,6 +599,7 @@ def apply_update_stub():
manifest = None manifest = None
state["in_progress"] = True state["in_progress"] = True
state["status"] = "in_progress"
state["progress"] = "Starting update…" state["progress"] = "Starting update…"
save_update_state(state) save_update_state(state)
@@ -635,7 +644,8 @@ def apply_update_stub():
ensure_dir(backup_dir) ensure_dir(backup_dir)
# Backup web and api # Backup web and api
if WEB_ROOT.exists(): if WEB_ROOT.exists():
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web") ensure_dir(backup_dir / "pikit-web")
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
if API_PATH.exists(): if API_PATH.exists():
shutil.copy2(API_PATH, backup_dir / "pikit-api.py") shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
@@ -665,7 +675,7 @@ def apply_update_stub():
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
state["status"] = "up_to_date" state["status"] = "error"
state["message"] = f"No release available ({e.code})" state["message"] = f"No release available ({e.code})"
except Exception as e: except Exception as e:
state["status"] = "error" state["status"] = "error"
@@ -707,17 +717,24 @@ def rollback_update_stub():
state["message"] = "Another update is running" state["message"] = "Another update is running"
save_update_state(state) save_update_state(state)
return state return state
state["in_progress"] = True
state["status"] = "in_progress"
state["progress"] = "Rolling back…"
save_update_state(state)
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
if not backups: if not backups:
state["status"] = "error" state["status"] = "error"
state["message"] = "No backup available to rollback." state["message"] = "No backup available to rollback."
state["in_progress"] = False
state["progress"] = None
save_update_state(state) save_update_state(state)
release_lock(lock)
return state return state
target = backups[0] target = backups[0]
try: try:
if (target / "pikit-web").exists(): if (target / "pikit-web").exists():
shutil.rmtree(WEB_ROOT, ignore_errors=True) shutil.rmtree(WEB_ROOT, ignore_errors=True)
shutil.copytree(target / "pikit-web", WEB_ROOT) shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True)
if (target / "pikit-api.py").exists(): if (target / "pikit-api.py").exists():
shutil.copy2(target / "pikit-api.py", API_PATH) shutil.copy2(target / "pikit-api.py", API_PATH)
os.chmod(API_PATH, 0o755) os.chmod(API_PATH, 0o755)
@@ -728,12 +745,32 @@ def rollback_update_stub():
except Exception as e: except Exception as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"Rollback failed: {e}" state["message"] = f"Rollback failed: {e}"
state["in_progress"] = False
state["progress"] = None
save_update_state(state) save_update_state(state)
if lock:
release_lock(lock) release_lock(lock)
return state return state
def start_background_task(mode: str):
"""
Kick off a background update/rollback via systemd-run so nginx/API restarts
do not break the caller connection.
mode: "apply" or "rollback"
"""
assert mode in ("apply", "rollback"), "invalid mode"
unit = f"pikit-update-{mode}"
flag = f"--{mode}-update"
cmd = ["systemd-run", "--unit", unit, "--quiet"]
# Pass manifest URL/token if set in environment
if DEFAULT_MANIFEST_URL:
cmd += [f"--setenv=PIKIT_MANIFEST_URL={DEFAULT_MANIFEST_URL}"]
if AUTH_TOKEN:
cmd += [f"--setenv=PIKIT_AUTH_TOKEN={AUTH_TOKEN}"]
cmd += ["/usr/bin/env", "python3", str(API_PATH), flag]
subprocess.run(cmd, check=False)
def acquire_lock(): def acquire_lock():
try: try:
ensure_dir(UPDATE_LOCK.parent) ensure_dir(UPDATE_LOCK.parent)
@@ -892,11 +929,20 @@ class Handler(BaseHTTPRequestHandler):
state = check_for_update() state = check_for_update()
return self._send(200, state) return self._send(200, state)
if self.path.startswith("/api/update/apply"): if self.path.startswith("/api/update/apply"):
state = apply_update_stub() # Start background apply to avoid breaking caller during service restart
return self._send(200, state) start_background_task("apply")
state = load_update_state()
state["status"] = "in_progress"
state["message"] = "Starting background apply"
save_update_state(state)
return self._send(202, state)
if self.path.startswith("/api/update/rollback"): if self.path.startswith("/api/update/rollback"):
state = rollback_update_stub() start_background_task("rollback")
return self._send(200, state) state = load_update_state()
state["status"] = "in_progress"
state["message"] = "Starting rollback"
save_update_state(state)
return self._send(202, state)
if self.path.startswith("/api/update/auto"): if self.path.startswith("/api/update/auto"):
state = load_update_state() state = load_update_state()
state["auto_check"] = bool(payload.get("enable")) state["auto_check"] = bool(payload.get("enable"))
@@ -1036,4 +1082,20 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pi-Kit API / updater")
parser.add_argument("--apply-update", action="store_true", help="Apply latest release (non-HTTP mode)")
parser.add_argument("--check-update", action="store_true", help="Check for latest release (non-HTTP mode)")
parser.add_argument("--rollback-update", action="store_true", help="Rollback to last backup (non-HTTP mode)")
args = parser.parse_args()
if args.apply_update:
apply_update_stub()
sys.exit(0)
if args.check_update:
check_for_update()
sys.exit(0)
if args.rollback_update:
rollback_update_stub()
sys.exit(0)
main() main()

View File

@@ -101,11 +101,11 @@ const helpClose = document.getElementById("helpClose");
const aboutBtn = document.getElementById("aboutBtn"); const aboutBtn = document.getElementById("aboutBtn");
const aboutModal = document.getElementById("aboutModal"); const aboutModal = document.getElementById("aboutModal");
const aboutClose = document.getElementById("aboutClose"); const aboutClose = document.getElementById("aboutClose");
const readyOverlay = document.getElementById("readyOverlay");
const busyOverlay = document.getElementById("busyOverlay"); const busyOverlay = document.getElementById("busyOverlay");
const busyTitle = document.getElementById("busyTitle"); const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText"); const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer"); const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay");
const TOAST_POS_KEY = "pikit-toast-pos"; const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim"; const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -398,6 +398,7 @@ async function loadReleaseStatus() {
auto_check = false, auto_check = false,
progress = null, progress = null,
} = data || {}; } = data || {};
window.__lastReleaseState = data;
setReleaseChip(data); setReleaseChip(data);
if (releaseCurrent) releaseCurrent.textContent = current_version; if (releaseCurrent) releaseCurrent.textContent = current_version;
if (releaseLatest) releaseLatest.textContent = latest_version; if (releaseLatest) releaseLatest.textContent = latest_version;
@@ -485,9 +486,10 @@ function wireReleaseControls() {
releaseApplyBtn?.addEventListener("click", async () => { releaseApplyBtn?.addEventListener("click", async () => {
try { try {
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…"; if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
await applyRelease(); await applyRelease();
await loadReleaseStatus(); pollReleaseStatus();
showToast("Update started", "success"); showToast("Update started", "success");
} catch (e) { } catch (e) {
showToast(e.error || "Update failed", "error"); showToast(e.error || "Update failed", "error");
@@ -498,10 +500,11 @@ function wireReleaseControls() {
releaseRollbackBtn?.addEventListener("click", async () => { releaseRollbackBtn?.addEventListener("click", async () => {
try { try {
showBusy("Rolling back…", "Restoring previous backup.");
if (releaseProgress) releaseProgress.textContent = "Rolling back…"; if (releaseProgress) releaseProgress.textContent = "Rolling back…";
await rollbackRelease(); await rollbackRelease();
await loadReleaseStatus(); pollReleaseStatus();
showToast("Rollback complete", "success"); showToast("Rollback started", "success");
} catch (e) { } catch (e) {
showToast(e.error || "Rollback failed", "error"); showToast(e.error || "Rollback failed", "error");
} finally { } finally {
@@ -520,6 +523,27 @@ function wireReleaseControls() {
}); });
} }
function pollReleaseStatus() {
let attempts = 0;
const maxAttempts = 30; // ~1 min at 2s
const tick = async () => {
attempts += 1;
await loadReleaseStatus();
const state = window.__lastReleaseState || {};
if (state.status === "in_progress" && attempts < maxAttempts) {
setTimeout(tick, 2000);
} else {
hideBusy();
if (state.status === "up_to_date") {
showToast("Update complete", "success");
} else if (state.status === "error") {
showToast(state.message || "Update failed", "error");
}
}
};
tick();
}
function showBusy(title = "Working…", text = "This may take a few seconds.") { function showBusy(title = "Working…", text = "This may take a few seconds.") {
if (!busyOverlay) return; if (!busyOverlay) return;
busyTitle.textContent = title; busyTitle.textContent = title;

View File

@@ -1374,6 +1374,11 @@ select:focus-visible,
#addServiceModal .controls { #addServiceModal .controls {
padding: 0 2px 4px; padding: 0 2px 4px;
} }
/* Busy overlay already defined; ensure modal width for release modal */
#releaseModal .modal-card.wide {
max-width: 760px;
}
.modal:not(.hidden) .modal-card { .modal:not(.hidden) .modal-card {
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }

0
tools/release/make-release.sh Normal file → Executable file
View File