Files
plantillas-proyectos/Jenkinsfile
AlexeerCT 5a4bbfd739 fix(ci): pasar credenciales SITAR al backend del stack E2E
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>
2026-05-22 14:02:09 -05:00

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}"
}
}
}