Come organizzare le pipeline Gitlab nel progetto

Ultimamente sto lavorando su un progetto complesso e oltre allo sviluppo mi sto occupando della parte DevOps. Il repository del progetto è su Gitlab (un’istanza self hosted) e prima di lavorare sulla pipeline ho predisposto i Gitlab runner per poter eseguire la pipeline (ne ho parlato in questo articolo).

In questo articolo vi spiegherò quali sono i miei consigli per organizzare al meglio i file di una pipeline in un progetto “complesso”. Il progetto sarà in .NET 10 ma i concetti si applicano a qualsiasi tipo di tecnologia.

Gli esempi che vedremo in questo articolo si applicano molto bene in contesti dove la pipeline è automatica. Gitlab permette anche di eseguire una pipeline in modo manuale (inserendo eventuali parametri prima di avviarla), ma dalla mia esperienza consiglio di utilizzare altri strumenti più evoluti (es. Jenkins).

Struttura base del file gitlab-ci.yml

Le pipeline in Gitlab sono scritte in yaml e vengono eseguite se viene trovato il file gitlab-ci.yml nella root del progetto (funziona anche con un nome differente, ma va configurato).
In un progetto “semplice” basterà inserire tutto nel file gitlab-ci.yml e potrebbe essere simile a questo:

.gitlab-ci.yml
#########################################
# Pipeline stages
#########################################
stages:
  - build
  - test

#########################################
# .NET Build
#########################################
dotnet-build:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

#########################################
# .NET Test execution
#########################################
dotnet-tests:
  stage: test
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    - build_dotnet
  dependencies:
    # this step depends from build step
    - build
  script:
    - echo "Start tests..."
    - dotnet test --configuration $BUILD_CONFIGURATION --verbosity minimal
    - echo "Tests completed"
  allow_failure: false

Questo semplice script si concretizzerà in questa pipeline:

Nella prima parte dello script sono definiti i vari stage (che corrispondono ai rettangoli). Ogni step (dotnet-build e dotnet-tests) apparterrà ad uno stage (posso avere più step all’interno di un singolo stage).

Step condivisi

In un progetto complesso è verosimile che molti step (a prescindere dallo stage) condividano alcuni elementi. Riscrivere le stesse cose è sbagliato, in quanto se dobbiamo fare un cambiamento questo andrà fatto in molti punti. Esiste però un modo per poter creare degli step di pipeline condivisi, riutilizzabili da altri step (che li estendono).

Prendiamo come esempio lo step di build che abbiamo appena visto:

YAML
dotnet-build:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

Questo semplice script può essere spezzato in due, una parte “condivisibile” e una “ufficiale”.

YAML
##############################################
# Shared build step
##############################################
.dotnet-build-shared:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables with DEFAULT value
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

##############################################
# Build step
##############################################
dotnet-build:
    extends: .dotnet-build-shared
  variables:
    # Variable value override
    BUILD_CONFIGURATION: "Release"

come potete vedere ora abbiamo due step: .dotnet-build-shared è lo step condiviso, qualunque step che necessiterà di una build del progetto prima di eseguire delle azioni potrà utilizzarlo.
Lo step che avevamo prima è invece diventato molto piccolo, abbiamo aggiunto la dicitura extends e l’override della variabile utilizzata nella fase di build.

E’ buona norma definire sempre un valore di default delle variabili

Inoltre potete notare come lo step condiviso abbia un . davanti al nome: non è un caso, il punto davanti al nome definisce uno step “nascosto”, che non è possibile richiamare direttamente, ma dovrà sempre essere esteso da un altro step.

Gli step condivisi possono anche essere centralizzati in un repository dedicato, ma ne parleremo in un altro articolo per non andare fuori tema rispetto a questo🙂

Organizzare i file nel progetto

L’esempio che stiamo vedendo è volutamente semplice, ma come gestire una pipeline complessa, con magari una decina di stage e una ventina di step parametrici?
Il mio consiglio è di creare dei file .yml dedicati, raggruppati nella directory .gitlab/pipeline nella root del progetto (se non c’è createla). Il file gitlab-ci.yml rimarrà sempre come entry point della pipeline, ma i vari step della pipeline li organizzeremo divisi per ambito.

