Co-authored-by: Kay Kayyali <kaykayyali@gmail.com> Co-committed-by: Kay Kayyali <kaykayyali@gmail.com>
269 lines
10 KiB
Bash
Executable File
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
|