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:
2026-06-27 16:43:36 +00:00
committed by kaykayyali
parent cf274f50fa
commit 99714ada82
4 changed files with 537 additions and 101 deletions

113
README.md
View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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 ==="