Exemple d’un projet Iac avec Terraform et Gitlab

Le code présenté ci-dessous est issue d’un projet dont le but était de déployer plusieurs plateformes semblables (qualif, preprod, prod) comportant chacune plusieurs centaines de VM, reparties dans différente VAPP et connecté a plusieurs réseaux.

Les réseaux, VAPP et VM sont définis dans des fichiers d’entrés au format csv, avec un fichier pour chaque type d’objet. Ils seront gérés dans un mode Infra as code (IaC) au travers des outils Terraform et Gitlab.

Par exemple chaque VM est défini par la VAPP qui la contient, la quantité de CPU et de RAM qui lui est alloué, le template utilisé, la taille du disque système, le nombre et la taille des disques supplémentaires, la liste des réseaux connectés à la VM.

Le code contient également un exemple de configuration Gitlab CI, ce qui permet de déployer automatiquement l’infrastructure à chaque fois que le code ou les fichiers d’entré sont modifiés.

La configuration Gitlab CI défini un pipeline qui va être exécuter dans un container docker, ce pipeline va lancer l’exécution de l’ensemble des commandes Terraform nécessaire (init, plan, apply) en utilisant certaine variables géré par le Gitlab (par exemple les identifiants de connexion a Cloud Avenue, l’environnement de déploiement en fonction de la branche git modifié, etc.

Organisation du projet

L’ensemble d’une configuration Terraform est constitué d’un ou plusieurs fichiers Terraform (extension : .tf), en général on découpe la configuration en plusieurs fichier pour une meilleurs lisibilité.

Si la configuration utilise des variables il faudra également un fichier de variables (extension : .tfvars). On pourra avoir un fichier de variables par environnement.

terraform_ngp/
  .gitlab-ci.yml
  backend.tf
  inputs.tf
  network.tf
  output.tf
  output.tmpl
  provider.tf
  exemple.tfvars
  terraform.tf
  vapps.tf
  variables.tf
  vms.tf
  inputs/
    networks.csv
    vapps.csv
    vms.csv
  vm_modules/
    main.tf
    outputs.tf
    terraform.tf
    variables.tf

Fichier Terraform mandatory

Les 4 fichiers ci-dessous se retrouve dans la plupart des configurations Terraform.

Le fichier terraform.tf

Contient la définition des providers Terraform utilisés : le provider VCD de Vmware pour utiliser l’API VCD et le provider local pour lire et écrire les fichiers csv

terraform {
  required_providers {
    # list provider used by the configuration.
    vcd = {
      source = "vmware/vcd"
      version = ">=3.5.1"
    }
    local = {
      source = "hashicorp/local"
      version = ">=2.1.0"
    }
  }
}

Le fichier backend.tf

Contient la définition du backend ou sera stocké le fichier d’état, dans cet exemple on utilise la fonctionnalité de Gitlab pour stocker le fichier d’état.

# using gitlab registry to store tfstate
terraform {
  # all parameters are defined in environment by the gitlab CI/CD 
  backend "http" {
  }
}

Le fichier provider.tf

Contient la définition des paramètres pour le ou les providers utilisés : information de connexion a l’API VCD, les paramètres sont définis sous forme de variable.

provider "vcd" {
  # parameteres of provider vmware/vcd
  user                 = var.vcd_user
  password             = var.vcd_password
  org                  = var.vcd_org
  url                  = var.vcd_url
  vdc                  = var.edgegw_vdc
  allow_unverified_ssl = var.vcd_allow_unverified_ssl
}

# the provider local has no parameter

Le fichier variables.tf

Défini les variables utilisées par la configuration Terraform, toutes les variables doivent être déclaré pour pouvoir être utilisé dans la configuration et défini dans le fichier tfvars, ou dans l’environnement a lors de l’exécution de Terraform.

variable "vcd_user" {
    type = string
    description = "VCD User"
    sensitive = true
}
variable "vcd_password" {
    type = string
    description = "VCD Password"
    # sensitive variable are not displayed in standard output
    sensitive = true
}
variable "vcd_url" {
    type = string
    description = "VCD URL"
}
variable "vcd_allow_unverified_ssl" {
    type = bool
    description = "skip ssl certificate verification"
    default = false
}
variable "vcd_org" {
    type = string
    description = "VCD Organization name"
}
variable "catalog_name" {
    type = string
    description = "name of the catalog that contains the templates"
}
variable "edgegw_vdc" {
    type = string
    description = "Name of vdc that contains the edge gateway"
}
variable "edgegw_name" {
    type = string
    description = "Name of the edge gateway"
}
variable "net_csv" {
    type = string
    description = "filename of networks definition"
}
variable "vapp_csv" {
    type = string
    description = "filename of vapps definition"
}
variable "vm_csv" {
    type = string
    description = "filename of vms definition"
}
variable "output_csv" {
    type = string
    description = "finename of output data"
}

Fichiers spécifiques au projet

Les fichiers suivant sont spécifique a ce projet terraform

Le fichier inputs.tf

Lecture des fichiers d’entré. Le code contenu dans ce fichier lit les fichiers csv d’entré présents dans le répertoire « inputs » et les transforme en variable utilisé dans le reste de la configuration.

# read input data from csv file
data "local_file" "input_network" {
  # read the list of network in csv format
    filename = "${path.root}/inputs/${var.net_csv}"
}
data "local_file" "input_vapp" {
  # read the list of vapp in csv format
    filename = "${path.root}/inputs/${var.vapp_csv}"
}
data "local_file" "input_vm" {
  # read the list of vm in csv fornat
    filename = "${path.root}/inputs/${var.vm_csv}"
}
  
# transform the files content to a list of map for each input file
locals {
    net_data = csvdecode(data.local_file.input_network.content)
    vapp_data = csvdecode(data.local_file.input_vapp.content)
    vm_data = csvdecode(data.local_file.input_vm.content)
}

Le fichier networks.tf

Construction des objets de type réseau. Ce fichier contient 2 type de ressources « vcd_network_routed_v2 » et « vcd_network_isolated_v2 », il crée les réseaux routé et isolé, les réseaux routés sont reliés à une edge gateway.

data "vcd_nsxt_edgegateway" "edgegateway1" {
  # get the data for the edge gateway,
  # edge gateway id is needed for router network
     name = var.edgegw_name
     org  = var.vcd_org
     vdc  = var.edgegw_vdc
}

resource "vcd_network_routed_v2" "rnetwork" {
  # create a network for each network in the list with a network type of 'routed'
  for_each = {for x, net in local.net_data : net.NETWORK_NAME => net
                if lower(net.NETWORK_TYPE) == "routed" &&
                    net.ORG_NAME == var.vcd_org }
    org             = each.value.ORG_NAME
    vdc             = each.value.VDC_NAME
    name            = each.key

    # use the vcd_nsxt_edgegateway data to find the gateway id
    edge_gateway_id = data.vcd_nsxt_edgegateway.edgegateway1.id

    # define the ip address used by the gateway in the network
    # and define the network prefix. input is in format : x.x.x.x/y
    gateway         = split("/", each.value.GATEWAY)[0]
    prefix_length   = split("/", each.value.GATEWAY)[1]

    # define the ip pool address for the network,
    # input format is x.x.x.x|y.y.y.y
    static_ip_pool {
        start_address = split("|", each.value.STATIC_ADDRESS_POOL)[0]
        end_address   = split("|", each.value.STATIC_ADDRESS_POOL)[1]
    }
}

resource "vcd_network_isolated_v2" "inetwork" {
  # create a network for each network in the list with a network type of 'isolated'
  for_each = {for x, net in local.net_data : net.NETWORK_NAME => net
                if lower(net.NETWORK_TYPE) == "isolated" &&
                    net.ORG_NAME == var.vcd_org }
    org             = each.value.ORG_NAME
    vdc             = each.value.VDC_NAME
    name            = each.key

    # even if the network is not routed it need a ip address
    # for the gateway in the network
    # and define the network prefix. input is in format : x.x.x.x/y
    gateway         = split("/", each.value.GATEWAY)[0]
    prefix_length   = split("/", each.value.GATEWAY)[1]

    static_ip_pool {
        start_address = split("|", each.value.STATIC_ADDRESS_POOL)[0]
        end_address   = split("|", each.value.STATIC_ADDRESS_POOL)[1]
    }
}

Le fichier vapps.tf

Construction des objets Vapps et association des réseaux à chacune des Vapp.

resource "vcd_vapp" "vapp1" {
  # define a vapp for each line in input file
    for_each    = {for x, vapp in local.vapp_data : vapp.VAPP_NAME => vapp if vapp.ORG_NAME == var.vcd_org }
    name        = each.key
    org         = each.value.ORG_NAME
    vdc         = each.value.VDC_NAME
}

locals {
  # transform data format since each vapp has a variable number of networks
    vapp_net = flatten([
        for vapp in local.vapp_data : [
            for net in split("|",vapp.ORG_NETWORK) : {
                org     = vapp.ORG_NAME
                vdc     = vapp.VDC_NAME
                vapp    = vapp.VAPP_NAME
                netname = net
                }
            ] if vapp.ORG_NAME == var.vcd_org
        ])
}

resource "vcd_vapp_org_network" "network1" {
  # attach org network into each vapp
    depends_on          = [vcd_vapp.vapp1,vcd_network_routed_v2.rnetwork, vcd_network_isolated_v2.inetwork]
    for_each            = {for x, net in local.vapp_net : "${net.vapp}-${net.netname}" => net }
    vapp_name           = each.value.vapp
    org                 = each.value.org
    vdc                 = each.value.vdc
    org_network_name    = each.value.netname
}

Le fichier vms.tf

Construction des objets VM. Ce fichier utilise un « module » Terraform car chaque VM est constitué de plusieurs ressource : une VM et un ou plusieurs additionnels. voir ci dessous la section « Utilisation de Module Terraform »

module "vm_instance" {
    depends_on          = [vcd_vapp.vapp1, vcd_vapp_org_network.network1]
    source              = "./vm_modules"
    for_each            = {for x,vm in local.vm_data : vm.HOSTNAME => vm if
                              vm.ORGANIZATION == var.vcd_org }
    vm_name             = each.key
    organization        = each.value.ORGANIZATION
    vdc                 = each.value.VDC_NAME
    vapp_name           = each.value.VAPP
    catalog_name        = var.catalog_name
    template_name       = each.value.TEMPLATE
    memory              = tonumber(each.value.RAM_QUANTITY)
    cpus                = tonumber(each.value.CPU_QUANTITY)
    vm_storage_profile  = each.value.STORAGE_POLICY
    networks            = [
                            {name = each.value.NET1, ip = each.value.IP1},
                            {name = each.value.NET2, ip = each.value.IP2},
                            {name = each.value.NET3, ip = each.value.IP3},
                          ]
    osdisk              = each.value.DISK0
    disks               = [each.value.DISK1, each.value.DISK2, each.value.DISK3]
}
# the module returns the ip assigned to each interface and the admin password
output "out_vm" {
  value = module.vm_instance
  sensitive = true
}

Les fichiers output.tf et output.tmpl

Le fichier output.tf crée un fichier de résultats au format csv qui contient les même informations que le fichier d’entrée des VM avec en plus l’information des adresse IP assigné a la VM et le mot de passe admin défini lors de la création de la VM. Le fichier output.tmpl est un template pour la création de ce fichier de résultat.

locals {
  # transform the output of module vm_instance in a variable usable by the template
  out_data = {for k,v in module.vm_instance : k => {ip = v.vm_ip, admin = v.admin_password}}
}

resource "local_file" "output" {
  # create the output file form input vm data et output variable
    filename = var.output_csv
    file_permission = 0644
    content = templatefile("output.tmpl", {input = local.vm_data, out = local.out_data})
}

Terraform peut utiliser des templates pour generer des fichiers, les directives utilisables dans un template sont documentées ici

HOSTNAME,ORGANIZATION,VAPP,TEMPLATE,VDC_NAME,CPU_QUANTITY,RAM_QUANTITY,STORAGE_POLICY,DISK0,DISK1,DISK2,DISK3,NET1,IP1,NET2,IP2,NET3,IP3,ADMIN
%{ for host in input ~}
${host.HOSTNAME},${host.ORGANIZATION},${host.VAPP},${host.TEMPLATE},${host.VDC_NAME},${host.CPU_QUANTITY},${host.RAM_QUANTITY},${host.STORAGE_POLICY},${host.DISK0},${host.DISK1},${host.DISK2},${host.DISK3},${host.NET1},${out[host.HOSTNAME]["ip"][0]},${host.NET2},%{ if length(out[host.HOSTNAME]["ip"]) > 1 }${out[host.HOSTNAME]["ip"][1]}%{ endif },${host.NET3},%{ if length(out[host.HOSTNAME]["ip"]) > 2 }${out[host.HOSTNAME]["ip"][2]}%{ endif },${out[host.HOSTNAME]["admin"]}
%{ endfor ~}

Le fichier exemple.tfvars

Fichier d’exemple de définition des variables. Toutes les variables utilisé par la configurations doivent être défini lors de l’exécution de Terraform, elles peuvent l’être dans ce fichier, ou sous forme de variable d’environnement, sinon Terraform demandera de façon interactive la valeur de chacune des variables non défini. Pour les variables sensibles (login, mot de passe) il est préférable de ne pas les mettre dans un fichier, mais plutôt de les définir dans l’environnement ce que peut faire automatiquement le pipeline de CI/CD

vcd_org = "cav01xxxxxxxxxxxx"
vcd_url = "https://console2.cloudavenue.orange-business.com/api"
vcd_allow_unverified_ssl = true

edgegw_name = "tn01xxxxxxxxxxx"
edgegw_vdc = "your_vdc"
catalog_name = "your_catalog"

net_csv = "networks.csv"
vapp_csv = "vapps.csv"
vm_csv = "vms.csv"
output_csv = "output.csv"

Les fichiers d’entrées

placés dans le repertoire « inputs » leur noms sont définis dans le fichiers de variables.

definition des reseaux

chaque réseau est défini par le vdc qui le contient, son nom, le type de réseau isolé ou routé, l’adresse ip de la gateway et le mask en notation CIDR, la definition du static address pool avec son ip de debut et celle de fin.

ORG_NAME,VDC_NAME,NETWORK_NAME,NETWORK_TYPE,GATEWAY,STATIC_ADDRESS_POOL
cav01xxxxxxxxxxxx,your_vdc,rtr_01,routed,192.168.0.1/24,192.168.0.10|192.168.0.100
cav01xxxxxxxxxxxx,your_vdc,iso_02,isolated,192.168.2.1/24,192.168.2.10|192.168.2.100
cav01xxxxxxxxxxxx,your_vdc,iso_03,isolated,192.168.3.1/24,192.168.3.10|192.168.3.100
definitions des vapps

chaque vapp est défini par le vdc qui la contient, son nom, et la liste des réseaux qui lui sont attaché séparé par le caractère « | »

ORG_NAME,VDC_NAME,VAPP_NAME,ORG_NETWORK
cav01xxxxxxxxxxxx,your_vdc,vapp_01,rtr_01|iso_02|iso_03
cav01xxxxxxxxxxxx,your_vdc,vapp_02,rtr_01|iso_03
cav01xxxxxxxxxxxx,your_vdc,vapp_03,rtr_01|iso_03
definitions des vms

chaque vm est défini par son nom, la vapp et le vdc qui la contient, le nombre de cpu, la quantité de memoire, la politique de stockage, la taille des disques système (disk0) et additionnels (disk1 a disk3), le nom des réseaux connectés et eventuellement une ip si on veut la fixé sinon elle est assigné dans le pool.

HOSTNAME,ORGANIZATION,VAPP,TEMPLATE,VDC_NAME,CPU_QUANTITY,RAM_QUANTITY,STORAGE_POLICY,DISK0,DISK1,DISK2,DISK3,NET1,IP1,NET2,IP2,NET3,IP3
vm-01,cav01xxxxxxxxxxxx,vapp_01,ubuntu-20,your_vdc,2,4,gold,4,0,0,0,rtr_01,,iso_02,,iso_03,
vm-02,cav01xxxxxxxxxxxx,vapp_01,ubuntu-20,your_vdc,2,2,gold,10,10,20,0,rtr_01,,iso_03,,,
vm-03,cav01xxxxxxxxxxxx,vapp_03,ubuntu-20,your_vdc,2,2,gold,15,10,0,0,rtr_01,,iso_02,,,

Utilisation de Module Terraform

Pour factoriser le code on peut définir des modules Terraform qui se présente sous forme d’un répertoire contenant également des fichiers Terraform.

Ici on utilisera un module pour définir les VM car elles sont constitué de plusieurs ressources (une VM et des disques additionnels).

Le répertoire vm_module

Module Terraform pour créer une vm avec des disques supplémentaire, ce module est lui-même constitué de 4 fichiers :

Le fichier vm_module/main.tf

Contient la définition d’une VM et de ses disques additionnel.

# create a vm, most argument are variables
resource "vcd_vapp_vm" "vapp_vm" {
  name                   = var.vm_name
  computer_name          = var.vm_name
  org                    = var.organization
  vdc                    = var.vdc
  vapp_name              = var.vapp_name
  catalog_name           = var.catalog_name
  template_name          = var.template_name
  memory                 = var.memory * 1024
  cpus                   = var.cpus
  storage_profile        = var.vm_storage_profile
  cpu_hot_add_enabled    = true
  memory_hot_add_enabled = true

  dynamic "network" {
    for_each = [for net in var.networks : net if net.name != ""]
    content {
      adapter_type       = "VMXNET3"
      type               = "org"
      name               = network.value.name
      ip_allocation_mode = network.value.ip == "" ? "POOL" : "MANUAL"
      ip                 = network.value.ip
    }
  }

  override_template_disk {
    bus_type            = "paravirtual"
    size_in_mb          = var.osdisk * 1024
    bus_number          = 0
    unit_number         = 0
  }

  customization {
    allow_local_admin_password = true
    auto_generate_password     = true
    enabled                    = true
    force                      = false
  }
}

resource "vcd_vm_internal_disk" "diskadd" {
  depends_on  = [vcd_vapp_vm.vapp_vm]
  for_each    = {for idx, disk in var.disks : idx => disk if disk != "0"}
  org         = var.organization
  vdc         = var.vdc
  vapp_name   = var.vapp_name
  vm_name     = var.vm_name
  size_in_mb  = each.value * 1024
  bus_type    = var.disk_bus
  bus_number  = 0
  unit_number = each.key < 6 ? each.key + 1 : each.key + 2
}

Le fichier vm_module/terraform.tf

Défini le provider utilisé par le module, ici comme dans la configuration principale le provider Vmware VCD.

terraform {
  required_providers {
    vcd = {
      source  = "vmware/vcd"
      version = "3.5.1"
    }
  }
}

Le fichier vm_module/output.tf

Défini les valeurs retournées par le module, ici les adresse IP assigné à la VM et le mot de passe admin défini lors de la construction de la VM.

output "vm_ip" {
  value = flatten([
    for network in resource.vcd_vapp_vm.vapp_vm.network : [
      network.ip
    ]
  ])
}

output "admin_password" {
  value = resource.vcd_vapp_vm.vapp_vm.customization[0].admin_password
  sensitive = true
}

Le fichier vm_module/variables.tf

Déclare les variables utilisées par le module ces variables sont les paramètres d’appel du module, et doivent être défini lors de l’appel du module.

variable "vm_name" {
  type        = string
  description = "name of vm (hostname)"
}
variable "organization" {
  type        = string
  description = "name of organization"
}
variable "vdc" {
  type        = string
  description = "name of vdc"
}
variable "vapp_name" {
  type        = string
  description = "name of the vapp containing the vm"
}
variable "catalog_name" {
  type        = string
  description = "name of catalog"
}
variable "template_name" {
  type        = string
  description = "name of vm template"
}
variable "memory" {
  type        = number
  description = "vm memory in GB"
}
variable "cpus" {
  type        = number
  description = "number of cpus"
}
variable "vm_storage_profile" {
  type        = string
  description = "storage profile for vm disks"
}
variable "networks" {
  type        = list(any)
  description = "network description"
}
variable "osdisk" {
  type        = number
  description = "size of system disk"
}
variable "disks" {
  type        = list(any)
  description = "list of additional disks size"
}
variable "disk_bus" {
  type        = string
  description = "type of disk bus"
  default     = "paravirtual"
}

Deployement a l’aide de Gitlab CI

Le fichier .gitlab-ci.yml

Définition du pipeline de CI/CD.

Dans cet exemple le pipepline exécute automatiquement une tache Terraform plan, puis une tache Terraform apply, il utilise une image docker fournie par gitlab.com qui contient terraform plus un script gitlab-terraform qui simplifie la configuration du pipeline.

Les variables sensibles (utilisateur et mot de passe de l’API: QUALIF_VCD_USER et QUALIF_VCD_PASSWORD) sont stockées dans Gitlab (procédure pour définir des variables dans Gitlab). Et le pipeline les défini dans l’environnement, ainsi que les paramètres pour stocker le fichier d’état dans le Gitlab a l’aide des variables prédéfinies par la CI (Listes des variables prédéfinies). Le fichier d’output est sauvegardé dans les artifacts gitlab.

image: registry.gitlab.com/gitlab-org/terraform-images/stable:latest

variables:
  TF_ROOT: ${CI_PROJECT_DIR}

cache:
  paths:
    - ${TF_ROOT}/.terraform

before_script:
  - cd ${TF_ROOT}
  - terraform --version

stages:
  - qualif-plan
  - qualif-apply

.qualif:
  variables:
    TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/qualif
    TF_VAR_vcd_user: ${QUALIF_VCD_USER}
    TF_VAR_vcd_password: ${QUALIF_VCD_PASSWORD}
  tags:
    - private
    - prod
    - docker

qualif-plan:
  extends: .qualif
  stage: qualif-plan
  script:
    - gitlab-terraform init -backend-config=skip_cert_verification=true
    - gitlab-terraform validate
    - gitlab-terraform plan -var-file qualif.tfvars
    - gitlab-terraform plan-json
  artifacts:
    name: qualif-plan
    paths:
      - ${TF_ROOT}/plan.cache
    reports:
      terraform: ${TF_ROOT}/plan.json

qualif-apply:
  extends: .qualif
  stage: qualif-apply
  script:
    - gitlab-terraform apply
  dependencies:
    - qualif-plan
  artifacts:
    name: qualif-output
    paths:
      - ${TF_ROOT}/output.csv

Notes a propos des identifiants

Ce projet utilise des variables Gitlab pour stocker les identifiants utilisés, pour une plus grande sécurité il est recommandé d’utiliser un gestionnaire de secret externe, la documentation de Gitlab décrit comment utiliser par exemple Hashicorp Vault.