Création d’un plan Terraform pour Azure

Dans notre dernier article, nous avons présenté de manière générale l’infrastructure as code et les effets bénéfiques que cela pouvait avoir sur notre capacité à livrer les changements. Nous allons maintenant rentrer dans le vif du sujet et écrire notre premier plan Terraform.

Structure d’un projet Terraform

Nous allons voir ici comment se découpe un projet Terraform, ci-dessous une arborescence de projet Terraform classique :

.
├── main.tf
├── variables.tf
├── outputs.tf

Nous allons passer en revu ces trois fichiers et détailler leur utilité

Main.tf

C’est le fichier principal dans lequel on va détailler les ressources de notre infrastructure par convention on le nomme main.tf mais vous pouvez choisir un nom qui décrit mieux le contenu de ce fichier.

Le fichier principal ce compose de 2 parties dans un premier temps la déclaration des providers que nous utiliserons :

provider "azurerm" {
  version = "~>1.33.0"
}

Ici nous déclarons que nous allons utiliser uniquement le provider azurerm qui permet de déployer tous les types de services Azure excepté ce qui touche à l’Active Directory et on demande la dernière révision de la version 1.33.

Ensuite nous allons déclarer toutes les ressources dont nous aurons besoin. Il y a 2 types de bloc pour décrire les ressources nécessaires à notre infrastructure :

  • resource : Ce sont des blocs qui vont créer des services dans azure. Ici nous déclarons un « resource group ».
resource "azurerm_resource_group" "main" {
  name     = "${var.appName}-${var.env}"
  location = "France Central"
}
  • datasource : Ce sont des blocs qui ne vont pas créer de service mais qui vont nous permettre d’accéder à des informations à propos d’un service Azure déjà déployé et que nous ne souhaitons pas gérer dans notre plan actuel.

Ci-dessous nous allons récupérer les informations d’un utilisateur AD pour le mettre administrateur d’une base de données SQL.

data "azuread_user" "sql" {
  user_principal_name = "${var.db_ad_admin.login}"
}

resource "azurerm_sql_active_directory_administrator" "sql" {
  server_name         = "${azurerm_sql_server.sql.name}"
  resource_group_name = "${azurerm_resource_group.ressource_group.name}"
  login               = "${azuread_user.sql.mail}"
  tenant_id           = "${var.tenant_id}"
  object_id           = "${azuread_user.sql.id}"
}

Ci-dessous le script complet qui nous permet de déployer une BDD SQL, avec un administrateur Active Directory afin de mettre à profit les managed identities for azure resources pour autoriser nos applications identifiées dans l’Active Directory à accéder à la BDD sans mot de passe dans la chaine de connexion. Pour plus de détails sur les managed identities, Nicolas vous explique ça ici.

provider "azurerm" {
  version = "~>1.33.0"
}

provider "azuread" {
  version = "~>0.6.0"
}

data "azuread_user" "sql" {
  user_principal_name = "${var.db_ad_admin.login}"
}

# Ressource group

resource "azurerm_resource_group" "ressource_group" {
  name     = "${var.appName}-${var.env}"
  location = "France Central"

    tags = {
    environement="${var.env}"
  } 
}

# SQL database

resource "azurerm_sql_server" "sql" {
  name                         = "${var.appName}-sqlsvr"
  resource_group_name          = "${azurerm_resource_group.ressource_group.name}"
  location                     = "${azurerm_resource_group.ressource_group.location}"
  version                      = "12.0"
  administrator_login          = "${var.dblogin}"
  administrator_login_password = "${var.dbpwd}"

  tags = {
    environement="${var.env}"
  } 
}

resource "azurerm_sql_database" "sql" {
  name                             = "${var.appName}"
  resource_group_name              = "${azurerm_resource_group.ressource_group.name}"
  location                         = "${azurerm_resource_group.ressource_group.location}"
  server_name                      = "${azurerm_sql_server.sql.name}"
  edition                          = "Basic"
  collation                        = "Latin1_General_CI_AI"
  create_mode                      = "Default"
  requested_service_objective_name = "Basic"

    tags = {
    environement="${var.env}"
  } 
}

