- Updated the Alembic upgrade command in the Jenkinsfile to directly use Python for execution, eliminating reliance on a separate shell script that caused issues with command parsing. - Revised comments to clarify the changes and their implications for the command context during E2E testing. - Deleted the now-unnecessary jenkins-alembic-e2e.sh script to streamline the process.
467 lines
18 KiB
Groovy
467 lines
18 KiB
Groovy
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 ~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" "$@"
|
||
}
|
||
|
||
# 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
|
||
# Sin sh -c ni .sh bajo /app: sin comillas frágiles y sin depender de un fichero no montado aún (push)
|
||
if ! e2e_compose run --rm --no-deps --entrypoint python3 backend -m alembic -c /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
|
||
'''
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|