Compare commits
6 Commits
v0.1.0-dev
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e933fb325d | ||
|
|
712efba6f9 | ||
|
|
9f17c1a087 | ||
|
|
b2307e0b0b | ||
|
|
f9297b68e3 | ||
|
|
4c47a583e3 |
84
pikit-api.py
84
pikit-api.py
@@ -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)
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
0
tools/release/make-release.sh
Normal file → Executable file
Reference in New Issue
Block a user