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
+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}