diff --git a/Jenkinsfile b/Jenkinsfile index 5ff65cbd..614df6b2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -29,6 +29,7 @@ pipeline { exit 1 fi docker --version + docker compose version ''' } } @@ -47,133 +48,144 @@ pipeline { } } - stage('Test backend') { + // 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 - 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 - ' + 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 ''' } } - // 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 ' + // 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 - test -f package.json - node --version - corepack enable - pnpm --version - pnpm install --frozen-lockfile - pnpm run i18n:compile - pnpm run test:unit -- --run - ' - ''' + echo "== Test backend: pytest en anexo76-backend:latest ==" + + 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}" + + 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 × 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 + ' + ''' + } + } } } @@ -184,9 +196,9 @@ pipeline { 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)" + 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}" @@ -194,6 +206,7 @@ pipeline { 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)) @@ -210,10 +223,14 @@ pipeline { 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" "$@" + 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 contenedors → "name already in use" + # 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 \ @@ -236,9 +253,7 @@ pipeline { 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 ---" @@ -246,72 +261,54 @@ pipeline { 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 + 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 } - # 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 - # e2e_alembic_upgrade.py: carga /app/alembic.ini en la API; la CLI a veces queda con config vacío bajo compose (No 'script_location') - if ! e2e_compose run --rm --no-deps --entrypoint /usr/local/bin/python3 backend /app/e2e_alembic_upgrade.py; 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_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 + 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 \ + + # 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. + 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 \ - -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 - ' + sleep infinity + docker exec "$PW_CONTAINER" mkdir -p /workspace + docker cp "$WORKSPACE/." "$PW_CONTAINER:/workspace" + + if ! docker exec -w /workspace/frontend "$PW_CONTAINER" bash -lc ' + set -euxo pipefail + test -f package.json + corepack enable + pnpm install --frozen-lockfile + pnpm run i18n:compile + pnpm run test:e2e + '; then + docker rm -f "$PW_CONTAINER" >/dev/null 2>&1 || true + e2e_compose_fail_logs + exit 1 + fi + docker rm -f "$PW_CONTAINER" >/dev/null 2>&1 || true ''' } } @@ -353,6 +350,9 @@ pipeline { } } + // 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 { @@ -375,6 +375,8 @@ pipeline { } } + // 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 { @@ -412,7 +414,7 @@ pipeline { git remote set-url origin "https://${GIT_USERNAME}:${GIT_PASSWORD}@git.aduanasoft.com/ADUANASOFT/anexo76.git" git push origin "v${APP_VERSION}" ''' - } + } } } diff --git a/backend/e2e_alembic_upgrade.py b/backend/e2e_alembic_upgrade.py deleted file mode 100644 index 3aa2e58e..00000000 --- a/backend/e2e_alembic_upgrade.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -E2E/Jenkins: aplica migraciones sin la CLI de Alembic. - -docker compose run … alembic --config=… a veces deja un Config vacío (No 'script_location'); -aquí se carga explícitamente /app/alembic.ini y se llama a command.upgrade(). -""" -from __future__ import annotations - -import os -import sys -from pathlib import Path - - -def main() -> int: - root = Path("/app") - if not root.is_dir(): - print("ERROR: /app no es un directorio (volumen backend).", file=sys.stderr) - return 1 - os.chdir(root) - sys.path.insert(0, str(root)) - - ini = root / "alembic.ini" - if not ini.is_file(): - print( - f"ERROR: {ini} no existe. Comprueba el bind ./backend:./app en el agente.", - file=sys.stderr, - ) - return 1 - - from alembic.config import Config - from alembic import command - - command.upgrade(Config(str(ini)), "head") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 00000000..ade259c0 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,32 @@ +# Override para CI/Jenkins (E2E + pipeline). +# +# Problema que resuelve: en Jenkins los bind mounts `./backend:/app` y +# `./frontend:/app` del compose base NO funcionan cuando el agente corre en un +# contenedor y el docker daemon del host no ve `$WORKSPACE`. El daemon monta +# un directorio vacío sobre `/app` y la app arranca sin código. +# +# Estrategia: con `!override` se reemplaza por completo la lista de volumes de +# cada servicio, dejando solo los volúmenes nombrados (cache, uploads, +# node_modules). El código se usa desde la imagen construida por +# `docker compose build`, que sí se transfiere por el daemon API. +# +# Requiere Docker Compose v2.24+ (tag `!override`). +services: + backend: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + + frontend: + volumes: !override + - frontend_node_modules:/app/node_modules + + celery_worker: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + + celery_beat: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads