#!/bin/bash # Deploy mcpctl stack to Portainer # Usage: ./deploy.sh [--dry-run] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STACK_DIR="$SCRIPT_DIR/stack" COMPOSE_FILE="$STACK_DIR/docker-compose.yml" ENV_FILE="$STACK_DIR/.env" # Portainer configuration PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}" PORTAINER_USER="${PORTAINER_USER:-michal}" STACK_NAME="mcpctl" ENDPOINT_ID="2" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } check_files() { if [[ ! -f "$COMPOSE_FILE" ]]; then log_error "Compose file not found: $COMPOSE_FILE" exit 1 fi if [[ ! -f "$ENV_FILE" ]]; then log_error "Environment file not found: $ENV_FILE" exit 1 fi log_info "Found compose file: $COMPOSE_FILE" log_info "Found env file: $ENV_FILE" } get_password() { if [[ -n "$PORTAINER_PASSWORD" ]]; then echo "$PORTAINER_PASSWORD" return fi if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then cat "$SCRIPT_DIR/.portainer_password" return fi if [[ -f "$HOME/.portainer_password" ]]; then cat "$HOME/.portainer_password" return fi read -s -p "Enter Portainer password for $PORTAINER_USER: " password echo >&2 echo "$password" } get_jwt_token() { local password="$1" log_info "Authenticating to Portainer..." local escaped_password escaped_password=$(printf '%s' "$password" | jq -Rs .) local response response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \ -H "Content-Type: application/json" \ -d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}") local token token=$(echo "$response" | jq -r '.jwt // empty') if [[ -z "$token" ]]; then log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')" exit 1 fi echo "$token" } parse_env_to_json() { local env_file="$1" local json_array="[" local first=true while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^#.*$ ]] && continue [[ -z "$line" ]] && continue local name="${line%%=*}" local value="${line#*=}" [[ "$name" == "$line" ]] && continue if [[ "$first" == "true" ]]; then first=false else json_array+="," fi value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') json_array+="{\"name\":\"$name\",\"value\":\"$value\"}" done < "$env_file" json_array+="]" echo "$json_array" } # Find existing stack by name find_stack_id() { local token="$1" local response response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \ -H "Authorization: Bearer $token") echo "$response" | jq -r --arg name "$STACK_NAME" \ '.[] | select(.Name == $name) | .Id // empty' } get_stack_info() { local token="$1" local stack_id="$2" curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id" \ -H "Authorization: Bearer $token" \ -H "Content-Type: application/json" } get_stack_file() { local token="$1" local stack_id="$2" local response response=$(curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id/file" \ -H "Authorization: Bearer $token" \ -H "Content-Type: application/json") if echo "$response" | jq -e '.StackFileContent' > /dev/null 2>&1; then echo "$response" | jq -r '.StackFileContent' else echo "# Could not retrieve current compose file" fi } show_diff() { local token="$1" local stack_id="$2" local env_json="$3" log_info "Fetching current state from Portainer..." local current_compose current_compose=$(get_stack_file "$token" "$stack_id") local current_env local stack_info stack_info=$(get_stack_info "$token" "$stack_id") current_env=$(echo "$stack_info" | jq -r 'if .Env then .Env[] | "\(.name)=\(.value)" else empty end' 2>/dev/null | sort) local new_env new_env=$(echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' | sort) local tmp_dir tmp_dir=$(mktemp -d) echo "$current_compose" > "$tmp_dir/current_compose.yml" cat "$COMPOSE_FILE" > "$tmp_dir/new_compose.yml" echo "$current_env" > "$tmp_dir/current_env.txt" echo "$new_env" > "$tmp_dir/new_env.txt" echo "" echo "=== ENVIRONMENT VARIABLES DIFF ===" echo "" if diff -u "$tmp_dir/current_env.txt" "$tmp_dir/new_env.txt" > "$tmp_dir/env_diff.txt" 2>&1; then echo -e "${GREEN}No changes in environment variables${NC}" else while IFS= read -r line; do if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then echo -e "${YELLOW}$line${NC}" elif [[ "$line" == -* ]]; then echo -e "${RED}$line${NC}" elif [[ "$line" == +* ]]; then echo -e "${GREEN}$line${NC}" else echo "$line" fi done < "$tmp_dir/env_diff.txt" fi echo "" echo "=== COMPOSE FILE DIFF ===" echo "" if diff -u "$tmp_dir/current_compose.yml" "$tmp_dir/new_compose.yml" > "$tmp_dir/compose_diff.txt" 2>&1; then echo -e "${GREEN}No changes in compose file${NC}" else while IFS= read -r line; do if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then echo -e "${YELLOW}$line${NC}" elif [[ "$line" == -* ]]; then echo -e "${RED}$line${NC}" elif [[ "$line" == +* ]]; then echo -e "${GREEN}$line${NC}" else echo "$line" fi done < "$tmp_dir/compose_diff.txt" fi rm -rf "$tmp_dir" } create_stack() { local token="$1" local env_json="$2" local compose_content compose_content=$(cat "$COMPOSE_FILE") local compose_escaped compose_escaped=$(echo "$compose_content" | jq -Rs .) log_info "Creating new stack '$STACK_NAME'..." local payload payload=$(jq -n \ --arg name "$STACK_NAME" \ --argjson env "$env_json" \ --argjson stackFileContent "$compose_escaped" \ '{ "name": $name, "env": $env, "stackFileContent": $stackFileContent }') local response response=$(curl -s -X POST "$PORTAINER_URL/api/stacks?type=2&method=string&endpointId=$ENDPOINT_ID" \ -H "Authorization: Bearer $token" \ -H "Content-Type: application/json" \ -d "$payload") local error_msg error_msg=$(echo "$response" | jq -r '.message // empty') if [[ -n "$error_msg" ]]; then log_error "Stack creation failed: $error_msg" echo "$response" | jq . exit 1 fi local new_id new_id=$(echo "$response" | jq -r '.Id') log_info "Stack created successfully! (ID: $new_id)" echo "$response" | jq '{Id, Name, Status, CreationDate}' } update_stack() { local token="$1" local stack_id="$2" local dry_run="$3" local compose_content compose_content=$(cat "$COMPOSE_FILE") local env_json env_json=$(parse_env_to_json "$ENV_FILE") if [[ "$dry_run" == "true" ]]; then log_warn "DRY RUN - Not actually deploying" show_diff "$token" "$stack_id" "$env_json" echo "" log_warn "DRY RUN complete - no changes made" log_info "Run without --dry-run to apply these changes" return 0 fi local env_count env_count=$(echo "$env_json" | jq 'length') log_info "Deploying $env_count environment variables" log_info "Updating stack '$STACK_NAME' (ID: $stack_id)..." local compose_escaped compose_escaped=$(echo "$compose_content" | jq -Rs .) local payload payload=$(jq -n \ --argjson env "$env_json" \ --argjson stackFileContent "$compose_escaped" \ '{ "env": $env, "stackFileContent": $stackFileContent, "prune": true, "pullImage": false }') 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") local error_msg error_msg=$(echo "$response" | jq -r '.message // empty') if [[ -n "$error_msg" ]]; then log_error "Deployment failed: $error_msg" echo "$response" | jq . exit 1 fi log_info "Stack updated successfully!" echo "$response" | jq '{Id, Name, Status, CreationDate, UpdateDate}' } main() { local dry_run=false while [[ $# -gt 0 ]]; do case $1 in --dry-run) dry_run=true shift ;; --help|-h) echo "Usage: $0 [--dry-run]" echo "" echo "Deploy mcpctl stack to Portainer" echo "" echo "Options:" echo " --dry-run Show what would be deployed without actually deploying" echo " --help Show this help message" echo "" echo "Environment variables:" echo " PORTAINER_URL Portainer URL (default: http://10.0.0.194:9000)" echo " PORTAINER_USER Portainer username (default: michal)" echo " PORTAINER_PASSWORD Portainer password (or store in ~/.portainer_password)" exit 0 ;; *) log_error "Unknown option: $1" exit 1 ;; esac done echo "========================================" echo " mcpctl Stack Deployment" echo "========================================" echo "" check_files local password password=$(get_password) local token token=$(get_jwt_token "$password") log_info "Authentication successful" # Find or create stack local stack_id stack_id=$(find_stack_id "$token") if [[ -z "$stack_id" ]]; then if [[ "$dry_run" == "true" ]]; then log_warn "Stack '$STACK_NAME' does not exist yet" log_info "A real deploy would create it" return 0 fi log_info "Stack '$STACK_NAME' not found, creating..." local env_json env_json=$(parse_env_to_json "$ENV_FILE") create_stack "$token" "$env_json" else local stack_info stack_info=$(get_stack_info "$token" "$stack_id") local status_code status_code=$(echo "$stack_info" | jq -r '.Status // 0') local status_text="Unknown" case "$status_code" in 1) status_text="Active" ;; 2) status_text="Inactive" ;; esac log_info "Current stack status: $status_text (ID: $stack_id, Env vars: $(echo "$stack_info" | jq '.Env | length'))" echo "" update_stack "$token" "$stack_id" "$dry_run" fi echo "" log_info "Done!" if [[ "$dry_run" == "false" ]]; then log_info "Check Portainer UI to verify containers are running" log_info "URL: $PORTAINER_URL/#!/$ENDPOINT_ID/docker/stacks/$STACK_NAME" fi } main "$@"