IaC Azure – OpenTofu via GitHub Actions

Dans cet article, nous allons mettre en place un pipeline GitHub Actions permettant de déployer automatiquement une infrastructure Azure à l’aide d’OpenTofu. L’objectif est de montrer comment industrialiser le provisioning de ressources cloud, en adoptant une approche Infrastructure as Code (IaC) reproductible, versionnée et totalement automatisée.

Cette approche me permet aussi d’approvisionner rapidement des environnements de test et de les détruire tout aussi facilement, ce qui contribue à mieux maîtriser les coûts.

OpenTofu en bref

OpenTofu est un fork open source de Terraform, créé suite au changement de licence de Terraform vers BUSL 1.1 (Business Source License). Cette licence n’étant plus considérée comme open source, OpenTofu propose une alternative 100 % ouverte, tout en restant compatible avec la syntaxe et les providers Terraform. Cela signifie que les utilisateurs peuvent migrer leurs configurations existantes sans effort, tout en conservant les mêmes workflows et outils.

Le projet OpenTofu est accessible ici

GitHub - opentofu/opentofu: OpenTofu lets you declaratively manage your cloud infrastructure.
OpenTofu lets you declaratively manage your cloud infrastructure. - opentofu/opentofu

Qu'est-ce que GitHub Actions

GitHub Actions est la plateforme CI/CD intégrée à GitHub. Elle permet d’automatiser des workflows. Dans ce contexte, elle servira simplement à exécuter OpenTofu afin de déployer notre infrastructure Azure de manière automatisée et reproductible.

Pour plus d’informations, consultez la page officielle de github

GitHub Actions
Easily build, package, release, update, and deploy your project in any language—on GitHub or any external system—without having to run code yourself.

Exemple d'Infrastructure Azure à déployer

Dans notre exemple, nous partons sur une architecture classique autour d’Azure Databricks pour construire des pipelines ETL batch et streaming avec Delta Lake.

Cette architecture sera composée d’un Azure Data Factory pour l’ingestion orchestrée, d’un Azure Event Hubs pour les flux temps réel, d’un compte Azure Data Lake Storage Gen2 pour le stockage des données en différentes zones, et d’un workspace Azure Databricks pour les transformations batch et streaming.

Concrètement, les ressources à créer seront :

  • Un Resource Group
  • Un compte Storage ADLS Gen2
  • Un Event Hub
  • Un Azure Data Factory
  • Un workspace Azure Databricks
  • Un Azure Key Vault pour centraliser les secrets

Ces ressources seront déployées sur plusieurs environnements (dev, int, uat et prd) afin de simuler un cas réaliste et de pouvoir tester des déploiements applicatifs de bout en bout.

Pour une description plus complète de cette architecture, vous pouvez consulter la documentation Microsoft.

Build ETL pipelines with Azure Databricks and Delta Lake - Azure Architecture Center
Create ETL pipelines for batch and streaming data with Azure Databricks to simplify data lake ingestion at any scale.

Pour faciliter le suivi, la reprise et la destruction des déploiements, un backend OpenTofu sera utilisé et hébergé dans Azure.

Autoriser Github action dans Azure

Pour permettre à GitHub Actions de déployer notre infrastructure, nous configurons l’authentification OIDC entre GitHub et Azure. Azure AD émettra alors un jeton d’accès éphémère pour chaque workflow. Techniquement, cela consiste à créer une Federated Credential sur le service principal, liée au repository et au workflow GitHub. Une fois en place, les pipelines peuvent s’authentifier sans secret statique et exécuter directement les commandes OpenTofu ou Azure CLI.

💡
j’utilise ici Bash avec Azure CLI, mais la même procédure peut être réalisée en PowerShell

Commencez par créer une application enregistrée dans Microsoft Entra ID, qui servira de service principal pour l’authentification OIDC depuis GitHub Actions.

az ad app create --display-name "sqlyotta-github-oidc"

Notez l’appId retourné ; nous l’utiliserons plus loin sous l’identifiant <APP_ID>.

