jordan gonzález
publicado

Pipelines Dinámicos en GitLab

Las pipelines estáticas son suficientes para procesos cuyos requerimientos no cambian drástricamente con el tiempo. No te recomiendo empezar tu Integración y Entrega Continua (Continous Integration / Continous Delivery [CI/CD]) con pipelines dinámicas, a menos que realmente sea necesario. Siempre sigue el principio YAGNI ("You Aren't Gonna Need It"), del inglés: no lo vas a necesitar.


Caso de Uso

Trabajando en el equipo Serverless en Datadog, me encontré con bastantes flujos de lanzamiento que no estaban automatizados. Esto no se había hecho pues no se podía justificar colocar horas de ingeniería en algo cuyo proceso manual funcionaba perfectamente bien. Hasta que los problemas salieron – varias veces.

Déjame explicarte como crear una pipeline dinámico. Usaremos el escenario de mi equipo como referencia. Aunque lo simplificaré por el bien de este blog.

Necesitábamos compilar un proyecto en múltiples tiempos de ejecución (runtimes). Las pruebas de integración tenían que seguir esta regla también. Usar matrices paralelas no eran una opción, pues por cada N runtimes, teníamos que esperar a los N trabajos, y eso no era paralelización real del trabajo.

Creando una Pipeline Dinámica

Para crear una pipeline dinámica en GitLab, necesitas tres componentes clave: definir un trabajo cuyo único propósito sea generar la pipeline deseada; también necesitas los parámetros para usar dinamicamente; y, la plantilla que el trabajo generador utilizará.

He escogido gomplate, un proyecto renderizador de plantillas codificado en Go, que nos permitirá simplificar el proceso. Primero, definamos las entradas, salidas, y los parámetros en el archivo de configuración.

Configuración

La configuración será el punto de entrada para gomplate. Esta debe ser escrita en sintaxis YAML. Para un caso de uso sencillo, se tienen que definir inputFiles, outputFiles, y datasources.

# ci/config.yaml

inputFiles:
  - ci/input_files/dynamic-pipeline.yaml.tpl  # aquí vivirá nuestra plantilla

outputFiles:
  - ci/dynamic-pipeline.yaml                  # esta será la pipeline producto a ejecutar

datasources:
  runtimes:
    url: ci/datasources/runtimes.yaml         # los runtimes deseados a ejecutar
  environments:
    url: ci/datasources/environments.yaml     # sandbox y producción
  regions:
    url: ci/datasources/regions.yaml          # después queremos publicar en estas regiones

Los archivos de entrada incluirán tu plantilla, o plantillas; los archivos de salida especifican el directorio de la pipeline de salida; y los parámetros (datasources) son los valores dinámicos que se usarán en la plantilla.

Datasources

Gomplate acepta múltiples datasources, pero aquí, usaremos un archivo local escrito en YAML.

# ci/datasources/runtimes.yaml

runtimes:
  - name: "node16"
    node_version: "16.14"
    node_major_version: "16"
  - name: "node18"
    node_version: "18.12"
    node_major_version: "18"
  - name: "node20"
    node_version: "20.9"
    node_major_version: "20"
# ci/datasources/regions.yaml

regions:
  - code: "us-east-1"
  - code: "us-east-2"
  - code: "us-west-1"
  - code: "us-west-2"
# ci/datasources/environments.yaml

environments:
  - name: sandbox
  - name: prod

Plantilla

Este es el elemento más importante, pues será la base de cualquier trabajo dinámico que generemos. Una plantilla puede acceder cualquier cantidad de parámetros especificados en la configuración.

Abajo, hemos definido tres etapas (stages) que serán ejecutadas por cada entrada en los parámetros runtimes. En la etapa publish, se generarán dos trabajos por entorno (environments): sandbox y prod. En esta misma sí usaremos una matríz paralela, pues es nuestra última etapa y nada depende de ella.

# ci/input_files/dynamic-pipeline.yaml.tpl

stages:
 - build
 - test
 - publish

# por cada tiempo de ejecución, queremos crear múltiples trabajos
{{ range $runtime := (ds "runtimes").runtimes }}

build  ({{ $runtime.name }}):
  stage: build
  image: node:{{ $runtime.node_major_version }}-bullseye
  artifacts:
    paths:
      - .dist/build_node{{ $runtime.node_version }}.zip
  script:
    - NODE_VERSION={{ $runtime.node_version }} ./scripts/build.sh

integration-test ({{ $runtime.name }}):
  stage: test
  image: node:{{ $runtime.node_major_version }}-bullseye
  needs: 
    - build  ({{ $runtime.name }})
  dependencies:
    - build  ({{ $runtime.name }})
  script:
    - RUNTIME_PARAM={{ $runtime.node_major_version }} ./ci/run_integration_tests.sh

# por cada entorno, crearemos dos trabajos de publicación
{{ range $environment := (ds "environments").environments }}

publish {{ $environment.name }} ({{ $runtime.name }}):
  stage: publish
  image: docker:20.10
  needs:
      - build  ({{ $runtime.name }})
      - integration-test ({{ $runtime.name }})
  dependencies:
      - build  ({{ $runtime.name }})
  parallel:
    matrix:
      - REGION: {{ range (ds "regions").regions }}
          - {{ .code }}
        {{- end}}
  script:
    - STAGE={{ $environment.name }} NODE_VERSION={{ $runtime.node_version }} ./ci/publish.sh

{{- end }} # environments range

{{- end }} # runtimes range

Trabajo Generador

Finalmente, necesitamos definir el trabajo que generará tu pipeline.

# .gitlab-ci.yml

stages:
  - generate
  - run

generate-pipeline:
  stage: generate
  image: golang:alpine                  # utilizaremos una imagen ligera de golang
  script:
    - apk add --no-cache gomplate       # instalamos gomplate
    - gomplate --config ci/config.yaml  # ejecutamos con nuestro archivo de configuración
  artifacts:
    paths:
      - ci/dynamic-pipeline.yaml        # delegamos la pipeline al siguiente trabajo

execute-dynamic-pipeline:
  stage: run
  trigger:
    include:
      - artifact: ci/dynamic-pipeline.yaml
        job: generate-pipeline
    strategy: depend

Pipeline en GitLab

En nuestro caso de uso, la pipeline será ejecutada en cada merge request. La pipeline generada será una pipeline downstream que será ejecutada después de que el trabajo generador haya terminado.

DynamicGitlabPipeline