#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SEARCH_DIR="$SCRIPT_DIR" CONFIG_PATH="" while true; do CANDIDATE="$SEARCH_DIR/.clawdbot/credentials/portainer/config.json" if [[ -f "$CANDIDATE" ]]; then CONFIG_PATH="$CANDIDATE" break fi PARENT="$(dirname "$SEARCH_DIR")" if [[ "$PARENT" == "$SEARCH_DIR" ]]; then break fi SEARCH_DIR="$PARENT" done if [[ -z "$CONFIG_PATH" && -f "$HOME/.clawdbot/credentials/portainer/config.json" ]]; then CONFIG_PATH="$HOME/.clawdbot/credentials/portainer/config.json" fi err() { echo "Error: $*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" } usage() { cat >&2 <<'EOF' Usage: update-stack.sh "" "" [options] Updates a Portainer stack by name with a new docker-compose file. Preserves existing env vars unless --env-file is provided. Arguments: stack-name Name of the stack to update compose-file Path to the new docker-compose.yml file Options: --env-file Path to a file with env vars (format: NAME=value per line) If not provided, existing env vars are preserved. --pull Force pull images and redeploy (like docker compose down/pull/up). --prune-old After update, remove images that were used by this stack but are no longer in use (old versions left dangling). Requires config at: workspace .clawdbot/credentials/portainer/config.json or ~/.clawdbot/credentials/portainer/config.json EOF exit 2 } # Parse arguments [[ $# -ge 2 ]] || usage STACK_NAME="$1" COMPOSE_FILE="$2" shift 2 ENV_FILE="" PRUNE_OLD=false PULL_IMAGE=false while [[ $# -gt 0 ]]; do case "$1" in --env-file) [[ $# -ge 2 ]] || err "--env-file requires a value" ENV_FILE="$2" shift 2 ;; --pull) PULL_IMAGE=true shift ;; --prune-old) PRUNE_OLD=true shift ;; *) err "Unknown option: $1" ;; esac done require_cmd curl require_cmd jq [[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" [[ -f "$COMPOSE_FILE" ]] || err "Compose file not found: $COMPOSE_FILE" BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" [[ -n "$BASE_URL" ]] || err "config.base_url is missing" [[ -n "$API_KEY" ]] || err "config.api_key is missing" BASE_URL="${BASE_URL%/}" # Resolve stack ID from name STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" [[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" # Get current stack info for EndpointId and existing env STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ -H "X-API-Key: $API_KEY" \ "$BASE_URL/api/stacks/$STACK_ID")" http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" body="$(printf '%s' "$STACK_INFO" | sed '$d')" if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" [[ -n "$msg" ]] || msg="$body" err "Failed to fetch stack info (HTTP $http_code): $msg" fi ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" [[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" # Capture old image IDs before update (if --prune-old) OLD_IMAGE_IDS="" if [[ "$PRUNE_OLD" == true ]]; then # Get containers for this stack using the compose project label # The label format is: com.docker.compose.project= FILTERS="$(jq -n --arg project "$STACK_NAME" '{"label": ["com.docker.compose.project=\($project)"]}')" CONTAINERS_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ -H "X-API-Key: $API_KEY" \ -G \ --data-urlencode "all=1" \ --data-urlencode "filters=$FILTERS" \ "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json")" containers_http_code="$(printf '%s' "$CONTAINERS_RESPONSE" | tail -n1)" containers_body="$(printf '%s' "$CONTAINERS_RESPONSE" | sed '$d')" if [[ "$containers_http_code" -ge 200 && "$containers_http_code" -lt 300 ]]; then # Extract image IDs from containers OLD_IMAGE_IDS="$(printf '%s' "$containers_body" | jq -r '.[].ImageID // empty' | sort -u | tr '\n' ' ' | sed 's/ $//')" if [[ -n "$OLD_IMAGE_IDS" ]]; then echo "Captured old image IDs for pruning: $OLD_IMAGE_IDS" else echo "No existing container images found to track for pruning" PRUNE_OLD=false fi else echo "Warning: Could not fetch containers for prune tracking, skipping prune" PRUNE_OLD=false fi fi # Determine env vars to use if [[ -n "$ENV_FILE" ]]; then # Read env file and convert to Portainer format [[ -f "$ENV_FILE" ]] || err "Env file not found: $ENV_FILE" ENV_JSON="$(grep -v '^#' "$ENV_FILE" | grep -v '^$' | while IFS='=' read -r name value; do [[ -n "$name" ]] && printf '{"name":"%s","value":"%s"},' "$name" "$value" done | sed 's/,$//')" ENV_PAYLOAD="[$ENV_JSON]" else # Preserve existing env vars ENV_PAYLOAD="$(printf '%s' "$body" | jq '.Env // []')" fi # Read new compose file content COMPOSE_CONTENT="$(cat "$COMPOSE_FILE")" # Build JSON payload (include pullImage if --pull flag is set) if [[ "$PULL_IMAGE" == true ]]; then PAYLOAD="$(jq -n \ --argjson env "$ENV_PAYLOAD" \ --arg compose "$COMPOSE_CONTENT" \ '{Env: $env, StackFileContent: $compose, pullImage: true}')" else PAYLOAD="$(jq -n \ --argjson env "$ENV_PAYLOAD" \ --arg compose "$COMPOSE_CONTENT" \ '{Env: $env, StackFileContent: $compose}')" fi # Build URL UPDATE_URL="$BASE_URL/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" # Update the stack response="$(curl -sS -w $'\n%{http_code}' -X PUT \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ "$UPDATE_URL")" http_code="$(printf '%s' "$response" | tail -n1)" body="$(printf '%s' "$response" | sed '$d')" if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" [[ -n "$msg" ]] || msg="$body" err "Failed to update stack (HTTP $http_code): $msg" fi echo "Stack '$STACK_NAME' (ID: $STACK_ID) updated successfully" # Prune old images if requested and we captured image IDs if [[ "$PRUNE_OLD" == true && -n "$OLD_IMAGE_IDS" ]]; then echo "Pruning old images no longer in use..." for IMAGE_ID in $OLD_IMAGE_IDS; do # Check if image is still in use by any container IMAGE_FILTERS="$(jq -n --arg imageId "$IMAGE_ID" '{"ancestor": [$imageId]}')" USAGE_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ -H "X-API-Key: $API_KEY" \ -G \ --data-urlencode "all=1" \ --data-urlencode "filters=$IMAGE_FILTERS" \ "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json")" usage_http_code="$(printf '%s' "$USAGE_RESPONSE" | tail -n1)" usage_body="$(printf '%s' "$USAGE_RESPONSE" | sed '$d')" if [[ "$usage_http_code" -ge 200 && "$usage_http_code" -lt 300 ]]; then container_count="$(printf '%s' "$usage_body" | jq 'length')" if [[ "$container_count" -eq 0 ]]; then # Image not in use, safe to remove DELETE_RESPONSE="$(curl -sS -w $'\n%{http_code}' -X DELETE \ -H "X-API-Key: $API_KEY" \ "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/images/$IMAGE_ID?force=false")" delete_http_code="$(printf '%s' "$DELETE_RESPONSE" | tail -n1)" delete_body="$(printf '%s' "$DELETE_RESPONSE" | sed '$d')" if [[ "$delete_http_code" -ge 200 && "$delete_http_code" -lt 300 ]]; then echo "Removed unused image: $IMAGE_ID" else msg="$(printf '%s' "$delete_body" | jq -r '.message // empty' 2>/dev/null || true)" [[ -n "$msg" ]] || msg="$delete_body" echo "Warning: Could not remove image $IMAGE_ID: $msg" fi else echo "Image $IMAGE_ID still in use by $container_count container(s), skipping" fi fi done echo "Pruning complete" fi