Add real update apply/rollback pipeline (staging, backup, deploy)
This commit is contained in:
152
pikit-api.py
152
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user