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:
menzelj
2026-06-12 09:01:08 +00:00
co-authored by Claude Fable 5
parent 11effdc2ca
commit a0dda120f5
10 changed files with 407 additions and 10 deletions
+9
View File
@@ -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
```
+2 -1
View File
@@ -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
View File
@@ -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}
+33 -3
View File
@@ -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
+198
View File
@@ -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}
+3
View File
@@ -0,0 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.32.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.31.1",
"version": "0.32.0",
"type": "module",
"scripts": {
"dev": "vite",
+15
View File
@@ -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),
};
+2 -3
View File
@@ -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&apos;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>
);
}