locals { server_node = [for name, n in var.nodes : n if n.role == "server"][0] server_ip = local.server_node.ip prefix_length = split("/", var.network_cidr)[1] } # A dedicated pool, separate from libvirt's own `default` — same # "isolated from anything else on the host" motivation as the network # below, and it's the fix for the storage-permissions problem # docs/02-k3s.md walks through by hand ("Storage: why a raw path in ~ # doesn't work"): a `dir` pool is a libvirtd-managed location, so volumes # created in it come out owned correctly for the `libvirt-qemu` process # that actually runs each VM, regardless of what user (`k8s`) asked for it. resource "libvirt_pool" "k3s" { name = var.storage_pool type = "dir" path = var.storage_pool_path } # Downloaded once and shared as a read-only backing image for every node's # own disk below (libvirt_volume.node's base_volume_id) — the same # copy-on-write relationship docs/02-k3s.md explains in detail # ("Building the base image..."). Terraform only re-downloads this if # base_image_url changes; it doesn't redo it on every apply. resource "libvirt_volume" "base" { name = "k3s-base.qcow2" pool = libvirt_pool.k3s.name source = var.base_image_url format = "qcow2" } # A dedicated, isolated NAT network so this project can't collide with # anything else already using the host's default libvirt network. DHCP is # off — every node gets a static IP via cloud-init instead. resource "libvirt_network" "k3s" { name = "k3s-homelab" mode = "nat" domain = "k3s.local" addresses = [var.network_cidr] dhcp { enabled = false } dns { enabled = true } } # One overlay disk per node, all backed by the single shared base image # above — `size` here is the overlay's logical capacity (what the guest # OS sees), not how much host disk it actually consumes; qcow2 overlays # start small and grow only as the node writes data. resource "libvirt_volume" "node" { for_each = var.nodes name = "${each.key}.qcow2" pool = libvirt_pool.k3s.name base_volume_id = libvirt_volume.base.id size = each.value.disk_gb * 1024 * 1024 * 1024 format = "qcow2" } # The NoCloud seed ISO for each node — this resource is the Terraform # equivalent of `virt-install --cloud-init` in docs/02-k3s.md, just # built from two separate templates instead of one hand-assembled file: resource "libvirt_cloudinit_disk" "node" { for_each = var.nodes name = "${each.key}-cloudinit.iso" pool = libvirt_pool.k3s.name # "what should exist on this machine" — which cloud-init template # (server vs agent) depends on each.value.role, so a server node and an # agent node run entirely different runcmd payloads despite sharing this # one resource block. See cloud-init/server.yaml.tpl and agent.yaml.tpl. user_data = templatefile("${path.module}/cloud-init/${each.value.role}.yaml.tpl", { hostname = each.key ssh_public_key = var.ssh_public_key k3s_token = var.k3s_token server_ip = local.server_ip k8s_api_hostname = var.k8s_api_hostname }) # "how should this machine's network be set up" — static, unlike stage # 2's DHCP-based VM (docs/02-k3s.md), because this network # (libvirt_network.k3s below) deliberately runs no DHCP server at all; # every node needs to know its own address up front instead of waiting # on a lease. network_config = templatefile("${path.module}/cloud-init/network-config.yaml.tpl", { ip = each.value.ip prefix_length = local.prefix_length gateway = var.gateway_ip }) } # The VM itself — everything above this point (pool, base image, per-node # overlay, cloudinit ISO, network) exists only to be wired together here. resource "libvirt_domain" "node" { for_each = var.nodes name = each.key vcpu = each.value.vcpu memory = each.value.memory cloudinit = libvirt_cloudinit_disk.node[each.key].id network_interface { network_id = libvirt_network.k3s.id # false because this network runs no DHCP (see libvirt_network.k3s) — # the node gets its address from cloud-init's network_config instead, # so waiting on a DHCP lease here would just block forever. wait_for_lease = false } disk { volume_id = libvirt_volume.node[each.key].id } # Serial console only, no display — this box doesn't need a GUI hop for # a couple of small demo VMs. console { type = "pty" target_type = "serial" target_port = "0" } # Terraform brings all VMs up in parallel; the agent cloud-init script # (cloud-init/agent.yaml.tpl) retries the join until the server's API is # reachable, so node boot order doesn't matter. }