From 066c1eaa08d681812c3253859728e7bfedad8f30 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 10 Dec 2025 19:18:24 -0500 Subject: [PATCH] Add real update apply/rollback pipeline (staging, backup, deploy) --- pikit-api.py | 152 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 11 deletions(-) diff --git a/pikit-api.py b/pikit-api.py index aef62fd..d7ca4db 100644 --- a/pikit-api.py +++ b/pikit-api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import json, os, subprocess, socket, shutil, pathlib, datetime +import json, os, subprocess, socket, shutil, pathlib, datetime, tarfile from http.server import BaseHTTPRequestHandler, HTTPServer import re import urllib.request @@ -39,6 +39,22 @@ DEFAULT_MANIFEST_URL = os.environ.get( "PIKIT_MANIFEST_URL", "https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json", ) +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") +TMP_ROOT = pathlib.Path("/var/tmp/pikit-update") + + +def ensure_dir(path: pathlib.Path): + path.mkdir(parents=True, exist_ok=True) + + +def sha256_file(path: pathlib.Path): + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() class FirewallToolMissing(Exception): @@ -515,6 +531,13 @@ def fetch_manifest(url: str = None): return manifest +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: + shutil.copyfileobj(resp, f) + return dest + + def check_for_update(): state = load_update_state() state["in_progress"] = True @@ -543,21 +566,109 @@ def check_for_update(): def apply_update_stub(): - """Placeholder apply that marks not implemented but keeps state coherent.""" + """Download + install release tarball with backup/rollback.""" state = load_update_state() + if state.get("in_progress"): + state["message"] = "Update already in progress" + save_update_state(state) + return state + + manifest = None state["in_progress"] = True - state["progress"] = "Applying update…" + state["progress"] = "Starting update…" save_update_state(state) + try: - # TODO: implement download + install - # For now, report that nothing was changed to avoid alarming errors. + manifest = fetch_manifest() + latest = manifest.get("version") or manifest.get("latest_version") + if not latest: + raise RuntimeError("Manifest missing version") + + # Paths + bundle_url = manifest.get("bundle") or manifest.get("url") + if not bundle_url: + raise RuntimeError("Manifest missing bundle url") + stage_dir = TMP_ROOT / latest + bundle_path = stage_dir / "bundle.tar.gz" + ensure_dir(stage_dir) + + state["progress"] = "Downloading release…" + save_update_state(state) + download_file(bundle_url, bundle_path) + + # Verify hash if provided + expected_hash = None + for f in manifest.get("files", []): + if f.get("path") == "bundle.tar.gz" and f.get("sha256"): + expected_hash = f["sha256"] + break + if expected_hash: + got = sha256_file(bundle_path) + if got.lower() != expected_hash.lower(): + raise RuntimeError("Bundle hash mismatch") + + state["progress"] = "Staging files…" + save_update_state(state) + # Extract + with tarfile.open(bundle_path, "r:gz") as tar: + tar.extractall(stage_dir) + + # Backup current + ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") + backup_dir = BACKUP_ROOT / ts + ensure_dir(backup_dir) + # Backup web and api + if WEB_ROOT.exists(): + shutil.copytree(WEB_ROOT, backup_dir / "pikit-web") + if API_PATH.exists(): + shutil.copy2(API_PATH, backup_dir / "pikit-api.py") + + # Deploy from staging + staged_web = stage_dir / "pikit-web" + if staged_web.exists(): + shutil.rmtree(WEB_ROOT, ignore_errors=True) + shutil.copytree(staged_web, WEB_ROOT) + staged_api = stage_dir / "pikit-api.py" + if staged_api.exists(): + shutil.copy2(staged_api, API_PATH) + os.chmod(API_PATH, 0o755) + + # Restart services (best-effort) + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + + VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) + VERSION_FILE.write_text(str(latest)) + + state["current_version"] = str(latest) + state["latest_version"] = str(latest) state["status"] = "up_to_date" - state["message"] = "Updater not implemented yet; no changes were made." - # Reset latest_version so the chip returns to neutral - state["latest_version"] = state.get("current_version") + state["message"] = "Update installed" + state["progress"] = None + save_update_state(state) except Exception as e: state["status"] = "error" - state["message"] = f"Apply failed: {e}" + state["message"] = f"Update failed: {e}" + state["progress"] = None + save_update_state(state) + # Attempt rollback if backup exists + backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) + if backups: + try: + latest_backup = backups[0] + if (latest_backup / "pikit-web").exists(): + shutil.rmtree(WEB_ROOT, ignore_errors=True) + shutil.copytree(latest_backup / "pikit-web", WEB_ROOT) + if (latest_backup / "pikit-api.py").exists(): + shutil.copy2(latest_backup / "pikit-api.py", API_PATH) + os.chmod(API_PATH, 0o755) + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + state["message"] += " (rolled back to previous backup)" + save_update_state(state) + except Exception as re: + state["message"] += f" (rollback failed: {re})" + save_update_state(state) finally: state["in_progress"] = False state["progress"] = None @@ -567,8 +678,27 @@ def apply_update_stub(): def rollback_update_stub(): state = load_update_state() - state["status"] = "up_to_date" - state["message"] = "Rollback not implemented yet; no changes were made." + backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) + if not backups: + state["status"] = "error" + state["message"] = "No backup available to rollback." + save_update_state(state) + 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) + if (target / "pikit-api.py").exists(): + shutil.copy2(target / "pikit-api.py", API_PATH) + os.chmod(API_PATH, 0o755) + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + state["status"] = "up_to_date" + state["message"] = f"Rolled back to backup {target.name}" + except Exception as e: + state["status"] = "error" + state["message"] = f"Rollback failed: {e}" save_update_state(state) return state