Clone-adapted from .wiki/concepts/portainer-stack-management-vds.md § Migration script. Diffs from VDS-infra version: - PORTAINER_URL = portainer.kzntsv.site - Auth via X-API-Key (PAT works, no JWT fallback needed) - DIR prefix /usr/docker/<stack> (not /opt/stacks/<stack>) - Down step via `docker rm -f` by compose-project label (bypasses docker-compose v1 ContainerConfig bug entirely; no compose binary on path required) - Refuses traefik/portainer migration (management plane) Idempotent: delete-then-create against existing stack by name on endpoint 1. Re-runs are safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.4 KiB
Bash
110 lines
4.4 KiB
Bash
#!/bin/bash
|
|
# books-vds-portainer-migration/migrate.sh
|
|
#
|
|
# Migrates one SSH-compose stack at /usr/docker/<STACK>/ 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/<stack> (not /opt/stacks/<stack>)
|
|
# 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 <stack-name>
|
|
#
|
|
# 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/<id>/, 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
|