#!/bin/bash # Deploy xBot stack to Portainer. No Caddy wiring needed (no HTTP endpoints). # # Required env: # PORTAINER_URL — e.g. https://portainer.yourdomain.com # PORTAINER_API_KEY — from docker-server setup.sh output # # Optional: # STACK_NAME — default: xbot # ENV_FILE — default: ./infra/.env # COMPOSE_FILE — default: ./infra/docker-compose.yml # # Usage: # PORTAINER_URL=https://portainer.yourdomain.com \ # PORTAINER_API_KEY=ptr_... \ # bash infra/deploy-stack.sh set -euo pipefail PORTAINER_URL="${PORTAINER_URL:?Set PORTAINER_URL}" PORTAINER_API_KEY="${PORTAINER_API_KEY:?Set PORTAINER_API_KEY}" STACK_NAME="${STACK_NAME:-xbot}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" COMPOSE_FILE="${COMPOSE_FILE:-$SCRIPT_DIR/docker-compose.yml}" for f in "$ENV_FILE" "$COMPOSE_FILE"; do [ -f "$f" ] || { echo "ERROR: not found: $f"; exit 1; } done API="$PORTAINER_URL/api" echo "[$STACK_NAME] Looking up Portainer endpoint..." ENDPOINT_ID=$(curl -s "$API/endpoints" \ -H "X-API-Key: $PORTAINER_API_KEY" | \ python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Id'])") [ -z "$ENDPOINT_ID" ] && { echo "ERROR: No Portainer endpoint found"; exit 1; } build_payload() { local mode="$1" python3 - "$COMPOSE_FILE" "$ENV_FILE" "$STACK_NAME" "$mode" <<'PYEOF' import json, sys, re compose_file, env_file, stack_name, mode = sys.argv[1:5] with open(compose_file) as f: compose = f.read() env_vars = [] with open(env_file) as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue key, _, value = line.partition('=') if key: env_vars.append({"name": key, "value": value}) def replace_env_file(m): indent = re.search(r'\n(\s+)env_file', m.group(0)).group(1) lines = [f'\n{indent}environment:'] for var in env_vars: lines.append(f'{indent} {var["name"]}: "${{{var["name"]}}}"') return '\n'.join(lines) compose = re.sub(r'\n\s+env_file:[^\n]*', replace_env_file, compose) payload = {"stackFileContent": compose, "env": env_vars} if mode == "create": payload["name"] = stack_name else: payload["prune"] = True json.dump(payload, sys.stdout) PYEOF } echo "[$STACK_NAME] Checking for existing stack..." EXISTING_ID=$(curl -s "$API/stacks" \ -H "X-API-Key: $PORTAINER_API_KEY" | \ python3 -c " import sys, json for s in json.load(sys.stdin): if s['Name'] == '$STACK_NAME': print(s['Id']); break " 2>/dev/null || true) if [ -n "$EXISTING_ID" ]; then echo "[$STACK_NAME] Updating stack (ID: $EXISTING_ID)..." build_payload update | curl -s -X PUT "$API/stacks/$EXISTING_ID?endpointId=$ENDPOINT_ID" \ -H "X-API-Key: $PORTAINER_API_KEY" \ -H "Content-Type: application/json" \ -d @- > /dev/null else echo "[$STACK_NAME] Creating stack..." build_payload create | curl -s -X POST "$API/stacks/create/standalone/string?endpointId=$ENDPOINT_ID" \ -H "X-API-Key: $PORTAINER_API_KEY" \ -H "Content-Type: application/json" \ -d @- > /dev/null fi echo "" echo "=== $STACK_NAME deployed ===" echo "Check status at: $PORTAINER_URL"