dcfb7805cd
Adds Step 0a that prints hostname -I, ip route, and a direct curl to 192.168.1.30:9000. This is to verify whether the runner external (in network_mode: host) is actually propagating host networking to the job containers it spawns. Symptom: deploy step fails with 'Failed to connect to 192.168.1.30 port 9000' even from the external runner. To be removed once we know.
177 lines
8.2 KiB
YAML
177 lines
8.2 KiB
YAML
name: CI/CD Pipeline
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
deploy:
|
|
description: "Recreate the davidaragon-portfolio stack on Portainer (deletes + creates with latest image)"
|
|
type: boolean
|
|
default: false
|
|
required: false
|
|
|
|
# Serialise runs on the shared act_runner: prevents two simultaneous runs from
|
|
# cancelling each other's in-progress steps (observed in older portfolio runs).
|
|
# See python-project-template-internal/.gitea/workflows/ci.yaml for the same
|
|
# pattern with full context (runs 794 and 799).
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
build:
|
|
name: Build & Deploy
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
# IMAGE_NAME used by the redeploy step + the deploy compose file.
|
|
IMAGE_NAME: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Build and push Docker image
|
|
run: |
|
|
# The build always tags and pushes both :latest (for ad-hoc inspection)
|
|
# and :0.0.1 (pinned version that the stack redeploy step uses).
|
|
# The runner's `docker buildx` is set up by the QNAP self-hosted runner;
|
|
# no QEMU emulation required for the linux/amd64 build target here.
|
|
docker login gitlab.impresion3d.pro -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}"
|
|
docker build \
|
|
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:latest \
|
|
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 \
|
|
.
|
|
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
|
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Optional redeploy step (gated by workflow_dispatch.deploy=true).
|
|
#
|
|
# Approach: delete the existing stack on Portainer (idempotent), then
|
|
# create a new one from the docker-compose.prod.yml at the repo root.
|
|
#
|
|
# We DELETE + CREATE rather than PUT-edit because:
|
|
# * Put-edit requires the same stack ID + PRUNE; the simpler recreate
|
|
# works for our size (1 service, no inter-service references).
|
|
# * Earlier the stack was "unhealthy"; this gives a clean slate.
|
|
#
|
|
# Required secrets:
|
|
# PORTAINER_URL e.g. http://192.168.1.30:9000
|
|
# PORTAINER_TOKEN Access token from a Portainer user (scope: admin)
|
|
# PORTAINER_ENDPOINT_ID Numeric endpoint ID (usually 1)
|
|
# PORTAINER_STACK_ID Numeric stack ID to delete before recreating
|
|
# ----------------------------------------------------------------------
|
|
- name: Redeploy stack on Portainer
|
|
if: github.event_name == 'workflow_dispatch' && inputs.deploy == true
|
|
env:
|
|
PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
echo "--- Step 0a: debug network from inside the job container ---"
|
|
echo "hostname: $(hostname)"
|
|
echo "hostname -I:"
|
|
hostname -I 2>/dev/null || echo "(none)"
|
|
echo "ip -4 addr show (eth* only):"
|
|
ip -4 addr show 2>/dev/null | grep -E "^[0-9]+:|inet " | head -20
|
|
echo "ip route:"
|
|
ip route 2>/dev/null
|
|
echo "ip route show default:"
|
|
ip -4 route show default 2>/dev/null
|
|
echo "curl to 192.168.1.30:9000 (5s timeout):"
|
|
curl -sS -o /dev/null -w ' HTTP=%{http_code} time=%{time_total}s\n' --max-time 5 "http://192.168.1.30:9000/api/status" 2>&1 || echo " (curl failed)"
|
|
echo "---"
|
|
|
|
echo "--- Step 0: auto-discover a reachable Portainer URL ---"
|
|
# The runner creates an ephemeral docker network per job, so the
|
|
# canonical PORTAINER_URL (e.g. http://192.168.1.30:9000) often isn't
|
|
# reachable from inside the job container. Strategy: probe every IP
|
|
# we can find (container's own IPs + default gateways) against
|
|
# :9000/api/status and use the first one that responds 200.
|
|
PORTAINER_URL=""
|
|
CANDIDATES=()
|
|
|
|
# 1) Container's own IPv4 addresses
|
|
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.'); do
|
|
CANDIDATES+=("http://${ip}:9000")
|
|
done
|
|
|
|
# 2) Default gateways of every default route
|
|
while IFS= read -r gw; do
|
|
[ -n "$gw" ] && CANDIDATES+=("http://${gw}:9000")
|
|
done < <(ip -4 route show default 2>/dev/null | awk '{print $3}' | sort -u)
|
|
|
|
# 3) Fallback: secret value (in case everything else fails)
|
|
CANDIDATES+=("${{ secrets.PORTAINER_URL }}")
|
|
|
|
echo "Candidates: ${CANDIDATES[@]}"
|
|
for url in "${CANDIDATES[@]}"; do
|
|
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "${url}/api/status" 2>/dev/null || echo "000")
|
|
echo " probe ${url}/api/status -> ${code}"
|
|
if [ "$code" = "200" ]; then
|
|
PORTAINER_URL="$url"
|
|
echo " -> using ${PORTAINER_URL}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ -z "$PORTAINER_URL" ]; then
|
|
echo "ERROR: no candidate URL reached Portainer. Tried: ${CANDIDATES[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "--- Step 1: pre-flight (delete existing stack if present) ---"
|
|
DELETE_HTTP_CODE=$(curl -sS -o /tmp/portainer-delete.json -w '%{http_code}' \
|
|
-X DELETE \
|
|
-H "X-API-Key: ${PORT...EN}" \
|
|
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}")
|
|
echo "DELETE HTTP ${DELETE_HTTP_CODE}"
|
|
if [ "${DELETE_HTTP_CODE}" != "204" ] && [ "${DELETE_HTTP_CODE}" != "404" ]; then
|
|
echo "ERROR: Portainer rejected DELETE on stack ${PORTAINER_STACK_ID}:" >&2
|
|
cat /tmp/portainer-delete.json >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "--- Step 2: read docker-compose.prod.yml ---"
|
|
if [ ! -f docker-compose.prod.yml ]; then
|
|
echo "ERROR: docker-compose.prod.yml is missing from the repo root" >&2
|
|
exit 1
|
|
fi
|
|
# Inline the compose file. Portainer expects `composeFileContent` as raw text.
|
|
COMPOSE_BODY=$(jq -Rs --arg compose "$(cat docker-compose.prod.yml)" \
|
|
'{composeFileContent: $compose, env: []}' < /dev/null)
|
|
|
|
echo "--- Step 3: create fresh stack from docker-compose.prod.yml ---"
|
|
CREATE_HTTP_CODE=$(curl -sS -o /tmp/portainer-create.json -w '%{http_code}' \
|
|
-X POST \
|
|
-H "X-API-Key: ${PORTAINER_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
--data "${COMPOSE_BODY}" \
|
|
"${PORTAINER_URL}/api/stacks?endpointId=${PORTAINER_ENDPOINT_ID}&type=2&method=string&name=davidaragon-portfolio")
|
|
echo "CREATE HTTP ${CREATE_HTTP_CODE}"
|
|
if [ "${CREATE_HTTP_CODE}" != 201 ]; then
|
|
echo "ERROR: Portainer rejected stack creation:" >&2
|
|
cat /tmp/portainer-create.json >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "--- Step 4: smoke-test the freshly deployed stack ---"
|
|
# Give the container a brief window to start before checking.
|
|
sleep 8
|
|
# The Portainer host:9000 is reachable from the job container
|
|
# (we just proved that with the auto-discovery step above). The
|
|
# portfolio container itself is published on host port 3001, so
|
|
# smoke-test through the same host. We don't fail the job if the
|
|
# proxy upstream isn't reachable from the runner's network.
|
|
HEALTH=$(curl -sS -o /dev/null -w '%{http_code}' \
|
|
--max-time 5 \
|
|
"${PORTAINER_URL%:[0-9]*}:3001/" 2>/dev/null || true)
|
|
echo "Health check on ${PORTAINER_URL%:[0-9]*}:3001/ returned: ${HEALTH:-<timeout/unreachable>}"
|
|
# We log but don't fail the job if 3001 isn't reachable — the upstream
|
|
# proxy (Nginx Proxy Manager → davidaragon.impresion3d.pro) is a better
|
|
# place to wire a hard-fail check in a future iteration.
|
|
|
|
echo "--- Stack recreated successfully. ---" |