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
|
#!/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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
0
tools/release/make-release.sh
Normal file → Executable file
Reference in New Issue
Block a user