From 19c5b76e602620af584c65402d5d10216b0e823e Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Sat, 21 Feb 2026 01:19:03 -0500 Subject: [PATCH] trips) portainer API deploy + preflight checks --- .gitea/workflows/main-image.yml | 105 ++++++++++++++++++++++++++++++++ README.md | 58 +++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/main-image.yml b/.gitea/workflows/main-image.yml index 245a292..878c532 100644 --- a/.gitea/workflows/main-image.yml +++ b/.gitea/workflows/main-image.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: env: REGISTRY_HOST: registry.campbellwireless.net @@ -50,3 +51,107 @@ jobs: tags: | ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }} + + - name: Preflight Portainer deploy config + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }} + PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} + PORTAINER_INSECURE_TLS: ${{ secrets.PORTAINER_INSECURE_TLS }} + run: | + set -eu + for required in PORTAINER_URL PORTAINER_API_KEY PORTAINER_STACK_ID PORTAINER_ENDPOINT_ID; do + if [ -z "$(eval "printf '%s' \"\${$required:-}\"")" ]; then + echo "Missing required secret: $required" >&2 + exit 1 + fi + done + + CURL_ARGS=(--fail --show-error --silent --retry 3 --retry-all-errors) + if [ "${PORTAINER_INSECURE_TLS:-false}" = "true" ]; then + CURL_ARGS+=(--insecure) + fi + + API_BASE="${PORTAINER_URL%/}/api" + + ENDPOINT_JSON="$( + curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + "${API_BASE}/endpoints/${PORTAINER_ENDPOINT_ID}" + )" + echo "${ENDPOINT_JSON}" | jq -e --arg id "${PORTAINER_ENDPOINT_ID}" \ + '((.Id // .id) | tostring) == $id' >/dev/null + + STACK_JSON="$( + curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + "${API_BASE}/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}" + )" + echo "${STACK_JSON}" | jq -e --arg id "${PORTAINER_STACK_ID}" \ + '((.Id // .id) | tostring) == $id' >/dev/null + + echo "Portainer preflight checks passed." + + - name: Trigger Portainer stack redeploy + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }} + PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} + PORTAINER_INSECURE_TLS: ${{ secrets.PORTAINER_INSECURE_TLS }} + run: | + set -eu + for required in PORTAINER_URL PORTAINER_API_KEY PORTAINER_STACK_ID PORTAINER_ENDPOINT_ID; do + if [ -z "$(eval "printf '%s' \"\${$required:-}\"")" ]; then + echo "Missing required secret: $required" >&2 + exit 1 + fi + done + + CURL_ARGS=(--fail --show-error --silent --retry 3 --retry-all-errors) + if [ "${PORTAINER_INSECURE_TLS:-false}" = "true" ]; then + CURL_ARGS+=(--insecure) + fi + + STACK_BASE="${PORTAINER_URL%/}/api/stacks/${PORTAINER_STACK_ID}" + QUERY="endpointId=${PORTAINER_ENDPOINT_ID}" + + # Git-based stacks can be redeployed directly. + if curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + --request POST \ + "${STACK_BASE}/git/redeploy?${QUERY}" >/dev/null; then + echo "Portainer deploy: git stack redeploy triggered." + exit 0 + fi + + # Non-git stacks: fetch current stack file and redeploy with pullImage=true. + STACK_FILE_CONTENT="$( + curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + "${STACK_BASE}/file" \ + | jq -r '.StackFileContent' + )" + + PAYLOAD_LOWER="$(jq -cn --arg stackFileContent "${STACK_FILE_CONTENT}" \ + '{stackFileContent: $stackFileContent, prune: false, pullImage: true}')" + if curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + --header "Content-Type: application/json" \ + --request PUT \ + --data "${PAYLOAD_LOWER}" \ + "${STACK_BASE}?${QUERY}" >/dev/null; then + echo "Portainer deploy: stack updated with lower-camel payload." + exit 0 + fi + + PAYLOAD_UPPER="$(jq -cn --arg StackFileContent "${STACK_FILE_CONTENT}" \ + '{StackFileContent: $StackFileContent, Prune: false, PullImage: true}')" + curl "${CURL_ARGS[@]}" \ + --header "X-API-Key: ${PORTAINER_API_KEY}" \ + --header "Content-Type: application/json" \ + --request PUT \ + --data "${PAYLOAD_UPPER}" \ + "${STACK_BASE}?${QUERY}" >/dev/null + echo "Portainer deploy: stack updated with upper-camel payload." diff --git a/README.md b/README.md index 51aad6d..e2ae38f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The container stores SQLite data at `/data/trips.db` (mounted as the `trips-data Workflows live in `.gitea/workflows`: - `pr-checks.yml`: runs lint, tests, and Docker build on pull requests. -- `main-image.yml`: builds and pushes a Docker image on push to `main`. +- `main-image.yml`: builds and pushes a Docker image on push to `main`, then calls the Portainer API to redeploy. It can also be run manually from the Actions UI. ### Registry secrets for image publish @@ -87,10 +87,62 @@ Configure these repository secrets in Gitea: - `REGISTRY_USERNAME` - `REGISTRY_PASSWORD` +- `PORTAINER_URL` (for example, `https://portainer.example.com`) +- `PORTAINER_API_KEY` (Portainer API key for a user with access to the stack) +- `PORTAINER_STACK_ID` (numeric stack ID in Portainer) +- `PORTAINER_ENDPOINT_ID` (numeric environment/endpoint ID in Portainer) +- `PORTAINER_INSECURE_TLS` (optional: set to `true` only if Portainer uses self-signed TLS) By default, the publish workflow pushes to: -- `git.campbellwireless.net//:latest` -- `git.campbellwireless.net//:` +- `registry.campbellwireless.net//:latest` +- `registry.campbellwireless.net//:` If your registry host differs, edit `REGISTRY_HOST` in `.gitea/workflows/main-image.yml`. + +## Deploy with Portainer API + +The `main-image.yml` workflow now calls the Portainer API after pushing `latest`. + +In Portainer, create/update your stack to use a published image (not `build`), for example: + +```yaml +services: + trips: + image: registry.campbellwireless.net//:latest + container_name: trips + restart: unless-stopped + ports: + - '3000:3000' + volumes: + - trips-data:/data + env_file: + - .env + environment: + NODE_ENV: production + DATABASE_URL: file:/data/trips.db + PORT: '3000' + +volumes: + trips-data: +``` + +Then in Portainer: + +1. Create an API key from your user profile (`My account` -> `API keys`). +2. Open the stack details page and note the stack ID. +3. Open `Environments` and note the endpoint/environment ID where the stack runs. +4. Save these values in your Gitea repository secrets: + - `PORTAINER_URL` + - `PORTAINER_API_KEY` + - `PORTAINER_STACK_ID` + - `PORTAINER_ENDPOINT_ID` + - optional `PORTAINER_INSECURE_TLS=true` + +Flow on each push to `main` (or manual run of `main-image.yml`): + +1. Build image. +2. Push `:latest` and `:`. +3. Run Portainer preflight checks (auth + stack/endpoint IDs). +4. Call Portainer API. +5. Portainer redeploys the stack and pulls the updated image. -- 2.49.1