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/`. - 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. - 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 ## 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`. 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`. 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 ## Notes
- Service paths are normalized (leading slash) and URLs include optional subpaths. - 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 import sys
from pikit_api import HOST, PORT 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 from pikit_api.server import run_server
@@ -22,7 +22,6 @@ def parse_args():
parser = argparse.ArgumentParser(description="Pi-Kit API / updater") 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("--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("--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("--host", default=HOST, help="Bind host (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)") parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)")
return parser.parse_args() return parser.parse_args()
@@ -36,9 +35,6 @@ def main():
if args.check_update: if args.check_update:
check_for_update() check_for_update()
return return
if args.rollback_update:
rollback_update()
return
run_server(host=args.host, port=args.port) run_server(host=args.host, port=args.port)

View File

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

View File

@@ -94,6 +94,60 @@
text-align: right; 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 { .modal-card .status-msg {
overflow-wrap: anywhere; overflow-wrap: anywhere;
margin-top: 6px; margin-top: 6px;

View File

@@ -5,7 +5,8 @@ import {
getReleaseStatus, getReleaseStatus,
checkRelease, checkRelease,
applyRelease, applyRelease,
rollbackRelease, applyReleaseVersion,
listReleases,
setReleaseAutoCheck, setReleaseAutoCheck,
setReleaseChannel, setReleaseChannel,
} from "./api.js"; } from "./api.js";
@@ -24,8 +25,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
const releaseProgress = document.getElementById("releaseProgress"); const releaseProgress = document.getElementById("releaseProgress");
const releaseCheckBtn = document.getElementById("releaseCheckBtn"); const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn"); 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 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 releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus"); const releaseLogStatus = document.getElementById("releaseLogStatus");
const releaseLogCopy = document.getElementById("releaseLogCopy"); const releaseLogCopy = document.getElementById("releaseLogCopy");
@@ -46,6 +53,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
let changelogCache = { version: null, text: "" }; let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null; let lastChangelogUrl = null;
let releaseChannel = "dev"; let releaseChannel = "dev";
let releaseOptions = [];
const logger = createReleaseLogger(logUi); const logger = createReleaseLogger(logUi);
logger.attach(releaseLog); 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) { function setReleaseChip(state) {
if (!releaseFlagTop) return; if (!releaseFlagTop) return;
releaseFlagTop.textContent = "Pi-Kit: n/a"; releaseFlagTop.textContent = "Pi-Kit: n/a";
@@ -155,6 +228,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
current_release_date = null, current_release_date = null,
latest_release_date = null, latest_release_date = null,
changelog_url = null, changelog_url = null,
last_check = null,
} = data || {}; } = data || {};
releaseChannel = channel || "dev"; releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "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 (releaseLatest) releaseLatest.textContent = latest_version;
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date); if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_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 (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
if (releaseProgress) releaseProgress.textContent = ""; if (releaseProgress) releaseProgress.textContent = "";
if (status === "in_progress" && progress) { if (status === "in_progress" && progress) {
@@ -237,6 +318,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseBtn?.addEventListener("click", () => { releaseBtn?.addEventListener("click", () => {
releaseModal?.classList.remove("hidden"); releaseModal?.classList.remove("hidden");
loadReleaseStatus(true); loadReleaseStatus(true);
loadReleaseList();
}); });
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden")); releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
// Do not allow dismiss by clicking backdrop (consistency with other modals) // 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 { try {
lastReleaseToastKey = null; lastReleaseToastKey = null;
logUi("Rollback requested"); const ver = selected.value;
logUi(`Install version ${ver} requested`);
releaseBusyActive = true; releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup."); showBusy(`Installing ${ver}`, "Applying selected release. This can take up to a minute.");
logRelease("Starting rollback…"); logRelease(`Installing ${ver}`);
await rollbackRelease(); await applyReleaseVersion(ver);
pollReleaseStatus(); pollReleaseStatus();
showToast("Rollback started", "success"); showToast(`Installing ${ver}`, "success");
} catch (e) { } catch (e) {
showToast(e.error || "Rollback failed", "error"); showToast(e.error || "Install failed", "error");
logRelease(`Error: ${e.error || "Rollback failed"}`); logRelease(`Error: ${e.error || "Install failed"}`);
} finally { } finally {
if (releaseProgress) releaseProgress.textContent = ""; if (releaseProgress) releaseProgress.textContent = "";
} }
@@ -334,6 +429,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseChannel = chan; releaseChannel = chan;
logRelease(`Channel set to ${chan}`); logRelease(`Channel set to ${chan}`);
await loadReleaseStatus(true); await loadReleaseStatus(true);
await loadReleaseList();
} catch (e) { } catch (e) {
showToast(e.error || "Failed to save channel", "error"); showToast(e.error || "Failed to save channel", "error");
releaseChannelToggle.checked = releaseChannel === "dev"; 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> </button>
</div> </div>
<div class="controls column"> <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 class="control-card release-versions">
<div> <div>
<p class="hint quiet">Current version</p> <p class="hint quiet">Current version</p>
@@ -165,8 +170,8 @@
<button id="releaseApplyBtn" title="Download and install the latest release"> <button id="releaseApplyBtn" title="Download and install the latest release">
Upgrade Upgrade
</button> </button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup"> <button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
Rollback Manual selection
</button> </button>
<label class="checkbox-row inline"> <label class="checkbox-row inline">
<input type="checkbox" id="releaseAutoCheck" /> <input type="checkbox" id="releaseAutoCheck" />
@@ -177,6 +182,18 @@
<span>Allow dev builds</span> <span>Allow dev builds</span>
</label> </label>
</div> </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 id="releaseProgress" class="hint status-msg"></div>
<div class="log-card"> <div class="log-card">
<div class="log-header"> <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 # Re-export commonly used helpers for convenience
from .constants import HOST, PORT # noqa: F401 from .constants import HOST, PORT # noqa: F401
from .server import run_server # 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 # Release updater
DEFAULT_MANIFEST_URL = os.environ.get( DEFAULT_MANIFEST_URL = os.environ.get(
"PIKIT_MANIFEST_URL", "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") AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")

View File

@@ -14,6 +14,8 @@ from .releases import (
read_current_version, read_current_version,
save_update_state, save_update_state,
start_background_task, start_background_task,
list_available_releases,
apply_update_version,
) )
from .services import ( from .services import (
FirewallToolMissing, FirewallToolMissing,
@@ -82,6 +84,11 @@ class Handler(BaseHTTPRequestHandler):
state = _load_diag_state() state = _load_diag_state()
return self._send(200, {"entries": entries, "state": 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"}) return self._send(404, {"error": "not found"})
# POST endpoints # POST endpoints
@@ -122,6 +129,15 @@ class Handler(BaseHTTPRequestHandler):
state = check_for_update() state = check_for_update()
return self._send(200, state) 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"): if self.path.startswith("/api/update/apply"):
start_background_task("apply") start_background_task("apply")
state = load_update_state() state = load_update_state()
@@ -130,14 +146,6 @@ class Handler(BaseHTTPRequestHandler):
save_update_state(state) save_update_state(state)
return self._send(202, 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"): if self.path.startswith("/api/update/auto"):
state = load_update_state() state = load_update_state()
state["auto_check"] = bool(payload.get("enable")) state["auto_check"] = bool(payload.get("enable"))

View File

@@ -16,8 +16,8 @@ from .constants import (
API_PACKAGE_DIR, API_PACKAGE_DIR,
API_PATH, API_PATH,
AUTH_TOKEN, AUTH_TOKEN,
BACKUP_ROOT,
DEFAULT_MANIFEST_URL, DEFAULT_MANIFEST_URL,
DEFAULT_DEV_MANIFEST_URL,
TMP_ROOT, TMP_ROOT,
UPDATE_LOCK, UPDATE_LOCK,
UPDATE_STATE, UPDATE_STATE,
@@ -43,29 +43,62 @@ def read_current_version() -> str:
def load_update_state() -> Dict[str, Any]: def load_update_state() -> Dict[str, Any]:
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) 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(): if UPDATE_STATE.exists():
try: try:
state = json.loads(UPDATE_STATE.read_text()) state = json.loads(UPDATE_STATE.read_text())
state.setdefault("changelog_url", None) state.setdefault("changelog_url", None)
state.setdefault("latest_release_date", None) state.setdefault("latest_release_date", None)
state.setdefault("current_release_date", None) state.setdefault("current_release_date", None)
return state return _reset_if_stale(state)
except Exception: except Exception:
pass pass
return { return _reset_if_stale(
"current_version": read_current_version(), {
"latest_version": None, "current_version": read_current_version(),
"last_check": None, "latest_version": None,
"status": "unknown", "last_check": None,
"message": "", "status": "unknown",
"auto_check": False, "message": "",
"in_progress": False, "auto_check": False,
"progress": None, "in_progress": False,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"), "progress": None,
"changelog_url": None, "channel": os.environ.get("PIKIT_CHANNEL", "dev"),
"latest_release_date": None, "changelog_url": None,
"current_release_date": None, "latest_release_date": None,
} "current_release_date": None,
}
)
def save_update_state(state: Dict[str, Any]) -> None: def save_update_state(state: Dict[str, Any]) -> None:
@@ -119,6 +152,17 @@ def fetch_manifest(url: str | None = None):
data = resp.read() data = resp.read()
return json.loads(data.decode()) return json.loads(data.decode())
except urllib.error.HTTPError as e: 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: if e.code == 404:
alt = _gitea_latest_manifest(target) alt = _gitea_latest_manifest(target)
if alt: if alt:
@@ -135,6 +179,31 @@ def _try_fetch(url: Optional[str]):
return None 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): def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
""" """
For stable: use normal manifest (latest non-prerelease). 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" channel = channel or "dev"
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
dev_manifest_url = os.environ.get("PIKIT_DEV_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 base_manifest_url stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or DEFAULT_MANIFEST_URL
manifest = None manifest = None
manual_dev_manifest = None
version_dates: Dict[str, Optional[str]] = {} 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: try:
manifest = fetch_manifest(stable_manifest_url) manifest = fetch_manifest(stable_manifest_url)
except Exception: except Exception:
@@ -194,21 +267,22 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
try: try:
parts = base_manifest_url.split("/") parts = base_manifest_url.split("/")
if "releases" not in parts: if "releases" not in parts:
if manifest: # No releases API for this URL; keep any fetched manifest and skip API discovery.
return (manifest, {"version_dates": version_dates}) if with_meta else manifest releases = []
mf = fetch_manifest(base_manifest_url) if not manifest:
return (mf, {"version_dates": version_dates}) if with_meta else mf manifest = fetch_manifest(base_manifest_url)
idx = parts.index("releases") else:
owner = parts[idx - 2] idx = parts.index("releases")
repo = parts[idx - 1] owner = parts[idx - 2]
base = "/".join(parts[:3]) repo = parts[idx - 1]
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases" base = "/".join(parts[:3])
req = urllib.request.Request(api_url) api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases"
token = _auth_token() req = urllib.request.Request(api_url)
if token: token = _auth_token()
req.add_header("Authorization", f"token {token}") if token:
resp = urllib.request.urlopen(req, timeout=10) req.add_header("Authorization", f"token {token}")
releases = json.loads(resp.read().decode()) 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 # Map release versions to published dates so we can surface them later
for rel in releases: for rel in releases:
@@ -252,8 +326,8 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
manifest["_release_date"] = version_dates[mver] manifest["_release_date"] = version_dates[mver]
if channel == "dev": if channel == "dev":
# Choose the newest by version comparison across stable/dev/base candidates # Choose the newest by version comparison across stable/dev/base/manual-dev candidates
candidates = [c for c in (latest_dev, latest_stable, manifest) if c] candidates = [c for c in (latest_dev, manual_dev_manifest, latest_stable, manifest) if c]
best = None best = None
best_ver = None best_ver = None
for c in candidates: 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") 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): def download_file(url: str, dest: pathlib.Path):
ensure_dir(dest.parent) ensure_dir(dest.parent)
req = urllib.request.Request(url) req = urllib.request.Request(url)
@@ -309,6 +433,19 @@ def fetch_text_with_auth(url: str):
def acquire_lock(): def acquire_lock():
try: try:
ensure_dir(UPDATE_LOCK.parent) 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") lockfile = UPDATE_LOCK.open("w")
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lockfile.write(str(os.getpid())) lockfile.write(str(os.getpid()))
@@ -327,75 +464,6 @@ def release_lock(lockfile):
pass 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(): def check_for_update():
state = load_update_state() state = load_update_state()
lock = acquire_lock() lock = acquire_lock()
@@ -455,6 +523,71 @@ def check_for_update():
return state 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)
# 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))
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: def _stage_backup() -> pathlib.Path:
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
backup_dir = BACKUP_ROOT / ts backup_dir = BACKUP_ROOT / ts
@@ -494,70 +627,7 @@ def apply_update():
try: try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True) manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
latest = manifest.get("version") or manifest.get("latest_version") _install_manifest(manifest, meta, state)
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)})
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"No release available ({e.code})" state["message"] = f"No release available ({e.code})"
@@ -570,18 +640,6 @@ def apply_update():
state["latest_release_date"] = None state["latest_release_date"] = None
save_update_state(state) save_update_state(state)
diag_log("error", "Update apply failed", {"error": str(e)}) 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: finally:
state["in_progress"] = False state["in_progress"] = False
state["progress"] = None state["progress"] = None
@@ -591,55 +649,64 @@ def apply_update():
return state 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() 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() lock = acquire_lock()
if lock is None: if lock is None:
state["status"] = "error" state["status"] = "error"
state["message"] = "Another update is running" state["message"] = "Another update is running"
save_update_state(state) save_update_state(state)
return state return state
state["in_progress"] = True state["in_progress"] = True
state["status"] = "in_progress" state["status"] = "in_progress"
state["progress"] = "Rolling back" state["progress"] = f"Preparing {version}"
save_update_state(state) save_update_state(state)
diag_log("info", "Rollback started") diag_log("info", "Manual update started", {"version": version, "channel": channel})
backup = choose_rollback_backup()
if not backup: 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["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["in_progress"] = False
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
release_lock(lock) if lock:
return state release_lock(lock)
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)
return state return state
def start_background_task(mode: str): 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. 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}" unit = f"pikit-update-{mode}"
cmd = ["systemd-run", "--unit", unit, "--quiet"] cmd = ["systemd-run", "--unit", unit, "--quiet"]
if DEFAULT_MANIFEST_URL: if DEFAULT_MANIFEST_URL:
@@ -653,4 +720,3 @@ def start_background_task(mode: str):
# Backwards compat aliases # Backwards compat aliases
apply_update_stub = apply_update apply_update_stub = apply_update
rollback_update_stub = rollback_update