Créez également un service principal associé à cet <APP_ID>.

az ad sp create --id "<APP_ID>"

Attribuez le rôle Owner au niveau de la souscription (dans notre cas, un environnement de test). En production, un rôle Contributor appliqué au niveau du Resource Group est généralement suffisant.

az role assignment create \
  --assignee <APP_ID> \
  --role "Owner" \
  --scope "/subscriptions/<SUBSCRIPTION_ID>"

Ajoutez maintenant une Federated Credential. C’est l’élément central du mécanisme OIDC entre GitHub et Azure. À chaque exécution d’un workflow, GitHub émet un jeton OIDC incluant des claims tels que le repository, la branche et l’environnement (si défini dans GitHub Actions). Azure utilisera ces claims pour valider l’identité du workflow et délivrer un jeton d’accès éphémère au service principal.

# DEV
az ad app federated-credential create \
  --id "<APP_ID>" \
  --parameters '{
    "name": "github-oidc-dev-credential",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<OWNER>/<REPO>:environment:dev",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# INT
az ad app federated-credential create \
  --id "<APP_ID>" \
  --parameters '{
    "name": "github-oidc-int-credential",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<OWNER>/<REPO>:environment:int",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# UAT
az ad app federated-credential create \
  --id "<APP_ID>" \
  --parameters '{
    "name": "github-oidc-uat-credential",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<OWNER>/<REPO>:environment:uat",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# PRD
az ad app federated-credential create \
  --id "<APP_ID>" \
  --parameters '{
    "name": "github-oidc-prd-credential",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<OWNER>/<REPO>:environment:prd",
    "audiences": ["api://AzureADTokenExchange"]
  }'

Création d’un Storage Account destiné au backend OpenTofu.

# Variables
LOCATION="francecentral"
RG_NAME="rg-iac-state"
STO_NAME="stsqlyottaiacstate"
CONTAINER_NAME="tfstate"

# Resource group
az group create \
  --name "$RG_NAME" \
  --location "$LOCATION"

# Storage Account
az storage account create \
  --name "$STO_NAME" \
  --resource-group "$RG_NAME" \
  --location "$LOCATION" \
  --sku Standard_LRS \
  --kind StorageV2

# Container pour les tfstate
az storage container create \
  --name "$CONTAINER_NAME" \
  --account-name "$STO_NAME"

Récupérer l’ID du Storage Account.

STO_ID=$(az storage account show \
  --name stsqlyottaiacstate \
  --resource-group rg-iac-state \
  --query "id" -o tsv)

Attribuer le rôle Storage Blob Data Contributor.

az role assignment create \
  --assignee <APP_ID> \
  --role "Storage Blob Data Contributor" \
  --scope "$STO_ID"

Création des secrets dans Github

Dans GitHub, créez les secrets nécessaires pour accéder à l’environnement de test. Notez que certains secrets sont définis au niveau du repository et d’autres au niveau des environnements GitHub Actions, afin de refléter une pratique courante où chaque environnement (dev, int, uat, prd) peut correspondre à une souscription Azure distincte.

Tester la connexion entre GitHub Actions et Azure

À l’aide d’un workflow minimal, nous allons valider la connectivité et les permissions entre GitHub Actions et Azure.

Créez la structure suivante dans votre repository GitHub :

Ci-dessous, le contenu du fichier oidc-test.yml.

name: Test Azure OIDC Connection

on:
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  test-azure-oidc:
    runs-on: ubuntu-latest
    environment: dev

    steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Login to Azure using OIDC
      uses: azure/login@v2
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    - name: Show Azure account
      run: |
        echo "== az account show =="
        az account show

    - name: List resource groups
      run: |
        echo "== az group list =="
        az group list --query "[].{name:name, location:location}" -o table

    - name: List storage accounts
      run: |
        echo "== az storage account list =="
        az storage account list --query "[].{name:name, rg:resourceGroup}" -o table

Lancez le workflow afin de confirmer que la connectivité est opérationnelle.

