Replace rollback with manual version selection and simplify updater
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -16,7 +16,6 @@ from .constants import (
|
||||
API_PACKAGE_DIR,
|
||||
API_PATH,
|
||||
AUTH_TOKEN,
|
||||
BACKUP_ROOT,
|
||||
DEFAULT_MANIFEST_URL,
|
||||
DEFAULT_DEV_MANIFEST_URL,
|
||||
TMP_ROOT,
|
||||
@@ -147,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).
|
||||
@@ -303,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)
|
||||
@@ -344,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()
|
||||
@@ -472,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
|
||||
@@ -511,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})"
|
||||
@@ -587,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
|
||||
@@ -608,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:
|
||||
@@ -670,4 +673,3 @@ def start_background_task(mode: str):
|
||||
|
||||
# Backwards compat aliases
|
||||
apply_update_stub = apply_update
|
||||
rollback_update_stub = rollback_update
|
||||
|
||||
Reference in New Issue
Block a user