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