Comme vous pouvez le constater, le workflow s’exécute avec succès et parvient à lister les Resource Groups ainsi que les comptes de stockage, dont celui destiné à héberger le backend OpenTofu (créé précédemment).

Définition de l’infrastructure (OpenTofu / Terraform)

Cette section présente l’ensemble des fichiers OpenTofu utilisés pour définir et déployer l’infrastructure Azure. On y retrouve la configuration du backend, les providers, les variables, ainsi que les ressources nécessaires pour provisionner Data Factory, Event Hubs, Databricks, le Data Lake et les composants IAM associés.

Chaque environnement (dev, int, uat, prd) dispose également de ses propres fichiers .tfvars pour isoler la configuration et permettre des déploiements indépendants.

infra/opentofu/backend.tf

Ce fichier configure le backend distant OpenTofu/Terraform dans Azure Storage. Il définit le Resource Group, le Storage Account, le container et la clé où sera stocké l’état (tfstate).

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-iac-state"
    storage_account_name = "stsqlyottaiacstate"
    container_name       = "tfstate"
    key                  = "placeholder.tfstate"
  }
}

infra/opentofu/main.tf

Ce fichier décrit le cœur de l’infrastructure : Resource Group, compte ADLS Gen2, Data Lake filesystem, Event Hub , Key Vault, Data Factory, Databricks workspace et les rôles IAM associés.

########################################
# Locals
########################################
locals {
  env_suffix      = lower(var.environment)

  rg_name         = "rg-${var.project_prefix}-${local.env_suffix}"
  storage_name    = "st${var.project_prefix}${local.env_suffix}" # doit être unique globalement
  kv_name         = "kv-${var.project_prefix}-${local.env_suffix}"
  adf_name        = "adf-${var.project_prefix}-${local.env_suffix}"
  databricks_name = "dbw-${var.project_prefix}-${local.env_suffix}"
  datalake_fs     = "datalake"

  eventhub_ns_name = "evhns-${var.project_prefix}-${local.env_suffix}"
  eventhub_name    = "evh-${var.project_prefix}-${local.env_suffix}"
}

data "azurerm_client_config" "current" {}

########################################
# 1. Resource Group
########################################
resource "azurerm_resource_group" "rg" {
  name     = local.rg_name
  location = var.location
}

########################################
# 2. Storage Account (Data Lake Gen2)
########################################
resource "azurerm_storage_account" "datalake" {
  name                     = local.storage_name
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  account_kind             = "StorageV2"

  # Data Lake Gen2
  is_hns_enabled = true
}

resource "azurerm_storage_data_lake_gen2_filesystem" "fs" {
  name               = local.datalake_fs
  storage_account_id = azurerm_storage_account.datalake.id
}

# Conteneur pour la capture Event Hub (raw events)
resource "azurerm_storage_container" "eventhub_capture" {
  name                  = "raw-eventhub"
  storage_account_id    = azurerm_storage_account.datalake.id
  container_access_type = "private"
}

########################################
# 3. Key Vault
########################################
resource "azurerm_key_vault" "kv" {
  name                = local.kv_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"

  soft_delete_retention_days = 7
  purge_protection_enabled   = false

  # Accès pour l'utilisateur courant (toi)
  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = data.azurerm_client_config.current.object_id

    secret_permissions = [
      "Get",
      "List",
      "Set",
      "Delete"
    ]
  }

  tags = {
    environment = var.environment
    project     = var.project_prefix
  }
}

########################################
# 4. Data Factory
########################################
resource "azurerm_data_factory" "adf" {
  name                = local.adf_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  identity {
    type = "SystemAssigned"
  }

  tags = {
    environment = var.environment
    project     = var.project_prefix
  }
}

########################################
# 5. IAM : ADF → Storage
########################################
data "azurerm_role_definition" "storage_blob_data_contributor" {
  name  = "Storage Blob Data Contributor"
  scope = azurerm_storage_account.datalake.id
}

resource "azurerm_role_assignment" "adf_to_datalake" {
  scope              = azurerm_storage_account.datalake.id
  role_definition_id = data.azurerm_role_definition.storage_blob_data_contributor.id
  principal_id       = azurerm_data_factory.adf.identity[0].principal_id
}

