# node-vm: a single MAAS-managed OpenStack node VM (D-103's "OpenTofu creates
# the node-VM libvirt domains (SHIM -- Section 9)" -- Stage 3 / buildout-design
# Phase 2). BLANK disk, PXE-boot -- this is deliberately NOT the cloud-init +
# pre-built-image pattern (that's a separate, still-unbuilt module for Office1
# service VMs / OPNsense; see opentofu/README.md). MAAS enlists, commissions,
# and deploys these VMs after OpenTofu creates them; OpenTofu does not image
# them itself.
#
# Every attribute below is used exactly as shown in the provider's own
# examples/domain_with_network.tf and examples/alpine_cloudinit.tf (fetched
# and read directly this session, tag v0.9.8) -- attribute-style nested
# objects (`os = { ... }`, `devices = { ... }`), NOT classic HCL blocks. The
# ONE inference in this file, the per-device `boot` object's internal shape
# (`order`), is now BETTER-SUPPORTED after a 2026-07-09 audit pass (see
# opentofu/README.md): the provider is confirmed to code-generate its schema
# as a 1:1 mirror of libvirt's own domain XML, and libvirt's own official
# docs confirm the native XML element is exactly `<boot order='N'/>` (one
# attribute, `order`) -- consistent with every other single-attribute
# element already confirmed in this schema (e.g. `mtu = { size = N }` mirrors
# `<mtu size='N'/>`). Reasonably confident, not a blind guess anymore, but
# still not seen in an actual generated schema dump -- VERIFY with
# `tofu providers schema -json` before the first real apply regardless; if
# wrong, `tofu plan` will simply reject the attribute name, not silently
# misconfigure anything.
resource "libvirt_volume" "disk" {
name = "${var.vm_name}-disk.qcow2"
pool = var.pool_name
capacity = var.disk_size_bytes
# `format` is NOT a top-level attribute of libvirt_volume -- it nests under
# `target`. Verified against the provider's own schema (dmacvicar/libvirt
# v0.9.8, `tofu providers schema -json`): libvirt_volume's top-level
# attributes are name/pool/capacity/allocation/physical/type/target/create/
# backing_store/... and there is NO `format` among them. The old top-level
# form was rejected outright -- "An argument named format is not expected
# here" -- so this module could never have applied. modules/base-image had
# the correct nesting all along; this one did not. (DOCFIX-194, 2026-07-13.)
target = {
format = {
type = "qcow2"
}
}
}
resource "libvirt_domain" "node" {
name = var.vm_name
# POWER STATE BELONGS TO MAAS, NOT OPENTOFU (operator-ruled 2026-07-21).
# `running = true` below is honored at CREATE (wanted: a new node's first
# boot is its PXE enlistment) and ignored forever after. Without this
# guard, any later apply re-asserts running=true against nodes MAAS has
# powered OFF -- measured 2026-07-21: MAAS held all 9 Ready nodes off
# while the config wanted them on, so the MAC-pin adoption plan carried 9
# out-of-band power-ons (docs/audit/inner-plan-20260721-macpin.txt).
# Same ownership statement as autostart=false (D-127): MAAS drives node
# power (virsh here, IPMI on Roosevelt metal); tofu owns the definition.
lifecycle {
ignore_changes = [running]
}
# memory_unit is REQUIRED -- see the opnsense-edge module for the full write-up. On
# dmacvicar/libvirt >=0.9, bare `memory` is interpreted in libvirt's default unit (KiB),
# NOT MiB, so omitting this gives the guest 1024x too little RAM and it triple-faults
# in its bootloader. (2026-07-12 incident.)
memory = var.memory_mib
memory_unit = "MiB"
vcpu = var.vcpu
type = "kvm"
running = true
autostart = var.autostart # D-127 boot matrix -- node VMs are MAAS-power-controlled: MANUAL (false)
# ACPI/APIC: with no `features` block libvirt renders `acpi=off` -- see the
# opnsense-edge module for the full write-up. Especially important here: these are
# the MAAS-managed node VMs, and MAAS drives power/shutdown via ACPI signalling.
# Without ACPI a graceful `virsh shutdown` (and therefore MAAS power-off) does not
# work -- the node would only ever hard-stop.
features = {
acpi = true
apic = {}
}
# CPU: host-passthrough, with the host's `svm` feature PASSED THROUGH. This is
# UNCONDITIONAL and has no opt-out on purpose -- a DC node runs `nova-compute`, so it
# MUST be able to run KVM guests. There is no correct value other than this one.
#
# WHY THIS IS EXPLICIT: with NO cpu block at all, libvirt renders a GENERIC EMULATED CPU
# MODEL that exposes no `svm` -- and the node then comes up looking healthy while being
# unable to launch a single instance. Measured 2026-07-13: the host is an AMD EPYC 9965,
# but a default-CPU guest is handed an "Opteron_G3" with no svm. The identical defect was
# found in modules/cloudinit-vm the same day and broke D-114's model outright.
#
# Contrast modules/opnsense-edge, which DISABLES svm: correct hardening there, because a
# router guest has no business running VMs. A compute node is the opposite case.
cpu = {
mode = "host-passthrough"
}
os = {
type = "hvm"
type_arch = "x86_64"
type_machine = "q35"
}
devices = {
disks = [
{
source = {
volume = {
pool = libvirt_volume.disk.pool
volume = libvirt_volume.disk.name
}
}
target = {
dev = "vda"
bus = "virtio"
}
driver = {
type = "qcow2"
}
# boot.order shape UNVERIFIED (see file header) -- disk boots second,
# network boots first (PXE).
boot = {
order = 2
}
}
]
interfaces = [
for i, net_name in var.network_names : {
model = {
type = "virtio"
}
# MAC pinning (see variables.tf interface_macs): an unpinned MAC is
# provider-owned and an "in-place" apply can regenerate it, stranding
# the node in MAAS (measured 2026-07-20/21). `mac = { address = ... }`
# verified against the provider schema (dmacvicar/libvirt 0.9.8,
# `tofu providers schema -json`, 2026-07-21).
mac = length(var.interface_macs) > 0 ? { address = var.interface_macs[i] } : null
source = {
network = {
network = net_name
}
}
# only the first (PXE) interface gets an explicit boot order;
# see the disk boot-order note above for the same caveat.
boot = i == 0 ? { order = 1 } : null
}
]
# SERIAL CONSOLE + BOOT LOG. Added 2026-07-20 because its ABSENCE blocked a
# real diagnosis: six of nine vr1-dc0 nodes stalled in MAAS's `Loading
# ephemeral` and, with no console and no agent, a stuck node was a sealed
# box -- nothing to read, nothing to attach to. Contention, boot-image sync
# and DHCP were all refuted by measurement; the boot itself could not be
# observed at all.
#
# This is the SAME pattern modules/opnsense-edge already carries, and for
# the same reason: that module's log is what made the 2026-07-12 boot bugs
# legible. A PXE-booting node is exactly the case where the interesting
# output happens BEFORE anything you could ssh into exists.
#
# log -> the full boot capture, including everything emitted before an
# operator could possibly attach (`virsh console` only shows from
# the moment you attach).
# NO `source` -> libvirt's default `<serial type='pty'>`, which is what
# `virsh console` requires. A pty's /dev/pts/N path is allocated at
# start time and cannot be known in advance, so it must not be set.
#
# Unconditional, not opt-in: these node VMs are MAAS-managed and rebuilt at
# will, so there is no live-router bounce risk of the kind that made the
# D-129 qga channel opt-in. Every node should be observable by default.
serials = [
{
log = { file = "${var.serial_log_dir}/${var.vm_name}-serial.log", append = "on" }
target = { port = "0" }
}
]
}
}