Updater: channel-aware apply, UI polish, cache-bust
This commit is contained in:
311
pikit-api.py
311
pikit-api.py
@@ -4,8 +4,10 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import re
|
||||
import urllib.request
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import fcntl
|
||||
from functools import partial
|
||||
import json as jsonlib
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 4000
|
||||
@@ -518,6 +520,7 @@ def load_update_state():
|
||||
"auto_check": False,
|
||||
"in_progress": False,
|
||||
"progress": None,
|
||||
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
||||
}
|
||||
|
||||
|
||||
@@ -526,27 +529,146 @@ def save_update_state(state: dict):
|
||||
UPDATE_STATE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def _auth_token():
|
||||
return os.environ.get("PIKIT_AUTH_TOKEN") or AUTH_TOKEN
|
||||
|
||||
|
||||
def _gitea_latest_manifest(target: str):
|
||||
"""
|
||||
Fallback: when a manifest URL 404s, try hitting the Gitea API to grab the
|
||||
latest release asset named manifest.json.
|
||||
"""
|
||||
try:
|
||||
# target like https://host/owner/repo/releases/download/vX/manifest.json
|
||||
parts = target.split("/")
|
||||
if "releases" not in parts:
|
||||
return None
|
||||
idx = parts.index("releases")
|
||||
if idx < 2:
|
||||
return None
|
||||
base = "/".join(parts[:3]) # scheme + host
|
||||
owner = parts[idx - 2]
|
||||
repo = parts[idx - 1]
|
||||
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases/latest"
|
||||
req = urllib.request.Request(api_url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
rel = json.loads(resp.read().decode())
|
||||
assets = rel.get("assets") or []
|
||||
manifest_asset = next((a for a in assets if a.get("name") == "manifest.json"), None)
|
||||
if manifest_asset and manifest_asset.get("browser_download_url"):
|
||||
# Download that manifest
|
||||
return fetch_manifest(manifest_asset["browser_download_url"])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_manifest(url: str = None):
|
||||
target = url or DEFAULT_MANIFEST_URL
|
||||
target = url or os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||
req = urllib.request.Request(target)
|
||||
if AUTH_TOKEN:
|
||||
req.add_header("Authorization", f"token {AUTH_TOKEN}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = resp.read()
|
||||
manifest = json.loads(data.decode())
|
||||
return manifest
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = resp.read()
|
||||
return json.loads(data.decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
alt = _gitea_latest_manifest(target)
|
||||
if alt:
|
||||
return alt
|
||||
raise
|
||||
|
||||
|
||||
def fetch_manifest_for_channel(channel: str):
|
||||
"""
|
||||
For stable: use normal manifest (latest non-prerelease).
|
||||
For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API.
|
||||
"""
|
||||
channel = channel or "dev"
|
||||
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||
manifest = None
|
||||
try:
|
||||
manifest = fetch_manifest(base_manifest_url)
|
||||
except Exception:
|
||||
manifest = None
|
||||
|
||||
# If we already have a manifest and channel is stable, return it
|
||||
if manifest and channel == "stable":
|
||||
return manifest
|
||||
# If dev channel and manifest is dev, return it
|
||||
if manifest:
|
||||
version = manifest.get("version") or manifest.get("latest_version")
|
||||
if channel == "dev" and version and "dev" in str(version):
|
||||
return manifest
|
||||
|
||||
# Try Gitea API for latest release (include prerelease)
|
||||
try:
|
||||
parts = base_manifest_url.split("/")
|
||||
if "releases" not in parts:
|
||||
if manifest:
|
||||
return manifest
|
||||
return fetch_manifest(base_manifest_url)
|
||||
idx = parts.index("releases")
|
||||
owner = parts[idx - 2]
|
||||
repo = parts[idx - 1]
|
||||
base = "/".join(parts[:3])
|
||||
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
||||
req = urllib.request.Request(api_url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
releases = json.loads(resp.read().decode())
|
||||
|
||||
def pick(predicate):
|
||||
for r in releases:
|
||||
if predicate(r):
|
||||
asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||
if asset and asset.get("browser_download_url"):
|
||||
return fetch_manifest(asset["browser_download_url"])
|
||||
return None
|
||||
|
||||
if channel == "dev":
|
||||
m = pick(lambda r: r.get("prerelease") is True)
|
||||
if m:
|
||||
return m
|
||||
m = pick(lambda r: r.get("prerelease") is False)
|
||||
if m:
|
||||
return m
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# last resort: return whatever manifest we had
|
||||
if manifest:
|
||||
return manifest
|
||||
raise RuntimeError("No manifest found for channel")
|
||||
|
||||
|
||||
def download_file(url: str, dest: pathlib.Path):
|
||||
ensure_dir(dest.parent)
|
||||
req = urllib.request.Request(url)
|
||||
if AUTH_TOKEN:
|
||||
req.add_header("Authorization", f"token {AUTH_TOKEN}")
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
return dest
|
||||
|
||||
|
||||
def fetch_text_with_auth(url: str):
|
||||
req = urllib.request.Request(url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.read().decode()
|
||||
|
||||
|
||||
def check_for_update():
|
||||
state = load_update_state()
|
||||
lock = acquire_lock()
|
||||
@@ -559,16 +681,21 @@ def check_for_update():
|
||||
state["progress"] = "Checking for updates…"
|
||||
save_update_state(state)
|
||||
try:
|
||||
manifest = fetch_manifest()
|
||||
manifest = fetch_manifest_for_channel(state.get("channel") or "dev")
|
||||
latest = manifest.get("version") or manifest.get("latest_version")
|
||||
state["latest_version"] = latest
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
if latest and latest != state.get("current_version"):
|
||||
state["status"] = "update_available"
|
||||
state["message"] = manifest.get("changelog", "Update available")
|
||||
else:
|
||||
channel = state.get("channel") or "dev"
|
||||
if channel == "stable" and latest and "dev" in str(latest):
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Up to date"
|
||||
state["message"] = "Dev release available; enable dev channel to install."
|
||||
else:
|
||||
if latest and latest != state.get("current_version"):
|
||||
state["status"] = "update_available"
|
||||
state["message"] = manifest.get("changelog", "Update available")
|
||||
else:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Up to date"
|
||||
except Exception as e:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = f"Could not reach update server: {e}"
|
||||
@@ -604,11 +731,27 @@ def apply_update_stub():
|
||||
save_update_state(state)
|
||||
|
||||
try:
|
||||
manifest = fetch_manifest()
|
||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||
manifest = fetch_manifest_for_channel(channel)
|
||||
latest = manifest.get("version") or manifest.get("latest_version")
|
||||
if not latest:
|
||||
raise RuntimeError("Manifest missing version")
|
||||
|
||||
# Backup current BEFORE download/install to guarantee rollback point
|
||||
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():
|
||||
ensure_dir(backup_dir / "pikit-web")
|
||||
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
|
||||
if API_PATH.exists():
|
||||
shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
|
||||
if VERSION_FILE.exists():
|
||||
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
|
||||
|
||||
prune_backups(keep=1)
|
||||
|
||||
# Paths
|
||||
bundle_url = manifest.get("bundle") or manifest.get("url")
|
||||
if not bundle_url:
|
||||
@@ -638,19 +781,6 @@ def apply_update_stub():
|
||||
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():
|
||||
ensure_dir(backup_dir / "pikit-web")
|
||||
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
|
||||
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():
|
||||
@@ -683,19 +813,12 @@ def apply_update_stub():
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
# Attempt rollback if backup exists
|
||||
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
|
||||
if backups:
|
||||
backup = choose_rollback_backup()
|
||||
if backup:
|
||||
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)"
|
||||
restore_backup(backup)
|
||||
state["current_version"] = read_current_version()
|
||||
state["message"] += f" (rolled back to backup {backup.name})"
|
||||
save_update_state(state)
|
||||
except Exception as re:
|
||||
state["message"] += f" (rollback failed: {re})"
|
||||
@@ -721,8 +844,8 @@ def rollback_update_stub():
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Rolling back…"
|
||||
save_update_state(state)
|
||||
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
|
||||
if not backups:
|
||||
backup = choose_rollback_backup()
|
||||
if not backup:
|
||||
state["status"] = "error"
|
||||
state["message"] = "No backup available to rollback."
|
||||
state["in_progress"] = False
|
||||
@@ -730,18 +853,14 @@ def rollback_update_stub():
|
||||
save_update_state(state)
|
||||
release_lock(lock)
|
||||
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, dirs_exist_ok=True)
|
||||
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)
|
||||
restore_backup(backup)
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = f"Rolled back to backup {target.name}"
|
||||
state["current_version"] = read_current_version()
|
||||
state["latest_version"] = state.get("latest_version") or state["current_version"]
|
||||
ver = get_backup_version(backup)
|
||||
suffix = f" (version {ver})" if ver else ""
|
||||
state["message"] = f"Rolled back to backup {backup.name}{suffix}"
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Rollback failed: {e}"
|
||||
@@ -795,12 +914,70 @@ def release_lock(lockfile):
|
||||
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)
|
||||
backups = list_backups()
|
||||
for old in backups[keep:]:
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
|
||||
|
||||
def list_backups():
|
||||
"""Return backups sorted by mtime (newest first)."""
|
||||
ensure_dir(BACKUP_ROOT)
|
||||
backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()]
|
||||
backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return backups
|
||||
|
||||
|
||||
def get_backup_version(path: pathlib.Path):
|
||||
vf = path / "version.txt"
|
||||
if not vf.exists():
|
||||
web_version = path / "pikit-web" / "data" / "version.json"
|
||||
if not web_version.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(web_version.read_text()).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return vf.read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def choose_rollback_backup():
|
||||
"""
|
||||
Pick the most recent backup whose version differs from the currently
|
||||
installed version. If none differ, fall back to the newest backup.
|
||||
"""
|
||||
backups = list_backups()
|
||||
if not backups:
|
||||
return None
|
||||
current = read_current_version()
|
||||
for b in backups:
|
||||
ver = get_backup_version(b)
|
||||
if ver and ver != current:
|
||||
return b
|
||||
return backups[0]
|
||||
|
||||
|
||||
def restore_backup(target: pathlib.Path):
|
||||
if (target / "pikit-web").exists():
|
||||
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
||||
shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True)
|
||||
if (target / "pikit-api.py").exists():
|
||||
shutil.copy2(target / "pikit-api.py", API_PATH)
|
||||
os.chmod(API_PATH, 0o755)
|
||||
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if (target / "version.txt").exists():
|
||||
shutil.copy2(target / "version.txt", VERSION_FILE)
|
||||
else:
|
||||
# Fall back to the version recorded in the web bundle
|
||||
ver = get_backup_version(target)
|
||||
if ver:
|
||||
VERSION_FILE.write_text(str(ver))
|
||||
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
||||
subprocess.run(["systemctl", "restart", svc], check=False)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
"""Minimal JSON API for the dashboard (status, services, updates, reset)."""
|
||||
def _send(self, code, data):
|
||||
@@ -896,7 +1073,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path.startswith("/api/update/status"):
|
||||
state = load_update_state()
|
||||
state["current_version"] = read_current_version()
|
||||
state["channel"] = state.get("channel", os.environ.get("PIKIT_CHANNEL", "dev"))
|
||||
self._send(200, state)
|
||||
elif self.path.startswith("/api/update/changelog"):
|
||||
# Fetch changelog text (URL param ?url= overrides manifest changelog)
|
||||
try:
|
||||
qs = urllib.parse.urlparse(self.path).query
|
||||
params = urllib.parse.parse_qs(qs)
|
||||
url = params.get("url", [None])[0]
|
||||
if not url:
|
||||
manifest = fetch_manifest()
|
||||
url = manifest.get("changelog")
|
||||
if not url:
|
||||
return self._send(404, {"error": "no changelog url"})
|
||||
text = fetch_text_with_auth(url)
|
||||
return self._send(200, {"text": text})
|
||||
except Exception as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
@@ -948,6 +1141,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state["auto_check"] = bool(payload.get("enable"))
|
||||
save_update_state(state)
|
||||
return self._send(200, state)
|
||||
if self.path.startswith("/api/update/channel"):
|
||||
chan = payload.get("channel", "dev")
|
||||
if chan not in ("dev", "stable"):
|
||||
return self._send(400, {"error": "channel must be dev or stable"})
|
||||
state = load_update_state()
|
||||
state["channel"] = chan
|
||||
save_update_state(state)
|
||||
return self._send(200, state)
|
||||
if self.path.startswith("/api/services/add"):
|
||||
name = payload.get("name")
|
||||
port = int(payload.get("port", 0))
|
||||
|
||||
Reference in New Issue
Block a user