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]>
157 lines
5.0 KiB
Terraform
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
|
|
}
|
|
}
|