########################################
# 6. IAM : ADF → Key Vault
########################################
resource "azurerm_key_vault_access_policy" "adf_kv_policy" {
  key_vault_id = azurerm_key_vault.kv.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = azurerm_data_factory.adf.identity[0].principal_id

  secret_permissions = [
    "Get",
    "List"
  ]
}

########################################
# 7. Databricks Workspace
########################################
resource "azurerm_databricks_workspace" "dbw" {
  name                = local.databricks_name
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  # Standard / Premium / Trial
  sku = "standard"

  # RG managé créé automatiquement par Databricks
  managed_resource_group_name = "rg-${var.project_prefix}-${local.env_suffix}-dbw-managed"

  tags = {
    environment = var.environment
    project     = var.project_prefix
  }
}

########################################
# 9. Azure Event Hubs
########################################

resource "azurerm_eventhub_namespace" "eh_ns" {
  name                = local.eventhub_ns_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  sku                 = "Standard"
  capacity            = 1

  tags = {
    environment = var.environment
    project     = var.project_prefix
  }
}

resource "azurerm_eventhub" "eh" {
  name                = local.eventhub_name
  namespace_name      = azurerm_eventhub_namespace.eh_ns.name
  resource_group_name = azurerm_resource_group.rg.name
  partition_count     = 4
  message_retention   = 1 # jours

  capture_description {
    enabled  = true
    encoding = "Avro"

    interval_in_seconds = 300
    size_limit_in_bytes = 10485760

    destination {
      name                = "EventHubArchive.AzureBlockBlob"
      storage_account_id  = azurerm_storage_account.datalake.id
      blob_container_name = azurerm_storage_container.eventhub_capture.name
      archive_name_format = "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}"
    }
  }
}

resource "azurerm_eventhub_namespace_authorization_rule" "eh_ns_sas" {
  name                = "sas-adf-or-apps"
  namespace_name      = azurerm_eventhub_namespace.eh_ns.name
  resource_group_name = azurerm_resource_group.rg.name

  listen = true
  send   = true
  manage = false
}

resource "azurerm_key_vault_secret" "eh_connection_string" {
  name         = "eventhub-connection-string"
  value        = azurerm_eventhub_namespace_authorization_rule.eh_ns_sas.primary_connection_string
  key_vault_id = azurerm_key_vault.kv.id
}

infra/opentofu/providers.tf

Ce fichier déclare les providers nécessaires : azurerm pour les ressources Azure et azuread pour l’identité (Entra ID). C’est ici que l’on active les fonctionnalités du provider Azure.

provider "azurerm" {
  features {}
  resource_provider_registrations = "none"
}

provider "azuread" {}

infra/opentofu/variables.tf

Ce fichier centralise les variables partagées : préfixe de projet, environnement et région. Les différents environnements (dev/int/uat/prd) surchargent ces valeurs via leurs fichiers .tfvars.

variable "project_prefix" {
  type    = string
  default = "sqlyotta"
}

variable "environment" {
  type = string
}

variable "location" {
  type    = string
  default = "francecentral"
}

infra/opentofu/envs/dev.tfvars (int/uat/prd)

Les fichiers .tfvars définissent les valeurs spécifiques à chaque environnement (nom logique, région, etc.). Ils permettent de réutiliser le même code main.tf pour dev, int, uat et prd simplement en changeant le fichier de variables passé à OpenTofu.

environment   = "dev"
location      = "westus"

Workflows GitHub Actions

Cette section présente les workflows GitHub Actions utilisés pour automatiser le déploiement de l’infrastructure Azure.

.github/workflows/opentofu-pipeline.yml

Ce workflow GitHub Actions orchestre le déploiement multi-environnements de l’infrastructure OpenTofu. Il exécute successivement les stages DEV, INT, UAT et PROD, chacun utilisant son fichier .tfvars et son backend d’état dédié. À chaque étape, le pipeline s’authentifie via OIDC, initialise le backend, puis applique la configuration OpenTofu de manière automatisée.