resource "azurerm_sql_firewall_rule" "allow_all_azure_ips" {
  name                = "AllowAllAzureIps"
  resource_group_name = "${azurerm_resource_group.ressource_group.name}"
  server_name         = "${azurerm_sql_server.sql.name}"
  start_ip_address    = "0.0.0.0"
  end_ip_address      = "0.0.0.0"
}

resource "azurerm_sql_active_directory_administrator" "sql" {
  server_name         = "${azurerm_sql_server.sql.name}"
  resource_group_name = "${azurerm_resource_group.ressource_group.name}"
  login               = "${azuread_user.sql.mail}"
  tenant_id           = "${var.tenant_id}"
  object_id           = "${azuread_user.sql.id}"
}

Variables.tf

Dans notre fichier main.tf vous avez sans doute remarqué l’utilisation de ${var.[var_name]} cela indique que nous avons déclaré une variable dans le fichier variables.tf.

variable "env" {
  description = "The environement used for all resources"
  default = "test"
}

Un bloc de variable est composé d’un nom, ici « env », et d’une description (qui n’est pas obligatoire mais fortement recommandé) et d’une valeur par défaut (non obligatoire). Les variables sans valeur par défaut seront demandées par l’invite de commande au lancement du plan. Il est aussi possible de déclarer des objets comme variable.

variable "appPlan" {
  type        = object({tier = string, size = string})
  description = "The plan in witch the app services will run"
  default     = {tier = "F1", size = "Dev"}
}

Il est aussi possible de remplir ces variables automatiquement en créant un fichier terraform.tfvars et de simplement affecter les valeurs aux variables. Ce fichier ne devrait jamais être commité avec des valeurs dedans. Par contre il peut être utiliser dans Azure Dev Ops avec des variables tokenisé remplies en fonction de l’environnement de déploiement.

env = "prod"

Outputs.tf

Le fichier outputs.tf lui permet de déclarer des variables de sorties qui pourront être utilisées dans un second temps pour lancer des scripts de configuration ou pour fournir des valeurs à un autre module Terraform (cf. ci-après).

output "connection_string" {
    value = "Server=tcp:${azurerm_sql_server.sql.fully_qualified_domain_name},1433;Database=${azurerm.azurerm_sql_database.sql.name}"
}

Ici par exemple nous allons construire la connection string de la BDD que nous venons de monter avec notre plan. On va récupérer le nom de domaine et le nom de la base en utilisant la syntaxe suivante :

${[resource_type].[resource_name].[resource_property]}

Les modules Terraform

Un autre élément important dans Terraform sont les modules, ils vont nous permettre de séparer notre plan en plusieurs briques logiques. Un module est un ensemble de ressources qui sont utilisées ensemble et qui servent le même but.

Ces modules pourront être réutilisés soit dans le même projet ou alors dans d’autres projets.

Notre plan actuel ne fait que monter une BDD SQL on peut donc l’isoler dans un module que l’on pourra réutiliser ultérieurement. Pour créer un module, c’est très simple il suffit juste de bouger nos trois fichier (main.tf, variables.tf & outputs.tf) dans un sous dossier, ci-dessous la nouvelle arborescence de notre projet :

.
├── main.tf
├── variables.tf
├── outputs.tf
├── ...
├── modules/
│   ├── SQL/
│   │   ├── variables.tf
│   │   ├── main.tf
│   │   ├── outputs.tf

A noter que nous n’avons modifié aucun de nos fichiers, toutes nos valeurs de configurations qui pouvaient potentiellement changer ont été variabilisées. C’est un point très important quand on veut rendre nos modules réutilisables de tous, sortir dans des variables quitte à leur affecter des valeurs par défaut.

