Skip to content

Pipelines CI/CD ​

El despliegue se realiza en dos niveles de pipelines de GitLab:

  1. Pipeline de cada repositorio de servicio: construye la imagen Docker, la publica en el registry y dispara el orquestador.
  2. Pipeline del orquestador (infra-deploy): se conecta por SSH al host del entorno y actualiza los contenedores.
RamaEntornoTag latestVariables usadas
developTestdevelop-latestDEV_*
mainProducciónmain-latestPROD_*

Todos los jobs se ejecutan en runners con el tag test (excepto el build de Windows de Custodio, que usa el tag windows).

Pipeline de los repositorios de servicio ​

Aplica a todos los backends, frontends y manuales.

Stages ​

StageJobImagen del job
deploybuilddocker:26 + servicio docker:26-dind
triggertrigger-infra-deploycurlimages/curl:8.8.0

Job build ​

Se ejecuta en develop y main:

yaml
build:
  stage: deploy
  image: docker:26
  services:
    - docker:26-dind
  resource_group: docker-build
  variables:
    BUILDER_NAME: ci-${CI_PROJECT_PATH_SLUG}
    BUILDKIT_IMAGE: moby/buildkit:v0.15.2
    BUILD_CACHE_KEEP_DURATION: 24h
    BUILD_CACHE_MAX_STORAGE: 1gb   # 2gb en los backends
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'
    - if: '$CI_COMMIT_BRANCH == "main"'
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
    - docker buildx create --name "$BUILDER_NAME" --driver docker-container --driver-opt image="$BUILDKIT_IMAGE" --bootstrap
  script:
    - export TAG="${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
    - docker buildx build --builder "$BUILDER_NAME" --load -t "$IMAGE:$TAG" -t "$IMAGE:${CI_COMMIT_REF_SLUG}-latest" .
    - docker push "$IMAGE:$TAG"
    - docker push "$IMAGE:${CI_COMMIT_REF_SLUG}-latest"
    - docker buildx prune --builder "$BUILDER_NAME" --force --filter "until=$BUILD_CACHE_KEEP_DURATION"
    - docker buildx prune --builder "$BUILDER_NAME" --force --keep-storage "$BUILD_CACHE_MAX_STORAGE"
    - docker buildx stop "$BUILDER_NAME"
  after_script:
    - |
      n=0
      docker image ls "$IMAGE" --format '{{.Tag}} {{.ID}}' | while read -r tag id; do
        case "$tag" in
          *-latest) ;;
          "<none>") n=$((n + 1)); [ "$n" -le 2 ] || docker image rm "$id" || true ;;
          develop-*|main-*) docker image rm "$IMAGE:$tag" || true ;;
        esac
      done

Caché de compilación por proyecto ​

El runner comparte el daemon Docker con otros proyectos. Antes, docker build usaba el builder por defecto, cuya caché es común a todos los proyectos, no se puede atribuir a uno y no tenía límite (llegó a ~24 GB). Ahora cada repositorio usa un builder BuildKit propio:

Recurso en el daemonEjemplo (auth-backend)
Contenedor buildx_buildkit_<BUILDER_NAME>0buildx_buildkit_ci-proceso-seleccion-auth-backend0
Volumen buildx_buildkit_<BUILDER_NAME>0_statebuildx_buildkit_ci-proceso-seleccion-auth-backend0_state
  • docker buildx create ... --bootstrap reutiliza el contenedor y su caché si ya existen; solo los crea la primera vez.
  • Al final de cada build se limpia solo la caché de ese builder: se descarta lo no usado en BUILD_CACHE_KEEP_DURATION (7 días) y se limita a BUILD_CACHE_MAX_STORAGE (1 GB frontends y manuales, 2 GB backends; ~15 GB como máximo para los 11 repositorios). Luego el builder se detiene; su caché queda en el volumen para el siguiente build.
  • --load deja la imagen en el daemon para publicarla con docker push (mismo camino de confianza hacia el registry de antes).
  • resource_group: docker-build serializa los builds del mismo repositorio para que no compitan por el builder.
  • Si el runner usa dind aislado, el builder desaparece con el job y todo lo anterior es inocuo.

Imágenes en el daemon del runner ​