name: "OpenTofu Multi-Stage Pipeline (Databricks)"

on:
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  TF_WORKING_DIR: infra/opentofu

jobs:
  # -------------------------------
  #  STAGE 1 : DEV
  # -------------------------------
  deploy_dev:
    name: "Deploy DEV"
    runs-on: ubuntu-latest
    environment: dev

    env:
      ARM_USE_OIDC: true
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Install OpenTofu
        uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: "1.8.3"

      - name: OpenTofu Init DEV
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu init \
            -backend-config="key=lakehouse-dev.tfstate"

      - name: OpenTofu Apply DEV (full)
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu apply \
            -lock-timeout=5m \
            -auto-approve \
            -input=false \
            -var-file="envs/dev.tfvars"

  # -------------------------------
  #  STAGE 2 : INT
  # -------------------------------
  deploy_int:
    name: "Deploy INT"
    runs-on: ubuntu-latest
    needs: deploy_dev
    environment: int

    env:
      ARM_USE_OIDC: true
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Install OpenTofu
        uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: "1.8.3"

      - name: OpenTofu Init INT
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu init \
            -backend-config="key=lakehouse-int.tfstate"

      - name: OpenTofu Apply INT (full)
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu apply \
            -lock-timeout=5m \
            -auto-approve \
            -input=false \
            -var-file="envs/int.tfvars"

  # -------------------------------
  #  STAGE 3 : UAT
  # -------------------------------
  deploy_uat:
    name: "Deploy UAT"
    runs-on: ubuntu-latest
    needs: deploy_int
    environment: uat

    env:
      ARM_USE_OIDC: true
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Install OpenTofu
        uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: "1.8.3"

      - name: OpenTofu Init UAT
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu init \
            -backend-config="key=lakehouse-uat.tfstate"

      - name: OpenTofu Apply UAT (full)
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu apply \
            -lock-timeout=5m \
            -auto-approve \
            -input=false \
            -var-file="envs/uat.tfvars"

  # -------------------------------
  #  STAGE 4 : PROD
  # -------------------------------
  deploy_prd:
    name: "Deploy PROD"
    runs-on: ubuntu-latest
    needs: deploy_uat
    environment: prd

    env:
      ARM_USE_OIDC: true
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Install OpenTofu
        uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: "1.8.3"

      - name: OpenTofu Init PROD
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu init \
            -backend-config="key=lakehouse-prd.tfstate"

      - name: OpenTofu Apply PROD (full)
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu apply \
            -lock-timeout=5m \
            -auto-approve \
            -input=false \
            -var-file="envs/prd.tfvars"

.github/workflows/opentofu-destroy.yml

Ce second workflow permet de détruire sélectivement un environnement OpenTofu. L’utilisateur choisit l’environnement à supprimer (dev, int, uat ou prd) lors du déclenchement manuel. Le pipeline s’authentifie via OIDC, initialise le backend associé à l’environnement, puis exécute un tofu destroy avec le fichier .tfvars correspondant.

name: "OpenTofu Destroy"

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Environment to destroy"
        required: true
        type: choice
        options:
          - dev
          - int
          - uat
          - prd

permissions:
  id-token: write
  contents: read

env:
  TF_WORKING_DIR: infra/opentofu

jobs:
  destroy:
    name: "Destroy environment"
    runs-on: ubuntu-latest

    environment: ${{ github.event.inputs.environment }}

    env:
      ARM_USE_OIDC: true
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Install OpenTofu
        uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: "1.8.3" # ou "latest"

      - name: OpenTofu Init
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu init \
            -backend-config="key=lakehouse-${{ github.event.inputs.environment }}.tfstate"

      - name: OpenTofu Destroy
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          tofu destroy -auto-approve \
          -input=false \
            -var-file="envs/${{ github.event.inputs.environment }}.tfvars"

.github/workflows/opentofu-unlock.yml

