2026-02-22 22:24:35 +00:00
|
|
|
#!/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,
|
feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red)
- Add healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold)
- Add healthStatus, lastHealthCheck, events fields to instances
- Create grafana, home-assistant, node-red templates with healthcheck probes
- Add healthcheck probes to existing templates (github, slack, postgres, jira)
- Show HEALTH column in `get instances` and Events section in `describe instance`
- Display healthCheck details in `describe server` and `describe template`
- Schema + storage + display only; actual probe runner is future work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:59 +00:00
|
|
|
"pullImage": true
|
2026-02-22 22:24:35 +00:00
|
|
|
}')
|
|
|
|
|
|
|
|
|
|
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 "$@"
|