- Changed health check URL from localhost:8000 to localhost:3467 in Jenkinsfile. - Reduced wait times in various e2e tests to improve performance and responsiveness. - Cleaned up logging in middleware.py by removing unnecessary log statement. These changes aim to enhance the CI pipeline efficiency and streamline the testing process.
658 lines
36 KiB
Groovy
658 lines
36 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 ────────────────────────────────────────────────
|
|
# --reporter=list,junit,html:
|
|
# - list → progreso por test a stdout (visible en Jenkins)
|
|
# - junit → archivo XML para junit() plugin
|
|
# - html → carpeta playwright-report con UI
|
|
# stdbuf -oL fuerza buffer por líneas para que Jenkins vea el output
|
|
# incremental aunque pnpm/playwright bufferen.
|
|
E2E_EXIT=0
|
|
docker exec \
|
|
-e CI=true \
|
|
-e FORCE_COLOR=0 \
|
|
-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" \
|
|
stdbuf -oL -eL pnpm exec playwright test \
|
|
--reporter=list,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:3467/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}"
|
|
}
|
|
}
|
|
}
|