16 Commits

Author SHA1 Message Date
Aaron
e87b90bf9f Auto-clear stale updater state 2025-12-14 19:07:47 -05:00
Aaron
8557140193 Avoid restarting API during installs 2025-12-14 19:01:46 -05:00
Aaron
86438b11f3 Handle stale updater lockfiles by removing dead PID entries 2025-12-14 18:56:15 -05:00
Aaron
3a785832b1 Bump to 0.1.3-dev3 and update dev manifest 2025-12-14 18:51:03 -05:00
Aaron
a94cd17186 Redesign updater UI with manual version picker and status bar 2025-12-14 18:48:00 -05:00
Aaron
b01bfcd38e Replace rollback with manual version selection and simplify updater 2025-12-14 18:20:11 -05:00
Aaron
831a98c5a1 Document release build and OTA packaging steps 2025-12-14 18:05:55 -05:00
Aaron
daea783d38 Allow dev manifest selection when using non-releases URLs 2025-12-14 18:02:36 -05:00
Aaron
90d3e5676a Import DEFAULT_DEV_MANIFEST_URL for dev channel fetch 2025-12-14 18:01:14 -05:00
Aaron
8a054c5d85 Default manifests to public raw files (no token needed) 2025-12-14 17:59:50 -05:00
Aaron
009ac8cdd0 Retry manifest fetch with access_token on 404 when token present 2025-12-14 17:37:35 -05:00
Aaron
7a9ffb710a Point default manifests to public release assets 2025-12-14 17:36:30 -05:00
Aaron
15da438625 Fix dev channel selection to only use dev manifest when allowed 2025-12-14 17:35:47 -05:00
Aaron
50ddc3e211 Use stable manifest by default; only load dev manifest when dev channel enabled 2025-12-14 17:34:24 -05:00
Aaron
e7a79246b8 Publish dev/stable manifest files with release dates 2025-12-14 17:30:38 -05:00
Aaron
bb2fb2dcf2 Add public dev manifest fallback and bundle manifests 2025-12-14 17:28:57 -05:00
13 changed files with 516 additions and 240 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-dev3",
"_release_date": "2025-12-14T23:50:00Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/pikit-0.1.3-dev3.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/CHANGELOG-0.1.3-dev3.txt",
"files": [
{ "path": "bundle.tar.gz", "sha256": "1352f6a90d91e21871684e9a2c175c8cb48c53aa0b121fcdc94a3004c3d4d3f2" }
]
}

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

