HCL (HashiCorp Configuration Language) é a linguagem declarativa do Terraform: você descreve o estado final desejado da infraestrutura, e o Terraform calcula como chegar lá. Este guia apresenta os conceitos da linguagem usando exclusivamente exemplos reais da nossa série Terraform + KVM/Libvirt — cada seção remete ao tutorial onde o padrão aparece em contexto completo.
Pré-requisito: não é preciso conhecer KVM nem Libvirt — eles são apenas o cenário. Tudo aqui se aplica a qualquer provider (AWS, Azure, GCP, vSphere...).
Tudo em HCL é um bloco com um tipo, rótulos e um corpo de argumentos:
resource "libvirt_volume" "os_volume" { # bloco "resource", 2 rótulos
name = "vm-tf.qcow2" # argumento: nome = valor
pool = "default"
capacity = 16 * 1024 * 1024 * 1024 # valor pode ser expressão
target = { # bloco/objeto aninhado
format = {
type = "qcow2"
}
}
}
terraform, provider, resource, data, variable, output, locals e module;resource "libvirt_volume" "os_volume", o primeiro é o tipo do recurso (definido pelo provider) e o segundo é o nome local — juntos formam o endereço libvirt_volume.os_volume;# ou // para linha, /* */ para bloco.Os principais blocos, como aparecem na série:
| Bloco | Papel | Onde usamos |
|---|---|---|
terraform |
Versão do CLI e providers | Todos (required_version = "~> 1.14") |
provider |
Conexão com a plataforma | Parte 1: uri = "qemu:///system" |
resource |
Objeto de infraestrutura a criar | Todos |
variable / output |
Entradas e saídas (contrato) | A partir da parte 2 |
locals |
Valores calculados reutilizáveis | Parte 3: network_ips |
module |
Chamada de módulo filho | Parte 4 |
HCL é tipada. Nas variáveis da série usamos praticamente todo o sistema de tipos:
variable "memory_mib" { type = number } # 2048
variable "libvirt_uri" { type = string } # "qemu:///system"
variable "running" { type = bool } # true / false
variable "dns_servers" {
type = list(string) # ["8.8.4.4", "1.0.0.1"]
}
variable "default_vm_user" {
type = object({ # estrutura com campos nomeados
name = string
gecos = string
})
}
variable "os_profiles" {
type = map(object({ # mapa chave => objeto
template_name = string
default_groups = list(string)
}))
}
E o modificador mais útil da série — optional() com valor padrão:
networks = list(object({
network_name = string
ipv4_address = optional(string, "dhcp") # omitido? assume "dhcp"
is_default_gateway = optional(bool, false) # omitido? assume false
}))
É o que permite escrever no tfvars apenas { network_name = "external", interface_name = "enp1s0" } e deixar o resto por conta dos defaults (parte 3).
As referências são o "sistema nervoso" do Terraform — e o que constrói o grafo de dependências:
| Expressão | Significado | Exemplo real |
|---|---|---|
var.nome |
Variável de entrada | pool = var.pool_images |
local.nome |
Valor calculado | ips = local.network_ips[each.key] |
<tipo>.<nome>.<atributo> |
Atributo de recurso | libvirt_volume.os_volume.name |
module.<nome>.<output> |
Saída de módulo filho | module.network.network_gateways |
each.key / each.value |
Chave/valor do for_each |
name = each.key |
path.module |
Diretório do módulo atual | "${path.module}/user-data.yaml" |
name = "${each.key}_init.iso" # "vm-tf_init.iso"
path = "${var.path_templates}/${var.os_profiles[each.value.base_os].template_name}"
Fora de strings, a interpolação é desnecessária: memory = each.value.memory_mib, e não "${...}".
A série usa ternários em dois padrões clássicos:
# Padrão 1: valor condicional simples
dhcp4 = net.ipv4_address == "dhcp"
# Padrão 2: incluir um bloco/objeto ou OMITI-LO com null
dns = length(local.network_ips[each.key]) > 0 ? {
enable = "yes"
} : null
O null final é a idiomática "não emita este bloco" — sem ele, o provider receberia um bloco vazio e poderia reclamar (parte 3).
for_each: um recurso, N instânciasO meta-argumento que transformou a parte 2: recebe um mapa e cria uma instância por entrada:
resource "libvirt_domain" "domain" {
for_each = var.servers # mapa "vm-tf" => { vcpus = 2, ... }
name = each.key # "vm-tf"
memory = each.value.memory_mib # 2048
}
Consequências práticas:
libvirt_domain.domain["vm-tf"];apply;for_each precisam da chave: libvirt_volume.os_volume[each.key].name.for em listas e mapasAs interfaces do domain são uma list comprehension sobre a lista de redes da VM (parte 3):
interfaces = [
for net in each.value.networks : {
type = "network"
source = {
network = {
network = lookup(var.network_names, net.network_name, net.network_name)
}
}
}
]
Lê-se: "para cada net em each.value.networks, emita um objeto assim".
for_eachOs outputs da parte 4 são map comprehensions:
output "os_volumes" {
value = { for k, v in libvirt_volume.os_volume : k => {
pool = v.pool
name = v.name
} }
}
Lê-se: "para cada chave k e valor v no mapa de volumes, emita k apontando para este objeto".
O local network_ips combina tudo — iteração sobre mapa, filtro por condição e concatenação:
locals {
network_ips = {
for name, net in var.networks : name => concat(
net.ipv4_cidr != null && net.dhcp_ipv4_start != null ? [
{ family = "ipv4", address = cidrhost(net.ipv4_cidr, 1), /* ... */ }
] : [], # condição falsa: contribui lista vazia
# idem IPv6...
)
}
}
merge(): montar objetos por camadas condicionaisO padrão mais sofisticado da série (o network_config do cloud-init): merge() combina objetos, e camadas vazias {} são neutras — então cada ternário "liga ou desliga" um pedaço do objeto final:
ethernets = {
for net in each.value.networks : net.interface_name => merge(
{ dhcp4 = net.ipv4_address == "dhcp" }, # camada base (sempre)
net.ipv4_address != "dhcp" ? {
addresses = ["${net.ipv4_address}/${net.ipv4_prefix}"]
} : {}, # camada IP estático (às vezes)
net.is_default_gateway ? {
routes = [{ to = "0.0.0.0/0", via = "10.16.1.1" }]
} : {}, # camada gateway (às vezes)
net.is_default_dns ? {
nameservers = { addresses = var.dns_servers }
} : {} # camada DNS (às vezes)
)
}
Resultado: um único merge() gera desde uma interface DHCP minimalista até uma completa com IP fixo, rota default e DNS — sem if imperativo.
| Função | Faz | Onde usamos |
|---|---|---|
templatefile(path, vars) |
Renderiza um template ${...} |
user-data.yaml (parte 2) |
yamlencode(v) / yamldecode |
Objeto HCL ⇄ YAML | meta_data e network_config |
join(sep, list) / split(sep, str) |
Lista ⇄ string | join(", ", ...default_groups) |
concat(l1, l2) |
Junta listas | addresses IPv4+IPv6 |
merge(o1, o2, ...) |
Funde objetos | network_config (seção 8) |
try(expr, fallback) |
Expr, ou fallback se falhar | Referência a recurso que pode não existir |
lookup(map, key, default) |
Busca com padrão | lookup(var.network_names, net, net) |
cidrhost(cidr, n) |
N-ésimo IP da rede | cidrhost(net.ipv4_cidr, 1) → o .1 do host |
element(list, i) |
Item i da lista | Extrair o prefixo de "10.16.0.0/24" |
tonumber(v) / tostring(v) |
Conversão de tipo | Prefixo string → número |
trimspace(s) |
Remove espaços/quebras | Higiene da chave SSH |
file(path) / pathexpand(p) |
Lê arquivo / expande ~ |
file(pathexpand("~/.ssh/kvm.pub")) |
length(v) |
Tamanho | Teste "há IPs na rede?" |
Da maior para a menor precedência (o Terraform sobrepõe nesta ordem):
-var e -var-file na linha de comando;*.auto.tfvars (ordem alfabética) — na série: servers.auto.tfvars, networks.auto.tfvars;terraform.tfvars;TF_VAR_<nome>;default na declaração da variável.A série explora isso deliberadamente: infraestrutura estável no terraform.tfvars, o que muda sempre (VMs, redes) em .auto.tfvars, e o segredo (chave SSH) fora de qualquer arquivo — via TF_VAR_ssh_public_key com sensitive = true (parte 2).
Um módulo é um diretório de arquivos .tf com contrato (variable = parâmetros, output = retorno). A chamada é o bloco module (parte 4):
module "network" {
source = "../../modules/network" # caminho local (ou registry/git)
networks = var.networks # argumento = variável do módulo
}
# consumindo o retorno:
network_gateways = module.network.network_gateways
As referências module.X.output → argumento de module.Y são o que monta o grafo entre módulos — o Terraform executa network antes de cloudinit sem nenhum depends_on explícito.
| Conceito HCL | Tutorial onde aparece |
|---|---|
| Blocos, resource, provider, tipos primitivos | Parte 1 — VM única |
Variables, tfvars, sensitive, for_each, templatefile |
Parte 2 — múltiplas VMs |
Locals, comprehension com filtro, ternário + null, merge, funções de rede |
Parte 3 — redes customizadas |
| Modules, outputs, grafo implícito, contratos | Parte 4 — estrutura modular |
object() e optional()for