Files
portainer-deploy-action/deploy.sh
2026-06-27 16:43:36 +00:00

269 lines
10 KiB
Bash
Executable File

#!/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.<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
# ── 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