11 Commits

11 changed files with 332 additions and 224 deletions

View File

@@ -10,10 +10,23 @@ Lightweight dashboard for DietPi-based Pi-Kit images.
- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`.
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
## Build, package, and publish a release
1) Bump `pikit-web/data/version.json` to the target version (e.g., `0.1.3-dev1`), then `cd pikit-web && npm run build`.
2) Package: `./tools/release/make-release.sh <version> https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v<version>`
Outputs in `out/releases/`: `pikit-<version>.tar.gz` and `manifest.json` (with SHA256 + changelog URL).
3) Create a Gitea release/tag `v<version>` and upload:
- `pikit-<version>.tar.gz`
- `manifest.json`
- `CHANGELOG-<version>.txt` (add a short changelog in `out/releases/`)
4) Update repo manifests (public raw defaults used by devices): edit `manifests/manifest-stable.json` and `manifests/manifest-dev.json` with `version`, `bundle`, `changelog`, `_release_date`, `sha256` from the new bundle, then commit/push.
- Default OTA URLs (no token needed):
- Stable: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json`
- Dev: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json`
## Deploy to a Pi-Kit box
1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`.
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
3) Using release bundles: `./tools/release/make-release.sh <version> <base_url>`, upload the tarball + manifest, and point `PIKIT_MANIFEST_URL` (systemd drop-in) to that manifest URL for OTA.
3) OTA defaults (no per-device token required): `PIKIT_MANIFEST_URL` points to the stable manifest above; enabling “Allow dev builds” in the UI makes the updater consult the dev manifest URL. Override via systemd drop-in if you need to pin to a specific manifest.
## Notes
- Service paths are normalized (leading slash) and URLs include optional subpaths.

View File

@@ -0,0 +1,9 @@
{
"version": "0.1.3-dev1",
"_release_date": "2025-12-14T22:23:00Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev1/pikit-0.1.3-dev1.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev1/CHANGELOG-0.1.3-dev1.txt",
"files": [
{ "path": "bundle.tar.gz", "sha256": "290bc3ef0acbac8ffc1d283fdf5413bdd0dd6a90a9ccd2253dfd406773951b62" }
]
}

View File

@@ -0,0 +1,9 @@
{
"version": "0.1.2",
"_release_date": "2025-12-10T00:00:00Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt",
"files": [
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" }
]
}

View File

@@ -14,7 +14,7 @@ import argparse
import sys
from pikit_api import HOST, PORT
from pikit_api.releases import apply_update, check_for_update, rollback_update
from pikit_api.releases import apply_update, check_for_update
from pikit_api.server import run_server
@@ -22,7 +22,6 @@ def parse_args():
parser = argparse.ArgumentParser(description="Pi-Kit API / updater")
parser.add_argument("--apply-update", action="store_true", help="Apply latest release (non-HTTP mode)")
parser.add_argument("--check-update", action="store_true", help="Check for latest release (non-HTTP mode)")
parser.add_argument("--rollback-update", action="store_true", help="Rollback to last backup (non-HTTP mode)")
parser.add_argument("--host", default=HOST, help="Bind host (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)")
return parser.parse_args()
@@ -36,9 +35,6 @@ def main():
if args.check_update:
check_for_update()
return
if args.rollback_update:
rollback_update()
return
run_server(host=args.host, port=args.port)

View File

