feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

This commit is contained in:
2026-07-21 13:59:00 -05:00
commit bdd089954b
470 changed files with 70022 additions and 0 deletions

452
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,452 @@
// Pipeline CI/CD — Aduanasoft Anexo76
// Ejecuta: tests+security (paralelo) → docker build/push (paralelo) → E2E → deploy dev → smoke test
//
// Credenciales requeridas en Jenkins:
// - harbor-credentials : username/password para dev.aduanasoft.com
// - a76-public-url-dev : Secret Text — URL pública base (ej. https://anexo76-dev.aduanasoft.com)
// - dev-server-user : Secret Text — usuario SSH del servidor dev
// - dev-server-host : Secret Text — hostname/IP del servidor dev
// - dev-server-ssh : SSH Username with Private Key — llave privada para deploy
// - a76-e2e-credentials : Username with password — usuario de prueba en Workspace
// - a76-sitar-api-url : Secret Text — URL del API SITAR (provee TC para "Consultar DOF")
// - a76-sitar-credentials : Username with password — credenciales del API SITAR
//
// El agente Jenkins solo necesita: Docker CLI
pipeline {
agent any
environment {
HARBOR_REGISTRY = 'dev.aduanasoft.com'
IMAGE_BACKEND = "${HARBOR_REGISTRY}/anexo76/backend"
IMAGE_FRONTEND = "${HARBOR_REGISTRY}/anexo76/frontend"
PYTHON_IMAGE = 'python:3.12-slim'
// Debe coincidir con @playwright/test del frontend (ver frontend/pnpm-lock.yaml)
PLAYWRIGHT_IMAGE = 'mcr.microsoft.com/playwright:v1.56.1-noble'
DEPLOY_PATH = '~/projects/anexo76'
// URL de Keycloak/Workspace — Keycloak vive en Workspace, no en el servidor de Anexo76
KC_URL = 'https://workspace.aduanasoft.com/kcauth'
}
options {
timeout(time: 45, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '10'))
disableConcurrentBuilds()
timestamps()
}
stages {
// ── Tests + Security (4 trabajos en paralelo) ─────────────────────────
// Backend tests, frontend Vitest, pip-audit y npm audit son independientes:
// todos en un solo bloque parallel — la stage tarda el máximo de los 4,
// no la suma. pip y pnpm cachean en volumes persistentes del agente
// (jenkins-a76-pip-cache, jenkins-a76-pnpm-store) para reusar deps
// entre builds (~30-60s menos cada uno cuando lockfiles no cambian).
stage('Tests & Security') {
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
# Volume persistente para el cache de pip (~/.cache/pip):
# primer build lo puebla, siguientes reusan wheels si requirements.txt no cambió.
docker run -d --name "$C" --network "$NET" \
-v jenkins-a76-pip-cache:/root/.cache/pip \
"$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
# Volume persistente para el store de pnpm (content-addressable):
# primer build lo puebla, siguientes reusan paquetes si pnpm-lock.yaml no cambió.
# Path estándar de pnpm@9 en Linux como root: /root/.local/share/pnpm/store
docker run -d --name "$C" \
-v jenkins-a76-pnpm-store:/root/.local/share/pnpm/store \
"$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('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" \
-v jenkins-a76-pip-cache:/root/.cache/pip \
"$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 (backend y frontend en paralelo) ──────────────
// Antes era secuencial: ~6-10 min totales. Ahora cada imagen builda+pushea
// en su propio substage en paralelo → tarda el máximo de los dos.
// BuildKit inline cache + --cache-from reusan las layers de :latest cuando
// pip-lock / pnpm-lock no cambiaron (~1-3 min menos por imagen).
stage('Docker Build') {
when {
anyOf { branch 'main'; branch 'development' }
}
parallel {
stage('Build & Push — Backend') {
steps {
withCredentials([
usernamePassword(
credentialsId: 'harbor-credentials',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASS'
)
]) {
sh """
set -euo pipefail
export DOCKER_BUILDKIT=1
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
# Pull de :latest para alimentar --cache-from (silencioso si no existe)
docker pull ${IMAGE_BACKEND}:latest >/dev/null 2>&1 || true
docker build --progress=plain \\
--cache-from ${IMAGE_BACKEND}:latest \\
--build-arg BUILDKIT_INLINE_CACHE=1 \\
--build-arg APP_VERSION=${env.APP_VERSION} \\
-t ${IMAGE_BACKEND}:${env.APP_VERSION} \\
-t ${IMAGE_BACKEND}:latest \\
-f backend/Dockerfile \\
backend/
docker push ${IMAGE_BACKEND}:${env.APP_VERSION}
docker push ${IMAGE_BACKEND}:latest
"""
}
}
}
stage('Build & Push — Frontend') {
steps {
withCredentials([
usernamePassword(
credentialsId: 'harbor-credentials',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASS'
),
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL')
]) {
sh """
set -euo pipefail
export DOCKER_BUILDKIT=1
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
docker pull ${IMAGE_FRONTEND}:latest >/dev/null 2>&1 || true
docker build --progress=plain \\
--cache-from ${IMAGE_FRONTEND}:latest \\
--build-arg BUILDKIT_INLINE_CACHE=1 \\
--build-arg VITE_API_URL=\${A76_URL}/api/ \\
--build-arg VITE_KEYCLOAK_URL=${KC_URL} \\
--build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \\
--build-arg INTERNAL_API_URL=http://backend:3467/api/ \\
-t ${IMAGE_FRONTEND}:${env.APP_VERSION} \\
-t ${IMAGE_FRONTEND}:latest \\
-f frontend/Dockerfile.prod \\
frontend/
docker push ${IMAGE_FRONTEND}:${env.APP_VERSION}
docker push ${IMAGE_FRONTEND}:latest
"""
}
}
}
}
}
// ── E2E (Playwright) — DESACTIVADO TEMPORALMENTE ───────────────────────
// El stage completo de E2E se ha comentado temporalmente mientras se
// estabiliza el flujo de Workspace / Fixed Assets y los tests Playwright.
// Para reactivarlo, recuperar la definición anterior de stage('E2E (Playwright)').
// ── 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:3467/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}"
}
}
}