# main.tf — dedicated NAT network + 3 cloud-init'd VMs, each with a thin OS disk # (backed by the cloud image) and a blank thin Longhorn data disk (Phase 1 T-1.1). # # Node lifecycle stage 1 ("Create") per docs/02. cloud-init (infra/cloud-init/) # does stage 2 ("Baseline"); Ansible (infra/ansible/) does stage 3 ("Cluster-join"). locals { # 1 GiB in bytes; libvirt volume sizes are bytes. gib = 1073741824 # Per-node identity/addressing, KEYED BY NODE NAME (map, not list). # for_each over this map gives every node a stable, name-based state address # (libvirt_domain.node["k3s-dev-2"]) instead of a positional one (…node[1]). # Positional count indexes are brittle under `-target`/`-replace`: a single-node # destroy on a count-indexed resource can cascade to siblings (T-1.5 finding F1). # Name keys are individually addressable, so one node can be replaced/destroyed # without disturbing the others. See DECISIONS-LOG.md "T-1.5 … Finding F1". nodes = { for i in range(var.node_count) : "${var.node_name_prefix}${i + 1}" => { name = "${var.node_name_prefix}${i + 1}" ip = var.node_ips[i] fqdn = "${var.node_name_prefix}${i + 1}.${var.node_domain}" is_first = i == 0 } } # Ordered (node1..nodeN) list + primary view for outputs that need positional # access — the resource for_each uses the map above; these preserve ordering # for the Ansible inventory (primary = cluster-init server, rest = secondaries). node_list = [for i in range(var.node_count) : local.nodes["${var.node_name_prefix}${i + 1}"]] primary = local.nodes["${var.node_name_prefix}1"] } # --- dedicated NAT network (10.73.0.0/24, gw .1) --------------------------- # DHCP is OFF: nodes get static IPs from cloud-init network-config, so the # reserved VIP range (.240-.250) can never be leased to something else. resource "libvirt_network" "k3s_dev" { name = var.network_name mode = "nat" addresses = [var.network_cidr] autostart = true dhcp { enabled = false } dns { enabled = true local_only = true } } # --- base OS volume (downloaded/copied once, shared as a backing file) ------ resource "libvirt_volume" "base" { name = "${var.cluster_name}-base.qcow2" pool = var.pool_name source = var.image_source format = "qcow2" } # --- per-node OS disk: thin qcow2 backed by the base image ------------------ resource "libvirt_volume" "os" { for_each = local.nodes name = "${each.value.name}-os.qcow2" pool = var.pool_name base_volume_id = libvirt_volume.base.id size = var.os_disk_gib * local.gib format = "qcow2" } # --- per-node data disk: blank thin qcow2 for Longhorn ---------------------- resource "libvirt_volume" "data" { for_each = local.nodes name = "${each.value.name}-data.qcow2" pool = var.pool_name size = var.data_disk_gib * local.gib format = "qcow2" } # --- cloud-init seed (user-data + meta-data + static network-config) -------- # Templates live in the sibling infra/cloud-init/ subtree (T-1.2). resource "libvirt_cloudinit_disk" "seed" { for_each = local.nodes name = "${each.value.name}-seed.iso" pool = var.pool_name user_data = templatefile("${path.module}/../cloud-init/user-data.yaml.tftpl", { hostname = each.value.name fqdn = each.value.fqdn username = var.login_user ssh_authorized_key = var.ssh_authorized_key }) meta_data = templatefile("${path.module}/../cloud-init/meta-data.yaml.tftpl", { hostname = each.value.name fqdn = each.value.fqdn }) network_config = templatefile("${path.module}/../cloud-init/network-config.yaml.tftpl", { ip = each.value.ip prefix = var.network_prefix gateway = var.network_gateway }) } # --- the VMs ---------------------------------------------------------------- resource "libvirt_domain" "node" { for_each = local.nodes name = each.value.name memory = var.memory_mib vcpu = var.vcpu # host-passthrough so nested virt is available for KubeVirt in Phase 5. cpu { mode = var.cpu_mode } # qemu-guest-agent is installed by cloud-init; lets tofu read the lease/agent. qemu_agent = true cloudinit = libvirt_cloudinit_disk.seed[each.key].id # IP + hostname come from cloud-init (DHCP is off on this network), so we do NOT # set addresses/hostname here — the provider couples those to DHCP reservations. network_interface { network_id = libvirt_network.k3s_dev.id wait_for_lease = false } # OS disk first (vda, boot), Longhorn data disk second (vdb). disk { volume_id = libvirt_volume.os[each.key].id } disk { volume_id = libvirt_volume.data[each.key].id } console { type = "pty" target_port = "0" target_type = "serial" } console { type = "pty" target_port = "1" target_type = "virtio" } graphics { type = "spice" listen_type = "address" autoport = true } }