@@ -53,10 +53,12 @@ export const applyRelease = () =>
api("/api/update/apply", {
method: "POST",
});
export const rollbackRelease = () =>
api("/api/update/rollback", {
export const applyReleaseVersion = (version) =>
api("/api/update/apply_version", {
method: "POST",
body: JSON.stringify({ version }),
});
export const listReleases = () => api("/api/update/releases");
export const setReleaseAutoCheck = (enable) =>
api("/api/update/auto", {
method: "POST",

View File

@@ -5,7 +5,8 @@ import {
getReleaseStatus,
checkRelease,
applyRelease,
rollbackRelease,
applyReleaseVersion,
listReleases,
setReleaseAutoCheck,
setReleaseChannel,
} from "./api.js";
@@ -24,7 +25,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
const releaseProgress = document.getElementById("releaseProgress");
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
const releaseVersionSelect = document.getElementById("releaseVersionSelect");
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus");
@@ -46,6 +48,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null;
let releaseChannel = "dev";
let releaseOptions = [];
const logger = createReleaseLogger(logUi);
logger.attach(releaseLog);
@@ -64,6 +67,41 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
};
async function loadReleaseList() {
if (!releaseVersionSelect) return;
try {
const data = await listReleases();
releaseOptions = data.releases || [];
releaseVersionSelect.innerHTML = "";
if (!releaseOptions.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "No releases found";
releaseVersionSelect.appendChild(opt);
releaseVersionSelect.disabled = true;
releaseApplyVersionBtn && (releaseApplyVersionBtn.disabled = true);
return;
}
releaseVersionSelect.disabled = false;
releaseOptions.forEach((r) => {
const opt = document.createElement("option");
opt.value = r.version;
const tag = r.prerelease ? " (dev)" : "";
opt.textContent = `${r.version}${tag}${r.published_at ? `${fmtDate(r.published_at)}` : ""}`;
releaseVersionSelect.appendChild(opt);
});
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
} catch (e) {
releaseVersionSelect.innerHTML = "";
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "Failed to load releases";
releaseVersionSelect.appendChild(opt);
releaseVersionSelect.disabled = true;
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = true;
}
}
function setReleaseChip(state) {
if (!releaseFlagTop) return;
releaseFlagTop.textContent = "Pi-Kit: n/a";
@@ -237,6 +275,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseBtn?.addEventListener("click", () => {
releaseModal?.classList.remove("hidden");
loadReleaseStatus(true);
loadReleaseList();
});
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
// Do not allow dismiss by clicking backdrop (consistency with other modals)
@@ -299,19 +338,24 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
});
releaseRollbackBtn?.addEventListener("click", async () => {
releaseApplyVersionBtn?.addEventListener("click", async () => {
if (!releaseVersionSelect || !releaseVersionSelect.value) {
showToast("Select a version first", "error");
return;
}
try {
lastReleaseToastKey = null;
logUi("Rollback requested");
const ver = releaseVersionSelect.value;
logUi(`Install version ${ver} requested`);
releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup.");
logRelease("Starting rollback…");
await rollbackRelease();
showBusy(`Installing ${ver}`, "Applying selected release. This can take up to a minute.");
logRelease(`Installing ${ver}`);
await applyReleaseVersion(ver);
pollReleaseStatus();
showToast("Rollback started", "success");
showToast(`Installing ${ver}`, "success");
} catch (e) {
showToast(e.error || "Rollback failed", "error");
logRelease(`Error: ${e.error || "Rollback failed"}`);
showToast(e.error || "Install failed", "error");
logRelease(`Error: ${e.error || "Install failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
@@ -334,6 +378,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseChannel = chan;
logRelease(`Channel set to ${chan}`);
await loadReleaseStatus(true);
await loadReleaseList();
} catch (e) {
showToast(e.error || "Failed to save channel", "error");
releaseChannelToggle.checked = releaseChannel === "dev";

View File

@@ -165,8 +165,9 @@
<button id="releaseApplyBtn" title="Download and install the latest release">
Upgrade
</button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback
<select id="releaseVersionSelect" class="ghost" title="Select a specific release to install"></select>
<button id="releaseApplyVersionBtn" class="ghost" title="Install the selected release">
Install version
</button>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseAutoCheck" />

View File

@@ -8,4 +8,4 @@ modules while keeping the on-device entry point compatible.
# Re-export commonly used helpers for convenience
from .constants import HOST, PORT # noqa: F401
from .server import run_server # noqa: F401
from .releases import apply_update, check_for_update, rollback_update # noqa: F401
from .releases import apply_update, check_for_update # noqa: F401

View File

@@ -40,7 +40,13 @@ ALL_PATTERNS = [
# Release updater
DEFAULT_MANIFEST_URL = os.environ.get(
"PIKIT_MANIFEST_URL",
"https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json",
# Stable manifest (raw in repo, public)
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json",
)
DEFAULT_DEV_MANIFEST_URL = os.environ.get(
"PIKIT_DEV_MANIFEST_URL",
# Dev manifest (raw in repo, public)
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json",
)
AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")

View File

@@ -14,6 +14,8 @@ from .releases import (
read_current_version,
save_update_state,
start_background_task,
list_available_releases,
apply_update_version,
)
from .services import (
FirewallToolMissing,
@@ -82,6 +84,11 @@ class Handler(BaseHTTPRequestHandler):
state = _load_diag_state()
return self._send(200, {"entries": entries, "state": state})
if self.path.startswith("/api/update/releases"):
state = load_update_state()
channel = state.get("channel") or "stable"
return self._send(200, {"releases": list_available_releases(channel)})
return self._send(404, {"error": "not found"})
# POST endpoints
@@ -122,6 +129,15 @@ class Handler(BaseHTTPRequestHandler):
state = check_for_update()
return self._send(200, state)
if self.path.startswith("/api/update/apply_version"):
version = payload.get("version")
if not version:
return self._send(400, {"error": "version required"})
state = load_update_state()
chan = payload.get("channel") or state.get("channel") or "stable"
result = apply_update_version(version, chan)
return self._send(200, result)
if self.path.startswith("/api/update/apply"):
start_background_task("apply")
state = load_update_state()
@@ -130,14 +146,6 @@ class Handler(BaseHTTPRequestHandler):
save_update_state(state)
return self._send(202, state)
if self.path.startswith("/api/update/rollback"):
start_background_task("rollback")
state = load_update_state()
state["status"] = "in_progress"
state["message"] = "Starting rollback"
save_update_state(state)
return self._send(202, state)
if self.path.startswith("/api/update/auto"):
state = load_update_state()
state["auto_check"] = bool(payload.get("enable"))

View File

@@ -16,8 +16,8 @@ from .constants import (
API_PACKAGE_DIR,
API_PATH,
AUTH_TOKEN,
BACKUP_ROOT,
DEFAULT_MANIFEST_URL,
DEFAULT_DEV_MANIFEST_URL,
TMP_ROOT,
UPDATE_LOCK,
UPDATE_STATE,
@@ -119,6 +119,17 @@ def fetch_manifest(url: str | None = None):
data = resp.read()
return json.loads(data.decode())
except urllib.error.HTTPError as e:
# If raw URL is protected, retry with access_token query param
if e.code == 404 and token and "access_token=" not in target:
try:
sep = "&" if "?" in target else "?"
retry_url = f"{target}{sep}access_token={token}"
req = urllib.request.Request(retry_url)
resp = urllib.request.urlopen(req, timeout=10)
data = resp.read()
return json.loads(data.decode())
except Exception:
pass
if e.code == 404:
alt = _gitea_latest_manifest(target)
if alt:
@@ -135,6 +146,31 @@ def _try_fetch(url: Optional[str]):
return None
def _derive_releases_api_url(manifest_url: str) -> Optional[str]:
"""
Best-effort: derive Gitea releases API endpoint from a manifest URL.
Supports raw URLs (/owner/repo/raw/...) or release asset URLs.
"""
try:
parsed = urllib.parse.urlparse(manifest_url)
parts = parsed.path.strip("/").split("/")
owner = repo = None
if "releases" in parts:
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
elif "raw" in parts:
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
elif len(parts) >= 2:
owner, repo = parts[0], parts[1]
if owner and repo:
base = f"{parsed.scheme}://{parsed.netloc}"
return f"{base}/api/v1/repos/{owner}/{repo}/releases"
except Exception:
return None
return None
def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
"""
For stable: use normal manifest (latest non-prerelease).
@@ -143,10 +179,14 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
"""
channel = channel or "dev"
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL")
stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or base_manifest_url
dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL") or DEFAULT_DEV_MANIFEST_URL
stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or DEFAULT_MANIFEST_URL
manifest = None
manual_dev_manifest = None
version_dates: Dict[str, Optional[str]] = {}
# Explicit dev manifest (raw file) only used for dev channel
if channel == "dev":
manual_dev_manifest = _try_fetch(dev_manifest_url)
try:
manifest = fetch_manifest(stable_manifest_url)
except Exception:
@@ -194,21 +234,22 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
try:
parts = base_manifest_url.split("/")
if "releases" not in parts:
if manifest:
return (manifest, {"version_dates": version_dates}) if with_meta else manifest
mf = fetch_manifest(base_manifest_url)
return (mf, {"version_dates": version_dates}) if with_meta else mf
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())
# No releases API for this URL; keep any fetched manifest and skip API discovery.
releases = []
if not manifest:
manifest = fetch_manifest(base_manifest_url)
else:
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())
# Map release versions to published dates so we can surface them later
for rel in releases:
@@ -252,8 +293,8 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
manifest["_release_date"] = version_dates[mver]
if channel == "dev":
# Choose the newest by version comparison across stable/dev/base candidates
candidates = [c for c in (latest_dev, latest_stable, manifest) if c]
# Choose the newest by version comparison across stable/dev/base/manual-dev candidates
candidates = [c for c in (latest_dev, manual_dev_manifest, latest_stable, manifest) if c]
best = None
best_ver = None
for c in candidates:
@@ -286,6 +327,56 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
raise RuntimeError("No manifest found for channel")
def list_available_releases(channel: str = "stable", limit: int = 20) -> List[Dict[str, Any]]:
"""
Return a list of releases with manifest URLs. Respects channel:
- stable: non-prerelease only
- dev: includes prereleases
"""
channel = channel or "stable"
api_url = os.environ.get("PIKIT_RELEASES_API") or _derive_releases_api_url(DEFAULT_MANIFEST_URL)
if not api_url:
return []
try:
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())
except Exception:
return []
items = []
for rel in releases:
prerelease = bool(rel.get("prerelease"))
if channel == "stable" and prerelease:
continue
version = rel.get("tag_name") or rel.get("name")
if not version:
continue
version = str(version).lstrip("vV")
manifest_asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
if not manifest_asset or not manifest_asset.get("browser_download_url"):
continue
items.append(
{
"version": version,
"prerelease": prerelease,
"published_at": rel.get("published_at") or rel.get("created_at"),
"manifest_url": manifest_asset["browser_download_url"],
"changelog_url": next(
(a.get("browser_download_url") for a in rel.get("assets", []) if a.get("name", "").startswith("CHANGELOG-")),
None,
),
}
)
# Sort newest first by published_at if present
items.sort(key=lambda x: x.get("published_at") or "", reverse=True)
return items[:limit]
def download_file(url: str, dest: pathlib.Path):
ensure_dir(dest.parent)
req = urllib.request.Request(url)
@@ -327,75 +418,6 @@ def release_lock(lockfile):
pass
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)
if (target / "pikit_api").exists():
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
shutil.copytree(target / "pikit_api", API_PACKAGE_DIR, dirs_exist_ok=True)
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
if (target / "version.txt").exists():
shutil.copy2(target / "version.txt", VERSION_FILE)
else:
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)
def prune_backups(keep: int = 2):
if keep < 1:
keep = 1
backups = list_backups()
for old in backups[keep:]:
shutil.rmtree(old, ignore_errors=True)
def check_for_update():
state = load_update_state()
lock = acquire_lock()
@@ -455,6 +477,70 @@ def check_for_update():
return state
def _install_manifest(manifest: Dict[str, Any], meta: Optional[Dict[str, Any]], state: Dict[str, Any]):
latest = manifest.get("version") or manifest.get("latest_version")
if not latest:
raise RuntimeError("Manifest missing version")
bundle_url = manifest.get("bundle") or manifest.get("url")
if not bundle_url:
raise RuntimeError("Manifest missing bundle url")
stage_dir = TMP_ROOT / str(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)
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
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")
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
state["progress"] = "Staging files…"
save_update_state(state)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(stage_dir)
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)
staged_pkg = stage_dir / "pikit_api"
if staged_pkg.exists():
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
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["changelog_url"] = manifest.get("changelog")
state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
state["current_release_date"] = state.get("latest_release_date")
state["status"] = "up_to_date"
state["message"] = "Update installed"
state["progress"] = None
save_update_state(state)
diag_log("info", "Update applied", {"version": str(latest)})
def _stage_backup() -> pathlib.Path:
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
backup_dir = BACKUP_ROOT / ts
@@ -494,70 +580,7 @@ def apply_update():
try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
latest = manifest.get("version") or manifest.get("latest_version")
if not latest:
raise RuntimeError("Manifest missing version")
backup_dir = _stage_backup()
prune_backups(keep=1)
bundle_url = manifest.get("bundle") or manifest.get("url")
if not bundle_url:
raise RuntimeError("Manifest missing bundle url")
stage_dir = TMP_ROOT / str(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)
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
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")
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
state["progress"] = "Staging files…"
save_update_state(state)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(stage_dir)
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)
staged_pkg = stage_dir / "pikit_api"
if staged_pkg.exists():
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
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["changelog_url"] = manifest.get("changelog")
state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
state["current_release_date"] = state.get("latest_release_date")
state["status"] = "up_to_date"
state["message"] = "Update installed"
state["progress"] = None
save_update_state(state)
diag_log("info", "Update applied", {"version": str(latest)})
_install_manifest(manifest, meta, state)
except urllib.error.HTTPError as e:
state["status"] = "error"
state["message"] = f"No release available ({e.code})"
@@ -570,18 +593,6 @@ def apply_update():
state["latest_release_date"] = None
save_update_state(state)
diag_log("error", "Update apply failed", {"error": str(e)})
backup = choose_rollback_backup()
if backup:
try:
restore_backup(backup)
state["current_version"] = read_current_version()
state["message"] += f" (rolled back to backup {backup.name})"
save_update_state(state)
diag_log("info", "Rollback after failed update", {"backup": backup.name})
except Exception as re:
state["message"] += f" (rollback failed: {re})"
save_update_state(state)
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
finally:
state["in_progress"] = False
state["progress"] = None
@@ -591,55 +602,64 @@ def apply_update():
return state
def rollback_update():
def apply_update_version(version: str, channel: Optional[str] = None):
"""
Install a specific version chosen by the user. Uses the releases list to
resolve the manifest URL.
"""
version = str(version).lstrip("vV")
state = load_update_state()
channel = channel or state.get("channel") or "stable"
if state.get("in_progress"):
state["message"] = "Update already in progress"
save_update_state(state)
return state
lock = acquire_lock()
if lock is None:
state["status"] = "error"
state["message"] = "Another update is running"
save_update_state(state)
return state
state["in_progress"] = True
state["status"] = "in_progress"
state["progress"] = "Rolling back"
state["progress"] = f"Preparing {version}"
save_update_state(state)
diag_log("info", "Rollback started")
backup = choose_rollback_backup()
if not backup:
diag_log("info", "Manual update started", {"version": version, "channel": channel})
try:
releases = list_available_releases("dev" if channel == "dev" else "stable", limit=40)
entry = next((r for r in releases if str(r.get("version")) == version), None)
if not entry:
raise RuntimeError(f"Version {version} not found")
manifest_url = entry.get("manifest_url")
manifest = fetch_manifest(manifest_url)
meta = {"version_dates": {version: entry.get("published_at")}}
_install_manifest(manifest, meta, state)
except Exception as e:
state["status"] = "error"
state["message"] = "No backup available to rollback."
state["message"] = f"Update failed: {e}"
state["progress"] = None
state["latest_release_date"] = None
save_update_state(state)
diag_log("error", "Manual update failed", {"error": str(e), "version": version})
finally:
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
release_lock(lock)
return state
try:
restore_backup(backup)
state["status"] = "up_to_date"
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}"
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
except Exception as e:
state["status"] = "error"
state["message"] = f"Rollback failed: {e}"
diag_log("error", "Rollback failed", {"error": str(e)})
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
release_lock(lock)
if lock:
release_lock(lock)
return state
def start_background_task(mode: str):
"""
Kick off a background update/rollback via systemd-run so nginx/API restarts
Kick off a background update via systemd-run so nginx/API restarts
do not break the caller connection.
mode: "apply" or "rollback"
mode: "apply"
"""
assert mode in ("apply", "rollback"), "invalid mode"
assert mode in ("apply",), "invalid mode"
unit = f"pikit-update-{mode}"
cmd = ["systemd-run", "--unit", unit, "--quiet"]
if DEFAULT_MANIFEST_URL:
@@ -653,4 +673,3 @@ def start_background_task(mode: str):
# Backwards compat aliases
apply_update_stub = apply_update
rollback_update_stub = rollback_update