Appearance
Pipelines CI/CD
El despliegue se realiza en dos niveles de pipelines de GitLab:
- Pipeline de cada repositorio de servicio: construye la imagen Docker, la publica en el registry y dispara el orquestador.
- Pipeline del orquestador (
infra-deploy): se conecta por SSH al host del entorno y actualiza los contenedores.
| Rama | Entorno | Tag latest | Variables usadas |
|---|---|---|---|
develop | Test | develop-latest | DEV_* |
main | Producción | main-latest | PROD_* |
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
| Stage | Job | Imagen del job |
|---|---|---|
deploy | build | docker:26 + servicio docker:26-dind |
trigger | trigger-infra-deploy | curlimages/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
doneCaché 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 daemon | Ejemplo (auth-backend) |
|---|---|
Contenedor buildx_buildkit_<BUILDER_NAME>0 | buildx_buildkit_ci-proceso-seleccion-auth-backend0 |
Volumen buildx_buildkit_<BUILDER_NAME>0_state | buildx_buildkit_ci-proceso-seleccion-auth-backend0_state |
docker buildx create ... --bootstrapreutiliza 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 aBUILD_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. --loaddeja la imagen en el daemon para publicarla condocker push(mismo camino de confianza hacia el registry de antes).resource_group: docker-buildserializa 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>-latestse conserva siempre. Docker rechaza borrar imágenes usadas por un contenedor (aparececonflict: ... must forceen 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íasCada build publica dos tags:
| Tag | Ejemplo | Uso |
|---|---|---|
<rama>-<sha corto> | main-a1b2c3d4 | Versión inmutable; permite rollback |
<rama>-latest | main-latest | Tag 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"refdetermina el entorno:develop→ test,main→ producción.SERVICE_NAMEdebe coincidir con el nombre del servicio en eldocker-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-latestPasos del despliegue
Conexión SSH: instala
openssh-client, carga la clave enssh-agenty registra el host enknown_hostsconssh-keyscan.Directorios: crea en el host
storage/publicystorage/privatede IDP y Seleccionador, y el directorio de docs.Archivos de configuración: copia por
scp:.envconIMAGE_TAG=<rama>-latesta los tres stacks.idp/,seleccionador/ydocs/docker-compose.yml.- El archivo de variables de cada backend como
back.env.tmp.
Composición de
back.env(en el host): por cada backend concatenaback.env.tmpcon los secretos individuales y elimina el temporal. Se regenera completo en cada ejecución:shcat 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.tmpLogin al registry con el deploy token del grupo (
REGISTRY_DEPLOY_TOKEN_USER/REGISTRY_DEPLOY_TOKEN_PASSWORD).Despliegue:
Con
SERVICE_NAME(disparado por un repositorio): ubica el stack al que pertenece el servicio y ejecuta solo para él:shdocker 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): hacepullyup -d --no-depsde todos los servicios de los tres stacks.Backends: entre el
pully elup,preparar_storageajusta el dueño destorage/publicystorage/privateaappuser:www-data(el usuario con el que corre la imagen). Se ejecuta con la propia imagen como root (docker compose run --user root), sinsudoen 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) provocanPermissionErroral iniciar el backend.
Verificación: espera hasta 120 s (compartidos entre los tres stacks) a que todos los contenedores estén
runningy, si tienen health check,healthy. Si un backend no terminó de iniciar, imprime sus últimas 200 líneas de log.Resumen: imprime una tabla con proyecto, servicio, estado, salud y puertos publicados.
Limpieza de imágenes anteriores: ejecuta
scripts/cleanup-project-images.shen el host, limitado a los repositorios de losdocker-compose.ymldeinfra-deploy(solo el del servicio desplegado si llegaSERVICE_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
| Stack | Servicios |
|---|---|
idp | auth-backend, auth-authorization-frontend, auth-frontend-admin, frontend-public-auth |
seleccionador | backend-seleccion-postulantes, frontend-portal-admin-seleccion-postulantes, frontend-portal-comisiones-seleccion-postulantes, frontend-portal-postulantes-seleccion-postulantes, frontend-estacion-examen |
docs | frontend-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
- Crear el
Dockerfile(conEXPOSE 80y endpoint/health/) en el repositorio del servicio. - Agregar los jobs
buildytrigger-infra-deploycon suSERVICE_NAME. - Agregar el servicio al
docker-compose.ymldel stack correspondiente eninfra-deploy, con un puerto libre. - Agregar el nombre en el
casedel despliegue selectivo y en el bloque de despliegue completo de.gitlab-ci.ymldeinfra-deploy. - Declarar su dominio en
proxy/sites/test.txtyproxy/sites/prod.txty seguir el alta de dominio en el proxy. - 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:
| Job | Stage | Imagen | Cuándo corre |
|---|---|---|---|
validate-proxy | validate | nginx:1.27-alpine | Todo pipeline de rama o MR, salvo trigger con SERVICE_NAME=<servicio> |
deploy-proxy | deploy | alpine:3.19 | Todo pipeline de develop/main (push, web o trigger), salvo SERVICE_NAME=<servicio> |
Reglas de ejecución combinadas del pipeline:
| Disparo | deploy-orquestador | validate-proxy | deploy-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-proxyno toca servidores: renderiza los sitios de test y prod, aplica las mismas validaciones del servidor (eje-proxy-deploy --lint) y ejecutanginx -tcon certificados falsos para ambas fases (http y https).deploy-proxyrenderiza el entorno de la rama (develop → test con upstreamDEV_DEPLOY_HOST; main → prod conPROD_DEPLOY_HOST), sube la configuración al proxy y la aplica consudo /usr/local/sbin/eje-proxy-deploy, que valida, respalda, aplica, ejecutanginx -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.
| Stage | Job | Runner | Descripción |
|---|---|---|---|
test | test | test | pytest tests/ sobre la lógica de negocio (python:3.12-slim) |
test | test:gui-import | test | Smoke test: inicializa la GUI Tkinter bajo Xvfb |
build | build:linux | test | PyInstaller con Custodio.spec en debian:bookworm-slim → Custodio-linux-<tag> |
build | build:windows | windows | PyInstaller en runner Windows nativo → Custodio-windows-<tag>.exe |
verify | checksums | test | Genera CHECKSUMS.txt (SHA-256 de ambos ejecutables) |
publish | publish | test | Manual. Sube ejecutables y checksums a packages/generic/custodio/<tag>/ |
- Los jobs de
testcorren automáticamente con tags; en merge requests y enmainquedan como manuales. - Los artefactos se conservan 6 meses.
publishes un paso manual deliberado para controlar qué versión se distribuye.