after_script limpia, aunque el build falle, solo imágenes del repositorio propio ($IMAGE, coincidencia exacta) y sin --force:

  • Tags <rama>-<sha> (incluidos los de builds anteriores): ya están en el registry.
  • Imágenes sin tag: se conservan las 2 más recientes (rollback local si el runner es también el host de despliegue).
  • <rama>-latest se conserva siempre. Docker rechaza borrar imágenes usadas por un contenedor (aparece conflict: ... must force en el log y se conserva).

DANGER

Los pipelines nunca ejecutan docker system prune, docker image prune -a, docker builder prune ni docker volume prune: afectarían a otros proyectos del host.

La caché que el builder por defecto acumuló antes de este cambio (~24 GB) ya no crece con estos builds, pero no se puede atribuir por proyecto. Debe liberarla una vez el administrador del host, coordinando con los demás proyectos. Solo afecta caché (no imágenes, contenedores ni volúmenes); el efecto en otros proyectos es que su siguiente build tarda más:

sh
docker builder du | tail -2                      # tamaño actual
docker builder prune --force --filter until=72h  # caché no usada en 3 días

Cada build publica dos tags:

TagEjemploUso
<rama>-<sha corto>main-a1b2c3d4Versión inmutable; permite rollback
<rama>-latestmain-latestTag que usa docker-compose.yml en el host

Frontends: variables por rama ​

En los frontends, cada regla asigna las variables VITE_* desde su versión DEV_ o PROD_ y se pasan como --build-arg:

yaml
rules:
  - if: '$CI_COMMIT_BRANCH == "develop"'
    variables:
      VITE_TITLE_APP: $DEV_VITE_TITLE_APP
      VITE_API_ENDPOINT: $DEV_VITE_API_ENDPOINT
      # ...
  - if: '$CI_COMMIT_BRANCH == "main"'
    variables:
      VITE_TITLE_APP: $PROD_VITE_TITLE_APP
      VITE_API_ENDPOINT: $PROD_VITE_API_ENDPOINT
      # ...
script:
  - |
    docker build \
      --build-arg VITE_TITLE_APP="${VITE_TITLE_APP}" \
      --build-arg VITE_API_ENDPOINT="${VITE_API_ENDPOINT}" \
      # ...
      -t "$IMAGE:$TAG" -t "$IMAGE:${CI_COMMIT_REF_SLUG}-latest" .

La lista de build args de cada frontend está en Imágenes Docker.

Job trigger-infra-deploy ​

Se ejecuta después de build (needs: build) y dispara el pipeline de infra-deploy en la misma rama, indicando qué servicio desplegar:

yaml
trigger-infra-deploy:
  stage: trigger
  image: curlimages/curl:8.8.0
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'
    - if: '$CI_COMMIT_BRANCH == "main"'
  needs:
    - job: build
  script:
    - |
      curl -fsSk --retry 3 -X POST \
        --form token=$INFRA_DEPLOY_TRIGGER_TOKEN \
        --form ref=$CI_COMMIT_BRANCH \
        --form "variables[SERVICE_NAME]=<nombre-del-servicio>" \
        "https://git.eje.gob.bo/api/v4/projects/13/trigger/pipeline"
  • ref determina el entorno: develop → test, main → producción.
  • SERVICE_NAME debe coincidir con el nombre del servicio en el docker-compose.yml (igual al nombre del repositorio).

Pipeline orquestador (infra-deploy) ​

El pipeline tiene dos stages: validate (proxy) y deploy. El job principal, deploy-orquestador (stage deploy, imagen alpine:3.19), se ejecuta en develop y main, salvo cuando SERVICE_NAME=proxy (en ese caso solo corren los jobs del proxy). Según la rama, mapea las variables DEV_* o PROD_* a nombres genéricos:

yaml
rules:
  - if: '$CI_COMMIT_BRANCH == "develop"'
    variables:
      DEPLOY_USER: $DEV_DEPLOY_USER
      DEPLOY_HOST: $DEV_DEPLOY_HOST
      SSH_PRIVATE_KEY: $DEV_SSH_PRIVATE_KEY
      IMAGE_TAG: develop-latest
      IDP_DEPLOY_PATH: $DEV_DEPLOY_BASE_PATH/idp
      SELECCIONADOR_DEPLOY_PATH: $DEV_DEPLOY_BASE_PATH/seleccionador
      DOCS_DEPLOY_PATH: $DEV_DEPLOY_BASE_PATH/docs
      # ... BACK_ENV y secretos de cada backend
  - if: '$CI_COMMIT_BRANCH == "main"'
    variables:
      # ... mismas variables con prefijo PROD_ e IMAGE_TAG: main-latest

