#!/bin/bash # books-vds-portainer-migration/migrate.sh # # Migrates one SSH-compose stack at /usr/docker// to Portainer-managed via API. # Pattern source: .wiki/concepts/portainer-stack-management-vds.md § Migration script. # Diffs from VDS-infra version: # PORTAINER_URL = https://portainer.kzntsv.site # ENDPOINT_ID = 1 (books VDS local docker daemon) # DIR prefix = /usr/docker/ (not /opt/stacks/) # Auth = X-API-Key header (PAT works, no JWT fallback) # Down step = direct `docker rm -f` by compose-project label # (bypasses both v1 standalone quirks and Portainer recreate edge cases) # # Usage (run on books VDS): # scp migrate.sh root@89.253.255.133:/tmp/ # ssh root@89.253.255.133 # export BOOKS_PORTAINER_API_KEY="ptr_..." # from `pass show books-vds/full-env` # bash /tmp/migrate.sh # # Targets (recommended ladder, lowest → highest blast radius): # proxy-chain → imgproxy → minio → mongo → books-db → elasticsearch # # Skip (management plane — would lose access): # traefik, portainer set -euo pipefail STACK="${1:?stack name required — e.g. proxy-chain | imgproxy | minio | mongo | books-db | elasticsearch}" DIR="/usr/docker/$STACK" PORTAINER_URL="https://portainer.kzntsv.site" ENDPOINT_ID=1 API_KEY="${BOOKS_PORTAINER_API_KEY:?env var required — pass show books-vds/full-env BOOKS_PORTAINER_API_KEY}" # Refuse to migrate management-plane stacks case "$STACK" in traefik|portainer) echo "✗ refusing to migrate '$STACK' — management plane (recreate breaks access)" exit 1 ;; esac [ -d "$DIR" ] || { echo "✗ missing $DIR"; exit 1; } [ -f "$DIR/docker-compose.yml" ] || { echo "✗ missing $DIR/docker-compose.yml"; exit 1; } echo "=== migrate '$STACK' from $DIR → Portainer endpoint $ENDPOINT_ID ===" # 1. Backup compose file (idempotent — keeps first backup if re-run) BACKUP="$DIR/docker-compose.yml.bak-pre-portainer-migration-$(date +%Y-%m-%d)" if [ ! -f "$BACKUP" ]; then cp "$DIR/docker-compose.yml" "$BACKUP" echo "→ backup: $BACKUP" else echo "→ backup exists: $BACKUP (keeping original)" fi # 2. Transform compose: # - strip env_file directives (defensive — Portainer string-mode can't materialize .env) # - absolutize ./ paths (Portainer working_dir = /data/compose//, not stack dir) COMPOSE=$(sed \ -e '/^\s*env_file:/d' \ -e '/^\s*-\s*\.env\s*$/d' \ -e "s|\./|$DIR/|g" \ "$DIR/docker-compose.yml") # 3. Parse .env into Portainer API env array (no-op if absent — books VDS has no .env files) ENV_ARRAY="[]" if [ -f "$DIR/.env" ]; then ENV_ARRAY=$(grep -vE '^\s*(#|$)' "$DIR/.env" | jq -Rs ' split("\n") | map(select(length>0) | split("=") | {name: .[0], value: (.[1:] | join("="))})') fi PAYLOAD=$(jq -n --arg name "$STACK" --arg compose "$COMPOSE" --argjson env "$ENV_ARRAY" \ '{name:$name, stackFileContent:$compose, env:$env, fromAppTemplate:false}') # 4. Idempotency: delete existing Portainer stack with same name on same endpoint EXISTING_ID=$(curl -ksS -H "X-API-Key: $API_KEY" "$PORTAINER_URL/api/stacks" | \ jq -r ".[] | select(.Name==\"$STACK\" and .EndpointId==$ENDPOINT_ID) | .Id" | head -1) if [ -n "$EXISTING_ID" ]; then echo "→ existing Portainer stack id=$EXISTING_ID — deleting" curl -ksS -X DELETE "$PORTAINER_URL/api/stacks/$EXISTING_ID?endpointId=$ENDPOINT_ID" \ -H "X-API-Key: $API_KEY" >/dev/null fi # 5. Down ad-hoc containers (direct `docker rm -f` by compose-project label — # bypasses docker-compose v1 ContainerConfig bug + any v2 recreate quirks) CONTAINERS=$(docker ps -aq --filter "label=com.docker.compose.project=$STACK" || true) if [ -n "$CONTAINERS" ]; then COUNT=$(echo "$CONTAINERS" | wc -l) echo "→ removing $COUNT ad-hoc container(s) (by compose-project label)" docker rm -f $CONTAINERS fi # 6. Create via Portainer API echo "→ POST /api/stacks/create/standalone/string" RESP=$(curl -ksS -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=$ENDPOINT_ID" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" --data "$PAYLOAD") NEW_ID=$(echo "$RESP" | jq -r '.Id // empty') if [ -n "$NEW_ID" ]; then echo "✓ stack '$STACK' created, Portainer Id=$NEW_ID" echo echo "→ containers now running:" docker ps --filter "label=com.docker.compose.project=$STACK" \ --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' else echo "✗ FAILED:" echo "$RESP" | jq . exit 1 fi