#!/bin/bash # 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} set -euo pipefail # ── 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 # 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 # ── 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" } # ── 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_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 # ── 5. Build registries array ─────────────────────────────── REGISTRIES_JSON="[]" 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: $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") # ── 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 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") # ── 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..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 # ── Update existing stack ──────────────────────────────── echo "Updating stack '$STACK_NAME' (ID: $STACK_ID)..." PAYLOAD=$(jq -n \ --arg content "$COMPOSE_CONTENT" \ --argjson env "$ENV_JSON" \ --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 }') 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 <<< "$RESP") U_BODY=$(sed '$ d' <<< "$RESP") 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 # ── 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" \ --argjson pull_image "$( [ "$PULL_IMAGE" = "true" ] && echo true || echo false )" \ '{ name: $name, stackFileContent: $content, env: $env, registries: $registries, pullImage: $pull_image }') 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 <<< "$RESP") C_BODY=$(sed '$ d' <<< "$RESP") 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 # ── 11. Final report ─────────────────────────────────────── report "success" "$ACTION" "$DEPLOYED_IMAGE" "${IMAGE_DIGEST:-}" "$STACK_ID" "$HEALTHCHECK_STATUS" "" exit 0