diff --git a/pikit-api.py b/pikit-api.py index b7895a7..943d846 100644 --- a/pikit-api.py +++ b/pikit-api.py @@ -4,6 +4,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer import re import urllib.request import hashlib +import fcntl HOST = "127.0.0.1" PORT = 4000 @@ -540,6 +541,12 @@ def download_file(url: str, dest: pathlib.Path): def check_for_update(): state = load_update_state() + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state state["in_progress"] = True state["progress"] = "Checking for updates…" save_update_state(state) @@ -562,6 +569,8 @@ def check_for_update(): state["in_progress"] = False state["progress"] = None save_update_state(state) + if lock: + release_lock(lock) return state @@ -573,6 +582,13 @@ def apply_update_stub(): save_update_state(state) return state + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state + manifest = None state["in_progress"] = True state["progress"] = "Starting update…" @@ -623,6 +639,8 @@ def apply_update_stub(): if API_PATH.exists(): shutil.copy2(API_PATH, backup_dir / "pikit-api.py") + prune_backups(keep=2) + # Deploy from staging staged_web = stage_dir / "pikit-web" if staged_web.exists(): @@ -676,11 +694,19 @@ def apply_update_stub(): state["in_progress"] = False state["progress"] = None save_update_state(state) + if lock: + release_lock(lock) return state def rollback_update_stub(): state = load_update_state() + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) if not backups: state["status"] = "error" @@ -703,9 +729,41 @@ def rollback_update_stub(): state["status"] = "error" state["message"] = f"Rollback failed: {e}" save_update_state(state) + if lock: + release_lock(lock) return state +def acquire_lock(): + try: + ensure_dir(UPDATE_LOCK.parent) + lockfile = UPDATE_LOCK.open("w") + fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lockfile.write(str(os.getpid())) + lockfile.flush() + return lockfile + except Exception: + return None + + +def release_lock(lockfile): + try: + fcntl.flock(lockfile.fileno(), fcntl.LOCK_UN) + lockfile.close() + UPDATE_LOCK.unlink(missing_ok=True) + except Exception: + pass + + +def prune_backups(keep: int = 2): + if keep < 1: + keep = 1 + ensure_dir(BACKUP_ROOT) + backups = sorted([p for p in BACKUP_ROOT.iterdir() if p.is_dir()], reverse=True) + for old in backups[keep:]: + shutil.rmtree(old, ignore_errors=True) + + class Handler(BaseHTTPRequestHandler): """Minimal JSON API for the dashboard (status, services, updates, reset).""" def _send(self, code, data): diff --git a/systemd/pikit-update.service b/systemd/pikit-update.service new file mode 100644 index 0000000..b9190e1 --- /dev/null +++ b/systemd/pikit-update.service @@ -0,0 +1,14 @@ +[Unit] +Description=Pi-Kit release update check/apply helper +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/curl -s -H "Content-Type: application/json" -X POST http://127.0.0.1/api/update/check +User=root +Group=root +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/pikit-update.timer b/systemd/pikit-update.timer new file mode 100644 index 0000000..8750acb --- /dev/null +++ b/systemd/pikit-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily Pi-Kit release update check + +[Timer] +OnCalendar=*-*-* 04:20:00 +RandomizedDelaySec=900 +Persistent=true +Unit=pikit-update.service + +[Install] +WantedBy=timers.target