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 ### 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 - **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents
(snapshotted via a throwaway helper container); restore via upload with optional (snapshotted via a throwaway helper container); restore via upload with optional
rename, volume restore, and overwrite/conflict detection. 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 GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
POST /api/stacks/convert (docker run → compose) POST /api/stacks/convert (docker run → compose)
GET /api/system/info | gpus | devices GET /api/audit 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 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 pydantic import BaseModel
from config import settings from config import settings
from version import APP_VERSION
from docker_client import DockerError, get_client, safe_call from docker_client import DockerError, get_client, safe_call
from services import ( from services import (
backup_service, backup_service,
@@ -66,7 +67,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler 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 sqlmodel import Session
from config import settings from config import settings
from version import APP_VERSION
from database import engine, init_db from database import engine, init_db
from docker_client import DockerError from docker_client import DockerError
from routers import ( from routers import (
@@ -66,7 +67,7 @@ async def lifespan(app: FastAPI):
uptime_task.cancel() 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -109,4 +110,4 @@ app.include_router(ws.router)
@app.get("/api/health") @app.get("/api/health")
def health() -> dict: 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 os
import shutil 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 config import settings
from database import get_session
from docker_client import DockerError, get_client, safe_call from docker_client import DockerError, get_client, safe_call
from models.user import User 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"]) 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: def devices(_user: User = Depends(get_current_user)) -> dict:
"""List host USB / serial / DRI devices for passthrough.""" """List host USB / serial / DRI devices for passthrough."""
return device_service.detect_devices() 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", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.31.1", "version": "0.32.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15
View File
@@ -1,10 +1,25 @@
import api from "./client"; import api from "./client";
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types"; 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 = { export const systemApi = {
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data), info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
audit: (limit = 10) => audit: (limit = 10) =>
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data), api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").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), 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 { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme"; import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { VersionBadge } from "./VersionBadge";
export const NAV_ITEMS = [ export const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
@@ -145,9 +146,7 @@ export function TopNav() {
{/* Right cluster */} {/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0"> <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"> <VersionBadge />
v{__APP_VERSION__}
</span>
{agentCount > 0 && ( {agentCount > 0 && (
<button <button
onClick={() => navigate("/settings")} 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>
);
}