0.32.0: StackPilot self-update (check on page load + one-click update)
- backend/version.py is now the single version source (main.py, agent). - GET /api/system/update: reads the version tags of the backend's own image repo (anonymous v2 token flow, https→http fallback for insecure registries), compares the highest semver tag against APP_VERSION; reports update_supported from the container's compose labels. 10 min cache. - POST /api/system/update (admin, audited): spawns a detached helper container from the current backend image that runs docker compose pull && up -d on StackPilot's own compose project (project name, working dir and config files resolved from its own container labels) — the helper outlives the backend being recreated. Non-compose installs get a 400. - /api/health now returns the version so the UI can detect the switchover. - TopNav version badge: queries the update status on page load; when a newer release exists an amber pill shows the version — one click (admin) confirms, triggers the update and overlays a wait screen that polls /api/health and reloads once the new version answers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
11effdc2ca
commit
a0dda120f5
@@ -75,6 +75,14 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
|
||||
### Phase 4 — Operations
|
||||
|
||||
- **Self-update (0.32.0)**: the top-bar version badge checks the registry for a
|
||||
newer StackPilot release on page load (`GET /api/system/update`, anonymous v2
|
||||
token flow, 10 min cache) and shows an amber update pill. One click
|
||||
(`POST /api/system/update`, admin) spawns a detached helper container that runs
|
||||
`docker compose pull && up -d` on StackPilot's own compose project (resolved
|
||||
from its container labels) — the helper survives the backend being recreated;
|
||||
the UI polls `/api/health` and reloads when the new version answers. Installs
|
||||
not managed by compose get a clear "update manually" error instead.
|
||||
- **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents
|
||||
(snapshotted via a throwaway helper container); restore via upload with optional
|
||||
rename, volume restore, and overwrite/conflict detection.
|
||||
@@ -422,6 +430,7 @@ POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone}
|
||||
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
||||
POST /api/stacks/convert (docker run → compose)
|
||||
GET /api/system/info | gpus | devices GET /api/audit
|
||||
GET /api/system/update POST /api/system/update (self-update)
|
||||
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
||||
```
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
from version import APP_VERSION
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from services import (
|
||||
backup_service,
|
||||
@@ -66,7 +67,7 @@ def _map_docker(exc: DockerError):
|
||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||
raise exc # falls through to the global 502 DockerError handler
|
||||
|
||||
AGENT_VERSION = "0.31.1"
|
||||
AGENT_VERSION = APP_VERSION
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from config import settings
|
||||
from version import APP_VERSION
|
||||
from database import engine, init_db
|
||||
from docker_client import DockerError
|
||||
from routers import (
|
||||
@@ -66,7 +67,7 @@ async def lifespan(app: FastAPI):
|
||||
uptime_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.31.1", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -109,4 +110,4 @@ app.include_router(ws.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "version": APP_VERSION}
|
||||
|
||||
@@ -4,13 +4,15 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from auth import get_current_user, require_admin
|
||||
from config import settings
|
||||
from database import get_session
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from models.user import User
|
||||
from services import device_service, gpu_service
|
||||
from services import audit_service, device_service, gpu_service, self_update_service
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
@@ -103,3 +105,31 @@ def gpus(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
def devices(_user: User = Depends(get_current_user)) -> dict:
|
||||
"""List host USB / serial / DRI devices for passthrough."""
|
||||
return device_service.detect_devices()
|
||||
|
||||
|
||||
@router.get("/update")
|
||||
async def self_update_status(
|
||||
refresh: bool = False,
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Is a newer StackPilot release available? (registry check, cached)"""
|
||||
return await self_update_service.get_status(refresh=refresh)
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
def self_update_apply(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Update this StackPilot in place via a detached compose helper."""
|
||||
try:
|
||||
result = self_update_service.apply_update()
|
||||
except self_update_service.SelfUpdateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
audit_service.record(
|
||||
session, user=user.username, action="system.update",
|
||||
target=result.get("helper", ""), detail=result.get("command"),
|
||||
ip=request.client.host if request.client else "",
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Self-update: is a newer StackPilot release in the registry, and apply it.
|
||||
|
||||
The check reads the version tags of the backend's *own* image repository
|
||||
(anonymous v2 token flow, https with http fallback for insecure registries)
|
||||
and compares the highest semver tag against the running APP_VERSION.
|
||||
|
||||
Applying the update spawns a detached **helper container** (from the current
|
||||
backend image — it ships the docker CLI + compose plugin) that runs
|
||||
``docker compose pull && up -d`` against the compose project this backend
|
||||
belongs to, resolved from its own container labels. The helper outlives the
|
||||
backend container being recreated, which is what makes self-update possible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from services import update_service
|
||||
from version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger("stackpilot.selfupdate")
|
||||
|
||||
STATUS_TTL = 600.0 # seconds between registry checks
|
||||
_VERSION_RE = re.compile(r"^\d+(\.\d+)*$")
|
||||
|
||||
_LABEL_PROJECT = "com.docker.compose.project"
|
||||
_LABEL_WORKING_DIR = "com.docker.compose.project.working_dir"
|
||||
_LABEL_CONFIG_FILES = "com.docker.compose.project.config_files"
|
||||
|
||||
_status_cache: dict = {"data": None, "ts": 0.0}
|
||||
|
||||
|
||||
class SelfUpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Own container / image discovery
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _own_container():
|
||||
"""The container this backend runs in (None outside a container)."""
|
||||
client = get_client()
|
||||
hostname = socket.gethostname()
|
||||
try:
|
||||
return safe_call(client.containers.get, hostname)
|
||||
except DockerError:
|
||||
pass
|
||||
# Fallback (custom hostname set): match by image name.
|
||||
try:
|
||||
for c in safe_call(client.containers.list):
|
||||
if "stackpilot-backend" in (c.attrs.get("Config", {}).get("Image") or ""):
|
||||
return c
|
||||
except DockerError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _compose_info(container) -> Optional[dict]:
|
||||
labels = container.attrs.get("Config", {}).get("Labels") or {}
|
||||
project = labels.get(_LABEL_PROJECT)
|
||||
working_dir = labels.get(_LABEL_WORKING_DIR)
|
||||
config_files = [f for f in (labels.get(_LABEL_CONFIG_FILES) or "").split(",") if f]
|
||||
if not project or not working_dir or not config_files:
|
||||
return None
|
||||
return {"project": project, "working_dir": working_dir, "config_files": config_files}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registry version lookup
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _version_key(v: str) -> tuple[int, ...]:
|
||||
return tuple(int(p) for p in v.split("."))
|
||||
|
||||
|
||||
async def _fetch_tags(registry: str, repo: str) -> list[str]:
|
||||
"""Tag list via the v2 API; anonymous token flow; http fallback for
|
||||
insecure registries (plain-IP registries usually aren't behind TLS)."""
|
||||
last_exc: Optional[Exception] = None
|
||||
for scheme in ("https", "http"):
|
||||
url = f"{scheme}://{registry}/v2/{repo}/tags/list"
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
if resp.status_code == 401:
|
||||
token = await update_service._get_token(
|
||||
client, resp.headers.get("WWW-Authenticate", "")
|
||||
)
|
||||
if not token:
|
||||
raise SelfUpdateError("Registry requires authentication")
|
||||
resp = await client.get(
|
||||
url, headers={"Authorization": f"Bearer {token}"}, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("tags") or []
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
last_exc = exc
|
||||
continue
|
||||
raise SelfUpdateError(f"Cannot reach registry {registry}: {last_exc}")
|
||||
|
||||
|
||||
async def get_status(refresh: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not refresh and _status_cache["data"] and now - _status_cache["ts"] < STATUS_TTL:
|
||||
return _status_cache["data"]
|
||||
|
||||
data = {
|
||||
"current_version": APP_VERSION,
|
||||
"latest_version": None,
|
||||
"update_available": False,
|
||||
"update_supported": False,
|
||||
"image": None,
|
||||
"error": None,
|
||||
}
|
||||
container = _own_container()
|
||||
if container is None:
|
||||
data["error"] = "Not running in a container"
|
||||
_status_cache.update(data=data, ts=now)
|
||||
return data
|
||||
image = container.attrs.get("Config", {}).get("Image") or ""
|
||||
data["image"] = image
|
||||
data["update_supported"] = _compose_info(container) is not None
|
||||
|
||||
try:
|
||||
registry, repo, _tag = update_service.parse_ref(image)
|
||||
tags = await _fetch_tags(registry, repo)
|
||||
versions = sorted(
|
||||
(t for t in tags if _VERSION_RE.match(t)), key=_version_key
|
||||
)
|
||||
if versions:
|
||||
latest = versions[-1]
|
||||
data["latest_version"] = latest
|
||||
data["update_available"] = _version_key(latest) > _version_key(APP_VERSION)
|
||||
else:
|
||||
data["error"] = "No version tags found in the registry"
|
||||
except SelfUpdateError as exc:
|
||||
data["error"] = str(exc)
|
||||
|
||||
_status_cache.update(data=data, ts=now)
|
||||
return data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Apply: helper container runs compose pull + up on our own project
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def apply_update() -> dict:
|
||||
container = _own_container()
|
||||
if container is None:
|
||||
raise SelfUpdateError("Not running in a container — update manually")
|
||||
info = _compose_info(container)
|
||||
if info is None:
|
||||
raise SelfUpdateError(
|
||||
"This StackPilot is not compose-managed — update it the way it was deployed"
|
||||
)
|
||||
image = container.attrs.get("Config", {}).get("Image") or ""
|
||||
|
||||
compose = f"docker compose --project-name {info['project']} --project-directory {info['working_dir']}"
|
||||
for f in info["config_files"]:
|
||||
compose += f" -f {f}"
|
||||
command = f"{compose} pull --quiet && {compose} up -d --remove-orphans"
|
||||
|
||||
# Bind the project dir (and any config file living outside it) read-only
|
||||
# at its host path so relative paths and .env resolve exactly as on host.
|
||||
volumes = {
|
||||
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"},
|
||||
info["working_dir"]: {"bind": info["working_dir"], "mode": "ro"},
|
||||
}
|
||||
for f in info["config_files"]:
|
||||
parent = f.rsplit("/", 1)[0] or "/"
|
||||
if parent != info["working_dir"] and not parent.startswith(info["working_dir"] + "/"):
|
||||
volumes.setdefault(parent, {"bind": parent, "mode": "ro"})
|
||||
|
||||
client = get_client()
|
||||
helper = safe_call(
|
||||
client.containers.run,
|
||||
image,
|
||||
["sh", "-c", command],
|
||||
detach=True,
|
||||
auto_remove=True,
|
||||
name=f"stackpilot-self-update-{int(time.time())}",
|
||||
labels={"stackpilot.helper": "self-update"},
|
||||
volumes=volumes,
|
||||
working_dir=info["working_dir"],
|
||||
environment={"DOCKER_CONFIG": "/tmp/.docker"}, # don't expect host creds
|
||||
)
|
||||
logger.info("Self-update helper %s started: %s", helper.short_id, command)
|
||||
return {"status": "updating", "helper": helper.short_id, "command": command}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.32.0"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.31.1",
|
||||
"version": "0.32.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import api from "./client";
|
||||
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
|
||||
|
||||
export interface SelfUpdateStatus {
|
||||
current_version: string;
|
||||
latest_version: string | null;
|
||||
update_available: boolean;
|
||||
update_supported: boolean;
|
||||
image: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
|
||||
audit: (limit = 10) =>
|
||||
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
|
||||
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").then((r) => r.data),
|
||||
devices: () => api.get<DeviceList>("/api/system/devices").then((r) => r.data),
|
||||
selfUpdate: (refresh = false) =>
|
||||
api
|
||||
.get<SelfUpdateStatus>(`/api/system/update${refresh ? "?refresh=true" : ""}`)
|
||||
.then((r) => r.data),
|
||||
applySelfUpdate: () =>
|
||||
api.post<{ status: string; helper: string }>("/api/system/update").then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { VersionBadge } from "./VersionBadge";
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
@@ -145,9 +146,7 @@ export function TopNav() {
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
|
||||
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
<VersionBadge />
|
||||
{agentCount > 0 && (
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { systemApi } from "@/api/system";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const TIMEOUT_MS = 4 * 60 * 1000;
|
||||
|
||||
/** Version pill in the top bar. Checks the registry for a newer StackPilot
|
||||
* release on page load and offers a one-click in-place update (admins). */
|
||||
export function VersionBadge() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["self-update"],
|
||||
queryFn: () => systemApi.selfUpdate(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const startUpdate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await systemApi.applySelfUpdate();
|
||||
setConfirming(false);
|
||||
setUpdating(true);
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAvailable = data?.update_available ?? false;
|
||||
const canClick = isAdmin && updateAvailable && (data?.update_supported ?? false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={() => canClick && setConfirming(true)}
|
||||
disabled={!canClick}
|
||||
className="hidden items-center gap-1.5 rounded-pill border border-sp-amber/40 bg-sp-amber/10 px-2.5 py-1 text-xs font-semibold text-sp-amber hover:bg-sp-amber/20 disabled:cursor-default sm:flex"
|
||||
title={
|
||||
canClick
|
||||
? `Update StackPilot to ${data?.latest_version}`
|
||||
: data?.update_supported
|
||||
? "A newer StackPilot is available (ask an admin to update)"
|
||||
: "A newer StackPilot is available — this install isn't compose-managed, update it manually"
|
||||
}
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
{data?.latest_version ?? "update"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<ConfirmDialog
|
||||
title={`Update StackPilot to ${data?.latest_version}?`}
|
||||
message="Pulls the new images and recreates the StackPilot containers in place. The UI will be briefly unavailable and reloads automatically."
|
||||
confirmLabel="Update now"
|
||||
busy={busy}
|
||||
onConfirm={startUpdate}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updating && <UpdatingOverlay fromVersion={data?.current_version ?? __APP_VERSION__} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-screen wait state while the helper recreates the containers:
|
||||
* polls /api/health until a different version answers, then reloads. */
|
||||
function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const started = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - started.current > TIMEOUT_MS) {
|
||||
clearInterval(timer);
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Raw fetch: no auth/interceptors, and the backend may be mid-restart.
|
||||
const res = await fetch("/api/health", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { version?: string };
|
||||
if (body.version && body.version !== fromVersion) {
|
||||
clearInterval(timer);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
/* backend restarting — keep polling */
|
||||
}
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [fromVersion]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-card p-6 text-center shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
{failed ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold">Still on v{fromVersion}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
The update didn't finish within a few minutes. Check the host with{" "}
|
||||
<code className="font-mono text-xs">docker ps</code> / the compose logs, then reload.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 rounded-pill border border-sp-border px-4 py-1.5 text-sm font-medium"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-sp-border border-t-transparent" />
|
||||
<p className="mt-4 text-sm font-semibold">Updating StackPilot…</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Pulling images and recreating containers. This page reloads automatically.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user