#!/bin/bash set -euo pipefail ############################################################################# # Portainer Stack Updater (Automation-Only) # # Updates Portainer stacks via REST API using local YAML files. # Credentials stored in .env file (gitignored). # # Usage: # ./update-stack.sh # # Example: # ./update-stack.sh open-webui.yml ############################################################################# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOKEN_FILE="$SCRIPT_DIR/.portainer-token" ENV_FILE="$SCRIPT_DIR/.env" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' NC='\033[0m' # No Color log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # Load credentials from .env if [ ! -f "$ENV_FILE" ]; then log_error ".env file not found at $ENV_FILE" exit 1 fi source "$ENV_FILE" if [ -z "${PORTAINER_USERNAME:-}" ] || [ -z "${PORTAINER_PASSWORD:-}" ]; then log_error "PORTAINER_USERNAME and PORTAINER_PASSWORD must be set in .env file" exit 1 fi PORTAINER_URL="${PORTAINER_URL:-http://localhost:8080}" ############################################################################# # Authentication ############################################################################# authenticate() { log_info "Authenticating with Portainer..." local response response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \ -H "Content-Type: application/json" \ -d "{\"username\":\"$PORTAINER_USERNAME\",\"password\":\"$PORTAINER_PASSWORD\"}" \ 2>&1) if [ $? -ne 0 ]; then log_error "Failed to connect to Portainer at $PORTAINER_URL" exit 1 fi # Extract token local token token=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin)['jwt'])" 2>/dev/null) if [ -z "$token" ]; then log_error "Failed to authenticate. Check credentials in .env file." exit 1 fi # Save token echo "$token" > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" log_success "Authentication successful" echo "$token" } get_token() { if [ -f "$TOKEN_FILE" ]; then cat "$TOKEN_FILE" else authenticate fi } ############################################################################# # Stack Operations ############################################################################# get_endpoint_id() { local token="$1" local response response=$(curl -s -X GET "$PORTAINER_URL/api/endpoints" \ -H "Authorization: Bearer $token" 2>&1) if [ $? -ne 0 ]; then log_error "Failed to get endpoints" return 1 fi # Get first endpoint (local docker) local endpoint_id endpoint_id=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin)[0]['Id'])" 2>/dev/null) if [ -z "$endpoint_id" ]; then log_error "No endpoints found" return 1 fi echo "$endpoint_id" } get_stack_id() { local token="$1" local stack_name="$2" local response response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \ -H "Authorization: Bearer $token" 2>&1) if [ $? -ne 0 ]; then log_error "Failed to get stacks" return 1 fi # Find stack by name local stack_id stack_id=$(echo "$response" | python3 -c " import sys, json stacks = json.load(sys.stdin) for stack in stacks: if stack['Name'] == '$stack_name': print(stack['Id']) break " 2>/dev/null) echo "$stack_id" } update_stack() { local token="$1" local stack_id="$2" local endpoint_id="$3" local yaml_content="$4" log_info "Updating stack ID $stack_id..." # Create JSON payload local payload payload=$(python3 -c " import json, sys data = { 'stackFileContent': '''$yaml_content''', 'prune': False, 'pullImage': False } print(json.dumps(data)) ") local response response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$endpoint_id" \ -H "Authorization: Bearer $token" \ -H "Content-Type: application/json" \ -d "$payload" 2>&1) if [ $? -ne 0 ]; then log_error "Failed to update stack" return 1 fi # Check if response contains error if echo "$response" | grep -q '"message"'; then log_error "Stack update failed: $response" return 1 fi log_success "Stack updated successfully" return 0 } ############################################################################# # Main ############################################################################# main() { # Validate arguments if [ $# -ne 1 ]; then log_error "Usage: $0 " log_error "Example: $0 open-webui.yml" exit 1 fi local yaml_file="$1" local yaml_path # Support both relative and absolute paths if [ -f "$yaml_file" ]; then yaml_path="$yaml_file" elif [ -f "$SCRIPT_DIR/$yaml_file" ]; then yaml_path="$SCRIPT_DIR/$yaml_file" else log_error "Stack file not found: $yaml_file" exit 1 fi # Extract stack name from filename (remove .yml extension) local stack_name stack_name=$(basename "$yaml_file" .yml) log_info "Updating stack: $stack_name" # Read YAML content local yaml_content yaml_content=$(cat "$yaml_path") # Get or create API token local token token=$(get_token) # Get endpoint ID (local Docker) local endpoint_id endpoint_id=$(get_endpoint_id "$token") if [ -z "$endpoint_id" ]; then log_error "Failed to get endpoint ID" exit 1 fi # Get stack ID local stack_id stack_id=$(get_stack_id "$token" "$stack_name") if [ -z "$stack_id" ]; then log_error "Stack '$stack_name' not found in Portainer" exit 1 fi # Update stack if update_stack "$token" "$stack_id" "$endpoint_id" "$yaml_content"; then log_success "✓ Stack '$stack_name' updated successfully" exit 0 else log_error "Failed to update stack" exit 1 fi } main "$@"