@@ -94,6 +94,60 @@
text-align: right;
}
.release-status-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
}
.release-advanced {
border: 1px dashed var(--border);
border-radius: 12px;
padding: 10px;
margin-top: 8px;
background: rgba(255, 255, 255, 0.02);
}
.release-advanced-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.release-list {
display: grid;
gap: 8px;
}
.release-card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
cursor: pointer;
}
.release-card input[type="radio"] {
accent-color: var(--accent);
}
.release-card-title {
font-weight: 600;
}
.release-card-tags {
display: flex;
gap: 8px;
align-items: center;
}
.modal-card .status-msg {
overflow-wrap: anywhere;
margin-top: 6px;

View File

@@ -5,7 +5,8 @@ import {
getReleaseStatus,
checkRelease,
applyRelease,
rollbackRelease,
applyReleaseVersion,
listReleases,
setReleaseAutoCheck,
setReleaseChannel,
} from "./api.js";
@@ -24,8 +25,14 @@ 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 releaseAdvancedToggle = document.getElementById("releaseAdvancedToggle");
const releaseAdvanced = document.getElementById("releaseAdvanced");
const releaseList = document.getElementById("releaseList");
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const releaseStatusChip = document.getElementById("releaseStatusChip");
const releaseChannelChip = document.getElementById("releaseChannelChip");
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
const releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus");
const releaseLogCopy = document.getElementById("releaseLogCopy");
@@ -46,6 +53,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 +72,71 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
};
async function loadReleaseList() {
if (!releaseList) return;
try {
const data = await listReleases();
releaseOptions = data.releases || [];
renderReleaseList();
} catch (e) {
renderReleaseList(true);
}
}
function renderReleaseList(error = false) {
if (!releaseList) return;
releaseList.innerHTML = "";
if (error) {
releaseList.textContent = "Failed to load releases.";
return;
}
if (!releaseOptions.length) {
releaseList.textContent = "No releases found.";
return;
}
releaseOptions.forEach((r, idx) => {
const card = document.createElement("label");
card.className = "release-card";
card.setAttribute("role", "option");
const input = document.createElement("input");
input.type = "radio";
input.name = "releaseVersion";
input.value = r.version;
if (idx === 0) input.checked = true;
const meta = document.createElement("div");
meta.className = "release-card-meta";
const title = document.createElement("div");
title.className = "release-card-title";
title.textContent = r.version;
const tags = document.createElement("div");
tags.className = "release-card-tags";
const chip = document.createElement("span");
chip.className = "status-chip ghost";
chip.textContent = r.prerelease ? "Dev" : "Stable";
tags.appendChild(chip);
if (r.published_at) {
const date = document.createElement("span");
date.className = "hint quiet";
date.textContent = fmtDate(r.published_at);
tags.appendChild(date);
}
meta.appendChild(title);
meta.appendChild(tags);
if (r.changelog_url) {
const link = document.createElement("a");
link.href = r.changelog_url;
link.target = "_blank";
link.className = "hint";
link.textContent = "Changelog";
meta.appendChild(link);
}
card.appendChild(input);
card.appendChild(meta);
releaseList.appendChild(card);
});
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
}
function setReleaseChip(state) {
if (!releaseFlagTop) return;
releaseFlagTop.textContent = "Pi-Kit: n/a";
@@ -155,6 +228,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
current_release_date = null,
latest_release_date = null,
changelog_url = null,
last_check = null,
} = data || {};
releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
@@ -176,6 +250,13 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
if (releaseLatest) releaseLatest.textContent = latest_version;
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
if (releaseStatusChip) {
releaseStatusChip.textContent = `Status: ${status.replaceAll("_", " ")}`;
releaseStatusChip.classList.toggle("chip-warm", status === "update_available");
releaseStatusChip.classList.toggle("chip-off", status === "error");
}
if (releaseChannelChip) releaseChannelChip.textContent = `Channel: ${releaseChannel}`;
if (releaseLastCheckChip) releaseLastCheckChip.textContent = `Last check: ${last_check ? fmtDate(last_check) : "—"}`;
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
if (releaseProgress) releaseProgress.textContent = "";
if (status === "in_progress" && progress) {
@@ -237,6 +318,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 +381,32 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
});
releaseRollbackBtn?.addEventListener("click", async () => {
releaseAdvancedToggle?.addEventListener("click", async () => {
releaseAdvanced?.classList.toggle("hidden");
if (!releaseAdvanced?.classList.contains("hidden")) {
await loadReleaseList();
}
});
releaseApplyVersionBtn?.addEventListener("click", async () => {
const selected = releaseList?.querySelector("input[name='releaseVersion']:checked");
if (!selected) {
showToast("Select a version first", "error");
return;
}
try {
lastReleaseToastKey = null;
logUi("Rollback requested");
const ver = selected.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 +429,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

@@ -1,3 +1,3 @@
{
"version": "0.1.3-dev1"
"version": "0.1.3-dev3"
}

View File

@@ -144,6 +144,11 @@
</button>
</div>
<div class="controls column">
<div class="release-status-bar">
<span id="releaseStatusChip" class="status-chip quiet">Status: n/a</span>
<span id="releaseChannelChip" class="status-chip quiet">Channel: n/a</span>
<span id="releaseLastCheckChip" class="status-chip quiet">Last check: —</span>
</div>
<div class="control-card release-versions">
<div>
<p class="hint quiet">Current version</p>
@@ -165,8 +170,8 @@
<button id="releaseApplyBtn" title="Download and install the latest release">
Upgrade
</button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
Manual selection
</button>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseAutoCheck" />
@@ -177,6 +182,18 @@
<span>Allow dev builds</span>
</label>
</div>
<div id="releaseAdvanced" class="release-advanced hidden">
<div class="release-advanced-head">
<div>
<p class="hint quiet">Choose a specific release</p>
<span class="hint">Dev builds only appear when “Allow dev builds” is on.</span>
</div>
<button id="releaseApplyVersionBtn" class="ghost" title="Install selected release">
Install selected
</button>
</div>
<div id="releaseList" class="release-list" role="listbox" aria-label="Available releases"></div>
</div>
<div id="releaseProgress" class="hint status-msg"></div>
<div class="log-card">
<div class="log-header">

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,
@@ -43,16 +43,48 @@ def read_current_version() -> str:
def load_update_state() -> Dict[str, Any]:
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
def _reset_if_stale(state: Dict[str, Any]) -> Dict[str, Any]:
"""
If state thinks an update is running but the lock holder is gone,
clear it so the UI can recover instead of getting stuck forever.
"""
lock_alive = False
if UPDATE_LOCK.exists():
try:
pid = int(UPDATE_LOCK.read_text().strip() or "0")
if pid > 0:
os.kill(pid, 0)
lock_alive = True
else:
UPDATE_LOCK.unlink(missing_ok=True)
except OSError:
UPDATE_LOCK.unlink(missing_ok=True)
except Exception:
UPDATE_LOCK.unlink(missing_ok=True)
if state.get("in_progress") and not lock_alive:
state["in_progress"] = False
state["progress"] = None
if state.get("status") == "in_progress":
state["status"] = "up_to_date"
state["message"] = state.get("message") or "Recovered from interrupted update"
try:
save_update_state(state)
except Exception:
pass
return state
if UPDATE_STATE.exists():
try:
state = json.loads(UPDATE_STATE.read_text())
state.setdefault("changelog_url", None)
state.setdefault("latest_release_date", None)
state.setdefault("current_release_date", None)
return state
return _reset_if_stale(state)
except Exception:
pass
return {
return _reset_if_stale(
{
"current_version": read_current_version(),
"latest_version": None,
"last_check": None,
@@ -66,6 +98,7 @@ def load_update_state() -> Dict[str, Any]:
"latest_release_date": None,
"current_release_date": None,
}
)
def save_update_state(state: Dict[str, Any]) -> None:
@@ -119,6 +152,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 +179,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 +212,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,10 +267,11 @@ 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
# 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]
@@ -252,8 +326,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 +360,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)
@@ -309,6 +433,19 @@ def fetch_text_with_auth(url: str):
def acquire_lock():
try:
ensure_dir(UPDATE_LOCK.parent)
# Clear stale lock if the recorded PID is not running
if UPDATE_LOCK.exists():
try:
pid = int(UPDATE_LOCK.read_text().strip() or "0")
if pid > 0:
os.kill(pid, 0)
else:
UPDATE_LOCK.unlink(missing_ok=True)
except OSError:
# Process not running
UPDATE_LOCK.unlink(missing_ok=True)
except Exception:
UPDATE_LOCK.unlink(missing_ok=True)
lockfile = UPDATE_LOCK.open("w")
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lockfile.write(str(os.getpid()))
@@ -327,75 +464,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,52 +523,11 @@ def check_for_update():
return state
def _stage_backup() -> pathlib.Path:
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
backup_dir = BACKUP_ROOT / ts
ensure_dir(backup_dir)
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 API_PACKAGE_DIR.exists():
shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True)
if VERSION_FILE.exists():
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
return backup_dir
def apply_update():
state = load_update_state()
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"] = "Starting update…"
save_update_state(state)
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
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")
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")
@@ -542,8 +569,9 @@ def apply_update():
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)
# Restart frontend to pick up new assets; avoid restarting this API process
# mid-apply to prevent leaving state in_progress.
subprocess.run(["systemctl", "restart", "dietpi-dashboard-frontend.service"], check=False)
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
VERSION_FILE.write_text(str(latest))
@@ -558,6 +586,48 @@ def apply_update():
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
ensure_dir(backup_dir)
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 API_PACKAGE_DIR.exists():
shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True)
if VERSION_FILE.exists():
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
return backup_dir
def apply_update():
state = load_update_state()
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"] = "Starting update…"
save_update_state(state)
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
_install_manifest(manifest, meta, state)
except urllib.error.HTTPError as e:
state["status"] = "error"
state["message"] = f"No release available ({e.code})"
@@ -570,18 +640,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 +649,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:
state["status"] = "error"
state["message"] = "No backup available to rollback."
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
release_lock(lock)
return state
diag_log("info", "Manual update started", {"version": version, "channel": channel})
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})
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"] = f"Rollback failed: {e}"
diag_log("error", "Rollback failed", {"error": str(e)})
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)
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 +720,3 @@ def start_background_task(mode: str):
# Backwards compat aliases
apply_update_stub = apply_update
rollback_update_stub = rollback_update