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
@@ -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}
|
||||
Reference in New Issue
Block a user