Example of IaC project with Terraform and Gitlab
Overview
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.