#!/usr/bin/env python3 """Build the dashboard image through Portainer, then trigger GitOps redeploy.""" from __future__ import annotations import io import json import os import tarfile import urllib.error import urllib.request from pathlib import Path PORTAINER_URL = os.environ["PORTAINER_URL"].rstrip("/") PORTAINER_KEY = os.environ["PORTAINER_API_KEY"] ENDPOINT_ID = 17 STACK_NAME = "arena-dashboard" IMAGE = "arena-dashboard:latest" ROOT = Path(__file__).resolve().parents[2] def request(path: str, *, method: str = "GET", data: bytes | None = None, content_type: str | None = None, timeout: int = 60): headers = {"X-API-Key": PORTAINER_KEY} if content_type: headers["Content-Type"] = content_type req = urllib.request.Request( f"{PORTAINER_URL}/api{path}", data=data, headers=headers, method=method ) try: with urllib.request.urlopen(req, timeout=timeout) as response: body = response.read() return response.status, body except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") raise RuntimeError(f"Portainer HTTP {exc.code}: {detail}") from exc def build_context() -> bytes: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as archive: for path in ROOT.rglob("*"): if not path.is_file() or ".git" in path.parts: continue archive.add(path, arcname=path.relative_to(ROOT).as_posix()) return buf.getvalue() def build_image() -> None: _, raw = request( f"/endpoints/{ENDPOINT_ID}/docker/build?t={IMAGE}&dockerfile=Dockerfile&rm=true", method="POST", data=build_context(), content_type="application/x-tar", timeout=600, ) errors = [] for line in raw.decode("utf-8", "replace").splitlines(): try: event = json.loads(line) except json.JSONDecodeError: continue if event.get("error"): errors.append(event["error"]) if event.get("stream"): print(event["stream"], end="") if errors: raise RuntimeError("image build failed: " + "; ".join(errors)) def redeploy_stack() -> None: _, raw = request("/stacks") stacks = json.loads(raw) stack = next( (item for item in stacks if item.get("Name") == STACK_NAME and item.get("EndpointId") == ENDPOINT_ID), None, ) if stack is None: raise RuntimeError(f"Portainer stack {STACK_NAME!r} was not found") # GitOps polling sees the pushed commit. ForceUpdate also makes later polls # recreate the service from the local image built by this workflow. payload = { "RepositoryReferenceName": "refs/heads/main", "RepositoryAuthentication": False, "TLSSkipVerify": False, "Env": stack.get("Env") or [], "AutoUpdate": { "Interval": "5m", "Webhook": "", "ForceUpdate": True, "ForcePullImage": False, }, } request( f"/stacks/{stack['Id']}/git?endpointId={ENDPOINT_ID}", method="POST", data=json.dumps(payload).encode(), content_type="application/json", timeout=300, ) print(f"Redeploy requested for stack {stack['Id']}") if __name__ == "__main__": build_image() redeploy_stack()