This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user