Maintenant nous allons utiliser notre module dans notre fichier main.tf à la racine du projet, c’est maintenant notre root module.

module "sql" {
  source          = "./Modules/SQL"

  env             = "${var.env}"
  app_name        = "${var.app_name}"
  db_access_group = "${var.db_access_group}"
  db_pwd          = "${var.db_pwd}"
  db_ad_admin     = "${var.db_ad_admin}"
  tenant_id       = "${var.tenant_id}"
}

On voit ici que l’on indique le path de notre module et qu’ensuite nous lui passons toutes les variables qui ont été déclarées dans /Modules/SQL/variables.tf. Et voilà notre module est consommé. A noter que les modules peuvent aussi être hébergé sur le web. Terraform propose un registre de modules créés par la communauté ou les fournisseurs de cloud. Pour ce faire il suffit d’enlever de spécifier le chemin du module dans le registre par exemple si nous voulons utiliser le module de création de BDD mis à disposition par Microsoft :

module "database" {
  source  = "Azure/database/azurerm"
  version = "1.1.0"
  # insert the required variables here
}

Vous noterez l’absence de « ./ », c’est ce qui permet à Terraform de savoir si il doit charger le module depuis un chemin local ou depuis le registre de modules.

Il y a un certain nombre de bonnes pratiques à mettre en place quand on commence à utiliser les modules comme on vient de le faire. Terraform est très permissif sur le sujet et il est possible d’avoir une arborescence de module très complexe avec des modules enfants, cela devient vite très compliqué à maintenir et on se retrouve avec des dépendances très difficiles à identifier. C’est pourquoi il vaut mieux garder une hiérarchie plate et d’utiliser ce que l’on appelle la composition de modules.

Dans ce cadre-là notre root module ne doit être plus qu’une suite de modules qui se transfère des infos et ne doit plus déclarer aucune ressource.

Ici nous utilisons par exemple à la suite de notre module SQL un module App Service qui va monter une App Service en lui passant la connection string crée par le module SQL :

module "sql" {
  source          = "./Modules/SQL"

  env             = "${var.env}"
  app_name        = "${var.app_name}"
  db_access_group = "${var.db_access_group}"
  db_pwd          = "${var.db_pwd}"
  db_ad_admin     = "${var.db_ad_admin}"
  tenant_id       = "${var.tenant_id}"
}


module "app_service" {
  source  = "./Module/AppService"
  
  connection_string = "${module.sql.connection_string}"
}

Les commandes Terraform

Comme je vous le disais plus haut Terraform ne fait pas que décrire l’infrastructure contrairement à ARM, qui est juste un système de templates, ils doivent être déployés en utilisant l’API de management d’Azure ou directement le portail. Terraform va déployer, maintenir et détruire si besoin notre infrastructure.

Pour cela il faut bien entendu s’identifier sur Azure. Nous utiliserons l’Azure CLI avec la commande suivante (uniquement dans le cadre de test) qui permet de nous authentifier sur Azure :

az login

puis

az account set –subscription [subscription_id]

avec le bon id de la subscription Azure ciblée si l’on à plusieurs subscription liées au même compte. Terraform utilisera ensuite notre compte pour déployer dans Azure. En production il faudra passer par un Service Principal dans Azure Active Directory pour exécuter le plan Terraform.

Terraform va nous exposer des commandes pour remplir ces différentes tâches. Je vais détailler uniquement les commandes les plus courantes pour déployer un plan.

Init : Cette commande va inspecter nos fichier «.tf» pour détecter les versions des providers à aller récupérer et créer le dossier «.terraform». Cette commande initialise le dossier de travail de Terraform.

Validate : Une fois que nous avons écrit notre plan, il est préférable de vérifier sa validité avant de le pousser sur le contrôle de code source. Cette commande va nous analyser le plan et afficher en output de la console chaque erreur détectée.

Cette commande n’est pas magique elle ne va pas nous identifier les erreurs de configuration et n’assure pas que notre infra soit fonctionnelle mais elle s’assure que Terraform pourra appliquer notre plan.

