Add real update apply/rollback pipeline (staging, backup, deploy)

This commit is contained in:
Aaron
2025-12-10 19:18:24 -05:00
parent d3925678d8
commit 066c1eaa08

View File

@@ -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