Per questo esempio i file potrebbero essere organizzati in questo modo:
📄 gitlab-ci.yml
📁 .gitlab/
└── 📁 pipeline/
├── 📄 build.yml
├── 📄 shared.yml
└── 📄 test.yml

Vediamo ora i contenuti dei file:

.gitlab/shared.yml
##############################################
# Shared .NET 10 steps
##############################################
.dotnet-10-shared:
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"

##############################################
# Shared build step
##############################################
.dotnet-build-shared:
  extends: .dotnet-10-shared # --> A shared step can extend another shared step!
  variables:
    # Custom variables with DEFAULT value
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour
.gitlab/build.yml
##############################################
# Build step
##############################################
dotnet-build:
    extends: .dotnet-build-shared
    stage: build
.gitlab/test.yml
#########################################
# .NET Test execution
#########################################
dotnet-tests:
  extends: .dotnet-10-shared
  stage: test
  dependencies:
    # this step depends from build step
    - dotnet-build
  script:
    - echo "Start tests..."
    - dotnet test --configuration $BUILD_CONFIGURATION --verbosity minimal
    - echo "Tests completed"
  allow_failure: false
.gitlab-ci.yml
# include all files in .gitlab directory
include:
  - local: .gitlab/pipeline/*.yml
    
variables:
    #Global variable
    BUILD_CONFIGURATION: "Release"

Come potete vedere il nostro file di pipeline è diventato molto più semplice, addirittura banale: contiene solamente un’istruzione che include tutti i file yaml nella directory .gitlab/pipeline e la definizione di una variabile globale (usata sia nello step di test che nello step di pipeline).

Inoltre nell’esempio completo che vi ho riportato potete notare che ho fatto un’ulteriore miglioria nel file shared.yml: considerato che sia lo step di build condiviso (.dotnet-build-shared) che quello di test (dotnet-tests) condividevano stage, immagine docker, tag e variabili, ho creato uno nuovo step condiviso per mettere a fattor comune questi elementi.

Quando Gitlab trova il file .gitlab-ci.yml importa tutti i file yaml definiti all’inizio (con l’istruzione include) e crea un grosso file composto dalla somma di tutti questi file. Successivamente inizia ad eseguire tutti gli step che corrispondono al primo stage, fino a concludere la pipeline.

In un progetto più complesso e strutturato, ipotizzando di avere una pipeline più articolata, l’alberatura della directory potrebbe essere la seguente:

📄 gitlab-ci.yml
📁 .gitlab/
└── 📁 pipeline/
├── 📄 build.yml
├── 📄 deploy.yml
├── 📄 docker.yml
├── 📄 quality.yml
├── 📄 template.yml
├── 📄 test.yml
└── 📄 utils.yml

Gestire una pipeline in progetti complessi ovviamente non si limita solo a questo! Ci sono molti altri aspetti che non ho affrontato in questo articolo, dalle cache, agli step condizionali, ecc. Vedremo di affrontare questi altri temi in altri post dedicati!

Conclusioni

L’ordine in un progetto non è solamente una questione estetica, ma un essere preparati alla crescita e all’evoluzione del progetto. Nello sviluppo l’uomo ha inventato la clean architecture o altri paradigmi per risolvere il problema del caos man mano che il progetto cresceva. Ovviamente non esistono pattern definiti per organizzare una pipeline, ma mantenerli in ordine permette di gestire in modo professionale la crescita del progetto, senza dover pronunciare la famosa frase “ora non ho tempo, lo farò domani” (che poi nessuno ci ha mai creduto 😅).

Spero che questo breve articolo vi sia piaciuto,
Alla prossima!

Condividi questo articolo
Shareable URL
Post precedente

Angular: da environment.ts ad entpoint http

Prosimo post

Da OneNote a Obsidian: come ho costruito il mio secondo cervello

Leggi il prossimo articolo