Plan : Cette commande va nous générer un plan d’exécution qui va détailler tout ce qui va être déployé/supprimé/modifié par Terraform. Cela va nous permettre de valider les changements induis par notre plan, cet output peut aussi être utilisé dans une pull-request afin d’indiquer au reviewer les modifications qui seront appliquées et ainsi valider ou non ces modifications de manière plus simple. Voici un exemple tronqué de plan généré par cette commande :

An execution plan has been generated and is shown below.                                                                                                                              
Resource actions are indicated with the following symbols:                                                                                                                            
  + create
  - destroy
  ~ modify                                                                                                                                                                           
                                                                                                                                                                                      
Terraform will perform the following actions:                                                                                                                                         
                                                                                                                                                                                      
  # azurerm_resource_group.ressource_group will be created                                                                                                                            
  + resource "azurerm_resource_group" "ressource_group" {                                                                                                                             
      + id       = (known after apply)                                                                                                                                                
      + location = "francecentral"                                                                                                                                                    
      + name     = "terraform-demo-dev"                                                                                                                                               
      ~ tags     = {                                                                                                                                                                  
          ~ "environement" = "dev"                                                                                                                                                    
        }                                                                                                                                                                             
    }                                                                                                                                                                                 
                                                                                                                                                                                      
  # azurerm_sql_active_directory_administrator.sql will be created                                                                                                                    
  + resource "azurerm_sql_active_directory_administrator" "sql" {                                                                                                                     
      + id                  = (known after apply)                                                                                                                                     
      + login               = "Etienne.Pommier@dcube.fr"                                                                                                                              
      + object_id           = "434a4493-851a-4927-b313-1cf9e8602f64"                                                                                                                  
      + resource_group_name = "terraform-demo-dev"                                                                                                                                    
      + server_name         = "terraform-demo-sqlsvr"                                                                                                                                 
      + tenant_id           = "84e25fff-8a01-49fa-9024-ac157f75279d"                                                                                                                  
    }                                                                                                                                                                                                                                                                                                                                                               
                                                                                                                                                                                                                                                                                                                                                     
  # azurerm_sql_firewall_rule.sql will be created                                                                                                                                     
  - resource "azurerm_sql_firewall_rule" "sql" {                                                                                                                                      
      - end_ip_address      = "0.0.0.0"                                                                                                                                               
      - id                  = "7684a4493-851a-4927-b313-1cf9e86065f"                                                                                                                                                                                                                                                       
      - name                = "AllowAllAzureIps"                                                                                                                                      
      - resource_group_name = "terraform-demo-dev"                                                                                                                                    
      - server_name         = "terraform-demo-sqlsvr"                                                                                                                                 
      - start_ip_address    = "0.0.0.0"                                                                                                                                               
    }                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
Plan: 2 to add, 1 to change, 1 to destroy.                                                                                                                                            
                                                                                                                                                                                      
------------------------------------------------------------------------                                                                                                               

Apply : Cette commande va tout simplement appliquer le plan d’exécution et donc appeler les API de notre fournisseur de Cloud (via les providers) pour appliquer tous les changements décris dans le plan.

Destroy : C’est la bombe nucléaire de Terraform, cela détruit entièrement tout notre environnement déployé. Cette commande est utile dans le cadre où on a monté un environnement de démo ou de tests temporaire mais ne devrait pas être utilisé en Production.

La gestion d’état avec Terraform

Quel est l’interêt ?

Le fichier terraform.tfstate est la clé de voute de Terraform car il contient l’état actuel de notre infrastructure et c’est grâce à ce fichier que la commande plan va savoir qu’elle sont les ressources à déployer/détruire ou modifier c’est grâce à ce fichier que Terraform est si pratique et si facilement industrialisable.

Vous l’aurez compris quand on travaille en équipe ce fichier doit être partagé entre tous, parce que si tout le monde à son fichier tfstate à soi il sera impossible de déterminer le résultat d’application du plan, il variera pour chaque personne.

