Déployer dbt avec Github Actions

dbt (data build tool) est un outil qui permet de transformer les données en exécutant des requêtes SQL directement sur un Data Warehouse. Pour plus d’informations sur le fonctionnement d’un projet dbt, je vous conseille l’article d’Arnaud Col : Découvrir dbt !

dbt possède une version hébergée : dbt Cloud, une interface web intuitive qui contient un IDE pour le développement. Elle permet de gérer directement ses projets Git et d’orchestrer ses jobs. Cependant, dbt Cloud a un coût (si plus d'un utilisateur) et l’évolution récente du modèle de pricing peut amener à se poser des questions quant à son utilisation. L’outil de CI/CD de GitHub, GitHub Actions, permet de déployer dbt et de s’abstraire en partie de dbt Cloud. Nous allons donc voir, à travers cet article, comment orchestrer ses jobs dbt avec cet outil. Pour plus de détails sur l’utilisation de Github Actions et la construction des workflows, je vous invite à consulter l’article de Timothée Aufort : GitHub Actions en … action !

L’ensemble du code présenté ci-dessous se trouve sur ce repository GitHub.

Pré-requis

Configurer son IDE

Puisque l’on se passe de dbt Cloud, il faut avoir un IDE correctement configuré pour pouvoir utiliser dbt. La configuration de l’IDE pour dbt n’est pas très intuitive donc je vous conseille l’article de Jérémy Nadal pour configurer VSCode : Passer de dbt Cloud à VSCode.

Configurer un dbt profile

Créer un fichier profiles.yml dans un répertoire .dbt_profiles (vous pouvez choisir n’importe quel nom, ou même le mettre à la racine, mais vous aurez besoin de l’emplacement plus tard). Ce fichier contient les informations de votre Data Warehouse et permet la connexion à celui-ci à chaque run de dbt.

project_name: #Changer pour le nom de votre projet
 target: dev #Target par défaut
 outputs:
   dev:
     account: "{{env_var('SNOWFLAKE_ACCOUNT')}}"
     database: "{{env_var('SNOWFLAKE_DATABASE_DEV')}}"
     password: "{{env_var('SNOWFLAKE_PASSWORD_DEV')}}"
     role: "{{env_var('SNOWFLAKE_ROLE_DBT_DEV')}}"
     schema: "{{env_var('DEV_SCHEMA_NAME')}}"
     threads: 4
     type: snowflake
     user: "{{env_var('SNOWFLAKE_USER_DBT_DEV')}}"
     warehouse: "{{env_var('WAREHOUSE_DEV')}}"
   prod:
     account: "{{env_var('SNOWFLAKE_ACCOUNT')}}"
     database: "{{env_var('SNOWFLAKE_DATABASE_PROD')}}"
     password: "{{env_var('SNOWFLAKE_PASSWORD_PROD')}}"
     role: "{{env_var('SNOWFLAKE_ROLE_DBT_PROD')}}"
     schema: "{{env_var('PROD_SCHEMA_NAME')}}"
     threads: 4
     type: snowflake
     user: "{{env_var('SNOWFLAKE_USER_DBT_PROD')}}"
     warehouse: "{{env_var('WAREHOUSE_PROD')}}"
   ci:
     account: "{{env_var('SNOWFLAKE_ACCOUNT')}}"
     database: "{{env_var('SNOWFLAKE_DATABASE_DEV')}}"
     password: "{{env_var('SNOWFLAKE_PASSWORD_DEV')}}"
     role: "{{env_var('SNOWFLAKE_ROLE_DBT_DEV')}}"
     schema: "{{env_var('PR_SCHEMA_NAME')}}"
     threads: 4
     type: snowflake
     user: "{{env_var('SNOWFLAKE_USER_DBT_DEV')}}"
     warehouse: "{{env_var('WAREHOUSE_DEV')}}"

Si vous utilisez un autre warehouse que Snowflake, changez les paramètres en fonction de celui-ci.

Une target par environnement est définie dans le fichier. Si vous avez des environnements supplémentaires, il faut ajouter des targets dans le fichier avec les paramètres correspondants. La target par défaut est définie comme dev. Pour lancer sur une autre target, il faut utiliser la commande dbt run --target target_name.

Maintenant que tout est configuré et que vous avez construit vos premiers modèles sql, voyons comment déployer dbt !

Déployer dbt

La première étape va être de tester le code dbt lors de chaque pull request pour vérifier que tout fonctionne correctement et comme attendu avant de merger. Le premier workflow pr.yml, qui doit être mis dans le dossier .github/workflows du projet, va ressembler à ceci :

name: CI_PR
run-name: ${{ github.actor }} lints & tests
on:
 pull_request:
   branches:
     - main

jobs:
 lint_dbt:
   name: lint_dbt
   runs-on: ubuntu-latest

   steps:
     - name: Checkout code
       uses: actions/checkout@v2

     - uses: ./.github/actions/install-python
       with:
         RequirementFilePath: ./requirements.txt


     - name: Linting
       run: |
         sqlfluff lint --dialect snowflake
  test_ci:
   name: test_dbt_ci
   runs-on: ubuntu-latest
   env:
     SNOWFLAKE_PASSWORD_DBT_DEV: ${{ secrets.SNOWFLAKE_PASSWORD_DBT_DEV }}
     DBT_ENV_SECRET_GIT_TOKEN: ${{ secrets.DBT_ENV_SECRET_GIT_TOKEN }}
   steps:
     - name: Checkout code
       uses: actions/checkout@v2

     - uses: ./.github/actions/install-python
       with:
         RequirementFilePath: ./requirements.txt

     - name: Integration tests
     	env:
         PR_SCHEMA_NAME: DBT_CI_${{ github.event.number }}
         DBT_PROFILES_DIR: ${{ vars.DBT_PROFILES_DIR }}
       run: |
         dbt deps --target ci
         dbt seed --full-refresh --target ci
         dbt run --target ci
         dbt test --target ci

Le workflow est déclenché lors de chaque pull request vers la branche main (et se relance lors de chaque nouveau push sur la branche tant que la pull request est ouverte).

Il se constitue de deux jobs différents :

  • un premier de linting pour vérifier la syntaxe du code sql,
  • un second pour effectuer des tests sur les données.

Les variables d’environnement sont mises dans les secrets/variables du repository github (settings puis secrets and variables).

Pour la variable du schema name, la commande ${{ github.event.number }} permet de récupérer le numéro de la pull request.

Pour ces deux jobs et les suivants, l’étape de checkout est obligatoire car elle permet à GitHub d’avoir accès aux différents fichiers.

Pour que GitHub ait accès aux commandes dbt, il faut qu’il installe les dépendances nécessaires. Ceci est fait avec l’action install-python (qui utilise python pour installer dbt). Une action GitHub est une “fonction/bout de code” réutilisable dans différents workflows. Pour définir une action, il faut créer un action.yml dans un dossier .github/actions/action_name.

Voici à quoi ressemble l’action install-python :

name: Install Python tooling
description: "Install python tools and dbt"

inputs: #Variable d'entrée de l'action
 PythonVersion:
   description: "PythonVersion"
   required: false
   default: 3.10.7
 RequirementFilePath:
   description: "Path to the requirements.txt"
   required: false
   default: "./requirements.txt"

runs:
 using: "composite"
 steps:
   - name: Set up Python
     uses: actions/setup-python@v2
     with:
       python-version: ${{ inputs.PythonVersion }}  # Specify your desired Python version
   - name: Install dependencies
     shell: bash
     run: |
       python -m pip install --upgrade pip
       pip install -r ${{ inputs.RequirementFilePath }}

Une fois que tous les tests sont passés, il faut déployer les modèles lors du merge de la pull request sur la branche de dev (main ici).

Le workflow main.yml commence donc avec ceci :

name: Deploy_dbt_dev
run-name: ${{ github.actor }} deploys dbt
on:
 push:
   branches:
     - main

Pour déployer en production, c’est la même chose avec le nom de la branche de production à la place. Il faut également penser, lors de commandes dbt, à changer le nom de la target pour respectivement "dev" et "prod".

La dernière étape est de faire tourner des jobs dbt des modèles de données de manière automatique afin que les nouvelles données soient transformées. Pour cela, il faut utiliser un cron :

name: scheduled_run

on:
 schedule:
   - cron: '30 13 * * *'#Le workflow se lance tous les jours à 13h30 UTC

L’inconvénient du cron est qu’il ne permet pas de choisir sur quelle branche il se lance. Il va toujours se déclencher sur la branche par défaut (main/master). Donc, pour déclencher le workflow sur, par exemple, la branche production, il faut préciser la branche lors du checkout :

steps:
     - name: Checkout code
       uses: actions/checkout@v2
       with:
         ref: prod

Voici le workflow complet de scheduled_run_prod.yml :

name: scheduled_run_prod

on:
 schedule:
   - cron: '30 13 * * *'#Le workflow se lance tous les jours à 13h30 UTC

jobs:
 scheduled_run:
   name: scheduled_run
   runs-on: ubuntu-latest
   env:
     SNOWFLAKE_PASSWORD_DBT_PROD: ${{ secrets.SNOWFLAKE_PASSWORD_DBT_PROD }}
     DBT_ENV_SECRET_GIT_TOKEN: ${{ secrets.DBT_ENV_SECRET_GIT_TOKEN }}

   steps:
     - name: Checkout code
       uses: actions/checkout@v2
       with:
         ref: prod

     - uses: ./.github/actions/install-python
       with:
         RequirementFilePath: ./requirements.txt

     - name: Run dbt
       env:
         DBT_PROFILES_DIR: ${{ vars.DBT_PROFILES_DIR }}
         PROD_SCHEMA_NAME: ${{ vars.PROD_SCHEMA_NAME }}
       run: |
         dbt deps --target prod
         dbt run --target prod 
         dbt test --target prod 

La CI/CD de votre projet dbt est désormais complète ! La syntaxe du code SQL et un échantillon de vos données sont d’abord testés lors d’une pull request (pr.yml). Puis vos modèles dbt ainsi que la documentation dbt sont générés lors d’un merge/push sur vos branches de développement (main.yml) et de production (prod.yml). Enfin, vos tables de données sont mises à jour et testées continuellement via le déclenchement du scheduled_run.yml (dev et prod) en fonction du paramétrage du cron.

Slim CI

dbt permet de ne lancer que les modèles qui ont été modifiés, ce qui permet de mettre en place une Slim CI et donc d’économiser des ressources et du temps de calcul.

Tous les projets dbt contiennent un fichier manifest.json, présent dans le dossier target/, qui représente l’état actuel du projet et qui se modifie à chaque lancement d’une commande dbt. La commande dbt run --select @state:modified --state comparison_manifest, avec comparison_manifest étant un manifest antérieur à celui actuel, permet de n'exécuter que les modèles ayant subis une modification.

L’objectif de la Slim CI est donc de stocker le manifest.json et de le récupérer à chaque appel de la CI, c’est-à-dire à chaque déclenchement du pr.yml , afin de le comparer à celui actuel du projet.

Pour stocker le fichier, plusieurs options sont possibles en fonction de votre cloud provider. L’une d’entre elles est de le stocker sur un bucket S3.

Le main.yml va donc contenir une étape supplémentaire pour publier le manifest.json sur le bucket S3. Dans ce cas, il faut également penser à configurer les informations d’authentification AWS (qui sont rentrées dans les secrets GitHub ici). On va donc avoir ceci dans notre main.yml :

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: eu-west-1

    - name: Copy manifest file to s3 bucket
      run: |
        aws s3 cp ./target/manifest.json s3://bucket_name/

Maintenant que le manifest est stocké sur le bucket S3 (et se met à jour à chaque déclenchement du main.yml), il faut aller le récupérer à chaque déclenchement de la CI. Le fichier pr.yml va donc également avoir une étape supplémentaire :

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: eu-west-1


    - name: Get manifest from s3
        run: |
          aws s3 cp s3://bucket_name/manifest.json ./ 

Ici, le manifest est déposé dans la racine du projet dbt. Mais il est possible de le mettre n’importe où, sauf à l’emplacement du manifest actuel du projet, pour ne pas que celui-ci soit remplacé.

Pour que la Slim CI soit effective, il faut maintenant changer les commandes dbt pour prendre en compte la comparaison des deux manifests (dans le pr.yml), en précisant l’emplacement du manifest récupéré depuis le bucket S3 :

     - name: Integration tests
     	env:
         PR_SCHEMA_NAME: DBT_CI_${{ github.event.number }}
         DBT_PROFILES_DIR: ${{ vars.DBT_PROFILES_DIR }}
       run: |
         dbt deps
         dbt seed --full-refresh --target ci
         dbt run --select @state:modified --state ./ --target ci
         dbt test --select @state:modified --state ./ --target ci

Publier la documentation dbt sur GitHub Pages

La documentation dbt, qui peut être générée avec la commande dbt docs generate, est au départ seulement disponible en local (avec la commande dbt docs serve) mais il existe différentes possibilités de la publier, dont l'utilisation des GitHub Pages.

Pour créer une GitHub Pages, il faut choisir une branche puis le répertoire qui contient les fichiers nécessaires à la création de la page web. Le répertoire est soit la racine, soit un répertoire docs. Dans le cas de cet article, la branche a été nommée gh-pages.

Dans dbt, la commande dbt docs generate crée tous les fichiers dans le dossier target/.

Il faut donc, pour créer la GitHub Pages contenant la documentation dbt, créer une branche (gh-pages dans l’article) qui contient, soit dans la racine, soit dans un répertoire docs, le dossier target/ uniquement. Une solution possible est de faire comme ceci :

#!/bin/bash

git checkout --orphan gh-pages
dbt deps --target dev 
dbt docs generate --target dev
mv docs tmp_docs
mv target docs
git config --global user.email "github_actions_bot@example.com"
git config --global user.name "github_bot"
git add -f docs
git commit -m "update documentation" docs
git push --force https://$DBT_ENV_SECRET_GIT_TOKEN@github.com/repository_name

Ce script (publish_docs.sh) renomme temporairement le dossier docs/ du projet dbt pour que le dossier target/ soit renommé docs/ à la place, avant de push sur le repository git sur la nouvelle branche gh-pages. Il est important de configurer un nouvel user et email puisque c’est un bot de GitHub qui va réaliser le commit et le push, même si les valeurs n’ont pas d’importance.

Il faut ensuite, dans le main.yml ou prod.yml, rajouter une étape qui fait tourner le script ci-dessus pour que le workflow déploie la documentation (cf workflow complet) .

Cependant, le workflow ne peut pas directement écrire sur un repository git. Il n’a pas les permissions nécessaires pour ceci. Il faut donc lui rajouter des droits d’écritures :

permissions: write-all

Au final, le workflow main.yml va être :

name: Deploy_dbt_dev
run-name: ${{ github.actor }} deploys dbt
on:
  push:
    branches:
      - main


permissions: write-all

jobs:
  dev:
    runs-on: ubuntu-latest
    env:
      SNOWFLAKE_PASSWORD_DBT_DEV: ${{ secrets.SNOWFLAKE_PASSWORD_DBT_DEV }}
      DBT_ENV_SECRET_GIT_TOKEN: ${{ secrets.DBT_ENV_SECRET_GIT_TOKEN }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - uses: ./.github/actions/install-python
        with:
          RequirementFilePath: ./requirements.txt

	- name: Deploy dbt dev
     	  env:
           DEV_SCHEMA_NAME: ${{ vars.DEV_SCHEMA_NAME }}
           DBT_PROFILES_DIR: ${{ vars.DBT_PROFILES_DIR }}
         run: |
           dbt deps –target dev
           dbt run --target dev
           dbt compile --target dev #génère la documentation

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-west-1

      - name: Copy manifest file to s3 bucket
        run: |
          aws s3 cp ./target/manifest.json s3://bucket_name/

      - name: Publish docs gh-pages
        env:
          DBT_PROFILES_DIR: ${{ vars.DBT_PROFILES_DIR }}
          DEV_SCHEMA_NAME: ${{ vars.DEV_SCHEMA_NAME }}
        run: ./cicd/publish_docs.sh

Désormais, à chaque déploiement de dbt sur la branche main (en dev ici), la documentation va être automatiquement générée et publiée sur une GitHub Pages (via la création de la branche gh-pages), dont le lien est accessible dans les settings (onglet pages) du repository git.

Conclusion

L’utilisation de GitHub Actions permet de se passer en partie de dbt Cloud et l’association avec un IDE tel que VSCode permet une meilleure expérience de développement.

Dans cet article, nous avons vu comment déployer dbt sur plusieurs environnements, faire tourner des jobs réguliers sur les modèles de données, optimiser du temps et des ressources avec la Slim CI et enfin de publier la documentation du projet dbt pour qu’elle soit hebergée sur GitHub Pages.

Documentation GitHub Actions : https://docs.github.com/fr/actions

Documentation dbt : https://docs.getdbt.com/docs/introduction