diff --git a/README.md b/README.md index 03cba9e..0db5221 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/backend/agent_app.py b/backend/agent_app.py index d3facff..497c7ae 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 19718e6..aae98c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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} diff --git a/backend/routers/system.py b/backend/routers/system.py index a46f90e..13f0c61 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -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 diff --git a/backend/services/self_update_service.py b/backend/services/self_update_service.py new file mode 100644 index 0000000..16a7b0c --- /dev/null +++ b/backend/services/self_update_service.py @@ -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} diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..426a640 --- /dev/null +++ b/backend/version.py @@ -0,0 +1,3 @@ +"""Single source of truth for the StackPilot release version.""" + +APP_VERSION = "0.32.0" diff --git a/frontend/package.json b/frontend/package.json index f44253a..02a5db7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.31.1", + "version": "0.32.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index eb99aa7..bf955b8 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -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("/api/system/info").then((r) => r.data), audit: (limit = 10) => api.get(`/api/audit?limit=${limit}`).then((r) => r.data), gpus: () => api.get("/api/system/gpus").then((r) => r.data), devices: () => api.get("/api/system/devices").then((r) => r.data), + selfUpdate: (refresh = false) => + api + .get(`/api/system/update${refresh ? "?refresh=true" : ""}`) + .then((r) => r.data), + applySelfUpdate: () => + api.post<{ status: string; helper: string }>("/api/system/update").then((r) => r.data), }; diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index 0094011..a586207 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -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 */}
- - v{__APP_VERSION__} - + {agentCount > 0 && ( + )} + + {confirming && ( + setConfirming(false)} + /> + )} + + {updating && } + + ); +} + +/** 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 ( +
+
+ {failed ? ( + <> +

Still on v{fromVersion}

+

+ The update didn't finish within a few minutes. Check the host with{" "} + docker ps / the compose logs, then reload. +

+ + + ) : ( + <> +
+

Updating StackPilot…

+

+ Pulling images and recreating containers. This page reloads automatically. +

+ + )} +
+
+ ); +}