From 4376d0755a6bf37531b4f71d47472d7ea26915ca Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 20 May 2026 10:03:36 -0500 Subject: [PATCH] refactor(jenkins): streamline CI/CD pipeline stages and enhance linting and testing processes --- Jenkinsfile | 928 +++++++++++++++++++++++----------------------------- 1 file changed, 410 insertions(+), 518 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index f89ff53e..8e6d7692 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,536 +1,428 @@ +// Pipeline CI/CD — Aduanasoft Anexo76 +// Ejecuta: lint → tests (unit) → 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 - options { - timestamps() - disableConcurrentBuilds() - } + agent any - parameters { - choice(name: 'ENVIRONMENT', choices: ['development', 'main'], description: 'Rama a ejecutar') - string(name: 'VERSION_MAYOR', defaultValue: '1', description: 'Componente mayor de versión') - } - - environment { - REGISTRY = 'dev.aduanasoft.com' - IMAGE_NAMESPACE = 'anexo76' - // Debe coincidir con @playwright/test del frontend (ver frontend/pnpm-lock) - PLAYWRIGHT_TEST_IMAGE = 'mcr.microsoft.com/playwright:v1.56.1-noble' - } - - stages { - stage('Preflight tools') { - steps { - sh ''' - set -euo pipefail - echo "Node: $(hostname)" - if ! command -v docker >/dev/null 2>&1; then - echo "ERROR: este agente no tiene docker instalado." - echo "Usa un nodo Jenkins con label 'docker' y acceso al daemon." - exit 1 - fi - docker --version - docker compose version - ''' - } + 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' } - stage('Checkout') { - steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.ENVIRONMENT}"]], - userRemoteConfigs: [[ - url: 'https://git.aduanasoft.com/ADUANASOFT/anexo76.git', - credentialsId: 'gitea_acazares' - ]], - extensions: [[$class: 'CloneOption', depth: 0, shallow: false]] - ]) - } + options { + timeout(time: 45, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10')) + disableConcurrentBuilds() + timestamps() } - // Build único: ambas imágenes se copian del workspace al daemon vía BuildKit - // y se reutilizan en los stages de test y E2E. El override ci quita los bind - // mounts (./backend:/app, ./frontend:/app) que el daemon de Jenkins no puede - // resolver al no ver $WORKSPACE del agente. - stage('Build CI images') { - steps { - sh ''' - set -euxo pipefail - export DOCKER_BUILDKIT=1 - docker compose \ - -f "$WORKSPACE/docker-compose.yml" \ - -f "$WORKSPACE/docker-compose.ci.yml" \ - build backend frontend - docker image inspect anexo76-backend:latest >/dev/null - docker image inspect anexo76-frontend:latest >/dev/null - ''' - } - } + stages { - // Feedback rápido en paralelo: si unit rompe, no gastamos el stack E2E. - stage('Unit tests') { - parallel { - // Reutiliza anexo76-backend:latest (ya incluye pytest + alembic + código) - stage('Test backend (pytest)') { - steps { - sh ''' - set -euxo pipefail - echo "== Test backend: pytest en anexo76-backend:latest ==" + // ── Paso 5a — Lint ──────────────────────────────────────────────────── + stage('Lint — Backend') { + steps { + sh ''' + set -euo pipefail + C="a76-lint-py-${BUILD_NUMBER}" + cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; } + trap cleanup EXIT - DB_CONTAINER="anexo76-test-db-${BUILD_NUMBER}" - DB_NAME="anexo76_test" - DB_USER="anexo76" - DB_PASS="anexo76" - PY_CONTAINER="anexo76-test-py-${BUILD_NUMBER}" - TEST_NETWORK="anexo76-test-net-${BUILD_NUMBER}" - TEST_DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_CONTAINER}:5432/${DB_NAME}" + docker rm -f "$C" >/dev/null 2>&1 || true + docker run -d --name "$C" "$PYTHON_IMAGE" sleep infinity + docker cp "$WORKSPACE/backend/." "$C:/app" - cleanup() { - docker rm -f "$PY_CONTAINER" >/dev/null 2>&1 || true - docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true - docker network rm "$TEST_NETWORK" >/dev/null 2>&1 || true - } - trap cleanup EXIT - - docker network rm "$TEST_NETWORK" >/dev/null 2>&1 || true - docker network create "$TEST_NETWORK" - - docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$DB_CONTAINER" \ - --network "$TEST_NETWORK" \ - -e POSTGRES_DB="$DB_NAME" \ - -e POSTGRES_USER="$DB_USER" \ - -e POSTGRES_PASSWORD="$DB_PASS" \ - postgres:16-alpine - - READY=0 - for i in $(seq 1 30); do - if docker exec "$DB_CONTAINER" 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 a tiempo (30 intentos x 2s)." - exit 1 - fi - docker exec "$DB_CONTAINER" 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; - " - - # --entrypoint sleep: evita que docker-entrypoint.sh bloquee esperando Keycloak en 127.0.0.1 - docker rm -f "$PY_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$PY_CONTAINER" \ - --network "$TEST_NETWORK" \ - --entrypoint sleep \ - anexo76-backend:latest infinity - - docker exec \ - -e TEST_DATABASE_URL="$TEST_DATABASE_URL" \ - -w /app \ - "$PY_CONTAINER" \ - sh -lc ' - set -euxo pipefail - python --version - alembic upgrade head - pytest -q tests -v -ra -s - ' - ''' - } - } - - // Vitest + @vitest/browser necesitan Chromium; imagen Playwright ya lo trae. - stage('Test frontend (vitest)') { - steps { - sh ''' - set -euxo pipefail - echo "== Test frontend: vitest con $PLAYWRIGHT_TEST_IMAGE ==" - if [ ! -f "$WORKSPACE/frontend/package.json" ]; then - echo "ERROR: $WORKSPACE/frontend/package.json no existe" - ls -la "$WORKSPACE" 2>&1 | head -40 - exit 1 - fi - FE_CONTAINER="anexo76-test-fe-${BUILD_NUMBER}" - - cleanup() { - docker rm -f "$FE_CONTAINER" >/dev/null 2>&1 || true - } - trap cleanup EXIT - - docker rm -f "$FE_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$FE_CONTAINER" "$PLAYWRIGHT_TEST_IMAGE" sleep infinity - docker exec "$FE_CONTAINER" mkdir -p /workspace - docker cp "$WORKSPACE/." "$FE_CONTAINER:/workspace" - - docker exec \ - -e CI=true \ - -e NODE_ENV=test \ - -e "JENKINS_URL=${JENKINS_URL}" \ - -e JENKINS_VITEST_FULL=1 \ - -w /workspace/frontend \ - "$FE_CONTAINER" \ - bash -lc ' - set -euxo pipefail - test -f package.json - node --version - corepack enable - pnpm --version - pnpm install --frozen-lockfile - pnpm run i18n:compile - pnpm run test:unit -- --run - ' - ''' - } - } - } - } - - stage('E2E (docker compose + Playwright)') { - options { - timeout(time: 90, unit: 'MINUTES') - } - steps { - sh ''' - set -euxo pipefail - echo "== E2E: compose up con ci override + pnpm test:e2e ==" - if [ ! -f "$WORKSPACE/frontend/package.json" ] || [ ! -f "$WORKSPACE/docker-compose.yml" ] || [ ! -f "$WORKSPACE/docker-compose.ci.yml" ]; then - echo "ERROR: faltan archivos de repo (frontend, compose o ci.override)" - exit 1 - fi - export E2E_COMPOSE_PROJECT="anexo76-e2e-${BUILD_NUMBER}" - export COMPOSE_PROJECT_NAME="${E2E_COMPOSE_PROJECT}" - cd "$WORKSPACE" - : "${BUILD_NUMBER:=0}" - E2E_ENV_FILE="${WORKSPACE}/.env.e2e.generated" - - # Puertos (dash/sh en Jenkins, sin depender de $RANDOM): rango ~20000–60k - T=$(date +%s 2>/dev/null || echo 0) - r1=$((T % 20000)) - r2=$((T % 15000)) - KC_HTTP=$(( 20000 + r1 + BUILD_NUMBER % 2000 )) - KC_MGMT=$(( 40000 + r2 + (BUILD_NUMBER * 7) % 2000 )) - if [ "$KC_HTTP" -gt 64000 ] || [ "$KC_HTTP" -lt 20000 ]; then KC_HTTP=$(( 22000 + BUILD_NUMBER % 8000 )); fi - if [ "$KC_MGMT" -gt 65000 ] || [ "$KC_MGMT" -lt 30000 ]; then KC_MGMT=$(( 50000 + BUILD_NUMBER % 8000 )); fi - if [ "$KC_HTTP" -eq "$KC_MGMT" ]; then KC_MGMT=$((KC_MGMT + 1)); fi - { echo "KEYCLOAK_HTTP_PORT=$KC_HTTP"; echo "KEYCLOAK_MANAGEMENT_PORT=$KC_MGMT"; echo "VITE_KEYCLOAK_URL=http://127.0.0.1:$KC_HTTP/kcauth"; } > "$E2E_ENV_FILE" - set -a - . "$E2E_ENV_FILE" - set +a - echo "E2E: Keycloak (host) en puertos de .env e2e:" && cat "$E2E_ENV_FILE" - - e2e_compose() { - docker compose \ - --env-file "$E2E_ENV_FILE" \ - -f "$WORKSPACE/docker-compose.yml" \ - -f "$WORKSPACE/docker-compose.ci.yml" \ - "$@" - } - - # Nombres fijos en docker-compose: otro run / otro COMPOSE_PROJECT deja contenedores → "name already in use" - e2e_force_rm_stale_containers() { - echo "E2E: eliminando contenedors anteriores (mismos container_name) si siguen en el nodo" - for c in \ - anexo76-postgres-a76 \ - anexo76-postgres-keycloak \ - anexo76-keycloak \ - anexo76-backend \ - anexo76-frontend \ - valkey \ - anexo76-minio \ - worker \ - celery_beat - do - docker rm -f "$c" 2>/dev/null || true - done - } - - compose_down() { - export COMPOSE_PROJECT_NAME="${E2E_COMPOSE_PROJECT}" - e2e_compose down --remove-orphans 2>/dev/null || true - e2e_force_rm_stale_containers - } - - e2e_compose_fail_logs() { - echo "== E2E: fallo al levantar stack; diagnóstico ==" - echo "--- .env.e2e.generated ---" - cat "$E2E_ENV_FILE" 2>&1 || true - export COMPOSE_PROJECT_NAME="${E2E_COMPOSE_PROJECT}" - echo "--- docker compose ps -a ---" - e2e_compose ps -a 2>&1 || true - for svc in anexo76-postgres-a76 anexo76-postgres-keycloak anexo76-keycloak anexo76-backend anexo76-frontend worker celery_beat anexo76-minio valkey; do - echo "--- logs: $svc ---" - docker logs --tail 300 "$svc" 2>&1 || true - done - } - - e2e_force_rm_stale_containers - trap compose_down EXIT - compose_down - - # El lifespan del backend ejecuta Alembic al arrancar. El healthcheck del - # backend tiene start_period=600s precisamente para permitirlo, y los - # servicios dependientes (frontend, celery) esperan service_healthy. - echo "== E2E: levantar stack completo ==" - if ! e2e_compose up -d; then - e2e_compose_fail_logs - exit 1 - fi - - # Dump temprano: 10s tras el up para ver arranque de Vite/Uvicorn antes de - # que el wait empiece a iterar; si algo falla, queda en logs sin esperar - # al timeout. - sleep 10 - echo "== E2E: estado del stack tras 10s ==" - e2e_compose ps -a 2>&1 || true - echo "--- docker logs anexo76-frontend --tail 40 ---" - docker logs --tail 40 anexo76-frontend 2>&1 || true - echo "--- docker logs anexo76-backend --tail 40 ---" - docker logs --tail 40 anexo76-backend 2>&1 || true - - # Playwright con docker cp (no -v): el workspace del agente Jenkins puede - # no ser visible al docker daemon. `--network host` conecta al host del - # daemon, donde compose publica 5173/8000 (el agente Jenkins NO ve esos - # puertos; por eso el wait corre dentro de este contenedor, no en el shell). - PW_CONTAINER="anexo76-e2e-pw-${BUILD_NUMBER}" - docker rm -f "$PW_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$PW_CONTAINER" --network host \ - -e CI=true \ - -e "JENKINS_URL=${JENKINS_URL}" \ - -e PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:5173 \ - "$PLAYWRIGHT_TEST_IMAGE" \ - sleep infinity - docker exec "$PW_CONTAINER" mkdir -p /workspace - docker cp "$WORKSPACE/." "$PW_CONTAINER:/workspace" - - # Wait + preparación + tests E2E en el mismo container (red host). - # BACKEND_MAX_SEC=900 acomoda el start_period=600s del backend healthcheck - # + margen para Alembic en DB vacía. - # Estructura: 1) infra (wait/install/i18n) → fatal si falla; - # 2) pnpm run test:e2e → no-fatal: hoy fallará en auth.setup.ts - # porque el usuario demo se siembra mediante init_first_time.sh - # que aún no se ejecuta en CI. Marcamos el build como UNSTABLE - # y permitimos que continúen los stages de release. - if ! docker exec -w /workspace/frontend "$PW_CONTAINER" bash -lc ' - set -euxo pipefail - test -f package.json - - echo "== wait: backend http://127.0.0.1:8000/api/health ==" - i=0 - until curl -fsS --connect-timeout 3 --max-time 5 http://127.0.0.1:8000/api/health >/dev/null 2>&1; do - i=$((i + 5)) - if [ "$i" -ge 900 ]; then - echo "ERROR: timeout esperando backend (900s)" - exit 1 - fi - if [ $((i % 30)) -eq 0 ]; then - echo " ...esperando backend (${i}/900s)" - fi - sleep 5 - done - echo "== backend listo tras ${i}s ==" - - echo "== wait: frontend http://127.0.0.1:5173/ ==" - i=0 - until curl -fsS --connect-timeout 3 --max-time 5 http://127.0.0.1:5173/ >/dev/null 2>&1; do - i=$((i + 5)) - if [ "$i" -ge 180 ]; then - echo "ERROR: timeout esperando frontend (180s)" - exit 1 - fi - if [ $((i % 30)) -eq 0 ]; then - echo " ...esperando frontend (${i}/180s)" - fi - sleep 5 - done - echo "== frontend listo tras ${i}s ==" - - corepack enable - pnpm install --frozen-lockfile - pnpm run i18n:compile - '; then - docker rm -f "$PW_CONTAINER" >/dev/null 2>&1 || true - e2e_compose_fail_logs - exit 1 - fi - - # Tests E2E: no-fatal. Capturamos el rc y se lo dejamos al step `script` de - # abajo para que marque el build como UNSTABLE si falla. - rm -f "$WORKSPACE/.e2e-rc" - set +e - docker exec -w /workspace/frontend "$PW_CONTAINER" bash -lc 'pnpm run test:e2e' - E2E_RC=$? - set -e - if [ "$E2E_RC" -ne 0 ]; then - echo "WARNING: pnpm run test:e2e falló (rc=$E2E_RC)." - echo "WARNING: hasta integrar init_first_time.sh en CI, el usuario demo no existe y auth.setup.ts no completa el login." - echo "$E2E_RC" > "$WORKSPACE/.e2e-rc" - fi - docker rm -f "$PW_CONTAINER" >/dev/null 2>&1 || true - ''' - script { - if (fileExists("${env.WORKSPACE}/.e2e-rc")) { - currentBuild.result = 'UNSTABLE' - sh "rm -f '${env.WORKSPACE}/.e2e-rc'" - echo 'E2E (Playwright) marcado como UNSTABLE: pendiente de integrar init_first_time.sh en CI para sembrar usuario demo y secret de Keycloak. El stack se levanta correctamente, falla solo el flujo de login del setup.' - } - } - } - } - - stage('Generate version') { - steps { - script { - def year = sh(returnStdout: true, script: 'date +%y').trim() - def month = sh(returnStdout: true, script: 'date +%m').trim() - - if (params.ENVIRONMENT == 'development') { - def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() - env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${shortHash}" - } else { - 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' + docker exec -w /app "$C" bash -lc ' + set -euxo pipefail + pip install --quiet "ruff==0.6.0" + ruff check . --exclude venv + ruff format --check . --exclude venv + ' + ''' + } + } + + stage('Lint — Frontend') { + steps { + sh ''' + set -euo pipefail + C="a76-lint-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 -w /workspace/frontend "$C" bash -lc ' + set -euxo pipefail + npm install -g pnpm@9 --quiet + pnpm install --frozen-lockfile + pnpm run lint + pnpm run i18n:compile + pnpm run check + ' + ''' + } + } + + // ── Paso 5b — Tests ─────────────────────────────────────────────────── + 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 + alembic upgrade head + pytest tests/ \ + --cov=. \ + --cov-fail-under=80 \ + --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') { + 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 + ' + ''' + } + } + + // ── 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 + ''' + } + } } - env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${commitCount}" - } - - echo "VERSION: ${env.APP_VERSION}" } - } } - stage('Docker login') { - steps { - withCredentials([usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')]) { - sh ''' - set -euo pipefail - echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" -u "$HARBOR_USERNAME" --password-stdin - ''' + post { + always { + sh "docker logout ${HARBOR_REGISTRY} || true" } - } - } - - // Build independiente con APP_VERSION (el tag release se materializa como build-arg - // y como tag de imagen). No reutilizamos anexo76-backend:latest: aquí queda el tag - // remoto ${REGISTRY}/${IMAGE_NAMESPACE}/backend:${APP_VERSION}. - stage('Build + push backend') { - steps { - script { - retry(3) { - sh ''' - set -euo pipefail - export DOCKER_BUILDKIT=1 - docker build \ - --build-arg APP_VERSION="${APP_VERSION}" \ - -t "${REGISTRY}/${IMAGE_NAMESPACE}/backend:${APP_VERSION}" \ - -t "${REGISTRY}/${IMAGE_NAMESPACE}/backend:latest" \ - -f ./backend/Dockerfile \ - ./backend - - docker push "${REGISTRY}/${IMAGE_NAMESPACE}/backend:${APP_VERSION}" - docker push "${REGISTRY}/${IMAGE_NAMESPACE}/backend:latest" - ''' - } + success { + echo "Pipeline OK — ${env.BRANCH_NAME} #${env.BUILD_NUMBER} (${env.APP_VERSION ?: 'n/a'})" } - } - } - - // Frontend Dockerfile.prod ≠ Dockerfile (dev). No reutilizamos anexo76-frontend:latest - // del stage CI; el build de producción necesita VITE_* fijos al dominio dev. - stage('Build + push frontend') { - steps { - script { - retry(3) { - sh ''' - set -euo pipefail - export DOCKER_BUILDKIT=1 - docker build \ - --build-arg VITE_API_URL=https://anexo76-dev.aduanasoft.com/api/ \ - --build-arg VITE_KEYCLOAK_URL=https://anexo76-dev.aduanasoft.com/kcauth/ \ - --build-arg INTERNAL_API_URL=http://backend:3467/api/ \ - -t "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:${APP_VERSION}" \ - -t "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:latest" \ - -f ./frontend/Dockerfile.prod \ - ./frontend - - docker push "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:${APP_VERSION}" - docker push "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:latest" - ''' - } + failure { + echo "PIPELINE FALLIDO — ${env.BRANCH_NAME} #${env.BUILD_NUMBER}" } - } } - - stage('Tag release (main)') { - when { - expression { params.ENVIRONMENT == 'main' } - } - steps { - withCredentials([usernamePassword(credentialsId: 'gitea_acazares', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_PASSWORD')]) { - sh ''' - set -euo pipefail - git -c user.name='Jenkins' -c user.email='jenkins@gitea.local' \ - tag -a "v${APP_VERSION}" -m "Release version ${APP_VERSION}" - git remote set-url origin "https://${GIT_USERNAME}:${GIT_PASSWORD}@git.aduanasoft.com/ADUANASOFT/anexo76.git" - git push origin "v${APP_VERSION}" - ''' - } - } - } - - stage('Deploy development') { - when { - expression { params.ENVIRONMENT == 'development' } - } - steps { - withCredentials([ - usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD'), - string(credentialsId: 'dev-server-user', variable: 'DEV_SERVER_USER'), - string(credentialsId: 'dev-server-host', variable: 'DEV_SERVER_HOST'), - sshUserPrivateKey( - credentialsId: 'dev-server-ssh', - keyFileVariable: 'DEV_SERVER_KEY', - usernameVariable: 'DEV_SERVER_SSH_USER', - passphraseVariable: 'DEV_SERVER_KEY_PASSPHRASE' - ) - ]) { - script { - def devHost = env.DEV_SERVER_HOST?.trim() - def knownHostsFile = "${env.WORKSPACE}/.jenkins-anexo76-known_hosts" - sh """ - set -euo pipefail - ssh-keyscan -T 15 -H '${devHost}' > '${knownHostsFile}' - """ - def remote = [ - name: 'dev-server', - host: devHost, - user: env.DEV_SERVER_USER ?: env.DEV_SERVER_SSH_USER, - identityFile: env.DEV_SERVER_KEY, - passphrase: env.DEV_SERVER_KEY_PASSPHRASE, - allowAnyHosts: false, - knownHosts: knownHostsFile - ] - sshCommand remote: remote, command: ''' - set -euo pipefail - cd ~/projects/anexo76 - # Asume docker login ya configurado en el host destino para evitar exponer secretos por interpolación. - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml up -d - # Solo imágenes colgantes (sin tag); no borra imágenes en uso por otros contenedores. - docker image prune -f - ''' - } - } - } - } - } - }