trips) portainer API deploy + preflight checks
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m4s
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m4s
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY_HOST: registry.campbellwireless.net
|
REGISTRY_HOST: registry.campbellwireless.net
|
||||||
@@ -50,3 +51,107 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }}
|
${{ 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."
|
||||||
|
|||||||
58
README.md
58
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`:
|
Workflows live in `.gitea/workflows`:
|
||||||
|
|
||||||
- `pr-checks.yml`: runs lint, tests, and Docker build on pull requests.
|
- `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
|
### Registry secrets for image publish
|
||||||
|
|
||||||
@@ -87,10 +87,62 @@ Configure these repository secrets in Gitea:
|
|||||||
|
|
||||||
- `REGISTRY_USERNAME`
|
- `REGISTRY_USERNAME`
|
||||||
- `REGISTRY_PASSWORD`
|
- `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:
|
By default, the publish workflow pushes to:
|
||||||
|
|
||||||
- `git.campbellwireless.net/<owner>/<repo>:latest`
|
- `registry.campbellwireless.net/<owner>/<repo>:latest`
|
||||||
- `git.campbellwireless.net/<owner>/<repo>:<short-sha>`
|
- `registry.campbellwireless.net/<owner>/<repo>:<short-sha>`
|
||||||
|
|
||||||
If your registry host differs, edit `REGISTRY_HOST` in `.gitea/workflows/main-image.yml`.
|
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/<owner>/<repo>: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 `:<short-sha>`.
|
||||||
|
3. Run Portainer preflight checks (auth + stack/endpoint IDs).
|
||||||
|
4. Call Portainer API.
|
||||||
|
5. Portainer redeploys the stack and pulls the updated image.
|
||||||
|
|||||||
Reference in New Issue
Block a user