Example of IaC project with Terraform and Gitlab

Code presented below is the result of a project whose goal was to deploy several similar platforms (qualif, preprod, prod) each comprising several hundred VMs, distributed in different VAPP and connected to several networks.

Networks, VAPPs and VMs are defined in CSV input files, with one file for each type of object. They will be managed in an Infra as code (IaC) mode through the Terraform and Gitlab tools.

PFor example, each VM is defined by the vAPP that contains it, the amount of CPU and RAM allocated to it, the template used, the size of the system disk, the number and size of additional disks, the list of networks connected to the VM.

The code also contains a sample of Gitlab CI configuration, which allows the infrastructure to be automatically deployed whenever the code or input files are changed.

The Gitlab CI configuration defines a Pipeline that will be executed in a docker container. This pipeline will launch the execution of all the necessary Terraform commands (init, plan, apply) using some variables managed by the Gitlab (for example the connection identifiers to Cloud Avenue, the deployment environment depending on the modified git branch, etc…).

Project Organization

The entire Terraform configuration consists of one or more Terraform files (extension: .tf). Configuration is usually splited into several files for better readability.

If the configuration uses variables, a variables file (extension: .tfvars) will also be needed. You can have one variable file per environment.

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

Terraform mandatory files

The 4 files below can be found in most Terraform configurations.

File terraform.tf

Contains the definition of the Terraform providers used: the Vmware VCD provider to use the VCD API and the local provider to read and write the csv files.

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"
    }
  }
}

File backend.tf

Contains the definition of the backend where the status file will be stored, in this example we use the Gitlab functionality to store the status file.

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

File provider.tf

Contains the definition of the parameters for the provider(s) used: connection information to the vCD API, the parameters are defined as a 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

File variables.tf

Defines the variables used by the Terraform configuration, all variables must be declared to be used in the configuration and defined in the tfvars file, or in the environment a when running 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"
}

Project Specific Files

The following files are specific to this Terraform project.

File inputs.tf

Reading of input files. The code contained in this file reads the input csv files present in the “inputs” directory and transforms them into variables used in the rest of the 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)
}

File networks.tf

Construction of objects of type network. This file contains 2 types of resources “vcd_network_routed_v2” and “vcd_network_isolated_v2”, it creates the routed and isolated networks, the routed networks are connected to an 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]
    }
}

File vapps.tf

Construction of vAPP objects and association of networks to each 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
}

File vms.tf

Construction of VM objects. This file uses a Terraform “module” because each VM consists of several resources: a VM and one or more add-ons. See below the section “Using Terraform Module”.

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
}

File output.tf and output.tmpl

The output.tf file creates a result file in CSV format that contains the same information as the VM input file with the addition of the IP address assigned to the VM and the admin password defined when the VM was created. The output.tmpl file is a template for the creation of this result file.

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 can use templates to generate files, the directives that can be used in a template are documented here

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 ~}

File exemple.tfvars

Example file for defining variables. All the variables used by the configuration must be defined during Terraform execution. They can be defined in this file or as environment variable, otherwise Terraform will interactively ask for the value of each undefined variable. For sensitive variables (login, password) it is preferable not to put them in a file, but rather to define them in the environment which the CI/CD pipeline can do automatically.

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"

Input Files

Inputs files are stored in the “inputs” directory and their names are defined in the variables file.

Define Networks

Each network is defined by the vdc that contains it, its name, the network type (isolated or routed), the IP address of the gateway, the mask in CIDR notation, the definition of the static address pool with its first and last IPs.

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
Define vAPPs

Each vAPP is defined by the vdc that contains it, its name, and the list of networks that are attached to it separated by the “|” character

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
Define VMs

Each VM is defined by its name, the vAPP and the vdc that contains it, the number of cpu, the amount of memory, the storage policy, the size of the system disks (disk0) and additional disks (disk1 to disk3), the name of the connected networks and possibly an ip if we want to fix it, otherwise it is assigned to the 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,,,

Using Terraform Module

In order to factorize the code, Terraform modules can be defined, which take the form of a directory containing Terraform files.

Here we will use a module to define VMs because they are made of several resources (a VM and additional disks).

Directory vm_module

Terraform module to create a vm with additional disks, this module itself consists of 4 files :

File vm_module/main.tf

Contains the definition of a VM and its additional disks.

# 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
}

File vm_module/terraform.tf

Defines the provider(s) used by the module, here as in the main configuration the Vmware VCD provider.

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

File vm_module/output.tf

Defines values returned by the module, here the IP address assigned to the VM and the admin password defined when building the 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
}

File vm_module/variables.tf

Declare the variables used by the module. These variables are the call parameters of the module, and must be defined when calling the 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"
}

Deploy with Gitlab CI

File .gitlab-ci.yml

Definition of CI/CD pipeline.

In this example, the pipepline automatically executes a plan Terraform task, then an apply Terraform task, it uses a docker image provided by gitlab.com that contains terraform plus a gitlab-terraform script that simplifies the pipeline configuration.

The sensitive variables (API user and password: QUALIF_VCD_USER and QUALIF_VCD_PASSWORD) are stored in Gitlab (procedure to define variables in Gitlab). And the pipeline defines them in the environment, as well as the parameters to store the state file in the Gitlab using the variables predefined by the CI (List of predefined variables). The output file is saved in the Gitlab artifacts.

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

Note regarding credentials

This project uses Gitlab variables to store the credentials used, for greater security it is recommended to use an external secret manager, the Gitlab documentation describes how to use for example Hashicorp Vault.