diff --git a/docs/changelog-20260709-opentofu-node-vm.md b/docs/changelog-20260709-opentofu-node-vm.md
new file mode 100644
index 0000000..bd01b15
--- /dev/null
+++ b/docs/changelog-20260709-opentofu-node-vm.md
@@ -0,0 +1,81 @@
+# Changelog 2026-07-09 -- OpenTofu node-VM module + syntax-bug fix (DOCFIX-144)
+
+No live infrastructure touched -- unapplied IaC source only. Continues the
+DOCFIX-142 OpenTofu scaffold.
+
+## Items
+
+### 1. DOCFIX-144 -- `opentofu/modules/node-vm` (D-103's PXE-boot node-VM pattern)
+FILES (new): `opentofu/modules/node-vm/variables.tf`, `main.tf`, `outputs.tf`.
+
+WHAT: one MAAS-managed OpenStack node VM per call -- a blank `libvirt_volume`
+boot disk (no image, no cloud-init) plus a `libvirt_domain` with PXE boot
+priority on its first network interface. This is the D-103 "OpenTofu creates
+the node-VM libvirt domains (SHIM)" need for buildout-design Stage 3 /
+Phase 2. Deliberately NOT the cloud-init/pre-built-image pattern (confirmed
+as a separate, distinct provider mechanism via a real example file -- see
+item 2 -- but not built as its own module this session; needed for Office1
+service VMs and OPNsense instead).
+
+VERIFICATION: this time, built from the provider's own real, runnable example
+files (`examples/domain_with_network.tf`, `examples/alpine_cloudinit.tf` --
+fetched and read directly, not summarized-and-guessed) rather than
+doc-summarization passes alone, after the previous session's attempt at
+`libvirt_domain` was deferred for exactly that reason. Every attribute in
+`main.tf` is used in the same shape as a real example, EXCEPT one, flagged
+explicitly in the file and in `opentofu/README.md`: the per-device `boot`
+attribute's internal shape (`{ order = N }`) is an inference by pattern
+(confirmed to exist as a field name on both `interfaces[]` and `disks[]`,
+matching native libvirt's per-device ``, but not
+independently confirmed from a real example this session). Flagged as the
+first thing to check with `tofu providers schema -json` before relying on
+PXE-first boot working as written.
+
+NOT instantiated in root `main.tf`: node count, memory, vcpu, and disk sizing
+are a Phase-0 host/disk-budget decision (buildout-design Section 3) that
+hasn't been made. Call the module once real values exist -- no placeholder
+specs invented to wire it in sooner.
+
+### 2. DOCFIX-144 (cont.) -- real syntax bug found and fixed in the DOCFIX-142 delivery
+FILES: `opentofu/modules/dc-planes/main.tf`, `opentofu/modules/mesh-link/main.tf`,
+`opentofu/modules/dc-storage-pool/main.tf`.
+
+WHAT: all three originally used classic HCL block syntax for nested
+structures (`domain { name = ... }`, `mtu { size = ... }`, `target { path =
+... }`). Building the node-VM module required fetching real example `.tf`
+files from the provider's repo, which showed those examples consistently use
+attribute-style nested OBJECTS instead (`os = { ... }`, `devices = { ... }`,
+`target = { format = { ... } }`) -- a provider-wide schema convention (this
+provider's current major version appears to use a terraform-plugin-framework
+object-typed-attribute schema mirroring raw libvirt XML, not classic blocks).
+All three files corrected to match; each carries a syntax-note comment
+explaining the change and its verification source.
+
+WHY THIS MATTERS: this was a real correctness bug in already-committed,
+already-pushed code (DOCFIX-142, commit 14d7382) -- not caught by
+`repo-lint` (it checks encoding/staleness, not HCL semantics) and not
+catchable without either a real `tofu validate` pass (unavailable this
+session, still) or, as it turned out, real example code from the provider
+itself. Logged plainly rather than glossed over: the doc-summarization
+verification process used for DOCFIX-142 was insufficient on its own: it
+correctly identified WHICH fields exist, but could not reliably convey HOW
+they're called (block vs. attribute syntax). Real example code closed that
+gap; doc-summarization alone did not.
+
+DOCUMENTED (not fixed, same class of remaining risk): `opentofu/README.md`'s
+"Schema notes" section now states this attribute-object convention as a
+provider-wide assumption to apply to ANY nested field not personally
+confirmed otherwise -- since the reasoning that surfaced this bug (a handful
+of real examples, not an exhaustive schema dump) could plausibly have missed
+a resource-specific exception.
+
+## Next actionable step (unchanged, still logged not actioned)
+
+On a machine with the `tofu` binary and registry network access: run
+`bash scripts/opentofu-validate.sh` against all of `opentofu/` (this would
+have caught the DOCFIX-142 syntax bug immediately), then
+`tofu providers schema -json` to confirm the `boot`-order attribute shape
+before trusting PXE-first boot in `modules/node-vm`.
+
+REVERT: `git checkout HEAD~ -- opentofu/` (drops the node-vm module and
+reverts the three modules to their pre-fix, WRONG syntax -- not recommended).
diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md
index 404910c..b17fcb1 100644
--- a/docs/dc-dc-deployment-workflow.md
+++ b/docs/dc-dc-deployment-workflow.md
@@ -79,7 +79,7 @@
| **Gate** | MAAS rack controller per DC up; MAAS discovers the OpenTofu-created node VMs; edge routing + simulated ISP uplink up; netem parameters applied and measured. |
| **Owns** | D-103 (seam), D-100 (edges). |
| **Reuse vs new** | NEW (no OpenTofu/multi-rack precedent in VR0 DC0). Explicitly tagged shim: node-VM creation has no Roosevelt analog (physical racking + BMC enlistment replaces it there) -- do not treat as reusable production IaC. |
-| **Authoring status** | Runbook not started. The plane/link/pool layer of `opentofu/` (see Stage 1) is reusable here too -- what's still missing for THIS stage specifically is the node-VM (`libvirt_domain`) module, the virsh-VM-host-to-MAAS-rack registration, and the OPNsense edge itself; all deferred, see `opentofu/README.md`. |
+| **Authoring status** | Runbook not started. The plane/link/pool layer of `opentofu/` (see Stage 1) is reusable here too. `modules/node-vm` (2026-07-09) covers the MAAS-managed OpenStack node VM pattern (blank disk, PXE-boot) -- not yet instantiated (node count/sizing is a pending Phase-0 decision). Still missing for THIS stage: the virsh-VM-host-to-MAAS-rack registration and the OPNsense edge itself (OPNsense needs the DIFFERENT cloud-init/base-image VM pattern, not `node-vm`'s PXE pattern -- still unbuilt). See `opentofu/README.md`. |
**State:** NOT STARTED.
@@ -191,20 +191,28 @@
a `$DC` (or `--dc`) selector, with both libraries becoming DC-keyed lookups
instead of flat constants. Do this BEFORE writing the Stage 5 runbook, or the
"high reuse" claim above is not actually true operationally.
-2. **OpenTofu -- PARTIALLY SCAFFOLDED 2026-07-09 (was: zero files).** `opentofu/`
- now has the network + storage-pool layer: `modules/dc-planes` (six per-DC
- planes), `modules/mesh-link` (the D-100 dark-fiber triangle legs),
+2. **OpenTofu -- network/pool/node-VM layer scaffolded 2026-07-09 (was: zero
+ files).** `opentofu/` now has: `modules/dc-planes` (six per-DC planes),
+ `modules/mesh-link` (the D-100 dark-fiber triangle legs),
`modules/dc-storage-pool`, wired for DC1 + Office1 (DC2 deliberately not
- wired -- its CIDRs aren't assigned yet, D-101). Provider schema (confirmed
- against the actual current docs, not memory) turned out to differ
- materially from older common examples of this provider. Still missing,
- deliberately deferred (see `opentofu/README.md` for why): the `libvirt_domain`
- node-VM module (schema too large/unfamiliar to author safely without a
- real `tofu providers schema` pass), `libvirt_volume`, OPNsense edge config,
- and `tc netem` application. **UNVALIDATED** -- no `tofu` binary was
- available to run `scripts/opentofu-validate.sh` (the harness written
- alongside it) this session; run it on a connected machine before trusting
- any of this.
+ wired -- its CIDRs aren't assigned yet, D-101); `modules/node-vm` (blank
+ disk, PXE-boot MAAS-managed node VM, the D-103 pattern for Stage 3) --
+ not yet instantiated, node count/sizing is a pending Phase-0 decision.
+ Provider schema was confirmed against the actual current docs AND real
+ example `.tf` files (not memory) -- it differs materially from older
+ common examples: nested structures are attribute-style objects (`key =
+ { ... }`), not classic HCL blocks. **A real bug from that first pass was
+ found and fixed:** `dc-planes`/`mesh-link`/`dc-storage-pool` originally
+ used block syntax for `libvirt_network`'s `domain`/`mtu` and
+ `libvirt_pool`'s `target`; corrected once real provider examples surfaced
+ the actual convention. Still missing, deliberately deferred (see
+ `opentofu/README.md`): the cloud-init/base-image VM pattern (needed for
+ Office1 service VMs and OPNsense -- a genuinely different module from
+ `node-vm`'s PXE pattern), the per-device `boot`-order attribute's exact
+ internal shape (flagged UNVERIFIED in `node-vm/main.tf`, an inference by
+ pattern not a confirmed field), and `tc netem` application. **UNVALIDATED**
+ -- no `tofu` binary was available to run `scripts/opentofu-validate.sh`
+ this session; run it on a connected machine before trusting any of this.
3. **NetBox multi-DC/dual-stack import pipeline.** `netbox/` has exactly two
scripts (`ipv4-prefixes-import.py`, `ipv6-mark-reserved.py`) -- single-DC v4
import and v6-reservation-marking only. D-101 requires DC2's supernet, the
@@ -219,10 +227,14 @@
- **(a) Network substrate -- DONE.** `opentofu/modules/mesh-link` creates
the inter-site L2 segments (DC1<->DC2, DC1<->Office1, DC2<->Office1) an
OPNsense VM's interfaces would eventually attach to.
- - **(b) The OPNsense VM/image itself -- BLOCKED on the deferred
- `libvirt_domain` module** (see gap #2). OPNsense is a VM; it inherits
- that same schema-verification prerequisite. No image source (official
- OPNsense qcow2/ISO vs a custom-built image) has been chosen either.
+ - **(b) The OPNsense VM/image itself -- needs a DIFFERENT module than
+ `node-vm` (gap #2's now-built domain module).** `node-vm` is the blank-
+ disk PXE pattern for MAAS-managed OpenStack nodes; OPNsense needs a real
+ bootable image + likely cloud-init, confirmed as a distinct, separately
+ schema'd provider pattern (`libvirt_cloudinit_disk` + a downloaded base
+ `libvirt_volume`) but not yet built as its own module. No image source
+ (official OPNsense qcow2/ISO vs a custom-built image) has been chosen
+ either.
- **(c) OPNsense routing/firewall configuration -- NOT STARTED, no
mechanism chosen.** Once the VM exists, per-site routing + the "simulated
ISP uplink" role need configuring. OPNsense's own config is an XML file
@@ -282,8 +294,8 @@
| Decisions (D-100..D-110) | DRAFTED, ALL PROPOSED -- **Stage 0 is the very next action** |
| DR mechanism seed | DONE (`dc-dc-replication-DR-seed.md`), superseded-carrier corrected into D-108 |
| Stage 0-7 runbooks | NOT YET WRITTEN -- see Tooling gap register above for the itemized breakdown |
-| `opentofu/` (networks/pools) | SCAFFOLDED 2026-07-09, UNVALIDATED (no `tofu` binary available this session) -- see gap #2 and `opentofu/README.md` |
-| `opentofu/` (node VMs, OPNsense, netem) | NOT STARTED -- deliberately deferred, see `opentofu/README.md` |
+| `opentofu/` (networks/pools/node-VM PXE pattern) | SCAFFOLDED 2026-07-09, UNVALIDATED (no `tofu` binary available this session) -- see gap #2 and `opentofu/README.md` |
+| `opentofu/` (cloud-init/base-image VM pattern, OPNsense, netem) | NOT STARTED -- deliberately deferred, see `opentofu/README.md` |
| Reusable as-is, repo-agnostic (2026-07-09 sweep) | `bundle.yaml`, `phase-01..08` VR0 DC0 runbooks (Stage 5/7 template, once DC-parameterized -- gap #1), `preflight.sh`, `cloud-assert.sh`, `repo-lint.sh`, `run-logged.sh`, `ledger-scan.sh` |
| `lib-net.sh` / `lib-hosts.sh` | NOT yet DC-agnostic -- single-deployment hardcoded literals; see gap #1 |
| Environment/skill docs (`environment.md`, README.md) | Still describe VR0 DC0/v1 single-DC scope wholesale -- flagged, not yet rewritten for VR1; not a blocker for Stage 0 but will need doing before/during Stage 1 |
diff --git a/docs/session-ledger.md b/docs/session-ledger.md
index eaa6f08..f3288f9 100644
--- a/docs/session-ledger.md
+++ b/docs/session-ledger.md
@@ -757,7 +757,24 @@
`write_bytes()` with an explicit ascii-encode instead of relying on
`.gitattributes` eol=lf to clean up CRLF after the fact. Verified 0 CR bytes
on a live re-record; harness 34/34 unchanged; repo-lint 0 fail/1 warn.
- Uncommitted -- operator has not yet asked to commit this piece.
+ COMMITTED + PUSHED (f93dd0d).
+- **DONE -- DOCFIX-144 (changelog `docs/changelog-20260709-opentofu-node-vm.md`):**
+ moved into the domain module as requested. Built `opentofu/modules/node-vm`
+ (blank-disk PXE-boot MAAS-managed node VM, D-103's pattern for Stage 3) --
+ this time from the provider's own real example `.tf` files, not
+ doc-summarization alone, after last session deferred `libvirt_domain` for
+ exactly that confidence gap. **Found and fixed a real bug in the
+ already-committed DOCFIX-142 delivery in the process:** `dc-planes`/
+ `mesh-link`/`dc-storage-pool` used classic HCL block syntax
+ (`domain { ... }`) where the provider's current schema actually needs
+ attribute-style objects (`domain = { ... }`) -- confirmed once real
+ provider examples surfaced the convention; doc-summarization alone had NOT
+ caught this. All three corrected; `opentofu/README.md`'s schema notes now
+ state the attribute-object convention as a provider-wide assumption.
+ UNVERIFIED, flagged: the per-device `boot`-order attribute's exact shape
+ (an inference by pattern). Still deferred: the cloud-init/base-image VM
+ pattern (Office1 services + OPNsense), DC2 planes, netem. Uncommitted --
+ operator has not yet asked to commit this piece.
diff --git a/opentofu/README.md b/opentofu/README.md
index a7e45f8..72bd3ef 100644
--- a/opentofu/README.md
+++ b/opentofu/README.md
@@ -27,26 +27,40 @@
dark-fiber triangle (DC1<->DC2, DC1<->Office1, DC2<->Office1).
- `modules/dc-storage-pool` -- a directory-backed `libvirt_pool` per DC/Office1
for future node-VM disk images.
+- `modules/node-vm` (2026-07-09) -- one MAAS-managed OpenStack node VM per
+ call: a BLANK `libvirt_volume` boot disk + a `libvirt_domain` with PXE-boot
+ priority on its first network interface. This is the D-103 "OpenTofu creates
+ the node-VM libvirt domains (SHIM)" need for Stage 3 -- deliberately NOT the
+ cloud-init/pre-built-image pattern (see below), since MAAS images these VMs
+ itself after enlistment. NOT instantiated in root `main.tf` yet: node count/
+ memory/vcpu/disk sizing is a Phase-0 host/disk-budget decision
+ (buildout-design Section 3) that hasn't been made -- call the module once
+ real values exist, don't invent placeholder specs to wire it in sooner.
- Root wiring for DC1 (planes + pool, using DC1's inherited DC0 CIDRs) and
Office1 (pool only), plus all three mesh links.
+**Syntax bug fixed 2026-07-09:** `dc-planes`, `mesh-link`, and `dc-storage-pool`
+originally used classic HCL block syntax (`domain { name = ... }`) for
+`libvirt_network`'s `domain`/`mtu` and `libvirt_pool`'s `target`. Building the
+domain module surfaced real example `.tf` files from the provider's own repo
+(`examples/domain_with_network.tf`, `examples/alpine_cloudinit.tf`) that
+consistently use attribute-style nested objects instead (`os = { ... }`,
+`devices = { ... }`) -- this provider has moved to a schema style where nested
+structures are object-typed attributes, not blocks. All three modules were
+corrected to match once this was confirmed; see each file's syntax-note
+comment. This was not previously verifiable from the doc-summarization passes
+alone (the docs describe fields as "nested blocks" without showing the actual
+call syntax) -- real example code is what settled it.
+
Deliberately NOT built yet, and why:
-- **`libvirt_domain` (the actual node VMs).** The provider's current schema for
- this resource turned out to be a large (~40 top-level argument), deeply
- nested rewrite that closely mirrors raw libvirt domain XML (`numa_tune`,
- `launch_security`, `memory_backing`, a `devices` block containing `disks`,
- `interfaces`, `filesystems`, `graphics`, etc.) -- materially different from
- older, commonly-referenced examples of this provider. Doc-summarization
- tools available this session could not give high-confidence, verbatim field
- names for the nested `devices.interfaces`/`devices.disks` shape specifically.
- Authoring this blind risks exactly the "fabricated flag/value" failure mode
- this repo's operating discipline calls out as the cardinal sin. **Next
- action:** on a machine with the `tofu` binary, run
- `tofu providers schema -json` (after `terraform init`) and read the actual
- `libvirt_domain` schema directly, or start from the provider's own
- `examples/` directory in its GitHub repo, before writing this module.
-- **`libvirt_volume` (per-VM disk images).** Follows directly from the domain
- module above; deferred with it.
+- **The cloud-init/pre-built-image VM pattern** (a `libvirt_cloudinit_disk` +
+ a downloaded base `libvirt_volume` + a domain referencing both), needed for
+ Office1's own service VMs and for OPNsense -- confirmed as a real, distinct
+ provider pattern via `examples/alpine_cloudinit.tf`, but not yet built as a
+ module here. Different enough from `node-vm` (image source, `backing_store`
+ copy-on-write, `libvirt_cloudinit_disk`'s `user_data`/`meta_data`/
+ `network_config`) to warrant its own module rather than bolting it onto
+ `node-vm`.
- **DC2's plane networks.** D-101 has not assigned DC2's supernet yet (it's an
explicit open sub-item). `main.tf` has the DC2 module block written and
commented out, ready to uncomment once NetBox assigns real CIDRs -- do not
@@ -59,9 +73,11 @@
need a second pass regardless.
- **OPNsense edges -- four separable pieces, not one, all currently blocked or
unstarted:** (a) the network substrate is done (`modules/mesh-link`, above);
- (b) the VM/image itself is blocked on the deferred domain module, and no
- image source (official OPNsense qcow2/ISO vs. a custom build) has been
- chosen; (c) OPNsense's own routing/firewall config is a `config.xml` the
+ (b) the VM/image itself needs the cloud-init/base-image module (above, still
+ unbuilt) -- `modules/node-vm`'s blank-PXE-boot pattern does NOT apply to
+ OPNsense, which needs a real bootable image -- and no image source (official
+ OPNsense qcow2/ISO vs. a custom build) has been chosen either; (c) OPNsense's
+ own routing/firewall config is a `config.xml` the
libvirt provider never touches -- pre-baked image vs. cloud-init first-boot
vs. API/CLI-driven provisioner is an open choice, not started; (d) `tc
netem` WAN simulation is a separate OS-level `tc qdisc` setting with no
@@ -75,21 +91,49 @@
## Schema notes (read before extending)
Verified this session against the provider's actual current docs
-(`docs/resources/*.md` in `dmacvicar/terraform-provider-libvirt`, tag
-`v0.9.8`, fetched 2026-07-09) -- NOT from training-data memory of older
-provider versions, which would have been wrong:
+(`docs/resources/*.md`) AND real example `.tf` files (`examples/*.tf`) in
+`dmacvicar/terraform-provider-libvirt`, tag `v0.9.8`, fetched 2026-07-09 --
+NOT from training-data memory of older provider versions, which would have
+been wrong (and initially WAS wrong here once, see the syntax-bug note above):
-- `libvirt_network`'s isolation control lives at `forward.mode` (a NESTED
- block field), not a top-level `mode` argument as older examples show. This
- scaffold OMITS the `forward` block entirely for isolated planes/links
+- **Nested structures are attribute-style objects (`key = { ... }`),
+ everywhere, not classic HCL blocks (`key { ... }`).** Confirmed directly
+ from `examples/domain_with_network.tf` and `examples/alpine_cloudinit.tf`
+ (`os = { ... }`, `devices = { ... }`, `target = { format = { ... } }`).
+ This is a provider-wide convention, not resource-specific -- assume it
+ applies to any nested field you haven't personally confirmed otherwise.
+- `libvirt_network`'s isolation control lives at `forward.mode` (a nested
+ attribute), not a top-level `mode` argument as older examples show. This
+ scaffold OMITS the `forward` attribute entirely for isolated planes/links
(documented native-libvirt behavior for a private, unforwarded switch)
rather than guess a `mode = "none"` value this session could not confirm is
still valid. Re-check this specific choice first if `tofu validate` objects.
-- `domain` and `mtu` on `libvirt_network` are nested blocks (`domain { name =
- ... }`, `mtu { size = ... }`), not flat string/number arguments.
-- `libvirt_pool`'s `target` is a nested block (`target { path = ... }`), types
- like `dir` are native libvirt storage-pool vocabulary (stable, long-standing,
- not provider-specific) -- reasonably high confidence.
+- `libvirt_domain.devices.disks[].source.volume` takes `{ pool = ..., volume =
+ ... }` (pool name + volume name, both strings); `.target` takes `{ dev =
+ "vda", bus = "virtio" }`; `.driver` takes `{ type = "qcow2" }`. Confirmed
+ directly from `examples/alpine_cloudinit.tf`.
+- `libvirt_domain.devices.interfaces[].source.network` takes `{ network =
+ }` (attach to a libvirt-managed network, what this
+ repo's planes/mesh-links are); `.source.bridge` takes `{ bridge =
+ }` (attach to a plain OS bridge instead) -- confirmed from
+ `examples/domain_with_network.tf`. Not used here (planes/mesh-links are
+ libvirt-managed networks), but relevant if OPNsense ends up needing a direct
+ bridge attach to a host NIC.
+- `libvirt_volume.target.format.type` (nested twice: `target = { format = {
+ type = "qcow2" } }`) and `libvirt_volume.backing_store = { path = ...,
+ format = { type = "qcow2" } }` (copy-on-write from another volume's path)
+ -- confirmed from `examples/alpine_cloudinit.tf`.
+- **UNVERIFIED, flagged in `modules/node-vm/main.tf`:** the per-device `boot`
+ attribute's internal shape (used to set PXE-first boot order). Confirmed to
+ EXIST as a field name on both `interfaces[]` and `disks[]` entries (matching
+ native libvirt's per-device ``), but `{ order = N }` is an
+ inference by pattern, not confirmed from a real example. Check this first
+ with `tofu providers schema -json` before relying on PXE-first boot working
+ as written.
+- `libvirt_pool`'s `target` is likewise attribute-style (`target = { path =
+ ... }`); types like `dir` are native libvirt storage-pool vocabulary
+ (stable, long-standing, not provider-specific) -- reasonably high
+ confidence.
## Conventions carried from the rest of this repo
diff --git a/opentofu/modules/dc-planes/main.tf b/opentofu/modules/dc-planes/main.tf
index 048fd1e..de8dd71 100644
--- a/opentofu/modules/dc-planes/main.tf
+++ b/opentofu/modules/dc-planes/main.tf
@@ -14,6 +14,14 @@
# `ips`/`dhcp` are likewise intentionally NOT set: NetBox is the IPAM apex
# (skill hard rule 3); libvirt-managed DHCP on these planes would create a
# second, conflicting source of truth for addresses OpenStack itself assigns.
+#
+# SYNTAX NOTE (corrected 2026-07-09): this provider's current schema uses
+# attribute-style nested objects (`domain = { ... }`), not classic HCL nested
+# blocks (`domain { ... }`) -- confirmed against real example .tf files in the
+# provider's own repo (examples/domain_with_network.tf,
+# examples/alpine_cloudinit.tf use `os = { ... }`, `devices = { ... }`
+# consistently). The first version of this file used block syntax, which is
+# very likely wrong for this provider version; fixed to match.
resource "libvirt_network" "plane" {
for_each = var.planes
@@ -21,11 +29,11 @@
name = "${var.dc_name}-${each.key}"
autostart = true
- domain {
+ domain = {
name = "${each.key}.${var.dc_name}.${var.domain_suffix}"
}
- mtu {
+ mtu = {
size = var.mtu
}
}
diff --git a/opentofu/modules/dc-storage-pool/main.tf b/opentofu/modules/dc-storage-pool/main.tf
index 106ca49..b9459e3 100644
--- a/opentofu/modules/dc-storage-pool/main.tf
+++ b/opentofu/modules/dc-storage-pool/main.tf
@@ -5,15 +5,18 @@
# filesystem/volume, not a libvirt_pool argument; there is no thin-provisioning
# switch on this resource to set.
#
-# This module stops at the pool. It does NOT create libvirt_volume resources
-# (per-VM disk images) -- that is deferred alongside the domain/node-VM module;
-# see opentofu/README.md for why.
+# This module stops at the pool. Per-VM disk (libvirt_volume) creation lives
+# in modules/node-vm now that the domain module has been authored.
+#
+# SYNTAX NOTE (corrected 2026-07-09): attribute-style (`target = { ... }`),
+# not block syntax -- see modules/dc-planes/main.tf's note for the
+# verification source (real example .tf files in the provider's own repo).
resource "libvirt_pool" "dc" {
name = "${var.dc_name}-pool"
type = "dir"
- target {
+ target = {
path = var.target_path
}
}
diff --git a/opentofu/modules/mesh-link/main.tf b/opentofu/modules/mesh-link/main.tf
index e829eb3..146e8fb 100644
--- a/opentofu/modules/mesh-link/main.tf
+++ b/opentofu/modules/mesh-link/main.tf
@@ -13,11 +13,14 @@
# resource to fill the gap; track it as real follow-up work instead (see
# opentofu/README.md).
+# SYNTAX NOTE (corrected 2026-07-09): attribute-style (`mtu = { ... }`), not
+# block syntax -- see dc-planes/main.tf's note for the verification source.
+
resource "libvirt_network" "link" {
name = "mesh-${var.link_name}"
autostart = true
- mtu {
+ mtu = {
size = var.mtu
}
}
diff --git a/opentofu/modules/node-vm/main.tf b/opentofu/modules/node-vm/main.tf
new file mode 100644
index 0000000..dcabf23
--- /dev/null
+++ b/opentofu/modules/node-vm/main.tf
@@ -0,0 +1,83 @@
+# 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, clearly marked below, is the per-device `boot`
+# object's internal shape (`order`) -- confirmed to EXIST as a field name on
+# both `interfaces` and `disks` entries (matching native libvirt's per-device
+# ), but its exact internal shape was not independently
+# confirmed from a real example. VERIFY with `tofu providers schema -json`
+# before the first real apply; 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 = {
+ type = "qcow2"
+ }
+}
+
+resource "libvirt_domain" "node" {
+ name = var.vm_name
+ memory = var.memory_mib
+ vcpu = var.vcpu
+ type = "kvm"
+ running = true
+
+ 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"
+ }
+ 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
+ }
+ ]
+ }
+}
diff --git a/opentofu/modules/node-vm/outputs.tf b/opentofu/modules/node-vm/outputs.tf
new file mode 100644
index 0000000..d86e00d
--- /dev/null
+++ b/opentofu/modules/node-vm/outputs.tf
@@ -0,0 +1,9 @@
+output "domain_id" {
+ description = "libvirt domain id -- for the MAAS enlistment step (D-103: OpenTofu registers each DC's libvirt host to that DC's MAAS rack controller as a virsh VM-host, so MAAS discovers VMs created here)."
+ value = libvirt_domain.node.id
+}
+
+output "domain_name" {
+ description = "libvirt domain name."
+ value = libvirt_domain.node.name
+}
diff --git a/opentofu/modules/node-vm/variables.tf b/opentofu/modules/node-vm/variables.tf
new file mode 100644
index 0000000..102fcdb
--- /dev/null
+++ b/opentofu/modules/node-vm/variables.tf
@@ -0,0 +1,34 @@
+variable "vm_name" {
+ description = "Domain name, e.g. \"dc1-compute-01\"."
+ type = string
+}
+
+variable "memory_mib" {
+ description = "Memory in MiB. Sizing is a Phase-0 host/disk-budget decision (buildout-design Section 3) -- no default, pass explicitly."
+ type = number
+}
+
+variable "vcpu" {
+ description = "Virtual CPU count. No default -- pass explicitly, per the measured Phase-0 CPU budget."
+ type = number
+}
+
+variable "pool_name" {
+ description = "libvirt_pool name this VM's disk lives in (output of modules/dc-storage-pool)."
+ type = string
+}
+
+variable "disk_size_bytes" {
+ description = "Blank boot-disk size in bytes. No default -- pass explicitly."
+ type = number
+}
+
+variable "network_names" {
+ description = <<-EOT
+ Ordered list of libvirt_network names (outputs of modules/dc-planes) this
+ VM attaches to. Order matters for boot priority -- the FIRST entry is
+ given PXE boot priority (see main.tf's boot-order note; MAAS-managed
+ node VMs PXE-boot per D-103, they are not given a pre-built OS image).
+ EOT
+ type = list(string)
+}