Pasos del despliegue ​

  1. Conexión SSH: instala openssh-client, carga la clave en ssh-agent y registra el host en known_hosts con ssh-keyscan.

  2. Directorios: crea en el host storage/public y storage/private de IDP y Seleccionador, y el directorio de docs.

  3. Archivos de configuración: copia por scp:

    • .env con IMAGE_TAG=<rama>-latest a los tres stacks.
    • idp/, seleccionador/ y docs/docker-compose.yml.
    • El archivo de variables de cada backend como back.env.tmp.
  4. Composición de back.env (en el host): por cada backend concatena back.env.tmp con los secretos individuales y elimina el temporal. Se regenera completo en cada ejecución:

    sh
    cat back.env.tmp > back.env
    echo "SECRET_KEY=${IDP_SECRET_KEY}" >> back.env
    echo "SIGNING_KEY_MASTER_SECRET=${IDP_SIGNING_KEY_MASTER_SECRET}" >> back.env
    echo "DATABASE_PASS=${IDP_DATABASE_PASS}" >> back.env
    rm back.env.tmp
  5. Login al registry con el deploy token del grupo (REGISTRY_DEPLOY_TOKEN_USER / REGISTRY_DEPLOY_TOKEN_PASSWORD).

  6. Despliegue:

    • Con SERVICE_NAME (disparado por un repositorio): ubica el stack al que pertenece el servicio y ejecuta solo para él:

      sh
      docker compose pull "$TARGET_SERVICE"
      docker compose up -d --no-deps "$TARGET_SERVICE"

      Si el valor no es un servicio conocido, el job falla.

    • Sin SERVICE_NAME (ejecución manual del pipeline): hace pull y up -d --no-deps de todos los servicios de los tres stacks.

    • Backends: entre el pull y el up, preparar_storage ajusta el dueño de storage/public y storage/private a appuser:www-data (el usuario con el que corre la imagen). Se ejecuta con la propia imagen como root (docker compose run --user root), sin sudo en el host, y solo modifica archivos o directorios cuyo dueño o permisos no coinciden. Sin esto, los directorios creados por el usuario de despliegue (o por Docker como root) provocan PermissionError al iniciar el backend.

  7. Verificación: espera hasta 120 s (compartidos entre los tres stacks) a que todos los contenedores estén running y, si tienen health check, healthy. Si un backend no terminó de iniciar, imprime sus últimas 200 líneas de log.

  8. Resumen: imprime una tabla con proyecto, servicio, estado, salud y puertos publicados.

  9. Limpieza de imágenes anteriores: ejecuta scripts/cleanup-project-images.sh en el host, limitado a los repositorios de los docker-compose.yml de infra-deploy (solo el del servicio desplegado si llega SERVICE_NAME). Ver Limpieza de imágenes. Si falla, solo muestra un aviso; no hace fallar el despliegue.

TIP

El paso 7 no hace fallar el job: si un servicio no queda saludable, el pipeline termina igual pero muestra el aviso y los logs. Revise siempre la salida del job tras un despliegue.

Valores válidos de SERVICE_NAME ​

StackServicios
idpauth-backend, auth-authorization-frontend, auth-frontend-admin, frontend-public-auth
seleccionadorbackend-seleccion-postulantes, frontend-portal-admin-seleccion-postulantes, frontend-portal-comisiones-seleccion-postulantes, frontend-portal-postulantes-seleccion-postulantes, frontend-estacion-examen
docsfrontend-manual-tecnico, frontend-manual-de-usuario
—proxy (solo configuración nginx del proxy compartido; no corre el orquestador)

Ejecución manual ​

Despliegue completo de un entorno (todos los servicios), desde una máquina con acceso a GitLab:

sh
curl -fsSk --retry 3 -X POST \
  --form token=$INFRA_DEPLOY_TRIGGER_TOKEN \
  --form ref=develop \
  "https://git.eje.gob.bo/api/v4/projects/13/trigger/pipeline"

