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 #!/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 from http.server import BaseHTTPRequestHandler, HTTPServer
import re import re
import urllib.request import urllib.request
@@ -39,6 +39,22 @@ 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",
) )
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): class FirewallToolMissing(Exception):
@@ -515,6 +531,13 @@ def fetch_manifest(url: str = None):
return manifest 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(): def check_for_update():
state = load_update_state() state = load_update_state()
state["in_progress"] = True state["in_progress"] = True
@@ -543,21 +566,109 @@ def check_for_update():
def apply_update_stub(): 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() 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["in_progress"] = True
state["progress"] = "Applying update…" state["progress"] = "Starting update…"
save_update_state(state) save_update_state(state)
try: try:
# TODO: implement download + install manifest = fetch_manifest()
# For now, report that nothing was changed to avoid alarming errors. 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["status"] = "up_to_date"
state["message"] = "Updater not implemented yet; no changes were made." state["message"] = "Update installed"
# Reset latest_version so the chip returns to neutral state["progress"] = None
state["latest_version"] = state.get("current_version") save_update_state(state)
except Exception as e: except Exception as e:
state["status"] = "error" 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: finally:
state["in_progress"] = False state["in_progress"] = False
state["progress"] = None state["progress"] = None
@@ -567,8 +678,27 @@ def apply_update_stub():
def rollback_update_stub(): def rollback_update_stub():
state = load_update_state() state = load_update_state()
state["status"] = "up_to_date" backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
state["message"] = "Rollback not implemented yet; no changes were made." 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) save_update_state(state)
return state return state