6 Commits

4 changed files with 107 additions and 16 deletions

View File

@@ -1,10 +1,11 @@
#!/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
import re
import urllib.request
import hashlib
import fcntl
from functools import partial
HOST = "127.0.0.1"
PORT = 4000
@@ -40,6 +41,7 @@ DEFAULT_MANIFEST_URL = os.environ.get(
"PIKIT_MANIFEST_URL",
"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")
API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
@@ -526,7 +528,10 @@ def save_update_state(state: dict):
def fetch_manifest(url: str = None):
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()
manifest = json.loads(data.decode())
return manifest
@@ -534,7 +539,10 @@ def fetch_manifest(url: str = None):
def download_file(url: str, dest: pathlib.Path):
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)
return dest
@@ -591,6 +599,7 @@ def apply_update_stub():
manifest = None
state["in_progress"] = True
state["status"] = "in_progress"
state["progress"] = "Starting update…"
save_update_state(state)
@@ -635,7 +644,8 @@ def apply_update_stub():
ensure_dir(backup_dir)
# Backup web and api
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():
shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
@@ -665,7 +675,7 @@ def apply_update_stub():
state["progress"] = None
save_update_state(state)
except urllib.error.HTTPError as e:
state["status"] = "up_to_date"
state["status"] = "error"
state["message"] = f"No release available ({e.code})"
except Exception as e:
state["status"] = "error"
@@ -707,17 +717,24 @@ def rollback_update_stub():
state["message"] = "Another update is running"
save_update_state(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)
if not backups:
state["status"] = "error"
state["message"] = "No backup available to rollback."
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
release_lock(lock)
return state
target = backups[0]
try:
if (target / "pikit-web").exists():
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():
shutil.copy2(target / "pikit-api.py", API_PATH)
os.chmod(API_PATH, 0o755)
@@ -728,12 +745,32 @@ def rollback_update_stub():
except Exception as e:
state["status"] = "error"
state["message"] = f"Rollback failed: {e}"
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
if lock:
release_lock(lock)
release_lock(lock)
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():
try:
ensure_dir(UPDATE_LOCK.parent)
@@ -892,11 +929,20 @@ class Handler(BaseHTTPRequestHandler):
state = check_for_update()
return self._send(200, state)
if self.path.startswith("/api/update/apply"):
state = apply_update_stub()
return self._send(200, state)
# Start background apply to avoid breaking caller during service restart
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"):
state = rollback_update_stub()
return self._send(200, state)
start_background_task("rollback")
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"):
state = load_update_state()
state["auto_check"] = bool(payload.get("enable"))
@@ -1036,4 +1082,20 @@ def 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()

View File

@@ -101,11 +101,11 @@ const helpClose = document.getElementById("helpClose");
const aboutBtn = document.getElementById("aboutBtn");
const aboutModal = document.getElementById("aboutModal");
const aboutClose = document.getElementById("aboutClose");
const readyOverlay = document.getElementById("readyOverlay");
const busyOverlay = document.getElementById("busyOverlay");
const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay");
const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -398,6 +398,7 @@ async function loadReleaseStatus() {
auto_check = false,
progress = null,
} = data || {};
window.__lastReleaseState = data;
setReleaseChip(data);
if (releaseCurrent) releaseCurrent.textContent = current_version;
if (releaseLatest) releaseLatest.textContent = latest_version;
@@ -485,9 +486,10 @@ function wireReleaseControls() {
releaseApplyBtn?.addEventListener("click", async () => {
try {
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
await applyRelease();
await loadReleaseStatus();
pollReleaseStatus();
showToast("Update started", "success");
} catch (e) {
showToast(e.error || "Update failed", "error");
@@ -498,10 +500,11 @@ function wireReleaseControls() {
releaseRollbackBtn?.addEventListener("click", async () => {
try {
showBusy("Rolling back…", "Restoring previous backup.");
if (releaseProgress) releaseProgress.textContent = "Rolling back…";
await rollbackRelease();
await loadReleaseStatus();
showToast("Rollback complete", "success");
pollReleaseStatus();
showToast("Rollback started", "success");
} catch (e) {
showToast(e.error || "Rollback failed", "error");
} 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.") {
if (!busyOverlay) return;
busyTitle.textContent = title;

View File

@@ -1374,6 +1374,11 @@ select:focus-visible,
#addServiceModal .controls {
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 {
transform: translateY(0) scale(1);
}

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