Files
plantillas-proyectos/Jenkinsfile
AlexeerCT 835eb51eaf Refactor Alembic command in Jenkinsfile for clarity and correctness
- Updated the Alembic upgrade command to use the `--config` option instead of `-c`, addressing ambiguity in configuration handling.
- Revised comments to enhance understanding of the command's context and implications during E2E testing.
2026-04-24 14:10:30 -05:00

467 lines
18 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
}
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
'''
}
}
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]]
])
}
}
stage('Test backend') {
steps {
sh '''
set -euxo pipefail
echo "== Test backend stage started =="
echo "Workspace: $WORKSPACE"
docker --version
DB_CONTAINER="anexo76-test-db-${BUILD_NUMBER}"
DB_NAME="anexo76_test"
DB_USER="anexo76"
DB_PASS="anexo76"
export TEST_DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_CONTAINER}:5432/${DB_NAME}"
PY_CONTAINER="anexo76-test-py-${BUILD_NUMBER}"
TEST_NETWORK="anexo76-test-net-${BUILD_NUMBER}"
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
# Espera a que Postgres acepte conexiones (hasta ~60s)
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 × 2s)."
exit 1
fi
docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME"
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;
"
docker rm -f "$PY_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$PY_CONTAINER" --network "$TEST_NETWORK" python:3.12-slim sleep infinity
docker exec "$PY_CONTAINER" mkdir -p /workspace
docker cp "$WORKSPACE/." "$PY_CONTAINER:/workspace"
docker exec \
-e TEST_DATABASE_URL="$TEST_DATABASE_URL" \
"$PY_CONTAINER" \
sh -lc '
set -euxo pipefail
python --version
python -m pip install --upgrade pip
if [ -f /workspace/backend/requirements.txt ]; then
pip install -r /workspace/backend/requirements.txt
elif [ -f /workspace/backend/requirements/base.txt ]; then
pip install -r /workspace/backend/requirements/base.txt
else
echo "ERROR: No requirements file found in /workspace/backend"
ls -la /workspace || true
ls -la /workspace/backend || true
ls -la /workspace/backend/requirements || true
exit 1
fi
cd /workspace/backend
alembic upgrade head
pytest -q tests -v -ra -s
'
'''
}
}
// Código = WORKSPACE (docker cp), no la imagen Harbor. Imagen base: mcr.microsoft.com/playwright (Chromium p/ Vitest y E2E).
stage('Test frontend (Vitest + Svelte browser)') {
steps {
sh '''
set -euxo pipefail
echo "== Test frontend: vitest (Node + @vitest/browser) 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: levanta docker-compose.yml (host) y pnpm test:e2e =="
if [ ! -f "$WORKSPACE/frontend/package.json" ] || [ ! -f "$WORKSPACE/docker-compose.yml" ]; then
echo "ERROR: faltan archivos de repo (frontend o compose)"
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 ~2000060k
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" "$@"
}
# Nombres fijos en docker-compose: otro run / otro COMPOSE_PROJECT deja contenedors → "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_force_rm_stale_containers
trap compose_down EXIT
compose_down
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
echo "--- logs: anexo76-postgres-a76 (Postgres app) ---"
docker logs --tail 250 anexo76-postgres-a76 2>&1 || true
echo "--- logs: anexo76-postgres-keycloak (Postgres de Keycloak) ---"
docker logs --tail 400 anexo76-postgres-keycloak 2>&1 || true
echo "--- logs: anexo76-keycloak (Keycloak) ---"
docker logs --tail 250 anexo76-keycloak 2>&1 || true
echo "--- logs: anexo76-backend (FastAPI / migraciones) ---"
docker logs --tail 400 anexo76-backend 2>&1 || true
echo "--- logs: worker (celery) ---"
docker logs --tail 200 worker 2>&1 || true
echo "--- logs: celery_beat ---"
docker logs --tail 200 celery_beat 2>&1 || true
}
# Postgresql (app) + Alembic antes del resto: evita 10+ min "Waiting" mientras el lifespan
# migra y frontend/celery esperan service_healthy. El start de backend hará "upgrade" idempotente.
echo "== E2E: Postgres (app) + migraciones Antes del resto =="
if ! e2e_compose build backend; then
e2e_compose_fail_logs
exit 1
fi
if ! e2e_compose up -d postgres-a76; then
e2e_compose_fail_logs
exit 1
fi
_pg=0
while [ "$_pg" -lt 90 ]; do
if e2e_compose exec -T postgres-a76 pg_isready -U postgres -d anexo76_core 2>/dev/null; then
echo "E2E: Postgres (anexo76_core) acepta conexiones"
break
fi
_pg=$((_pg + 1))
sleep 2
done
if ! e2e_compose exec -T postgres-a76 pg_isready -U postgres -d anexo76_core 2>/dev/null; then
echo "ERROR: timeout esperando a postgres-a76"
e2e_compose_fail_logs
exit 1
fi
# Nunca usar "python3 -m alembic -c": el -c es ambigüo; Alembic recibe un config vacío (No 'script_location')
if ! e2e_compose run --rm --no-deps --entrypoint python3 backend -m alembic --config /app/alembic.ini upgrade head; then
echo "ERROR: falló alembic upgrade head (job previo al stack completo)"
e2e_compose_fail_logs
exit 1
fi
echo "== E2E: stack completo =="
if ! e2e_compose up -d --build; then
e2e_compose_fail_logs
exit 1
fi
bash "$WORKSPACE/scripts/wait-for-jenkins-stack.sh"
# --network host: en Linux el contenedor de tests ve localhost:5173/8000 publicados por compose
docker run --rm --network host \
-e CI=true \
-e "JENKINS_URL=${JENKINS_URL}" \
-e PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:5173 \
-v "$WORKSPACE:/workspace" \
-w /workspace/frontend \
"$PLAYWRIGHT_TEST_IMAGE" \
bash -lc '
set -euxo pipefail
test -f package.json
corepack enable
pnpm install --frozen-lockfile
pnpm run i18n:compile
pnpm run test:e2e
'
'''
}
}
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'
}
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
'''
}
}
}
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"
'''
}
}
}
}
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"
'''
}
}
}
}
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
'''
}
}
}
}
}
}