Et là vous vous dite bon super simple je n’ai qu’a l’ajouter à mon contrôle de code source, et non ! C’est le piège de la facilité car ce fichier contient un certain nombre de données sensible (comme des mots de passe par exemple) sur votre infrastructure il ne faut donc surtout pas le commiter.

Stockage sécurisé dans Azure Storage Account

Du coup il faut bien que l’on stocke notre fichier tfstate quelque part pour se faire il faut un endroit sécurisé et accessible à tous les membres de l’équipe. Nous allons utiliser un Azure Storage Account comme « remote backend ».

Pour cela on va utiliser le script suivant :

#!/bin/bash
 
    RESOURCE_GROUP_NAME=terraform-states
    STORAGE_ACCOUNT_NAME=tfstates$RANDOM
    CONTAINER_NAME=tfstate
    VAULT_NAME=terraform-backend-vault
    SECRET_NAME=StorageAccountKey
  
# Création du resource group
    az group create --name $RESOURCE_GROUP_NAME --location westeurope
 
# Création du storage account
    az storage account create --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ACCOUNT_NAME --sku Standard_LRS --encryption-services blob
  
# Creation du blob container
    az storage container create --name $CONTAINER_NAME --account-name $STORAGE_ACCOUNT_NAME --account-key $ACCOUNT_KEY
 
    echo "container_name: $CONTAINER_NAME"
    echo "access_key: $ACCOUNT_KEY"

Ce script va créer un blob dans lequel Terraform ira stocker son fichier tfstate ainsi l’état de l’infrastructure est à un endroit unique et sera toujours synchronisé entre tous les postes et surtout avec ce qui est réellement déployé dans Azure.

Il faut ensuite que l’on configure Terraform pour utiliser notre « Storage Account » comme backend pour ce faire on va déclarer un bloc terraform en haut de notre root module.

terraform {
  backend "azurerm" {
    resource_group_name  = "terraform-states"
    storage_account_name = "tfstatesdcube"
    container_name       = "tfstate"
    key                  = "terraform.tfstate"
  }
}

Jusqu’à maintenant on se connectait à Azure en utilisant notre compte utilisateur, via la commande « az login », dorénavant nous allons utiliser notre Service Principal pour effectuer les opérations de déploiement.

Pour se faire on s’assure de ne plus être loggé via l’Azure CLI en utilisant la commande « az logout ». On va ensuite se créer un script pour stocker toutes nos variables sensibles dans les variables d’environnement bash :

echo "Setting environment variables for Terraform"
export ARM_SUBSCRIPTION_ID=[your_sub_id]
export ARM_CLIENT_ID=[your_service_principal_id]
export ARM_CLIENT_SECRET=[your_service_principal_secret]
export ARM_TENANT_ID=[your_tenant_id]

Les variables ne seront persistées en mémoire uniquement le temps de la session bash dans laquelle vous avez exécuté ce scripte. Il est évident qu’il faut ne faut surtout pas archiver ce script dans notre contrôle de code source, vu qu’il contient le secret de votre Service Principal.

Et voilà notre backend est prêt à être utilisé ! Quand vous lancerez le plan vous verrez que le fichier tfstate n’apparait plus dans l’arborescence du projet Terraform et nous sommes maintenant authentifié via notre Service Principal.

La suite

Nous avons vu ensemble la structure générale d’un projet Terraform puis nous sommes rentré dans le détail de celui-ci, puis nous avons vu comment déployer notre infrastructure depuis notre poste. Nous verrons dans un prochain article comme industrialiser nos déploiements d’infrastructure dans Azure Dev Ops.

Partie 1 : C’est quoi l’Infrastructure As Code (IaC)
Partie 2 : Création d’un plan Terraform pour Azure
Partie 3 : Industrialisation d’un plan Terraform dans Azure Dev Ops

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.