name: Deploy Production Stack on: workflow_run: workflows: ["Build And Publish Production Image"] types: [completed] workflow_dispatch: jobs: deploy: name: Deploy Stack Via Portainer if: ${{ gitea.event_name == 'workflow_dispatch' || gitea.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest env: STACK_NAME: condado-newsletter-stack PORTAINER_URL: ${{ secrets.PORTAINER_URL }} PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} ENV_VARS: ${{ secrets.ENV_VARS }} steps: - uses: actions/checkout@v4 with: github-server-url: http://gitea.lab - name: Validate ENV_VARS secret run: | set -eu if [ -z "${ENV_VARS}" ]; then echo "ENV_VARS secret is empty." exit 1 fi - name: Deploy stack via Portainer API run: | set -u set +e if ! command -v curl >/dev/null 2>&1; then echo "curl is not available in this runner image" exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "jq is not available in this runner image" exit 1 fi PORTAINER_BASE_URL=$(printf '%s' "${PORTAINER_URL:-http://portainer.lab/}" | sed -E 's/[[:space:]]+$//; s#/*$##') echo "Portainer deploy debug" echo "PORTAINER_URL=${PORTAINER_URL:-http://portainer.lab/}" echo "PORTAINER_BASE_URL=${PORTAINER_BASE_URL}" echo "STACK_NAME=${STACK_NAME}" echo "PORTAINER_ENDPOINT_ID=${PORTAINER_ENDPOINT_ID}" echo "HTTP_PROXY=${HTTP_PROXY:-}" echo "HTTPS_PROXY=${HTTPS_PROXY:-}" echo "NO_PROXY=${NO_PROXY:-}" echo "Current runner network info:" if command -v ip >/dev/null 2>&1; then ip -4 addr show || true ip route || true else hostname -I || true fi ENV_JSON=$(printf '%s\n' "${ENV_VARS}" | jq -R -s ' split("\n") | map(gsub("\r$"; "")) | map(select(length > 0)) | map(select(startswith("#") | not)) | map(select(test("^[A-Za-z_][A-Za-z0-9_]*=.*$"))) | map(capture("^(?[A-Za-z_][A-Za-z0-9_]*)=(?.*)$")) | map({name: .name, value: .value}) ') echo "Loaded $(printf '%s' "${ENV_JSON}" | jq 'length') env entries from ENV_VARS" echo "ENV names preview:" printf '%s' "${ENV_JSON}" | jq -r '.[0:10][]?.name' || true REQUIRED_ENV_KEYS=( APP_PASSWORD JWT_SECRET SPRING_DATASOURCE_USERNAME SPRING_DATASOURCE_PASSWORD APP_RECIPIENTS ) MISSING_KEYS=() for REQUIRED_KEY in "${REQUIRED_ENV_KEYS[@]}"; do if ! printf '%s' "${ENV_JSON}" | jq -e --arg required_key "${REQUIRED_KEY}" 'map(.name) | index($required_key) != null' >/dev/null; then MISSING_KEYS+=("${REQUIRED_KEY}") fi done if [ "${#MISSING_KEYS[@]}" -gt 0 ]; then echo "ENV_VARS is missing required keys: ${MISSING_KEYS[*]}" exit 1 fi echo "Portainer base URL: ${PORTAINER_BASE_URL}" echo "Target stack: ${STACK_NAME}" echo "Endpoint id set: $([ -n "${PORTAINER_ENDPOINT_ID}" ] && echo yes || echo no)" PORTAINER_HOST=$(printf '%s' "${PORTAINER_BASE_URL}" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s/:.*$//') PORTAINER_IP="" ACTIVE_PORTAINER_BASE_URL="${PORTAINER_BASE_URL}" if command -v getent >/dev/null 2>&1; then PORTAINER_IP=$(getent hosts "${PORTAINER_HOST}" | awk 'NR==1{print $1}') if [ -n "${PORTAINER_IP}" ]; then PORTAINER_IP_BASE_URL="${PORTAINER_BASE_URL/${PORTAINER_HOST}/${PORTAINER_IP}}" echo "Portainer DNS resolved ${PORTAINER_HOST} -> ${PORTAINER_IP}" echo "IP fallback URL: ${PORTAINER_IP_BASE_URL}" else echo "DNS lookup returned no IP for ${PORTAINER_HOST}" fi else echo "getent not available; skipping DNS pre-check" fi STACKS_BODY=$(mktemp) STACKS_HEADERS=$(mktemp) STACKS_ERR=$(mktemp) STACKS_HTTP_CODE=$(curl -sS \ --noproxy "*" \ -D "${STACKS_HEADERS}" \ -o "${STACKS_BODY}" \ -w "%{http_code}" \ "${ACTIVE_PORTAINER_BASE_URL}/api/stacks" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ 2>"${STACKS_ERR}") STACKS_CURL_EXIT=$? echo "GET /api/stacks curl exit: ${STACKS_CURL_EXIT}" echo "GET /api/stacks http code: ${STACKS_HTTP_CODE}" echo "GET /api/stacks headers:" cat "${STACKS_HEADERS}" || true if [ "${STACKS_CURL_EXIT}" -eq 6 ] && [ -n "${PORTAINER_IP:-}" ]; then echo "Retrying stack list with IP fallback due to DNS failure" STACKS_HTTP_CODE=$(curl -sS \ --noproxy "*" \ -D "${STACKS_HEADERS}" \ -o "${STACKS_BODY}" \ -w "%{http_code}" \ "${PORTAINER_IP_BASE_URL}/api/stacks" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ 2>"${STACKS_ERR}") STACKS_CURL_EXIT=$? if [ "${STACKS_CURL_EXIT}" -eq 0 ]; then ACTIVE_PORTAINER_BASE_URL="${PORTAINER_IP_BASE_URL}" fi echo "Retry GET /api/stacks curl exit: ${STACKS_CURL_EXIT}" echo "Retry GET /api/stacks http code: ${STACKS_HTTP_CODE}" fi if [ "${STACKS_CURL_EXIT}" -ne 0 ]; then echo "GET /api/stacks stderr:" cat "${STACKS_ERR}" || true exit "${STACKS_CURL_EXIT}" fi if [ "${STACKS_HTTP_CODE}" -lt 200 ] || [ "${STACKS_HTTP_CODE}" -ge 300 ]; then echo "GET /api/stacks body:" cat "${STACKS_BODY}" || true exit 1 fi STACK_ID=$(jq -r --arg stack_name "${STACK_NAME}" '.[] | select(.Name == $stack_name) | .Id' "${STACKS_BODY}" | head -n 1) APPLY_BODY=$(mktemp) APPLY_HEADERS=$(mktemp) APPLY_ERR=$(mktemp) # If the stack does not exist yet, remove orphan containers with names defined in compose. # This enables an idempotent create-or-recreate flow when old standalone containers exist. if [ -z "${STACK_ID}" ]; then echo "Stack not found in Portainer; checking for orphan containers with conflicting names" mapfile -t CONTAINER_NAMES < <(awk '/container_name:/{print $2}' docker-compose.prod.yml | tr -d '"' | sed '/^$/d') for CONTAINER_NAME in "${CONTAINER_NAMES[@]}"; do FILTERS=$(jq -cn --arg n "^/${CONTAINER_NAME}$" '{name: [$n]}') FILTERS_URLENC=$(printf '%s' "${FILTERS}" | jq -sRr @uri) LIST_URL="${ACTIVE_PORTAINER_BASE_URL}/api/endpoints/${PORTAINER_ENDPOINT_ID}/docker/containers/json?all=1&filters=${FILTERS_URLENC}" LIST_BODY=$(mktemp) LIST_ERR=$(mktemp) LIST_HTTP_CODE=$(curl -sS \ --noproxy "*" \ -o "${LIST_BODY}" \ -w "%{http_code}" \ "${LIST_URL}" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ 2>"${LIST_ERR}") LIST_CURL_EXIT=$? echo "Container pre-check [${CONTAINER_NAME}] curl=${LIST_CURL_EXIT} http=${LIST_HTTP_CODE}" if [ "${LIST_CURL_EXIT}" -ne 0 ]; then echo "Container pre-check stderr for ${CONTAINER_NAME}:" cat "${LIST_ERR}" || true continue fi if [ "${LIST_HTTP_CODE}" -lt 200 ] || [ "${LIST_HTTP_CODE}" -ge 300 ]; then echo "Container pre-check non-success response for ${CONTAINER_NAME}:" cat "${LIST_BODY}" || true continue fi mapfile -t MATCHING_IDS < <(jq -r '.[].Id' "${LIST_BODY}") if [ "${#MATCHING_IDS[@]}" -eq 0 ]; then echo "No conflicting container found for ${CONTAINER_NAME}" continue fi for CONTAINER_ID in "${MATCHING_IDS[@]}"; do DELETE_URL="${ACTIVE_PORTAINER_BASE_URL}/api/endpoints/${PORTAINER_ENDPOINT_ID}/docker/containers/${CONTAINER_ID}?force=1" DELETE_BODY=$(mktemp) DELETE_ERR=$(mktemp) DELETE_HTTP_CODE=$(curl -sS -X DELETE \ --noproxy "*" \ -o "${DELETE_BODY}" \ -w "%{http_code}" \ "${DELETE_URL}" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ 2>"${DELETE_ERR}") DELETE_CURL_EXIT=$? echo "Removed conflicting container ${CONTAINER_NAME} (${CONTAINER_ID}) curl=${DELETE_CURL_EXIT} http=${DELETE_HTTP_CODE}" if [ "${DELETE_CURL_EXIT}" -ne 0 ]; then echo "Delete stderr:" cat "${DELETE_ERR}" || true fi if [ "${DELETE_HTTP_CODE}" -lt 200 ] || [ "${DELETE_HTTP_CODE}" -ge 300 ]; then echo "Delete response body:" cat "${DELETE_BODY}" || true fi done done fi if [ -n "${STACK_ID}" ]; then echo "Updating existing stack id=${STACK_ID}" REQUEST_URL="${ACTIVE_PORTAINER_BASE_URL}/api/stacks/${STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}" PAYLOAD=$(jq -n \ --rawfile stack_file docker-compose.prod.yml \ --argjson env_vars "${ENV_JSON}" \ '{StackFileContent: $stack_file, Env: $env_vars, Prune: false, PullImage: true}') echo "Apply request URL: ${REQUEST_URL}" echo "Apply payload summary:" printf '%s' "${PAYLOAD}" | jq -r '{stackFileLength: (.StackFileContent | length), envCount: (.Env | length), prune: .Prune, pullImage: .PullImage}' || true APPLY_HTTP_CODE=$(curl -sS -X PUT \ --noproxy "*" \ -D "${APPLY_HEADERS}" \ -o "${APPLY_BODY}" \ -w "%{http_code}" \ "${REQUEST_URL}" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ -H "Content-Type: application/json" \ -d "${PAYLOAD}" \ 2>"${APPLY_ERR}") APPLY_CURL_EXIT=$? else echo "Creating new stack ${STACK_NAME}" REQUEST_URL="${ACTIVE_PORTAINER_BASE_URL}/api/stacks/create/standalone/string?endpointId=${PORTAINER_ENDPOINT_ID}" PAYLOAD=$(jq -n \ --arg name "${STACK_NAME}" \ --rawfile stack_file docker-compose.prod.yml \ --argjson env_vars "${ENV_JSON}" \ '{Name: $name, StackFileContent: $stack_file, Env: $env_vars, FromAppTemplate: false}') echo "Apply request URL: ${REQUEST_URL}" echo "Apply payload summary:" printf '%s' "${PAYLOAD}" | jq -r '{name: .Name, stackFileLength: (.StackFileContent | length), envCount: (.Env | length), fromAppTemplate: .FromAppTemplate}' || true APPLY_HTTP_CODE=$(curl -sS -X POST \ --noproxy "*" \ -D "${APPLY_HEADERS}" \ -o "${APPLY_BODY}" \ -w "%{http_code}" \ "${REQUEST_URL}" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ -H "Content-Type: application/json" \ -d "${PAYLOAD}" \ 2>"${APPLY_ERR}") APPLY_CURL_EXIT=$? fi echo "Apply curl exit: ${APPLY_CURL_EXIT}" echo "Apply http code: ${APPLY_HTTP_CODE}" echo "Apply response headers:" cat "${APPLY_HEADERS}" || true if [ "${APPLY_CURL_EXIT}" -ne 0 ]; then echo "Apply stderr:" cat "${APPLY_ERR}" || true exit "${APPLY_CURL_EXIT}" fi if [ "${APPLY_HTTP_CODE}" -lt 200 ] || [ "${APPLY_HTTP_CODE}" -ge 300 ]; then echo "Apply response body:" cat "${APPLY_BODY}" || true echo "Apply response parsed as JSON (if possible):" jq -r '.' "${APPLY_BODY}" 2>/dev/null || echo "" if [ ! -s "${APPLY_BODY}" ]; then echo "Apply body is empty; retrying once with verbose curl for diagnostics" curl -v -X "$( [ -n "${STACK_ID}" ] && echo PUT || echo POST )" \ --noproxy "*" \ -o /tmp/portainer-debug-body.txt \ "${REQUEST_URL}" \ -H "X-API-Key: ${PORTAINER_API_KEY}" \ -H "Content-Type: application/json" \ -d "${PAYLOAD}" \ 2>/tmp/portainer-debug-stderr.txt || true echo "Verbose retry stderr:" cat /tmp/portainer-debug-stderr.txt || true echo "Verbose retry body:" cat /tmp/portainer-debug-body.txt || true fi exit 1 fi echo "Portainer deploy completed successfully"