// Pipeline CI/CD — Aduanasoft Anexo76 // Ejecuta: tests (backend + frontend en paralelo) → security scan → docker build/push → 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) // Se usa para construir VITE_API_URL y VITE_KEYCLOAK_URL en el build del frontend // - 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 // // 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' } options { timeout(time: 45, unit: 'MINUTES') buildDiscarder(logRotator(numToKeepStr: '10')) disableConcurrentBuilds() timestamps() } stages { // ── Paso 5b — Tests (backend y frontend 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=30: 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=30 \ --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') { stages { stage('Unit Tests (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 ' ''' } } stage('E2E (Playwright)') { steps { // auth.setup.ts obtiene tokens directamente de Keycloak (password grant) // sin pasar por el browser ni por Workspace — /login hace 303 inmediato. // Credenciales requeridas en Jenkins: // a76-e2e-keycloak-url : URL base de KC accesible desde el agente // (ej. https://workspace.aduanasoft.com/kcauth) // a76-e2e-test-user : usuario de prueba en KC // a76-e2e-test-pass : contraseña del usuario de prueba withCredentials([ string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL'), string(credentialsId: 'a76-e2e-keycloak-url', variable: 'E2E_KC_URL'), string(credentialsId: 'a76-e2e-test-user', variable: 'E2E_USER'), string(credentialsId: 'a76-e2e-test-pass', variable: 'E2E_PASS') ]) { sh ''' set -euo pipefail C="a76-test-e2e-${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 PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 \ -e "VITE_API_URL=${A76_URL}/api/" \ -e "INTERNAL_API_URL=${A76_URL}/api/" \ -e "VITE_KEYCLOAK_URL=${A76_URL}/kcauth" \ -e "KEYCLOAK_URL=${A76_URL}/kcauth" \ -e VITE_KEYCLOAK_REALM=master \ -e KEYCLOAK_REALM=master \ -e VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \ -e KEYCLOAK_CLIENT_ID=anexo76-frontend \ -e ORIGIN=http://localhost:5173 \ -e "E2E_KEYCLOAK_URL=${E2E_KC_URL}" \ -e "E2E_TEST_USER=${E2E_USER}" \ -e "E2E_TEST_PASSWORD=${E2E_PASS}" \ -w /workspace/frontend \ "$C" bash -lc ' set -euxo pipefail npm install -g pnpm@9 --quiet pnpm install --frozen-lockfile pnpm run i18n:compile # Verificar que Keycloak acepta el password grant # (auth.setup.ts lo usa directamente — sin pasar por /login) echo "--- Verificando Keycloak token endpoint ---" KC_TOKEN_EP="${E2E_KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" KC_STATUS=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" \ -X POST "$KC_TOKEN_EP" \ -d "grant_type=password&client_id=anexo76-frontend&username=${E2E_TEST_USER}&password=${E2E_TEST_PASSWORD}&scope=openid" \ 2>/dev/null || echo "000") echo "Keycloak token endpoint → HTTP ${KC_STATUS}" if [ "$KC_STATUS" != "200" ]; then echo "ERROR: Keycloak no accesible o password grant no habilitado (HTTP ${KC_STATUS})." echo "URL: ${KC_TOKEN_EP}" exit 1 fi echo "Keycloak OK" # Arrancar dev server pnpm run dev & DEV_PID=$! # Esperar a que el dev server esté listo (máx 90s, primer request lento por compilación) READY=0 for i in $(seq 1 45); do if curl -sf --max-time 5 http://localhost:5173 >/dev/null 2>&1; then READY=1; break fi sleep 2 done if [ "$READY" != "1" ]; then echo "ERROR: Dev server no arrancó en 90s." kill "$DEV_PID" 2>/dev/null || true exit 1 fi # Ejecutar E2E; capturar exit code para no perder el reporte E2E_EXIT=0 PLAYWRIGHT_JUNIT_OUTPUT_NAME=playwright-results.xml \ pnpm exec playwright test --reporter=junit,html || E2E_EXIT=$? kill "$DEV_PID" 2>/dev/null || true exit "$E2E_EXIT" ' docker cp "$C:/workspace/frontend/playwright-results.xml" \ "$WORKSPACE/frontend/playwright-results.xml" 2>/dev/null || true docker cp "$C:/workspace/frontend/playwright-report" \ "$WORKSPACE/frontend/playwright-report" 2>/dev/null || true ''' } } post { always { junit allowEmptyResults: true, testResults: 'frontend/playwright-results.xml' archiveArtifacts artifacts: 'frontend/playwright-report/**', allowEmptyArchive: true } } } } } } } // ── Pasos 5c-5d — 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}" } } } // ── Paso 5e — 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=\${A76_URL}/kcauth/ \ --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 """ } } } } // ── Paso 5f — 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}" } } }