Parte 4 da série Terraform + KVM/Libvirt. Nas partes anteriores (VM única, for_each e redes customizadas), o projeto inteiro vivia num único diretório com um único state. Funciona — até você precisar de um segundo ambiente. Esta etapa refatora o mesmo cenário (gateway + vm-01, redes external/internal) em módulos reutilizáveis consumidos por ambientes independentes (hml e prd), cada um com seu state:
Grafo de dependências (implícito, via outputs → inputs):
┌──────────┐ ┌──────────┐
│ network │───────────────▶│ cloudinit│──┐
│ (redes) │ network_gateways │ │ cloudinit_volumes
└────┬─────┘ └──────────┘ │
│ network_names ▼
│ ┌──────────┐
└────────────────────────────▶│ compute │
┌──────────┐ │ (VMs) │
│ storage │──── os_volumes ──────▶│ │
└──────────┘ └──────────┘
Código completo: pasta
parte-4-modulosdo repositório da série no GitLab — o ambienteprdvai clonado dehmlcomo ponto de partida; ajuste os.tfvarsantes do primeiro apply.
Cada ambiente (environments/hml, environments/prd) chama os quatro módulos com seus próprios .tfvars — mesmas VMs, mesmas redes, states separados: destruir a homologação não encosta na produção.
terraform-kvm-libvirt/
├── cloud-init/
│ └── user-data.yaml # Template cloud-init COMPARTILHADO pelos ambientes
├── environments/
│ ├── hml/ # Homologação (state próprio)
│ │ ├── main.tf # Chamadas dos 4 módulos
│ │ ├── providers.tf # provider libvirt
│ │ ├── version.tf # required_version/providers
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── terraform.tfvars # Valores globais do ambiente
│ │ ├── networks.auto.tfvars
│ │ └── servers.auto.tfvars
│ └── prd/ # Produção (mesma estrutura, state próprio)
└── modules/
├── network/ # Redes libvirt (NAT, isoladas...)
├── storage/ # Volumes qcow2 (backing store)
├── cloudinit/ # ISOs de cloud-init (user-data + network-config)
└── compute/ # Domains (VMs)
Princípios da separação:
| Camada | Contém | Muda quando... |
|---|---|---|
modules/ |
Lógica de criação dos recursos | Evolui a arquitetura (nova feature do provider etc.) |
environments/*/ |
Valores e composição | Nasce/altera uma VM ou rede daquele ambiente |
cloud-init/ |
Template de user-data | Muda a política de usuários (vale para todos) |
Todos os módulos declaram a mesma versão do provider em version.tf:
# modules/{cloudinit,compute,network,storage}/version.tf
terraform {
required_version = "~> 1.14"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.9"
}
}
}
Cada módulo expõe um contrato — o que recebe (variables.tf) e o que devolve (outputs.tf):
| Módulo | Recebe (principais) | Devolve (outputs) |
|---|---|---|
network |
networks (mapa de redes) |
network_names, network_gateways |
storage |
servers, pool_images, path_templates, os_profiles |
os_volumes |
cloudinit |
servers, network_gateways, dns_servers, ssh_public_key (sensitive), user_data_template |
cloudinit_volumes |
compute |
servers, os_volumes, cloudinit_volumes, network_names |
domains |
Note que os módulos declaram apenas os atributos que usam nos seus tipos —
storagenão conhecevcpus,computenão conhecedisk_size_gb. Contratos mínimos deixam explícito quem depende de quê.
O main.tf é o mesmo da parte 3 (locals network_ips + libvirt_network com DHCP/DNS condicionais). O que muda é a saída: além dos nomes, o módulo exporta os gateways para o cloud-init:
# modules/network/outputs.tf
output "network_names" {
description = "Mapa nome-da-rede => nome da rede criada (para o módulo compute)"
value = { for k, n in libvirt_network.network : k => n.name }
}
output "network_gateways" {
description = "Mapa nome-da-rede => gateways (para as rotas default no cloud-init)"
value = { for k, net in var.networks : k => {
gateway_ipv4 = net.gateway_ipv4
gateway_ipv6 = net.gateway_ipv6
} }
}
Detalhe de implementação:
network_gatewaysé derivado da variável de entrada, não do recurso — os gateways são informação declarativa (a VM roteadora não é criada pela rede). Se dependessem do recurso, o Terraform esperaria a rede ser criada para calcular o plan do cloud-init.
# modules/storage/main.tf
resource "libvirt_volume" "os_volume" {
for_each = var.servers
name = "${each.key}.qcow2"
pool = var.pool_images
capacity = each.value.disk_size_gb * 1024 * 1024 * 1024
target = {
format = {
type = "qcow2"
}
}
backing_store = {
path = "${var.path_templates}/${var.os_profiles[each.value.base_os].template_name}"
format = {
type = "qcow2"
}
}
}
# modules/storage/outputs.tf
output "os_volumes" {
description = "Mapa nome-da-VM => { pool, name } do volume qcow2 (para o módulo compute)"
value = { for k, v in libvirt_volume.os_volume : k => {
pool = v.pool
name = v.name
} }
}
O main.tf é o da parte 3 (user-data + network_config v2), com duas adaptações: o caminho do template vem do ambiente (user_data_template) e os gateways chegam pelo input network_gateways — saída do módulo network — em vez de ler var.networks diretamente:
# modules/cloudinit/main.tf — trechos que mudaram
user_data = templatefile(var.user_data_template, {
ssh_key = trimspace(var.ssh_public_key)
vm_user = var.default_vm_user
groups = join(", ", var.os_profiles[each.value.base_os].default_groups)
})
# ...
# nas rotas default do network_config:
# via = var.network_gateways[net.network_name].gateway_ipv4
# modules/cloudinit/outputs.tf
output "cloudinit_volumes" {
description = "Mapa nome-da-VM => { pool, name } da ISO de cloud-init (para o módulo compute)"
value = { for k, v in libvirt_volume.common_init_volume : k => {
pool = v.pool
name = v.name
} }
}
Nota de migração registrada no código: no provider 0.8 o argumento se chamava
network_data; na 0.9 virounetwork_config.
Idêntico ao domain.tf da parte 3, com os acoplamentos trocados por inputs: volumes vêm de var.os_volumes/var.cloudinit_volumes, e a resolução de redes usa lookup() no mapa network_names:
# modules/compute/main.tf — trechos que mudaram
# disco do SO:
# pool = var.os_volumes[each.key].pool
# volume = var.os_volumes[each.key].name
# cdrom de cloud-init:
# pool = var.cloudinit_volumes[each.key].pool
# volume = var.cloudinit_volumes[each.key].name
interfaces = [
for net in each.value.networks : {
type = "network"
model = {
type = "virtio"
}
source = {
network = {
# Rede do módulo network → nome resolvido (e dependência garantida);
# rede externa ao projeto (ex.: "default") → nome direto
network = lookup(var.network_names, net.network_name, net.network_name)
}
}
}
]
# modules/compute/outputs.tf
output "domains" {
description = "Mapa nome-da-VM => { name, uuid } dos domains criados"
value = { for k, d in libvirt_domain.domain : k => {
name = d.name
uuid = d.uuid
} }
}
O main.tf do ambiente é só fiação — cada output de módulo alimenta o input de outro, e é essa referência que monta o grafo de execução (rede antes de cloudinit/compute, volumes antes das VMs):
# environments/hml/main.tf
module "network" {
source = "../../modules/network"
networks = var.networks
}
module "storage" {
source = "../../modules/storage"
servers = var.servers
pool_images = var.pool_images
path_templates = var.path_templates
os_profiles = var.os_profiles
}
module "cloudinit" {
source = "../../modules/cloudinit"
servers = var.servers
pool_images = var.pool_images
user_data_template = "${path.module}/../../cloud-init/user-data.yaml"
default_vm_user = var.default_vm_user
os_profiles = var.os_profiles
dns_servers = var.dns_servers
network_gateways = module.network.network_gateways
ssh_public_key = var.ssh_public_key
}
module "compute" {
source = "../../modules/compute"
servers = var.servers
os_volumes = module.storage.os_volumes
cloudinit_volumes = module.cloudinit.cloudinit_volumes
network_names = module.network.network_names
}
# environments/hml/outputs.tf
output "domains" {
description = "VMs criadas neste ambiente"
value = module.compute.domains
}
Os variables.tf, networks.auto.tfvars e servers.auto.tfvars do ambiente trazem as mesmas declarações e valores da parte 3 — com uma diferença de cenário: aqui a vm-01 voltou a ser Debian (base_os = "debian", interface enp1s0, disco de 16 GiB), e o perfil "oracle" do terraform.tfvars usa a template local ol9-base.qcow2:
# environments/hml/terraform.tfvars
pool_images = "default"
path_templates = "/datastore/templates"
dns_servers = ["8.8.4.4", "1.0.0.1", "2001:4860:4860::8844", "2606:4700:4700::1001"]
os_profiles = {
"debian" = {
template_name = "debian-13-generic-amd64.qcow2"
default_groups = ["users", "sudo"]
}
"oracle" = {
template_name = "ol9-base.qcow2" # template própria — build no guia /kvm/template-oracle-linux
default_groups = ["users", "wheel"]
}
}
default_vm_user = {
name = "suporte"
gecos = "Suporte User"
}
cd environments/hml
export TF_VAR_ssh_public_key="$(cat ~/.ssh/kvm.pub)"
terraform init && terraform validate && terraform plan && terraform apply
O state agora mostra o caminho dos módulos — cada recurso sabe a qual módulo e a qual ambiente pertence:
module.cloudinit.libvirt_cloudinit_disk.common_init["gateway"]
module.cloudinit.libvirt_cloudinit_disk.common_init["vm-01"]
module.cloudinit.libvirt_volume.common_init_volume["gateway"]
module.cloudinit.libvirt_volume.common_init_volume["vm-01"]
module.compute.libvirt_domain.domain["gateway"]
module.compute.libvirt_domain.domain["vm-01"]
module.network.libvirt_network.network["external"]
module.network.libvirt_network.network["internal"]
module.storage.libvirt_volume.os_volume["gateway"]
module.storage.libvirt_volume.os_volume["vm-01"]
Validação e acesso são os mesmos da parte 3 (virsh list, virsh domifaddr gateway, ~/.ssh/config com ProxyJump). Para subir a produção: repetir em environments/prd com os .tfvars dela — os mesmos nomes de VM e redes podem coexistir, pois os states são independentes.
Na prática, ambientes reais divergem em mais do que os tfvars: produção costuma ter mais memória, IPs diferentes e nomenclatura própria (
hml-nat-web01×prd-nat-web01, conforme a taxonomia). O ponto da estrutura é que essa divergência fica toda nos.tfvars— os módulos não mudam.
cd environments/hml && terraform destroy # derruba só a homologação
http) com lock;ip_forward + masquerade no gateway — pode ser entregue via runcmd no user-data.yaml;libvirt_uri de cada ambiente para um hypervisor diferente (qemu+ssh://host2/system) é o embrião do laboratório Multi-Homelab.lookup() e try() — resolução com fallback usada nas interfaces