Files
home-network-k3s-impl/infra/tofu/main.tf
T
jakman85andClaude Opus 4.8 95e3eafac6 iac: harden node lifecycle — tofu for_each + self-contained ansible rejoin
Implements T-1.5 findings F1/F2 (author-first, no cluster change).

F1 (tofu): refactor libvirt node resources count -> for_each keyed by
node name so single-node -replace/destroy cannot cascade to siblings.
Add ordered-list/primary helper locals; update outputs.tf off the map;
add moved.tf (12 moved blocks). tofu plan proves a pure state-address
migration: 0 to add, 0 to change, 0 to destroy (all 12 'has moved to').
State-persist via a 0-change apply is operator-permission-gated.

F2 (ansible): k3s_server secondary fetches the join token via
delegate_to the primary (published fact as fast path), so a single-node
rejoin with --limit k3s-dev-N is self-contained and no longer depends on
a fact an earlier play had to set. Syntax-check + CI gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 19:42:12 -05:00

157 lines
5.0 KiB
Terraform

# 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
}
}