Optimizaciones aplicadas al pipeline para reducir tiempo total ~5-10 min:
1. Tests + Security Scan fusionados en un único stage paralelo de 4
substages: Test Backend, Test Frontend Vitest, pip-audit, npm audit.
Antes secuencial (Tests → Security): ahora máximo de los 4 en vez de
la suma. Ahorro: ~30-60s.
2. Docker Build de backend y frontend paralelizado en substages propios.
Antes secuencial (~6-10 min totales), ahora ~max(3-5min). Cada substage
hace su propio docker login (cheap, ~1s). Ahorro: ~3-5 min.
3. --cache-from + BUILDKIT_INLINE_CACHE=1 en ambos builds de Harbor.
docker pull ${IMG}:latest antes del build alimenta el cache. Las
layers de pip install / pnpm install se reusan cuando los lockfiles
no cambian. Ahorro: ~1-3 min por imagen.
4. Volumes Docker nombrados (jenkins-a76-pip-cache, jenkins-a76-pnpm-store)
en los contenedores de Test Backend, Test Frontend Vitest y pip-audit.
Persisten entre builds en el mismo agente Jenkins; primer build los
puebla, siguientes reusan paquetes/wheels. Ahorro: ~30-60s por stage.
Estimado total: pipeline pasa de ~25-30min a ~15-20min.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
651 lines
35 KiB
Groovy
651 lines
35 KiB
Groovy
// Pipeline CI/CD — Aduanasoft Anexo76
|
|
// Ejecuta: tests+security (paralelo) → docker build/push (paralelo) → E2E → deploy dev → smoke test
|
|
//
|
|
// Credenciales requeridas en Jenkins:
|
|
// - harbor-credentials : username/password para dev.aduanasoft.com
|
|
// - a76-public-url-dev : Secret Text — URL pública base (ej. https://anexo76-dev.aduanasoft.com)
|
|
// - dev-server-user : Secret Text — usuario SSH del servidor dev
|
|
// - dev-server-host : Secret Text — hostname/IP del servidor dev
|
|
// - dev-server-ssh : SSH Username with Private Key — llave privada para deploy
|
|
// - a76-e2e-credentials : Username with password — usuario de prueba en Workspace
|
|
// - a76-sitar-api-url : Secret Text — URL del API SITAR (provee TC para "Consultar DOF")
|
|
// - a76-sitar-credentials : Username with password — credenciales del API SITAR
|
|
//
|
|
// El agente Jenkins solo necesita: Docker CLI
|
|
|
|
pipeline {
|
|
agent any
|
|
|
|
environment {
|
|
HARBOR_REGISTRY = 'dev.aduanasoft.com'
|
|
IMAGE_BACKEND = "${HARBOR_REGISTRY}/anexo76/backend"
|
|
IMAGE_FRONTEND = "${HARBOR_REGISTRY}/anexo76/frontend"
|
|
PYTHON_IMAGE = 'python:3.12-slim'
|
|
// Debe coincidir con @playwright/test del frontend (ver frontend/pnpm-lock.yaml)
|
|
PLAYWRIGHT_IMAGE = 'mcr.microsoft.com/playwright:v1.56.1-noble'
|
|
DEPLOY_PATH = '~/projects/anexo76'
|
|
// URL de Keycloak/Workspace — Keycloak vive en Workspace, no en el servidor de Anexo76
|
|
KC_URL = 'https://workspace.aduanasoft.com/kcauth'
|
|
}
|
|
|
|
options {
|
|
timeout(time: 45, unit: 'MINUTES')
|
|
buildDiscarder(logRotator(numToKeepStr: '10'))
|
|
disableConcurrentBuilds()
|
|
timestamps()
|
|
}
|
|
|
|
stages {
|
|
|
|
// ── Tests + Security (4 trabajos en paralelo) ─────────────────────────
|
|
// Backend tests, frontend Vitest, pip-audit y npm audit son independientes:
|
|
// todos en un solo bloque parallel — la stage tarda el máximo de los 4,
|
|
// no la suma. pip y pnpm cachean en volumes persistentes del agente
|
|
// (jenkins-a76-pip-cache, jenkins-a76-pnpm-store) para reusar deps
|
|
// entre builds (~30-60s menos cada uno cuando lockfiles no cambian).
|
|
stage('Tests & Security') {
|
|
parallel {
|
|
|
|
stage('Test — Backend') {
|
|
steps {
|
|
sh '''
|
|
set -euo pipefail
|
|
C="a76-test-py-${BUILD_NUMBER}"
|
|
DB_C="a76-test-db-${BUILD_NUMBER}"
|
|
NET="a76-test-net-${BUILD_NUMBER}"
|
|
DB_NAME="anexo76_test"
|
|
DB_USER="anexo76"
|
|
DB_PASS="anexo76"
|
|
TEST_DB_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_C}:5432/${DB_NAME}"
|
|
|
|
cleanup() {
|
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
|
docker rm -f "$DB_C" >/dev/null 2>&1 || true
|
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
|
docker network create "$NET"
|
|
|
|
docker rm -f "$DB_C" >/dev/null 2>&1 || true
|
|
docker run -d --name "$DB_C" \
|
|
--network "$NET" \
|
|
-e POSTGRES_DB="$DB_NAME" \
|
|
-e POSTGRES_USER="$DB_USER" \
|
|
-e POSTGRES_PASSWORD="$DB_PASS" \
|
|
postgres:16-alpine
|
|
|
|
# Esperar a que Postgres esté listo
|
|
READY=0
|
|
for i in $(seq 1 30); do
|
|
if docker exec "$DB_C" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
|
|
READY=1; break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$READY" != "1" ]; then
|
|
echo "ERROR: Postgres no quedó listo en 60s."
|
|
exit 1
|
|
fi
|
|
|
|
docker exec "$DB_C" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c "
|
|
CREATE SCHEMA IF NOT EXISTS core;
|
|
CREATE SCHEMA IF NOT EXISTS a24;
|
|
CREATE SCHEMA IF NOT EXISTS a76;
|
|
CREATE SCHEMA IF NOT EXISTS public;
|
|
"
|
|
|
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
|
# Volume persistente para el cache de pip (~/.cache/pip):
|
|
# primer build lo puebla, siguientes reusan wheels si requirements.txt no cambió.
|
|
docker run -d --name "$C" --network "$NET" \
|
|
-v jenkins-a76-pip-cache:/root/.cache/pip \
|
|
"$PYTHON_IMAGE" sleep infinity
|
|
docker cp "$WORKSPACE/backend/." "$C:/app"
|
|
|
|
docker exec \
|
|
-e TEST_DATABASE_URL="$TEST_DB_URL" \
|
|
-w /app "$C" bash -lc '
|
|
set -euxo pipefail
|
|
pip install --quiet -r requirements.txt
|
|
|
|
# Prueba de migraciones: upgrade → downgrade → upgrade
|
|
# Verifica que todos los down() sean reversibles
|
|
echo "--- alembic upgrade head ---"
|
|
alembic upgrade head
|
|
echo "--- alembic downgrade base ---"
|
|
alembic downgrade base
|
|
echo "--- alembic upgrade head (restaurar para tests) ---"
|
|
alembic upgrade head
|
|
|
|
# --cov=api,core: mide solo código fuente (excluye alembic, tests, layouts)
|
|
# setup.cfg contiene los patrones omit — ver backend/setup.cfg
|
|
# --cov-fail-under=25: umbral real actual (deuda técnica documentada)
|
|
# objetivo según estándares: 80% — incrementar conforme se agreguen tests
|
|
pytest tests/ \
|
|
--cov=api \
|
|
--cov=core \
|
|
--cov-fail-under=25 \
|
|
--cov-report=term-missing \
|
|
--cov-report=xml \
|
|
--junitxml=test-results.xml \
|
|
-v -ra
|
|
'
|
|
docker cp "$C:/app/test-results.xml" "$WORKSPACE/backend/test-results.xml"
|
|
docker cp "$C:/app/coverage.xml" "$WORKSPACE/backend/coverage.xml"
|
|
'''
|
|
}
|
|
post {
|
|
always {
|
|
junit allowEmptyResults: true, testResults: 'backend/test-results.xml'
|
|
archiveArtifacts artifacts: 'backend/coverage.xml', allowEmptyArchive: true
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Test — Frontend (Vitest)') {
|
|
steps {
|
|
sh '''
|
|
set -euo pipefail
|
|
C="a76-test-fe-${BUILD_NUMBER}"
|
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
|
trap cleanup EXIT
|
|
|
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
|
# Volume persistente para el store de pnpm (content-addressable):
|
|
# primer build lo puebla, siguientes reusan paquetes si pnpm-lock.yaml no cambió.
|
|
# Path estándar de pnpm@9 en Linux como root: /root/.local/share/pnpm/store
|
|
docker run -d --name "$C" \
|
|
-v jenkins-a76-pnpm-store:/root/.local/share/pnpm/store \
|
|
"$PLAYWRIGHT_IMAGE" sleep infinity
|
|
docker exec "$C" mkdir -p /workspace
|
|
docker cp "$WORKSPACE/." "$C:/workspace"
|
|
|
|
docker exec \
|
|
-e CI=true \
|
|
-e NODE_ENV=test \
|
|
-e "JENKINS_URL=${JENKINS_URL}" \
|
|
-e JENKINS_VITEST_FULL=1 \
|
|
-w /workspace/frontend \
|
|
"$C" bash -lc '
|
|
set -euxo pipefail
|
|
npm install -g pnpm@9 --quiet
|
|
pnpm install --frozen-lockfile
|
|
pnpm run i18n:compile
|
|
pnpm run test:unit -- --run
|
|
'
|
|
'''
|
|
}
|
|
}
|
|
|
|
stage('pip-audit') {
|
|
steps {
|
|
sh '''
|
|
set -euo pipefail
|
|
C="a76-audit-py-${BUILD_NUMBER}"
|
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
|
trap cleanup EXIT
|
|
|
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
|
docker run -d --name "$C" \
|
|
-v jenkins-a76-pip-cache:/root/.cache/pip \
|
|
"$PYTHON_IMAGE" sleep infinity
|
|
docker cp "$WORKSPACE/backend/requirements.txt" "$C:/requirements.txt"
|
|
|
|
docker exec "$C" bash -lc '
|
|
pip install --quiet pip-audit
|
|
pip-audit -r /requirements.txt -f json -o /pip-audit-report.json || true
|
|
'
|
|
docker cp "$C:/pip-audit-report.json" "$WORKSPACE/pip-audit-report.json"
|
|
'''
|
|
archiveArtifacts artifacts: 'pip-audit-report.json', allowEmptyArchive: true
|
|
}
|
|
}
|
|
|
|
stage('npm audit') {
|
|
steps {
|
|
sh '''
|
|
set -euo pipefail
|
|
C="a76-audit-fe-${BUILD_NUMBER}"
|
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
|
trap cleanup EXIT
|
|
|
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
|
docker run -d --name "$C" "$PLAYWRIGHT_IMAGE" sleep infinity
|
|
docker cp "$WORKSPACE/frontend/package.json" "$C:/package.json"
|
|
docker cp "$WORKSPACE/frontend/pnpm-lock.yaml" "$C:/pnpm-lock.yaml" 2>/dev/null || true
|
|
|
|
docker exec -w / "$C" bash -lc '
|
|
npm audit --audit-level=high || true
|
|
'
|
|
'''
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// ── Generación de versión ─────────────────────────────────────────────
|
|
stage('Generate version') {
|
|
when {
|
|
anyOf { branch 'main'; branch 'development' }
|
|
}
|
|
steps {
|
|
script {
|
|
def year = sh(returnStdout: true, script: 'date +%y').trim()
|
|
def month = sh(returnStdout: true, script: 'date +%m').trim()
|
|
def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
|
|
|
|
if (env.BRANCH_NAME == 'main') {
|
|
def lastTag = sh(returnStdout: true,
|
|
script: 'git describe --tags --abbrev=0 2>/dev/null || true').trim()
|
|
def commitCount = lastTag
|
|
? sh(returnStdout: true, script: "git rev-list ${lastTag}..HEAD --count").trim()
|
|
: sh(returnStdout: true, script: 'git rev-list --count HEAD').trim()
|
|
if (commitCount == '0') commitCount = '1'
|
|
env.APP_VERSION = "${year}.${month}.1.${commitCount}"
|
|
} else {
|
|
env.APP_VERSION = "${year}.${month}.1.${shortHash}"
|
|
}
|
|
|
|
echo "VERSION: ${env.APP_VERSION}"
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Docker Build + Push (backend y frontend en paralelo) ──────────────
|
|
// Antes era secuencial: ~6-10 min totales. Ahora cada imagen builda+pushea
|
|
// en su propio substage en paralelo → tarda el máximo de los dos.
|
|
// BuildKit inline cache + --cache-from reusan las layers de :latest cuando
|
|
// pip-lock / pnpm-lock no cambiaron (~1-3 min menos por imagen).
|
|
stage('Docker Build') {
|
|
when {
|
|
anyOf { branch 'main'; branch 'development' }
|
|
}
|
|
parallel {
|
|
|
|
stage('Build & Push — Backend') {
|
|
steps {
|
|
withCredentials([
|
|
usernamePassword(
|
|
credentialsId: 'harbor-credentials',
|
|
usernameVariable: 'HARBOR_USER',
|
|
passwordVariable: 'HARBOR_PASS'
|
|
)
|
|
]) {
|
|
sh """
|
|
set -euo pipefail
|
|
export DOCKER_BUILDKIT=1
|
|
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
|
|
|
|
# Pull de :latest para alimentar --cache-from (silencioso si no existe)
|
|
docker pull ${IMAGE_BACKEND}:latest >/dev/null 2>&1 || true
|
|
|
|
docker build --progress=plain \\
|
|
--cache-from ${IMAGE_BACKEND}:latest \\
|
|
--build-arg BUILDKIT_INLINE_CACHE=1 \\
|
|
--build-arg APP_VERSION=${env.APP_VERSION} \\
|
|
-t ${IMAGE_BACKEND}:${env.APP_VERSION} \\
|
|
-t ${IMAGE_BACKEND}:latest \\
|
|
-f backend/Dockerfile \\
|
|
backend/
|
|
|
|
docker push ${IMAGE_BACKEND}:${env.APP_VERSION}
|
|
docker push ${IMAGE_BACKEND}:latest
|
|
"""
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Build & Push — Frontend') {
|
|
steps {
|
|
withCredentials([
|
|
usernamePassword(
|
|
credentialsId: 'harbor-credentials',
|
|
usernameVariable: 'HARBOR_USER',
|
|
passwordVariable: 'HARBOR_PASS'
|
|
),
|
|
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL')
|
|
]) {
|
|
sh """
|
|
set -euo pipefail
|
|
export DOCKER_BUILDKIT=1
|
|
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
|
|
|
|
docker pull ${IMAGE_FRONTEND}:latest >/dev/null 2>&1 || true
|
|
|
|
docker build --progress=plain \\
|
|
--cache-from ${IMAGE_FRONTEND}:latest \\
|
|
--build-arg BUILDKIT_INLINE_CACHE=1 \\
|
|
--build-arg VITE_API_URL=\${A76_URL}/api/ \\
|
|
--build-arg VITE_KEYCLOAK_URL=${KC_URL} \\
|
|
--build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \\
|
|
--build-arg INTERNAL_API_URL=http://backend:3467/api/ \\
|
|
-t ${IMAGE_FRONTEND}:${env.APP_VERSION} \\
|
|
-t ${IMAGE_FRONTEND}:latest \\
|
|
-f frontend/Dockerfile.prod \\
|
|
frontend/
|
|
|
|
docker push ${IMAGE_FRONTEND}:${env.APP_VERSION}
|
|
docker push ${IMAGE_FRONTEND}:latest
|
|
"""
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// ── E2E (Playwright) — contra la imagen recién buildeada ────────────────
|
|
// Levanta un stack efímero (docker-compose.e2e.yml) con la imagen del backend
|
|
// recién pusheada a Harbor y una imagen del frontend buildeada localmente con
|
|
// VITE_API_URL=http://localhost:8000/api/ (la imagen de prod tiene la URL de dev
|
|
// bakeada en el bundle, ver Dockerfile.prod:19-20). Si E2E falla, el deploy
|
|
// no ocurre — el guardrail actúa sobre el código que se va a desplegar,
|
|
// no sobre la versión viva.
|
|
stage('E2E (Playwright)') {
|
|
when { branch 'development' }
|
|
steps {
|
|
withCredentials([
|
|
usernamePassword(
|
|
credentialsId: 'a76-e2e-credentials',
|
|
usernameVariable: 'E2E_USER',
|
|
passwordVariable: 'E2E_PASS'
|
|
),
|
|
string(credentialsId: 'sitar-api-url', variable: 'SITAR_API_URL'),
|
|
usernamePassword(
|
|
credentialsId: 'sitar-credentials',
|
|
usernameVariable: 'SITAR_API_USER',
|
|
passwordVariable: 'SITAR_API_PASSWORD'
|
|
)
|
|
]) {
|
|
sh '''
|
|
set -euo pipefail
|
|
export DOCKER_BUILDKIT=1
|
|
PROJECT="a76-e2e-${BUILD_NUMBER}"
|
|
FE_IMAGE_E2E="${IMAGE_FRONTEND}:e2e-${BUILD_NUMBER}"
|
|
BE_IMAGE_E2E="${IMAGE_BACKEND}:${APP_VERSION}"
|
|
PLAYWRIGHT_C="a76-test-e2e-${BUILD_NUMBER}"
|
|
BUILD_LOG="${WORKSPACE}/e2e-frontend-build.log"
|
|
PREP_LOG="${WORKSPACE}/e2e-playwright-prep.log"
|
|
|
|
export E2E_BACKEND_IMAGE="$BE_IMAGE_E2E"
|
|
export E2E_FRONTEND_IMAGE="$FE_IMAGE_E2E"
|
|
|
|
cleanup() {
|
|
echo "=== E2E cleanup ==="
|
|
# Dump logs del backend si el stack está arriba — ayuda a diagnosticar
|
|
# fallas de auth/SITAR/migraciones que de otro modo quedan opacas.
|
|
if docker compose -p "$PROJECT" -f docker-compose.e2e.yml ps -q backend >/dev/null 2>&1; then
|
|
echo "--- logs backend (últimas 200 líneas) ---"
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml logs --tail=200 backend 2>&1 || true
|
|
fi
|
|
docker rm -f "$PLAYWRIGHT_C" >/dev/null 2>&1 || true
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml down -v --remove-orphans >/dev/null 2>&1 || true
|
|
docker rmi -f "$FE_IMAGE_E2E" >/dev/null 2>&1 || true
|
|
rm -f "$BUILD_LOG" "$PREP_LOG" || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# ── PARALELO #1 ─────────────────────────────────────────────────
|
|
# Lanzar en background el build del frontend temporal Y el stack
|
|
# de dependencias. El build es lo más lento (~2-4 min); mientras
|
|
# corre, levantamos postgres+minio+valkey y aplicamos migraciones.
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
# Build temporal del frontend con VITE_API_URL=localhost.
|
|
# --cache-from reusa la layer de pnpm install de la imagen de prod
|
|
# recién buildeada (las deps son idénticas, solo cambia VITE_API_URL).
|
|
# No se pushea a Harbor; solo existe en este agente durante el stage.
|
|
( docker build --progress=plain \
|
|
--cache-from "${IMAGE_FRONTEND}:latest" \
|
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
--build-arg VITE_API_URL=http://localhost:8000/api/ \
|
|
--build-arg VITE_KEYCLOAK_URL=${KC_URL} \
|
|
--build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \
|
|
--build-arg INTERNAL_API_URL=http://backend:8000/api/ \
|
|
-t "$FE_IMAGE_E2E" \
|
|
-f frontend/Dockerfile.prod \
|
|
frontend/ ) > "$BUILD_LOG" 2>&1 &
|
|
BUILD_PID=$!
|
|
|
|
# En foreground: levantar dependencias y correr migraciones
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml up -d postgres-a76 minio valkey
|
|
|
|
# Esperar postgres healthy (timeout 60s)
|
|
READY=0
|
|
for i in $(seq 1 30); do
|
|
STATUS=$(docker inspect -f '{{.State.Health.Status}}' \
|
|
"$(docker compose -p "$PROJECT" -f docker-compose.e2e.yml ps -q postgres-a76)" 2>/dev/null || echo "starting")
|
|
if [ "$STATUS" = "healthy" ]; then READY=1; break; fi
|
|
sleep 2
|
|
done
|
|
if [ "$READY" != "1" ]; then
|
|
echo "ERROR: Postgres no quedó healthy en 60s"
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml logs postgres-a76 || true
|
|
exit 1
|
|
fi
|
|
|
|
# Crear schemas que el backend espera (mismo patrón que Test — Backend)
|
|
DB_C=$(docker compose -p "$PROJECT" -f docker-compose.e2e.yml ps -q postgres-a76)
|
|
docker exec "$DB_C" psql -U postgres -d anexo76_core -v ON_ERROR_STOP=1 -c "
|
|
CREATE SCHEMA IF NOT EXISTS core;
|
|
CREATE SCHEMA IF NOT EXISTS a24;
|
|
CREATE SCHEMA IF NOT EXISTS a76;
|
|
CREATE SCHEMA IF NOT EXISTS public;
|
|
"
|
|
|
|
# `run --rm --no-deps` ejecuta alembic con la imagen del backend SIN
|
|
# arrancar los servicios dependientes (ya están corriendo).
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml run --rm --no-deps \
|
|
--entrypoint "" backend alembic upgrade head
|
|
|
|
# Esperar a que termine el build del frontend (si no terminó ya)
|
|
echo "Esperando build temporal del frontend..."
|
|
if ! wait "$BUILD_PID"; then
|
|
echo "ERROR: build del frontend E2E falló — log:"
|
|
cat "$BUILD_LOG" || true
|
|
exit 1
|
|
fi
|
|
echo "✓ Build temporal del frontend completado"
|
|
|
|
# ── Arrancar backend y frontend ─────────────────────────────────
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml up -d backend frontend
|
|
|
|
# ── PARALELO #2 ─────────────────────────────────────────────────
|
|
# Mientras backend+frontend se vuelven healthy (~40-80s),
|
|
# preparar el contenedor Playwright en background.
|
|
# ────────────────────────────────────────────────────────────────
|
|
docker rm -f "$PLAYWRIGHT_C" >/dev/null 2>&1 || true
|
|
# --network host: para que "localhost:5173" dentro del contenedor
|
|
# de Playwright resuelva a los puertos publicados por el stack.
|
|
# Necesario porque ORIGIN=http://localhost:5173 y el callback de
|
|
# Keycloak redirige a esa URL exacta.
|
|
docker run -d --name "$PLAYWRIGHT_C" --network host "$PLAYWRIGHT_IMAGE" sleep infinity
|
|
docker exec "$PLAYWRIGHT_C" mkdir -p /workspace
|
|
docker cp "$WORKSPACE/." "$PLAYWRIGHT_C:/workspace"
|
|
|
|
( docker exec -w /workspace/frontend "$PLAYWRIGHT_C" bash -lc '
|
|
set -euxo pipefail
|
|
npm install -g pnpm@9 --quiet
|
|
pnpm install --frozen-lockfile
|
|
pnpm run i18n:compile
|
|
' ) > "$PREP_LOG" 2>&1 &
|
|
PREP_PID=$!
|
|
|
|
# En foreground: esperar backend y frontend healthy
|
|
for SVC in backend frontend; do
|
|
READY=0
|
|
for i in $(seq 1 60); do
|
|
CID=$(docker compose -p "$PROJECT" -f docker-compose.e2e.yml ps -q "$SVC" 2>/dev/null)
|
|
if [ -n "$CID" ]; then
|
|
STATUS=$(docker inspect -f '{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting")
|
|
if [ "$STATUS" = "healthy" ]; then READY=1; break; fi
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$READY" != "1" ]; then
|
|
echo "ERROR: $SVC no quedó healthy en 120s"
|
|
docker compose -p "$PROJECT" -f docker-compose.e2e.yml logs "$SVC" || true
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Esperar a que termine la preparación de Playwright
|
|
echo "Esperando preparación de Playwright..."
|
|
if ! wait "$PREP_PID"; then
|
|
echo "ERROR: preparación de Playwright falló — log:"
|
|
cat "$PREP_LOG" || true
|
|
exit 1
|
|
fi
|
|
echo "✓ Playwright listo"
|
|
|
|
# ── Correr tests ────────────────────────────────────────────────
|
|
E2E_EXIT=0
|
|
docker exec \
|
|
-e CI=true \
|
|
-e "JENKINS_URL=${JENKINS_URL}" \
|
|
-e "PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173" \
|
|
-e "E2E_TEST_USER=${E2E_USER}" \
|
|
-e "E2E_TEST_PASSWORD=${E2E_PASS}" \
|
|
-e "PLAYWRIGHT_JUNIT_OUTPUT_NAME=playwright-results.xml" \
|
|
-w /workspace/frontend \
|
|
"$PLAYWRIGHT_C" \
|
|
pnpm exec playwright test \
|
|
--reporter=junit,html \
|
|
--trace=retain-on-failure || E2E_EXIT=$?
|
|
|
|
# Copiar artefactos antes de salir (pase o falle)
|
|
docker cp "$PLAYWRIGHT_C:/workspace/frontend/playwright-results.xml" \
|
|
"$WORKSPACE/frontend/playwright-results.xml" 2>/dev/null || true
|
|
docker cp "$PLAYWRIGHT_C:/workspace/frontend/playwright-report" \
|
|
"$WORKSPACE/frontend/playwright-report" 2>/dev/null || true
|
|
# test-results contiene trace.zip + error-context.md + screenshots por test fallido
|
|
docker cp "$PLAYWRIGHT_C:/workspace/frontend/test-results" \
|
|
"$WORKSPACE/frontend/test-results" 2>/dev/null || true
|
|
|
|
exit "$E2E_EXIT"
|
|
'''
|
|
}
|
|
}
|
|
post {
|
|
always {
|
|
junit allowEmptyResults: true, testResults: 'frontend/playwright-results.xml'
|
|
archiveArtifacts artifacts: 'frontend/playwright-report/**', allowEmptyArchive: true
|
|
// Artefactos de debug: trace.zip (abrir con `pnpm exec playwright show-trace`),
|
|
// error-context.md (snapshot del DOM al fallo) y screenshots por test fallido.
|
|
archiveArtifacts artifacts: 'frontend/test-results/**', allowEmptyArchive: true
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Deploy Dev ────────────────────────────────────────────────────────
|
|
stage('Deploy — Dev') {
|
|
when { branch 'development' }
|
|
steps {
|
|
withCredentials([
|
|
usernamePassword(
|
|
credentialsId: 'harbor-credentials',
|
|
usernameVariable: 'HARBOR_USER',
|
|
passwordVariable: 'HARBOR_PASS'
|
|
),
|
|
string(credentialsId: 'dev-server-user', variable: 'DEV_SERVER_USER'),
|
|
string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'),
|
|
sshUserPrivateKey(
|
|
credentialsId: 'dev-server-ssh',
|
|
keyFileVariable: 'SSH_KEY',
|
|
usernameVariable: 'SSH_USER',
|
|
passphraseVariable: 'SSH_PASSPHRASE'
|
|
)
|
|
]) {
|
|
script {
|
|
def deployHost = env.DEPLOY_HOST?.trim()
|
|
def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts"
|
|
sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'"
|
|
def remote = [
|
|
name : 'dev-server',
|
|
host : deployHost,
|
|
user : env.DEV_SERVER_USER ?: env.SSH_USER,
|
|
identityFile : env.SSH_KEY,
|
|
passphrase : env.SSH_PASSPHRASE,
|
|
allowAnyHosts: false,
|
|
knownHosts : knownHostsFile
|
|
]
|
|
sshCommand remote: remote, command: """
|
|
set -euo pipefail
|
|
cd ${DEPLOY_PATH}
|
|
APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml pull
|
|
APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml up -d || {
|
|
echo '=== logs anexo76-backend ==='
|
|
docker logs --tail 100 anexo76-backend 2>&1 || true
|
|
echo '=== docker compose ps ==='
|
|
docker compose -f docker-compose.prod.yml ps -a 2>&1 || true
|
|
exit 1
|
|
}
|
|
docker image prune -f
|
|
"""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Smoke Test ────────────────────────────────────────────────────────
|
|
stage('Smoke Test') {
|
|
when { branch 'development' }
|
|
steps {
|
|
withCredentials([
|
|
string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'),
|
|
sshUserPrivateKey(
|
|
credentialsId: 'dev-server-ssh',
|
|
keyFileVariable: 'SSH_KEY',
|
|
usernameVariable: 'SSH_USER',
|
|
passphraseVariable: 'SSH_PASSPHRASE'
|
|
)
|
|
]) {
|
|
script {
|
|
def deployHost = env.DEPLOY_HOST?.trim()
|
|
def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts"
|
|
sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'"
|
|
def remote = [
|
|
name : 'dev-server',
|
|
host : deployHost,
|
|
user : env.SSH_USER,
|
|
identityFile : env.SSH_KEY,
|
|
passphrase : env.SSH_PASSPHRASE,
|
|
allowAnyHosts: false,
|
|
knownHosts : knownHostsFile
|
|
]
|
|
sshCommand remote: remote, command: '''
|
|
set -euo pipefail
|
|
for i in $(seq 1 12); do
|
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:8000/api/health 2>/dev/null || true)
|
|
if [ "$STATUS" = "200" ]; then
|
|
echo "Smoke test OK — HTTP $STATUS"
|
|
exit 0
|
|
fi
|
|
echo "Intento $i/12 — HTTP ${STATUS:-000} — reintentando en 10s..."
|
|
sleep 10
|
|
done
|
|
echo "ERROR: Smoke test fallido tras 120s"
|
|
exit 1
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
post {
|
|
always {
|
|
sh "docker logout ${HARBOR_REGISTRY} || true"
|
|
}
|
|
success {
|
|
echo "Pipeline OK — ${env.BRANCH_NAME} #${env.BUILD_NUMBER} (${env.APP_VERSION ?: 'n/a'})"
|
|
}
|
|
failure {
|
|
echo "PIPELINE FALLIDO — ${env.BRANCH_NAME} #${env.BUILD_NUMBER}"
|
|
}
|
|
}
|
|
}
|