Sin SITAR_API_URL/USER/PASSWORD, el botón "Consultar DOF" del dialog ExchangeRateGuard nunca llena el input (el backend no tiene cómo pedir el TC) y auth.setup.ts:72 falla con timeout. En el E2E viejo no salía porque apuntaba a dev en vivo, donde ya había TC para hoy y el dialog ni se abría. Cambios: - docker-compose.e2e.yml: backend recibe SITAR_API_URL/USER/PASSWORD por env var passthrough con defaults vacíos. - Jenkinsfile: lee a76-sitar-api-url (string) y a76-sitar-credentials (usernamePassword) vía withCredentials y los inyecta. - Cleanup ahora dumpea las últimas 200 líneas del backend cuando el stack está arriba, para diagnosticar fallas opacas (auth, SITAR, migraciones, etc.) sin tener que repetir el build. Requiere en Jenkins: - a76-sitar-api-url (Secret Text) - a76-sitar-credentials (Username with password) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
609 lines
33 KiB
Groovy
609 lines
33 KiB
Groovy
// Pipeline CI/CD — Aduanasoft Anexo76
|
|
// Ejecuta: tests → security scan → docker build/push → 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 (backend y frontend unit en paralelo) ───────────────────────
|
|
stage('Tests') {
|
|
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
|
|
docker run -d --name "$C" --network "$NET" "$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
|
|
docker run -d --name "$C" "$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
|
|
'
|
|
'''
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// ── Escaneo de vulnerabilidades ───────────────────────────────────────
|
|
stage('Security Scan') {
|
|
parallel {
|
|
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" "$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 ───────────────────────────────────────────────
|
|
stage('Docker Build') {
|
|
when {
|
|
anyOf { branch 'main'; branch 'development' }
|
|
}
|
|
steps {
|
|
script {
|
|
withCredentials([
|
|
usernamePassword(
|
|
credentialsId: 'harbor-credentials',
|
|
usernameVariable: 'HARBOR_USER',
|
|
passwordVariable: 'HARBOR_PASS'
|
|
)
|
|
]) {
|
|
sh "echo \"\${HARBOR_PASS}\" | docker login ${HARBOR_REGISTRY} -u \"\${HARBOR_USER}\" --password-stdin"
|
|
|
|
sh """
|
|
docker build --progress=plain \
|
|
--build-arg APP_VERSION=${env.APP_VERSION} \
|
|
-t ${IMAGE_BACKEND}:${env.APP_VERSION} \
|
|
-t ${IMAGE_BACKEND}:latest \
|
|
-f backend/Dockerfile \
|
|
backend/
|
|
"""
|
|
|
|
withCredentials([
|
|
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL')
|
|
]) {
|
|
sh """
|
|
docker build --progress=plain \
|
|
--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/
|
|
"""
|
|
}
|
|
|
|
sh """
|
|
docker push ${IMAGE_BACKEND}:${env.APP_VERSION}
|
|
docker push ${IMAGE_BACKEND}:latest
|
|
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: 'a76-sitar-api-url', variable: 'SITAR_API_URL'),
|
|
usernamePassword(
|
|
credentialsId: 'a76-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}"
|
|
}
|
|
}
|
|
}
|