- 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>
199 lines
7.4 KiB
Python
199 lines
7.4 KiB
Python
"""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}
|