All checks were successful
Build And Publish Production Image / Build And Publish Production Image (push) Successful in 7s
331 lines
13 KiB
YAML
331 lines
13 KiB
YAML
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:-<empty>}"
|
|
echo "HTTPS_PROXY=${HTTPS_PROXY:-<empty>}"
|
|
echo "NO_PROXY=${NO_PROXY:-<empty>}"
|
|
|
|
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("^(?<name>[A-Za-z_][A-Za-z0-9_]*)=(?<value>.*)$"))
|
|
| 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: false}')
|
|
|
|
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 "<non-json or empty body>"
|
|
|
|
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"
|