ci: build image before GitOps deployment
Build and deploy / build-deploy (push) Has been cancelled

This commit is contained in:
2026-10-17 19:48:02 +00:00
parent 2c5547b769
commit 82ea6ab868
3 changed files with 127 additions and 4 deletions
+107
View File
@@ -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()
+20
View File
@@ -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
-4
View File
@@ -2,11 +2,7 @@ version: "3.8"
services: services:
arena-dashboard: arena-dashboard:
build:
context: .
dockerfile: Dockerfile
image: arena-dashboard:latest image: arena-dashboard:latest
pull_policy: build
container_name: arena-dashboard container_name: arena-dashboard
restart: unless-stopped restart: unless-stopped
ports: ports: