- Updated Jenkinsfile to include Playwright's trace option for better debugging on test failures. - Added archiving of test results, including trace files, error context, and screenshots for failed tests. - Enhanced error handling in `auth.setup.ts` to log diagnostic information when authentication fails, improving visibility into issues. These changes aim to improve the reliability and debuggability of E2E tests.
472 lines
23 KiB
Groovy
472 lines
23 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
|
|
//
|
|
// 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=\${A76_URL}/kcauth/ \
|
|
--build-arg VITE_KEYCLOAK_CLIENT_ID=hub-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 versión actualmente desplegada ─────────
|
|
// Corre ANTES del deploy con la imagen ya en Harbor pero aún no desplegada.
|
|
// Playwright apunta a A76_URL (versión viva anterior) — dominio de confianza
|
|
// para Workspace. Si E2E falla, el deploy no ocurre.
|
|
stage('E2E (Playwright)') {
|
|
when { branch 'development' }
|
|
steps {
|
|
withCredentials([
|
|
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL'),
|
|
usernamePassword(
|
|
credentialsId: 'a76-e2e-credentials',
|
|
usernameVariable: 'E2E_USER',
|
|
passwordVariable: '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 "JENKINS_URL=${JENKINS_URL}" \
|
|
-e "PLAYWRIGHT_TEST_BASE_URL=${A76_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
|
|
|
|
E2E_EXIT=0
|
|
PLAYWRIGHT_JUNIT_OUTPUT_NAME=playwright-results.xml \
|
|
pnpm exec playwright test \
|
|
--reporter=junit,html \
|
|
--trace=retain-on-failure || E2E_EXIT=$?
|
|
|
|
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
|
|
# test-results contiene trace.zip + error-context.md + screenshots por test fallido
|
|
docker cp "$C:/workspace/frontend/test-results" \
|
|
"$WORKSPACE/frontend/test-results" 2>/dev/null || true
|
|
'''
|
|
}
|
|
}
|
|
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}"
|
|
}
|
|
}
|
|
}
|