diff --git a/.gitea/scripts/build_and_deploy.py b/.gitea/scripts/build_and_deploy.py new file mode 100644 index 0000000..a61cb8a --- /dev/null +++ b/.gitea/scripts/build_and_deploy.py @@ -0,0 +1,107 @@ +#!/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() diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..037f0f5 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Build and deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-deploy: + runs-on: arena-builder + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Build image on OptiPlex + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + run: | + python3 .gitea/scripts/build_and_deploy.py diff --git a/docker-compose.yml b/docker-compose.yml index 0d5d0b0..9893aa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,7 @@ version: "3.8" services: arena-dashboard: - build: - context: . - dockerfile: Dockerfile image: arena-dashboard:latest - pull_policy: build container_name: arena-dashboard restart: unless-stopped ports: