Newer
Older
openstack-caracal-dc-dc / opentofu / modules / node-vm / main.tf
# 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" }
      }
    ]
  }
}