Despliegue de un solo servicio:

sh
curl -fsSk --retry 3 -X POST \
  --form token=$INFRA_DEPLOY_TRIGGER_TOKEN \
  --form ref=main \
  --form "variables[SERVICE_NAME]=auth-backend" \
  "https://git.eje.gob.bo/api/v4/projects/13/trigger/pipeline"

También puede ejecutarse desde GitLab: Build → Pipelines → Run pipeline en infra-deploy, eligiendo la rama y, opcionalmente, la variable SERVICE_NAME.

Agregar un nuevo servicio ​

  1. Crear el Dockerfile (con EXPOSE 80 y endpoint /health/) en el repositorio del servicio.
  2. Agregar los jobs build y trigger-infra-deploy con su SERVICE_NAME.
  3. Agregar el servicio al docker-compose.yml del stack correspondiente en infra-deploy, con un puerto libre.
  4. Agregar el nombre en el case del despliegue selectivo y en el bloque de despliegue completo de .gitlab-ci.yml de infra-deploy.
  5. Declarar su dominio en proxy/sites/test.txt y proxy/sites/prod.txt y seguir el alta de dominio en el proxy.
  6. Actualizar el mapa de subdominios y puertos.

Proxy nginx ​

Dos jobs adicionales en infra-deploy gestionan la configuración nginx de los dominios propios en el proxy compartido:

JobStageImagenCuándo corre
validate-proxyvalidatenginx:1.27-alpineTodo pipeline de rama o MR, salvo trigger con SERVICE_NAME=<servicio>
deploy-proxydeployalpine:3.19Todo pipeline de develop/main (push, web o trigger), salvo SERVICE_NAME=<servicio>

Reglas de ejecución combinadas del pipeline:

Disparodeploy-orquestadorvalidate-proxydeploy-proxy
Trigger sin SERVICE_NAME✔✔✔
Trigger con SERVICE_NAME=<servicio>✔ (solo ese servicio)——
Trigger con SERVICE_NAME=proxy—✔✔
Push o ejecución web en develop/main✔✔✔
Push a otra rama—✔—
Merge request—✔—

INFO

Los jobs del proxy no dependen de los archivos modificados: corren en cada pipeline aplicable. deploy-proxy es idempotente, por lo que si la configuración renderizada no cambió no modifica archivos ni recarga nginx.

  • validate-proxy no toca servidores: renderiza los sitios de test y prod, aplica las mismas validaciones del servidor (eje-proxy-deploy --lint) y ejecuta nginx -t con certificados falsos para ambas fases (http y https).
  • deploy-proxy renderiza el entorno de la rama (develop → test con upstream DEV_DEPLOY_HOST; main → prod con PROD_DEPLOY_HOST), sube la configuración al proxy y la aplica con sudo /usr/local/sbin/eje-proxy-deploy, que valida, respalda, aplica, ejecuta nginx -t, recarga solo si hubo cambios y revierte si falla. También compara el SHA-256 del script instalado con el del repositorio y avisa si difieren.

El detalle completo (convenciones, runbook de instalación única, alta de dominios, rollback y solución de problemas) está en Proxy nginx compartido.

Custodio ​

custodio tiene un pipeline independiente que no despliega en servidores: genera ejecutables portables y los publica en el Package Registry de GitLab. Se dispara al crear un tag Git.

StageJobRunnerDescripción
testtesttestpytest tests/ sobre la lógica de negocio (python:3.12-slim)
testtest:gui-importtestSmoke test: inicializa la GUI Tkinter bajo Xvfb
buildbuild:linuxtestPyInstaller con Custodio.spec en debian:bookworm-slim → Custodio-linux-<tag>
buildbuild:windowswindowsPyInstaller en runner Windows nativo → Custodio-windows-<tag>.exe
verifychecksumstestGenera CHECKSUMS.txt (SHA-256 de ambos ejecutables)
publishpublishtestManual. Sube ejecutables y checksums a packages/generic/custodio/<tag>/
  • Los jobs de test corren automáticamente con tags; en merge requests y en main quedan como manuales.
  • Los artefactos se conservan 6 meses.
  • publish es un paso manual deliberado para controlar qué versión se distribuye.