Ce workflow sert à déverrouiller manuellement un fichier d’état OpenTofu lorsque celui-ci reste bloqué dans Azure Storage, par exemple après une exécution interrompue. L’utilisateur sélectionne l’environnement à débloquer, puis le pipeline s’authentifie via OIDC, identifie le blob correspondant au fichier tfstate et vérifie son état de verrouillage. Si un lease actif est détecté, il est automatiquement brisé via Azure CLI.

Ce mécanisme permet surtout de rétablir un état propre du backend et d’éviter les blocages lors des prochains déploiements.

name: "OpenTofu Unlock"

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Environment (dev, int, uat, prd)"
        required: true
        default: "dev"
        type: choice
        options:
          - dev
          - int
          - uat
          - prd

permissions:
  id-token: write
  contents: read

concurrency:
  group: opentofu-unlock
  cancel-in-progress: false

env:
  TFSTATE_RG: rg-iac-state
  TFSTATE_STORAGE: stsqlyottaiacstate
  TFSTATE_CONTAINER: tfstate

jobs:
  unlock:
    runs-on: ubuntu-latest
    environment: dev

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Compute tfstate blob name
        id: tfstate
        run: |
          ENV="${{ github.event.inputs.environment }}"
          echo "blob_name=lakehouse-${ENV}.tfstate" >> $GITHUB_OUTPUT

      - name: Show current lease state
        run: |
          az storage blob show \
            --account-name $TFSTATE_STORAGE \
            --container-name $TFSTATE_CONTAINER \
            --name "${{ steps.tfstate.outputs.blob_name }}" \
            --auth-mode login \
            --query "properties.lease"

      - name: Break lease if locked
        run: |
          STATUS=$(az storage blob show \
            --account-name $TFSTATE_STORAGE \
            --container-name $TFSTATE_CONTAINER \
            --name "${{ steps.tfstate.outputs.blob_name }}" \
            --auth-mode login \
            --query "properties.lease.status" -o tsv || echo "none")

          if [ "$STATUS" = "locked" ]; then
            echo "Lease locked, breaking..."
            az storage blob lease break \
              --account-name $TFSTATE_STORAGE \
              --container-name $TFSTATE_CONTAINER \
              --blob-name "${{ steps.tfstate.outputs.blob_name }}" \
              --auth-mode login
          else
            echo "No active lock (status=$STATUS)"
          fi

      - name: Check lease after break
        run: |
          az storage blob show \
            --account-name $TFSTATE_STORAGE \
            --container-name $TFSTATE_CONTAINER \
            --name "${{ steps.tfstate.outputs.blob_name }}" \
            --auth-mode login \
            --query "properties.lease"

Exécution du workflow

Pour lancer un workflow GitHub Actions, il suffit d’aller dans l’onglet Actions du repository, de sélectionner le workflow souhaité, puis de cliquer sur Run workflow. GitHub permet de choisir la branche ou les paramètres d’entrée définis dans le workflow.

Nous commençons par lancer le workflow chargé de créer l’infrastructure Azure. Celui-ci initialise le backend, s’authentifie via OIDC, puis déploie les ressources définies dans le code OpenTofu pour l’environnement sélectionné.

Nous avons bien entendu accès aux logs du workflow, ce qui permet de suivre en temps réel chaque étape du déploiement et de diagnostiquer rapidement d’éventuelles erreurs.

Dans Azure, les ressources sont correctement créées, comme illustré ci-dessous.

Comme nous déployons des workspaces Databricks, les Resource Groups managés associés sont également créés automatiquement.

Déstruction d'un environnement

Pour supprimer un environnement, il suffit de lancer le workflow de destruction dédié dans GitHub Actions. Après sélection de l’environnement ciblé (dev, int, uat ou prd), le pipeline s’authentifie via OIDC, initialise le backend correspondant puis exécute un tofu destroy avec les variables associées.

L’ensemble des ressources de l’environnement sont alors retirées proprement, garantissant un cycle complet de création–destruction entièrement automatisé.

Prochain article, nous parlerons davantage data… et un peu moins infra 😉