refactor(jenkins): streamline CI/CD pipeline stages and enhance linting and testing processes
This commit is contained in:
928
Jenkinsfile
vendored
928
Jenkinsfile
vendored
@@ -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
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user