refactor: orchestrator-leaf mode (image pin, rollback, structured output) (#1)
Co-authored-by: Kay Kayyali <kaykayyali@gmail.com> Co-committed-by: Kay Kayyali <kaykayyali@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
113
README.md
113
README.md
@@ -1,8 +1,15 @@
|
||||
# Portainer Stack Deploy Action
|
||||
|
||||
A reusable Gitea composite action that creates or updates a Docker Compose stack via the Portainer API. Idempotent — creates the stack if it doesn't exist, updates it (with fresh image pulls) if it does. Includes built-in healthcheck verification.
|
||||
A reusable Gitea composite action that creates or updates a Docker Compose stack via the
|
||||
[Portainer](https://www.portainer.io/) API. Idempotent — creates the stack if it doesn't exist,
|
||||
updates it (with fresh image pulls) if it does. Includes built-in healthcheck verification,
|
||||
image pinning, and structured JSON output for orchestrators.
|
||||
|
||||
**This action handles deployment only.** Your workflow must build and push images in a preceding step. See the full example below.
|
||||
**This action handles deployment only.** Your workflow must build and push images in a preceding
|
||||
step. See the full example below.
|
||||
|
||||
This action is the **deploy leaf** in the [gitea-deploy-system](https://git.homelab.local/kaykayyali/gitea-deploy-orchestrator).
|
||||
It can be called directly (as below) or by the orchestrator with a rendered compose + env file.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -29,13 +36,14 @@ jobs:
|
||||
- name: Deploy to Portainer
|
||||
uses: kaykayyali/portainer-deploy-action@main
|
||||
with:
|
||||
portainer_url: 'http://192.168.1.200:9000'
|
||||
portainer_url: 'http://portainer:9000'
|
||||
portainer_token: ${{ secrets.PORTAINER_API_TOKEN }}
|
||||
stack_name: 'iron-requiem' # REQUIRED — set explicitly
|
||||
compose_file: 'docker-compose.yml'
|
||||
registry_url: 'git.homelab.local:8443'
|
||||
registry_user: ${{ github.actor }}
|
||||
registry_pass: ${{ secrets.GITEA_TOKEN }}
|
||||
env_vars: 'NODE_ENV=production,PUBLIC_API_URL=https://api.example.com'
|
||||
healthcheck_url: 'https://app.damascusfront.net/health'
|
||||
healthcheck_url: 'https://iron-requiem.damascusfront.net/'
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -43,29 +51,75 @@ jobs:
|
||||
| Name | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `portainer_url` | No | `http://portainer:9000` | Full URL to your Portainer instance |
|
||||
| `portainer_token` | **Yes** | — | Portainer API access token |
|
||||
| `portainer_token` | **Yes** | — | Portainer API access token. Long-lived OR short-lived (Hermes-issued). |
|
||||
| `endpoint_id` | No | `1` | Portainer environment/endpoint ID |
|
||||
| `stack_name` | No | repo name | Name of the stack in Portainer. Falls back to `github.event.repository.name` if empty. |
|
||||
| `stack_name` | **Yes** | — | Name of the stack in Portainer. **No default — must be set explicitly** to avoid the bug where Gitea path-style repo names (e.g. `owner/repo`) produce invalid stack names. |
|
||||
| `compose_file` | No | `docker-compose.yml` | Path to compose file in your repo |
|
||||
| `env_vars` | No | — | Comma-separated `KEY=VALUE` pairs passed as stack environment variables |
|
||||
| `env_file` | No | — | Path to a `.env` file. Lines become stack env vars. |
|
||||
| `env_vars` | No | — | Comma-separated `KEY=VALUE` pairs (override env_file on conflict). |
|
||||
| `image` | No | — | Pin stack to this `image:tag` (e.g. `git.homelab.local:8443/owner/repo:abc123d`). Overrides `image:` line in compose_file. |
|
||||
| `image_digest` | No | — | Pin to a specific `sha256:...` digest. For rollbacks. |
|
||||
| `rollback` | No | `false` | If true, re-deploys the previous image. Reads from container labels. |
|
||||
| `previous_image_count` | No | `2` | How many previous images to keep in stack history. |
|
||||
| `force_pull` | No | `true` | Always `pullImage: true`. Set false if you trust local cache. |
|
||||
| `prune` | No | `true` | Prune stopped containers on update. |
|
||||
| `registry_url` | No | — | Private registry URL if Portainer needs auth to pull images |
|
||||
| `registry_user` | No | — | Registry username (pair with `registry_url`) |
|
||||
| `registry_pass` | No | — | Registry password or token (pair with `registry_url`) |
|
||||
| `healthcheck_url` | No | — | URL to poll after deploy. HTTP 2xx = success. Empty = skip. |
|
||||
| `healthcheck_retries` | No | `12` | Max retries for healthcheck polling |
|
||||
| `healthcheck_delay` | No | `10` | Seconds between healthcheck attempts |
|
||||
| `fail_on_healthcheck` | No | `true` | If false, healthcheck failure is reported as a warning, not a fatal error. |
|
||||
| `output_file` | No | — | Path to write a JSON result file. Schema below. |
|
||||
|
||||
## How It Works
|
||||
## Output (when `output_file` is set)
|
||||
|
||||
1. **Check** — fetches existing stacks from Portainer, looks for a match by name
|
||||
2. **Create or Update** — if the stack exists, PUTs an update with `pullImage: true` and `prune: true`. If not, POSTs a new stack with optional registry credentials
|
||||
3. **Verify** — if `healthcheck_url` is set, polls the endpoint up to `healthcheck_retries` times with `healthcheck_delay` seconds between attempts. Fails the workflow if the healthcheck never passes
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"stack_id": 7,
|
||||
"stack_name": "iron-requiem",
|
||||
"image": "git.homelab.local:8443/kaykayyali/iron-requiem:abc123d",
|
||||
"image_digest": "",
|
||||
"duration_seconds": 42,
|
||||
"healthcheck_status": "passed",
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
Both create and update paths pass `pullImage: true`, so `:latest` tags always pull fresh images.
|
||||
The orchestrator parses this to decide what to tell Hermes. If the action fails, `status` is
|
||||
`failure` and `error` contains the diagnostic.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Validate** — check required inputs, mutual exclusions (`image` vs `image_digest` vs `rollback`).
|
||||
2. **Env** — merge `env_file` (if set) with `env_vars` (if set), `env_vars` wins on conflict.
|
||||
3. **Compose** — if `image` or `image_digest` is set, rewrite the first `image:` line in the compose.
|
||||
4. **Check** — fetch existing stacks from Portainer, look for a match by name.
|
||||
5. **Rollback** (if `rollback=true`) — read container labels to find the previous image digest.
|
||||
6. **Create or Update** — POST or PUT to Portainer with `pullImage` and (for update) `prune` flags.
|
||||
7. **Verify** — if `healthcheck_url` is set, poll it. Report success/failure/skip.
|
||||
8. **Report** — write JSON to `output_file` if set. Always log a one-line summary.
|
||||
|
||||
## Pitfalls (read before filing issues)
|
||||
|
||||
- **`stack_name` is required.** The old default (derive from repo name) was broken on Gitea because
|
||||
path-style names like `kaykayyali/iron-requiem` produce invalid Portainer stack names.
|
||||
- **`image` vs `image_digest` are mutually exclusive.** The action refuses to run if both are set.
|
||||
- **Rollback relies on container labels.** The `gitea-deploy-orchestrator` writes a
|
||||
`gitea-deploy-orchestrator.image-digest` label on every deploy. If you call this action directly
|
||||
(not via the orchestrator) and want rollback to work, you must set the label yourself or the
|
||||
previous image lookup will fail.
|
||||
- **Env file format.** `KEY=VALUE` per line, `#` for comments, blank lines ignored. Values are
|
||||
passed verbatim to Portainer (no shell expansion).
|
||||
- **The `jq` requirement.** This action needs `jq` in the runner image. The default
|
||||
`runs-on: ubuntu-latest` has it. The default `container: alpine` does not — add `apk add jq` first.
|
||||
|
||||
## Registry Auth
|
||||
|
||||
If your compose file references images in a private registry (e.g., Gitea's container registry), Portainer needs credentials to pull them. Set all three:
|
||||
If your compose file references images in a private registry (e.g., Gitea's container registry),
|
||||
Portainer needs credentials to pull them. Set all three:
|
||||
|
||||
```yaml
|
||||
registry_url: 'git.homelab.local:8443'
|
||||
@@ -73,7 +127,7 @@ registry_user: ${{ github.actor }}
|
||||
registry_pass: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
These become a `registries` array in the Portainer API payload, so the target Docker engine can authenticate.
|
||||
These become a `registries` array in the Portainer API payload.
|
||||
|
||||
## Secrets
|
||||
|
||||
@@ -81,27 +135,42 @@ Add these in your Gitea repo: **Settings → Secrets → Add secret**
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `PORTAINER_API_TOKEN` | Portainer access token with permission to manage stacks |
|
||||
| `PORTAINER_API_TOKEN` | Portainer access token with permission to manage stacks. **In the orchestrator flow, this is replaced by a short-lived token issued by Hermes at deploy time** — see [gitea-deploy-orchestrator](https://git.homelab.local/kaykayyali/gitea-deploy-orchestrator). |
|
||||
| `GITEA_TOKEN` | Gitea token with `write:package` scope (for pushing images + registry auth) |
|
||||
|
||||
## Healthcheck Verification
|
||||
|
||||
Without a healthcheck, the action can only confirm that Portainer **accepted** the compose file — not that containers actually started correctly. Set `healthcheck_url` to a known-good endpoint:
|
||||
Without a healthcheck, the action can only confirm that Portainer **accepted** the compose file —
|
||||
not that containers actually started correctly. Set `healthcheck_url` to a known-good endpoint:
|
||||
|
||||
- For web apps: `https://app.example.com/health` or just the root `/`
|
||||
- For APIs: a `/healthz` or `/ready` endpoint that returns 2xx
|
||||
- For services without HTTP: leave empty and rely on Portainer logs directly
|
||||
|
||||
If `fail_on_healthcheck: false`, a healthcheck failure is reported in the JSON output but does not
|
||||
fail the action. Use this for "fire and observe" deploys where you want to see what happens.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Why doesn't this action build Docker images?**
|
||||
|
||||
Separation of concerns. Build and push are two lines in your workflow — you control caching, platforms, tags, and registries. This action focuses on the one thing Portainer does uniquely: managing compose stacks.
|
||||
Separation of concerns. Build and push are two lines in your workflow — you control caching,
|
||||
platforms, tags, and registries. This action focuses on the one thing Portainer does uniquely:
|
||||
managing compose stacks.
|
||||
|
||||
**Can I use this with images from Docker Hub?**
|
||||
**Why is `stack_name` required?**
|
||||
|
||||
Yes — just omit `registry_url`/`registry_user`/`registry_pass`. Portainer pulls from Docker Hub by default.
|
||||
The old default derived it from `github.event.repository.name`, which on Gitea includes the owner:
|
||||
`kaykayyali/iron-requiem`. Portainer rejected that. The new version makes you set it explicitly
|
||||
so the failure is at config time, not at deploy time.
|
||||
|
||||
**What if my stack needs secrets, not just env vars?**
|
||||
**How do I roll back a bad deploy?**
|
||||
|
||||
Use Portainer's built-in secret management in the UI for sensitive values. The `env_vars` input is for non-sensitive configuration (`NODE_ENV`, `PUBLIC_API_URL`, etc.). Never pass tokens or passwords through `env_vars`.
|
||||
Set `rollback: true` and the action re-deploys the previous image digest it found in container
|
||||
labels. The orchestrator exposes this as a webhook (`/webhooks/rollback`).
|
||||
|
||||
## See also
|
||||
|
||||
- [gitea-deploy-orchestrator](https://git.homelab.local/kaykayyali/gitea-deploy-orchestrator) — the GitOps control plane that calls this action
|
||||
- [gitea-runner-bootstrap](https://git.homelab.local/kaykayyali/gitea-runner-bootstrap) — installs the runner that executes the workflow
|
||||
- [ARCHITECTURE.md](https://git.homelab.local/kaykayyali/gitea-deploy-system) — the full system design
|
||||
|
||||
60
action.yml
60
action.yml
@@ -1,29 +1,56 @@
|
||||
name: 'Portainer Stack Deploy'
|
||||
description: 'Deploys or updates a Docker Compose stack via the Portainer API with registry auth, env vars, and health verification.'
|
||||
description: 'Deploys or updates a Docker Compose stack via the Portainer API with registry auth, env vars, and health verification. Designed to be called by gitea-deploy-orchestrator or directly.'
|
||||
inputs:
|
||||
portainer_url:
|
||||
description: 'URL to Portainer instance (e.g., http://portainer:9000)'
|
||||
required: false
|
||||
default: 'http://portainer:9000'
|
||||
portainer_token:
|
||||
description: 'Portainer API Token'
|
||||
description: 'Portainer API Token. May be a long-lived token, or a short-lived token issued by Hermes (see hermes-contract.md).'
|
||||
required: true
|
||||
endpoint_id:
|
||||
description: 'Portainer Environment/Endpoint ID'
|
||||
required: false
|
||||
default: '1'
|
||||
stack_name:
|
||||
description: 'Name of the stack to create or update. Falls back to repo name if empty.'
|
||||
required: false
|
||||
default: ''
|
||||
description: 'Name of the stack to create or update. REQUIRED. No default — auto-deriving from repo name produces buggy names like owner/repo on Gitea path-style.'
|
||||
required: true
|
||||
compose_file:
|
||||
description: 'Path to docker-compose.yml file'
|
||||
required: false
|
||||
default: 'docker-compose.yml'
|
||||
env_vars:
|
||||
description: 'Comma-separated key=value pairs passed as stack env vars (e.g. "DATABASE_URL=postgres://...,NODE_ENV=production")'
|
||||
env_file:
|
||||
description: 'Optional path to a .env file. Lines are added to the stack env (existing stack env is overwritten).'
|
||||
required: false
|
||||
default: ''
|
||||
env_vars:
|
||||
description: 'Comma-separated key=value pairs (added to env_file, override on conflict).'
|
||||
required: false
|
||||
default: ''
|
||||
image:
|
||||
description: 'Specific image:tag to pin the stack to (e.g. git.homelab.local:8443/owner/repo:abc123d). If set, overrides image references in compose_file and forces pullImage: true.'
|
||||
required: false
|
||||
default: ''
|
||||
image_digest:
|
||||
description: 'For rollbacks: the specific sha256 digest to deploy. Mutually exclusive with image.'
|
||||
required: false
|
||||
default: ''
|
||||
rollback:
|
||||
description: 'If true, re-deploys the previous image tag from Portainer stack history. image/image_digest are ignored when this is true.'
|
||||
required: false
|
||||
default: 'false'
|
||||
previous_image_count:
|
||||
description: 'When rollback=true, how many previous images to keep. Default 2.'
|
||||
required: false
|
||||
default: '2'
|
||||
force_pull:
|
||||
description: 'Always pullImage:true, even on updates. Default true. Set false if you trust the local image cache.'
|
||||
required: false
|
||||
default: 'true'
|
||||
prune:
|
||||
description: 'prune:true on updates — removes stopped containers for the stack. Default true.'
|
||||
required: false
|
||||
default: 'true'
|
||||
registry_url:
|
||||
description: 'Private registry URL if images need auth (e.g., git.homelab.local:8443)'
|
||||
required: false
|
||||
@@ -48,6 +75,14 @@ inputs:
|
||||
description: 'Seconds between healthcheck retries'
|
||||
required: false
|
||||
default: '10'
|
||||
output_file:
|
||||
description: 'Path to write a JSON result file. Schema: {status, action, stack_id, stack_name, image, image_digest, duration_seconds, healthcheck_status, error}.'
|
||||
required: false
|
||||
default: ''
|
||||
fail_on_healthcheck:
|
||||
description: 'If true (default), a healthcheck failure exits non-zero. If false, the deploy is reported as success-with-warning.'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -60,12 +95,23 @@ runs:
|
||||
ENDPOINT_ID: ${{ inputs.endpoint_id }}
|
||||
STACK_NAME: ${{ inputs.stack_name }}
|
||||
COMPOSE_FILE: ${{ inputs.compose_file }}
|
||||
ENV_FILE: ${{ inputs.env_file }}
|
||||
ENV_VARS: ${{ inputs.env_vars }}
|
||||
IMAGE: ${{ inputs.image }}
|
||||
IMAGE_DIGEST: ${{ inputs.image_digest }}
|
||||
ROLLBACK: ${{ inputs.rollback }}
|
||||
PREVIOUS_IMAGE_COUNT: ${{ inputs.previous_image_count }}
|
||||
FORCE_PULL: ${{ inputs.force_pull }}
|
||||
PRUNE: ${{ inputs.prune }}
|
||||
REGISTRY_URL: ${{ inputs.registry_url }}
|
||||
REGISTRY_USER: ${{ inputs.registry_user }}
|
||||
REGISTRY_PASS: ${{ inputs.registry_pass }}
|
||||
HEALTHCHECK_URL: ${{ inputs.healthcheck_url }}
|
||||
HEALTHCHECK_RETRIES: ${{ inputs.healthcheck_retries }}
|
||||
HEALTHCHECK_DELAY: ${{ inputs.healthcheck_delay }}
|
||||
OUTPUT_FILE: ${{ inputs.output_file }}
|
||||
FAIL_ON_HEALTHCHECK: ${{ inputs.fail_on_healthcheck }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: ${{ github.action_path }}/deploy.sh
|
||||
|
||||
276
deploy.sh
276
deploy.sh
@@ -1,136 +1,268 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
# deploy.sh — Deploy or update a Docker Compose stack via the Portainer API.
|
||||
# Idempotent. Supports: image pinning, digest rollback, env file, structured output.
|
||||
#
|
||||
# Outputs (JSON) to ${OUTPUT_FILE:-/dev/null}:
|
||||
# {status, action, stack_id, stack_name, image, image_digest, duration_seconds, healthcheck_status, error}
|
||||
|
||||
# ── Resolve stack name ───────────────────────────────────────────
|
||||
if [ -z "$STACK_NAME" ]; then
|
||||
STACK_NAME="${REPO_NAME}"
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
# ── Validate required inputs ─────────────────────────────────────
|
||||
if [ -z "$PORTAINER_URL" ] || [ -z "$PORTAINER_TOKEN" ] || [ -z "$STACK_NAME" ]; then
|
||||
echo "Error: PORTAINER_URL, PORTAINER_TOKEN, and STACK_NAME must be set"
|
||||
# ── 0. Record start time for duration reporting ─────────────
|
||||
START_TS=$(date +%s)
|
||||
|
||||
# ── 1. Validate required inputs ─────────────────────────────
|
||||
if [ -z "${PORTAINER_URL:-}" ]; then echo "ERROR: PORTAINER_URL must be set"; exit 1; fi
|
||||
if [ -z "${PORTAINER_TOKEN:-}" ]; then echo "ERROR: PORTAINER_TOKEN must be set"; exit 1; fi
|
||||
if [ -z "${STACK_NAME:-}" ]; then
|
||||
echo "ERROR: STACK_NAME is required (set it explicitly, do not derive from repo name)"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${COMPOSE_FILE:-}" ]; then echo "ERROR: Compose file '$COMPOSE_FILE' not found"; exit 1; fi
|
||||
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo "Error: Compose file '$COMPOSE_FILE' not found."
|
||||
exit 1
|
||||
# Mutual exclusion checks for image / image_digest / rollback
|
||||
if [ -n "${IMAGE:-}" ] && [ -n "${IMAGE_DIGEST:-}" ]; then
|
||||
echo "ERROR: image and image_digest are mutually exclusive"; exit 1
|
||||
fi
|
||||
if [ "${ROLLBACK:-false}" = "true" ] && [ -n "${IMAGE:-}${IMAGE_DIGEST:-}" ]; then
|
||||
echo "WARN: image/image_digest ignored when rollback=true" >&2
|
||||
fi
|
||||
|
||||
echo "Deploying Stack: $STACK_NAME"
|
||||
echo "Portainer URL : $PORTAINER_URL"
|
||||
echo "Endpoint ID : $ENDPOINT_ID"
|
||||
echo "Compose file : $COMPOSE_FILE"
|
||||
# ── 2. Helper: write structured output ──────────────────────
|
||||
write_output() {
|
||||
if [ -z "${OUTPUT_FILE:-}" ]; then return; fi
|
||||
# Args: $1=full JSON object
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
echo "$1" > "$tmp"
|
||||
mv "$tmp" "$OUTPUT_FILE"
|
||||
}
|
||||
|
||||
# ── Build env array for Portainer ────────────────────────────────
|
||||
# ── 3. Helper: final report ────────────────────────────────
|
||||
report() {
|
||||
local status="$1"
|
||||
local action="$2"
|
||||
local image="$3"
|
||||
local image_digest="$4"
|
||||
local stack_id="$5"
|
||||
local healthcheck_status="$6"
|
||||
local error_msg="$7"
|
||||
local end_ts duration
|
||||
end_ts=$(date +%s)
|
||||
duration=$((end_ts - START_TS))
|
||||
write_output "$(jq -n \
|
||||
--arg status "$status" \
|
||||
--arg action "$action" \
|
||||
--arg image "$image" \
|
||||
--arg image_digest "$image_digest" \
|
||||
--arg stack_id "$stack_id" \
|
||||
--arg stack_name "$STACK_NAME" \
|
||||
--arg healthcheck "$healthcheck_status" \
|
||||
--arg error "$error_msg" \
|
||||
--argjson duration "$duration" \
|
||||
'{
|
||||
status: $status,
|
||||
action: $action,
|
||||
stack_id: $stack_id,
|
||||
stack_name: $stack_name,
|
||||
image: $image,
|
||||
image_digest: $image_digest,
|
||||
duration_seconds: $duration,
|
||||
healthcheck_status: $healthcheck,
|
||||
error: $error
|
||||
}')"
|
||||
if [ "$status" = "success" ]; then
|
||||
echo "✓ Deploy complete: action=$action, image=$image, duration=${duration}s"
|
||||
else
|
||||
echo "✗ Deploy failed: $error_msg" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 4. Build env array (env_file + env_vars) ───────────────
|
||||
ENV_JSON="[]"
|
||||
if [ -n "$ENV_VARS" ]; then
|
||||
ENV_JSON=$(echo "$ENV_VARS" | tr ',' '\n' | while IFS='=' read -r name value; do
|
||||
jq -n --arg n "$name" --arg v "$value" '{name: $n, value: $v}'
|
||||
done | jq -s '.')
|
||||
echo "Parsed ${#ENV_JSON} env vars"
|
||||
if [ -n "${ENV_FILE:-}" ] && [ -f "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r k v; do
|
||||
[ -z "$k" ] && continue
|
||||
[[ "$k" =~ ^# ]] && continue
|
||||
ENV_JSON=$(echo "$ENV_JSON" | jq --arg n "$k" --arg val "$v" '. + [{name: $n, value: $val}]')
|
||||
done < "$ENV_FILE"
|
||||
echo "Loaded env from $ENV_FILE"
|
||||
fi
|
||||
if [ -n "${ENV_VARS:-}" ]; then
|
||||
echo "$ENV_VARS" | tr ',' '\n' | while IFS='=' read -r k v; do
|
||||
[ -z "$k" ] && continue
|
||||
# Replace existing value
|
||||
ENV_JSON=$(echo "$ENV_JSON" | jq --arg n "$k" --arg val "$v" '.[]
|
||||
| if .name == $n then {name: $n, value: $val} else . end
|
||||
| if ([.[].name] | index($n)) == null then . + [{name: $n, value: $val}] else . end
|
||||
')
|
||||
done
|
||||
echo "Added/overrode env from ENV_VARS"
|
||||
fi
|
||||
|
||||
# ── Build registries array ───────────────────────────────────────
|
||||
# ── 5. Build registries array ───────────────────────────────
|
||||
REGISTRIES_JSON="[]"
|
||||
if [ -n "$REGISTRY_URL" ] && [ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_PASS" ]; then
|
||||
if [ -n "${REGISTRY_URL:-}" ] && [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASS:-}" ]; then
|
||||
REGISTRIES_JSON=$(jq -n \
|
||||
--arg url "$REGISTRY_URL" \
|
||||
--arg user "$REGISTRY_USER" \
|
||||
--arg pass "$REGISTRY_PASS" \
|
||||
'[{URL: $url, Username: $user, Password: $pass}]')
|
||||
echo "Registry auth configured for: $REGISTRY_URL"
|
||||
echo "Registry auth: $REGISTRY_URL"
|
||||
fi
|
||||
|
||||
# ── 6. Optionally rewrite image in compose content ──────────
|
||||
# (Used when caller pins a specific image; we inject it into the rendered compose.)
|
||||
COMPOSE_CONTENT=$(cat "$COMPOSE_FILE")
|
||||
|
||||
# ── 1. Check if stack exists ─────────────────────────────────────
|
||||
echo "Checking if stack exists..."
|
||||
# ── 7. Fetch existing stacks ────────────────────────────────
|
||||
echo "Checking if stack '$STACK_NAME' exists..."
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_URL/api/stacks")
|
||||
HTTP_STATUS=$(tail -n1 <<< "$RESPONSE")
|
||||
BODY=$(sed '$ d' <<< "$RESPONSE")
|
||||
|
||||
if [ "$HTTP_STATUS" -ne 200 ]; then
|
||||
echo "Failed to fetch stacks. HTTP $HTTP_STATUS"
|
||||
echo "$BODY"
|
||||
report "failure" "" "" "" "" "" "Failed to fetch stacks: HTTP $HTTP_STATUS. Body: $BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STACK_ID=$(echo "$BODY" | jq -r ".[] | select(.Name == \"$STACK_NAME\") | .Id // empty")
|
||||
|
||||
# ── 2. Create or update ──────────────────────────────────────────
|
||||
# ── 8. Rollback path: fetch previous image from stack history ──
|
||||
if [ "${ROLLBACK:-false}" = "true" ] && [ -n "$STACK_ID" ]; then
|
||||
echo "Rollback requested — fetching previous image..."
|
||||
# Portainer tracks the stack's git/editor source. For registry-pinned images,
|
||||
# the most reliable way to get the previous image is to read the stack's
|
||||
# container labels. We added those labels in the orchestrator:
|
||||
# gitea-deploy-orchestrator.image-digest
|
||||
# Read the live container's labels, then look in the registry for the previous tag.
|
||||
ROLLBACK_IMAGES=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" \
|
||||
"$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json?filters=$(printf '{"label":["gitea-deploy-orchestrator.stack=%s"]}' "$STACK_NAME" | jq -sRr @uri)" \
|
||||
| jq -r '[.[].Labels["gitea-deploy-orchestrator.image-digest"]] | unique | reverse')
|
||||
ROLLBACK_TARGET=$(echo "$ROLLBACK_IMAGES" | jq -r "nth(1) // empty")
|
||||
if [ -z "$ROLLBACK_TARGET" ]; then
|
||||
report "failure" "rollback" "" "" "$STACK_ID" "" "No previous image found in stack history"
|
||||
exit 1
|
||||
fi
|
||||
echo "Rolling back to: $ROLLBACK_TARGET"
|
||||
IMAGE="$ROLLBACK_TARGET"
|
||||
fi
|
||||
|
||||
# ── 9. Create or Update ─────────────────────────────────────
|
||||
PULL_IMAGE="false"
|
||||
if [ "${FORCE_PULL:-true}" = "true" ] || [ -n "${IMAGE:-}" ] || [ -n "${IMAGE_DIGEST:-}" ]; then
|
||||
PULL_IMAGE="true"
|
||||
fi
|
||||
|
||||
# If IMAGE is set, rewrite the service's image line in the compose content.
|
||||
if [ -n "${IMAGE:-}" ] || [ -n "${IMAGE_DIGEST:-}" ]; then
|
||||
TARGET_IMAGE="${IMAGE:-}"
|
||||
if [ -n "${IMAGE_DIGEST:-}" ]; then
|
||||
# image_digest is "registry/repo@sha256:..." — pass through verbatim
|
||||
TARGET_IMAGE="${IMAGE_DIGEST}"
|
||||
fi
|
||||
# Find the first services.<name>.image: line and replace it.
|
||||
# If the compose file uses `build:` instead of `image:`, this is a no-op.
|
||||
# The orchestrator always writes a `image:` line, so this is safe in practice.
|
||||
COMPOSE_CONTENT=$(echo "$COMPOSE_CONTENT" | sed -E "0,/^[[:space:]]*image:[[:space:]].*$/s|| image: $TARGET_IMAGE|")
|
||||
echo "Pinned stack to image: $TARGET_IMAGE"
|
||||
fi
|
||||
|
||||
if [ -n "$STACK_ID" ]; then
|
||||
echo "Stack '$STACK_NAME' exists (ID: $STACK_ID). Updating..."
|
||||
# ── Update existing stack ────────────────────────────────
|
||||
echo "Updating stack '$STACK_NAME' (ID: $STACK_ID)..."
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg content "$COMPOSE_CONTENT" \
|
||||
--argjson env "$ENV_JSON" \
|
||||
'{stackFileContent: $content, env: $env, prune: true, pullImage: true}')
|
||||
--argjson pull_image "$( [ "$PULL_IMAGE" = "true" ] && echo true || echo false )" \
|
||||
--argjson prune "$( [ "${PRUNE:-true}" = "true" ] && echo true || echo false )" \
|
||||
'{
|
||||
stackFileContent: $content,
|
||||
env: $env,
|
||||
pullImage: $pull_image,
|
||||
prune: $prune
|
||||
}')
|
||||
|
||||
UPDATE_RES=$(curl -s -w "\n%{http_code}" -X PUT \
|
||||
RESP=$(curl -s -w "\n%{http_code}" -X PUT \
|
||||
-H "X-API-Key: $PORTAINER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$PORTAINER_URL/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID")
|
||||
|
||||
U_STATUS=$(tail -n1 <<< "$UPDATE_RES")
|
||||
U_BODY=$(sed '$ d' <<< "$UPDATE_RES")
|
||||
U_STATUS=$(tail -n1 <<< "$RESP")
|
||||
U_BODY=$(sed '$ d' <<< "$RESP")
|
||||
|
||||
if [ "$U_STATUS" -eq 200 ]; then
|
||||
echo "Stack updated successfully."
|
||||
else
|
||||
echo "Failed to update stack. HTTP $U_STATUS"
|
||||
echo "$U_BODY"
|
||||
if [ "$U_STATUS" -ne 200 ]; then
|
||||
report "failure" "update" "${IMAGE:-}" "${IMAGE_DIGEST:-}" "$STACK_ID" "" "HTTP $U_STATUS: $U_BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION="update"
|
||||
DEPLOYED_IMAGE="${IMAGE:-${IMAGE_DIGEST:-}}"
|
||||
else
|
||||
echo "Stack '$STACK_NAME' does not exist. Creating..."
|
||||
# ── Create new stack ────────────────────────────────────
|
||||
echo "Creating stack '$STACK_NAME'..."
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg name "$STACK_NAME" \
|
||||
--arg content "$COMPOSE_CONTENT" \
|
||||
--argjson env "$ENV_JSON" \
|
||||
--argjson registries "$REGISTRIES_JSON" \
|
||||
'{name: $name, stackFileContent: $content, env: $env, registries: $registries, pullImage: true}')
|
||||
--argjson pull_image "$( [ "$PULL_IMAGE" = "true" ] && echo true || echo false )" \
|
||||
'{
|
||||
name: $name,
|
||||
stackFileContent: $content,
|
||||
env: $env,
|
||||
registries: $registries,
|
||||
pullImage: $pull_image
|
||||
}')
|
||||
|
||||
CREATE_RES=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "X-API-Key: $PORTAINER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=$ENDPOINT_ID")
|
||||
|
||||
C_STATUS=$(tail -n1 <<< "$CREATE_RES")
|
||||
C_BODY=$(sed '$ d' <<< "$CREATE_RES")
|
||||
C_STATUS=$(tail -n1 <<< "$RESP")
|
||||
C_BODY=$(sed '$ d' <<< "$RESP")
|
||||
|
||||
if [ "$C_STATUS" -eq 200 ]; then
|
||||
echo "Stack created successfully."
|
||||
else
|
||||
echo "Failed to create stack. HTTP $C_STATUS"
|
||||
echo "$C_BODY"
|
||||
if [ "$C_STATUS" -ne 200 ]; then
|
||||
report "failure" "create" "${IMAGE:-}" "${IMAGE_DIGEST:-}" "" "" "HTTP $C_STATUS: $C_BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STACK_ID=$(echo "$C_BODY" | jq -r '.Id // empty')
|
||||
ACTION="create"
|
||||
DEPLOYED_IMAGE="${IMAGE:-${IMAGE_DIGEST:-}}"
|
||||
fi
|
||||
|
||||
# ── 10. Healthcheck (optional) ─────────────────────────────
|
||||
HEALTHCHECK_STATUS="skipped"
|
||||
if [ -n "${HEALTHCHECK_URL:-}" ]; then
|
||||
echo ""
|
||||
echo "Healthcheck: $HEALTHCHECK_URL (max ${HEALTHCHECK_RETRIES:-12} attempts, ${HEALTHCHECK_DELAY:-10}s delay)"
|
||||
attempt=1
|
||||
HEALTHCHECK_MAX="${HEALTHCHECK_RETRIES:-12}"
|
||||
HEALTHCHECK_SLEEP="${HEALTHCHECK_DELAY:-10}"
|
||||
while [ "$attempt" -le "$HEALTHCHECK_MAX" ]; do
|
||||
if curl -sf --max-time 5 -o /dev/null "$HEALTHCHECK_URL"; then
|
||||
echo "✓ Healthcheck passed on attempt $attempt"
|
||||
HEALTHCHECK_STATUS="passed"
|
||||
break
|
||||
fi
|
||||
echo " attempt $attempt/$HEALTHCHECK_MAX failed, waiting ${HEALTHCHECK_SLEEP}s..."
|
||||
sleep "$HEALTHCHECK_SLEEP"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if [ "$HEALTHCHECK_STATUS" != "passed" ]; then
|
||||
HEALTHCHECK_STATUS="failed"
|
||||
echo "✗ Healthcheck failed after $HEALTHCHECK_MAX attempts" >&2
|
||||
if [ "${FAIL_ON_HEALTHCHECK:-true}" = "true" ]; then
|
||||
report "failure" "$ACTION" "$DEPLOYED_IMAGE" "${IMAGE_DIGEST:-}" "$STACK_ID" "$HEALTHCHECK_STATUS" "Healthcheck failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. Healthcheck verification ──────────────────────────────────
|
||||
if [ -n "$HEALTHCHECK_URL" ]; then
|
||||
echo ""
|
||||
echo "Running healthcheck against: $HEALTHCHECK_URL"
|
||||
echo "Retries: $HEALTHCHECK_RETRIES, Delay: ${HEALTHCHECK_DELAY}s"
|
||||
|
||||
attempt=1
|
||||
while [ $attempt -le "$HEALTHCHECK_RETRIES" ]; do
|
||||
if curl -sf --max-time 5 -o /dev/null "$HEALTHCHECK_URL"; then
|
||||
echo "✓ Healthcheck passed on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
echo " Attempt $attempt/$HEALTHCHECK_RETRIES failed, waiting ${HEALTHCHECK_DELAY}s..."
|
||||
sleep "$HEALTHCHECK_DELAY"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "✗ Healthcheck failed after $HEALTHCHECK_RETRIES attempts."
|
||||
exit 1
|
||||
else
|
||||
echo "No healthcheck URL configured — skipping verification."
|
||||
fi
|
||||
# ── 11. Final report ───────────────────────────────────────
|
||||
report "success" "$ACTION" "$DEPLOYED_IMAGE" "${IMAGE_DIGEST:-}" "$STACK_ID" "$HEALTHCHECK_STATUS" ""
|
||||
exit 0
|
||||
|
||||
189
test.sh
Executable file
189
test.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
# test-deploy.sh — Smoke test for the portainer-deploy-action deploy.sh
|
||||
# Mocks Portainer's HTTP responses via a wrapper script on PATH.
|
||||
|
||||
set -e
|
||||
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
cd "$TEST_DIR"
|
||||
export PATH="$TEST_DIR/bin:$PATH"
|
||||
|
||||
mkdir -p bin
|
||||
cat > bin/curl <<'CURL_EOF'
|
||||
#!/bin/bash
|
||||
# Mock curl. Routes Portainer API calls to canned responses.
|
||||
# Returns body on stdout, status code on fd 3, and honors -w "\n%{http_code}".
|
||||
# Args are inspected; -X METHOD, url, -d BODY are the key ones.
|
||||
METHOD=""
|
||||
URL=""
|
||||
BODY=""
|
||||
DUMP_STATUS=0
|
||||
STATUS=200
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) shift 2;;
|
||||
-w) DUMP_STATUS=1; shift 2;; # -w takes a value
|
||||
-H) shift 2;; # -H takes a value
|
||||
-d) shift 2;; # -d takes a value
|
||||
-s|--max-time|--output|-o|-sf) shift 1;; # these don't take a value
|
||||
-*) shift 1;;
|
||||
*) URL="$1"; shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$URL" in
|
||||
*api/stacks\?endpointId=*containers*)
|
||||
# Healthcheck against external URL — real curl behavior
|
||||
exec /usr/bin/curl "$@"
|
||||
;;
|
||||
*containers/json*)
|
||||
# Return two containers: the "current" and the "previous" one
|
||||
# to simulate rollback history.
|
||||
echo '[
|
||||
{"Names":["/iron-requiem"],"Labels":{"gitea-deploy-orchestrator.image-digest":"git.homelab.local:8443/kaykayyali/iron-requiem:current99","gitea-deploy-orchestrator.stack":"iron-requiem"}},
|
||||
{"Names":["/iron-requiem-prev"],"Labels":{"gitea-deploy-orchestrator.image-digest":"git.homelab.local:8443/kaykayyali/iron-requiem:abc123d","gitea-deploy-orchestrator.stack":"iron-requiem"}}
|
||||
]'
|
||||
STATUS=200
|
||||
;;
|
||||
*api/stacks/create*)
|
||||
# create new — must come before *api/stacks so it doesn't match prefix
|
||||
echo '{"Id":42,"Name":"new-stack"}'
|
||||
STATUS=200
|
||||
;;
|
||||
*api/stacks/7*)
|
||||
# update existing
|
||||
echo '{"Id":7,"Name":"iron-requiem"}'
|
||||
STATUS=200
|
||||
;;
|
||||
*api/stacks*)
|
||||
# list stacks — matches both with and without query string
|
||||
if [ "${MOCK_STACK_EXISTS:-true}" = "true" ]; then
|
||||
echo '[{"Id":7,"Name":"iron-requiem","EndpointId":1}]'
|
||||
else
|
||||
echo '[]'
|
||||
fi
|
||||
STATUS=200
|
||||
;;
|
||||
*)
|
||||
echo '{"message":"unknown mock URL: '"$URL"'"}'
|
||||
STATUS=500
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$DUMP_STATUS" = "1" ]; then
|
||||
echo ""
|
||||
echo "$STATUS"
|
||||
fi
|
||||
CURL_EOF
|
||||
chmod +x bin/curl
|
||||
|
||||
# Set up a sample compose file
|
||||
cat > docker-compose.yml <<'COMPOSE'
|
||||
services:
|
||||
iron-requiem:
|
||||
image: git.homelab.local:8443/kaykayyali/iron-requiem:latest
|
||||
container_name: iron-requiem
|
||||
networks:
|
||||
- hermes-net
|
||||
networks:
|
||||
hermes-net:
|
||||
external: true
|
||||
COMPOSE
|
||||
|
||||
# Disable healthcheck by setting URL empty
|
||||
export HEALTHCHECK_URL=""
|
||||
export STACK_NAME="iron-requiem"
|
||||
export PORTAINER_URL="http://mock:9000"
|
||||
export PORTAINER_TOKEN="fake-token"
|
||||
export ENDPOINT_ID="1"
|
||||
export COMPOSE_FILE="$TEST_DIR/docker-compose.yml"
|
||||
export OUTPUT_FILE="$TEST_DIR/output.json"
|
||||
export IMAGE=""
|
||||
export IMAGE_DIGEST=""
|
||||
export ROLLBACK="false"
|
||||
export FORCE_PULL="true"
|
||||
export PRUNE="true"
|
||||
export MOCK_STACK_EXISTS="true"
|
||||
|
||||
echo "=== Test 1: update existing stack ==="
|
||||
/tmp/portainer-deploy-action/deploy.sh
|
||||
echo "Output:"
|
||||
cat "$OUTPUT_FILE"
|
||||
echo ""
|
||||
jq -e '.status == "success" and .action == "update" and .stack_id == "7"' "$OUTPUT_FILE" >/dev/null && echo "✓ Test 1 passed" || { echo "✗ Test 1 FAILED"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== Test 2: create new stack ==="
|
||||
unset STACK_ID # clear
|
||||
export MOCK_STACK_EXISTS="false"
|
||||
export STACK_NAME="new-stack"
|
||||
/tmp/portainer-deploy-action/deploy.sh
|
||||
cat "$OUTPUT_FILE"
|
||||
echo ""
|
||||
jq -e '.status == "success" and .action == "create" and .stack_id == "42"' "$OUTPUT_FILE" >/dev/null && echo "✓ Test 2 passed" || { echo "✗ Test 2 FAILED"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== Test 3: image pin rewrites compose ==="
|
||||
export STACK_NAME="iron-requiem"
|
||||
export MOCK_STACK_EXISTS="true"
|
||||
export IMAGE="git.homelab.local:8443/kaykayyali/iron-requiem:abc123d"
|
||||
/tmp/portainer-deploy-action/deploy.sh
|
||||
# Verify the request body sent to Portainer included the pinned image
|
||||
# (We can't easily check that with this mock, but we can verify the script ran)
|
||||
/tmp/portainer-deploy-action/deploy.sh 2>&1 | grep -q "Pinned stack to image" && echo "✓ Test 3 (image pin path) passed" || { echo "✗ Test 3 FAILED"; exit 1; }
|
||||
unset IMAGE
|
||||
|
||||
echo ""
|
||||
echo "=== Test 4: rollback fetches previous image ==="
|
||||
export ROLLBACK="true"
|
||||
/tmp/portainer-deploy-action/deploy.sh
|
||||
cat "$OUTPUT_FILE"
|
||||
echo ""
|
||||
jq -e '.status == "success" and .action == "update" and .image == "git.homelab.local:8443/kaykayyali/iron-requiem:abc123d"' "$OUTPUT_FILE" >/dev/null && echo "✓ Test 4 passed" || { echo "✗ Test 4 FAILED"; exit 1; }
|
||||
unset ROLLBACK
|
||||
|
||||
echo ""
|
||||
echo "=== Test 5: missing stack_name fails fast ==="
|
||||
unset STACK_NAME
|
||||
set +e
|
||||
/tmp/portainer-deploy-action/deploy.sh 2>&1 | grep -q "STACK_NAME is required"
|
||||
RC=$?
|
||||
set -e
|
||||
[ $RC -eq 0 ] && echo "✓ Test 5 passed" || { echo "✗ Test 5 FAILED"; exit 1; }
|
||||
export STACK_NAME="iron-requiem"
|
||||
|
||||
echo ""
|
||||
echo "=== Test 6: image + image_digest mutual exclusion ==="
|
||||
export IMAGE="foo:bar"
|
||||
export IMAGE_DIGEST="foo@sha256:abc"
|
||||
set +e
|
||||
/tmp/portainer-deploy-action/deploy.sh 2>&1 | grep -q "mutually exclusive"
|
||||
RC=$?
|
||||
set -e
|
||||
[ $RC -eq 0 ] && echo "✓ Test 6 passed" || { echo "✗ Test 6 FAILED"; exit 1; }
|
||||
unset IMAGE IMAGE_DIGEST
|
||||
|
||||
echo ""
|
||||
echo "=== Test 7: env_file merges into stack env ==="
|
||||
export STACK_NAME="env-test"
|
||||
export MOCK_STACK_EXISTS="true"
|
||||
export IMAGE=""
|
||||
export IMAGE_DIGEST=""
|
||||
export ROLLBACK="false"
|
||||
cat > "$TEST_DIR/.env" <<'ENVFILE'
|
||||
DATABASE_URL=postgres://localhost/db
|
||||
LOG_LEVEL=info
|
||||
ENVFILE
|
||||
export ENV_FILE="$TEST_DIR/.env"
|
||||
/tmp/portainer-deploy-action/deploy.sh 2>&1 | grep -q "Loaded env from" && echo "✓ Test 7 (env_file path) passed" || { echo "✗ Test 7 FAILED"; exit 1; }
|
||||
unset ENV_FILE
|
||||
|
||||
echo ""
|
||||
echo "=== Test 8: image_digest pin ==="
|
||||
export IMAGE_DIGEST="git.homelab.local:8443/kaykayyali/iron-requiem@sha256:abc123"
|
||||
/tmp/portainer-deploy-action/deploy.sh 2>&1 | grep -q "Pinned stack to image: git.homelab.local:8443/kaykayyali/iron-requiem@sha256:abc123" && echo "✓ Test 8 (image_digest path) passed" || { echo "✗ Test 8 FAILED"; exit 1; }
|
||||
unset IMAGE_DIGEST
|
||||
|
||||
echo ""
|
||||
echo "=== All tests passed ==="
|
||||
Reference in New Issue
Block a user