Newer
Older
openstack-caracal-dc-dc / opentofu / README.md

opentofu/ -- VR1 (DC-DC) infrastructure-as-code

Owns create/destroy of the "physical" layer per D-103: libvirt domains (node VMs), all virtual networks (dark-fiber mesh, per-DC planes, ISP-edge), the OPNsense edge VMs, and the Office1 service VMs. Runs from the Office1 operator VM against the vcloud host libvirt. See docs/dc-dc-buildout-design.md and docs/dc-dc-deployment-workflow.md (Stage 1-2, tooling gap register item 2) for the surrounding plan. See the skill's .claude/skills/openstack-cloud-ops/references/opentofu-provider-docs.md for where every provider fact below was sourced from and the fetch methodology that worked -- read that first if extending any module here.

Status: SCAFFOLD, UNVALIDATED. No tofu/terraform binary was available in the session that authored this -- nothing here has been run through tofu init/validate/plan, let alone apply. Before trusting any of it:

bash scripts/opentofu-validate.sh

on a machine with the binary and network access to the provider registry, and read the result before proceeding.

Scope of this delivery

Built:

  • modules/dc-planes -- the six per-DC plane segments (D-052/D-100) as isolated libvirt_network resources.
  • modules/mesh-link -- one inter-site L2 segment per leg of the D-100 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.
  • modules/base-image + modules/cloudinit-vm (2026-07-09) -- the cloud-init/ pre-built-image VM pattern, for Office1's own service VMs (MAAS-region, NetBox, GitBucket). Split into two, matching how examples/alpine_cloudinit.tf itself separates concerns: base-image downloads one shared cloud image ONCE (call it once per distinct image); cloudinit-vm creates a per-VM copy-on-write overlay disk + a libvirt_cloudinit_disk NoCloud seed + the domain (call it once per VM instance, passing module.<base-image-call>.path straight through for the overlay's backing_store.path -- a real attribute reference, not a reconstructed guess). user_data/meta_data/network_config are REQUIRED inputs with no default and no generic fallback -- what Office1's VMs actually need configured is real design work not done yet, and a plausible- looking default (e.g. dhcp4: true) would silently fail anyway since this repo's planes carry no libvirt-managed DHCP. NOT confirmed for OPNsense (FreeBSD-based; cloud-init/NoCloud support is not the same guarantee as for Linux cloud images) -- check that specifically before assuming this module covers OPNsense too. Neither module instantiated in root main.tf yet: no image source has been chosen for any Office1 service VM.
  • modules/opnsense-edge (2026-07-09) -- one OPNsense edge VM per call, using OPNsense's own Configuration Importer (NOT cloud-init -- see the dedicated research section below) via a plain ISO9660 volume containing /conf/config.xml, attached as a secondary cdrom disk -- mechanically identical to cloudinit-vm's seed-volume shape, no libvirt_cloudinit_disk resource involved (wrong format for this). Paired with two new scripts that do the OUT-OF-OPENTOFU preparation work: scripts/opnsense-prep-image.sh (download the nano image, decompress, convert to qcow2, resize) and scripts/opnsense-build-config-iso.sh (build the ISO9660 config-seed from a real config.xml). Both scripts require external tools not present in the session that wrote them (qemu-img; genisoimage/xorriso) -- their harnesses only test the guard-clause paths, not the real download/convert/build behavior; see each script's header. config_iso_path has no default: real config.xml content per site is design work not done. Not instantiated in root main.tf.
  • modules/maas-vm-host (2026-07-09) -- registers the vcloud host's virsh/ libvirt connection with MAAS as a VM host, via the official canonical/maas provider's maas_vm_host resource (D-103: "OpenTofu registers each DC's libvirt host to that DC's MAAS rack controller as a virsh VM-host, so MAAS DISCOVERS the OpenTofu-created node VMs"). Deliberately does NOT use maas_vm_host_machine -- see the dedicated research section below for why that resource is the wrong one (it composes new VMs; D-103 explicitly rules that out). Not instantiated: needs a real MAAS zone/pool and the vcloud host's real power_address.
  • modules/netem-link (2026-07-09) -- applies tc qdisc ... netem WAN-simulation parameters (D-100) to a mesh-link bridge, via a terraform_data resource with local-exec provisioners run over SSH (NOT a bare local command -- OpenTofu runs from Office1, not the vcloud host where the bridges live). See the dedicated research section below. Not instantiated: needs the real bridge name, the vcloud host's SSH target, and the actual netem parameters (still an unruled D-100 decision).
  • Root wiring for DC1 (planes + pool, using DC1's inherited DC0 CIDRs) and Office1 (pool only), plus all three mesh links, plus a provider "maas" {} block (api_url/api_key as new required variables, maas_api_key marked sensitive = true).

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:

  • 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 fill in a guessed default to make it "work" sooner.
  • OPNsense edges -- (a)-(c) DONE 2026-07-09, (d) mechanism now also DONE, only real parameters/instantiation remain: (a) network substrate (modules/mesh-link); (b)+(c) the image AND its config (modules/opnsense-edge + two prep scripts) -- UNVALIDATED (no real boot, no tofu binary, no ISO-building tool available this session); real config.xml content per site is still design work, not yet done, and the module has no fallback for it. (d) tc netem -- the injection MECHANISM is now built (modules/netem-link), but the actual latency/jitter/loss/rate values are still an unruled D-100 open item, so nothing is instantiated. Full breakdown in docs/dc-dc-deployment-workflow.md gap register item 4.
  • MAAS VM-host registration -- mechanism DONE (modules/maas-vm-host), not instantiated. Needs a real MAAS zone/pool and the vcloud host's power address; neither exists yet.
  • MAAS-region / NetBox / GitBucket service VMs -- module exists (cloudinit-vm), not instantiated. No image source has been chosen for any of them, and their actual user_data/meta_data/network_config content (what packages, what config, what static address) is real design work that hasn't been done -- required inputs, no invented placeholders.

OPNsense deployment research (2026-07-09) -- the basis for modules/opnsense-edge

Researched directly against OPNsense's own docs/forum/GitHub, not inferred -- confirms modules/cloudinit-vm genuinely does NOT apply, identifies the real mechanism (which turns out to need the same mechanical shape), and is now implemented as modules/opnsense-edge above. Read this before changing that module -- it's the "why," not just background.

  1. Cloud-init is confirmed unreliable on OPNsense, not just unconfirmed. Community consensus on OPNsense's own forum: "FreeBSD cloud-init support, which is not great yet" -- an experienced user's conclusion after investigating it directly. (https://forum.opnsense.org/index.php?topic=42517.0)
  2. The real native mechanism is the Configuration Importer, official docs: runs very early in boot (a 2-3 second window), scans attached volumes for /conf/config.xml. Supported filesystems: GPT, MBR, ZFS pool, msdosfs (FAT). (https://docs.opnsense.org/manual/backups.html, https://docs.opnsense.org/development/backend/autorun.html)
  3. ISO9660 was added to the Importer specifically for VM/cloud automation -- a closed, milestone-targeted, core-developer-assigned GitHub issue requested it explicitly so a config ISO could be attached as a secondary CD-ROM ("shared one to many," vs. a disk image needing a copy per VM). A later bug report confirms ISO9660 detection is live in current code. This means the exact SAME mechanical pattern as modules/cloudinit-vm (a secondary volume attached as a cdrom disk) applies to OPNsense too -- just with a different payload: a plain ISO9660 image containing /conf/config.xml, not a NoCloud-format seed. (https://github.com/opnsense/core/issues/5733, https://github.com/opnsense/core/issues/10017 -- could not pin the exact shipped version from the 22.7 changelog text directly; the feature's existence is corroborated by the later bug report describing its real-world behavior, which is a stronger signal than the milestone tag alone. Confirm on a real boot before relying on it.)
  4. There's also a deeper autorun hook for more advanced provisioning: scripts in /usr/local/etc/rc.syshook.d/import/ can override or extend the default import, running before standard network startup. (https://docs.opnsense.org/development/backend/autorun.html)
  5. Use the nano image for KVM, not the vga/serial/dvd installer images. Nano is pre-installed (serial console pre-configured, auto-expands to fill the disk on first boot) -- the installer images need an interactive install wizard, which defeats automation. Confirmed real-world workflow: download the .img.bz2 -> bunzip2 -> qemu-img convert -f raw -O qcow2 -> qemu-img resize (grow) -> boot via libvirt with a FreeBSD os-variant, serial console, no graphics. (https://nickcharlton.net/posts/installing-opnsense-virt-install-kvm-serial, https://opnsense.org/download/)
  6. A dedicated "build KVM-ready OPNsense images" community project confirms there is no built-in cloud-init/config-injection in official images -- the pattern is always "deploy the immutable base image, then provision separately," matching finding 2 above (seed minimal config via the Importer to get SSH/API reachable, then drive the rest over the network). (https://github.com/maurice-w/opnsense-vm-images)
  7. Network interface convention: interface 1 = LAN (default 192.168.1.1), interface 2 = WAN. Order matters when wiring network_names, the same way modules/node-vm's first-interface-gets-PXE-boot-priority does. (https://docs.opnsense.org/manual/install.html)

Built from this research (2026-07-09): scripts/opnsense-prep-image.sh (step 5's decompress+convert, since create.content.url almost certainly does a plain HTTP fetch with no decompression and can't consume the .img.bz2 directly), scripts/opnsense-build-config-iso.sh (a plain ISO9660 image containing /conf/config.xml, to feed a libvirt_volume's create.content.url the same way libvirt_cloudinit_disk.path feeds cloudinit-vm's seed volume), and modules/opnsense-edge wiring both together. Still not done: the actual config.xml content for OPNsense's own LAN/WAN/routing role at each site -- real design work, no fallback provided; and real-world verification of the whole chain (no tofu binary, no ISO-building tool, and no real OPNsense boot were available this session to confirm any of it end-to-end).

MAAS registration + tc netem research (2026-07-09) -- the basis for modules/maas-vm-host and modules/netem-link

Researched directly against the canonical/terraform-provider-maas provider's own docs and OpenTofu's own official docs, not inferred.

  1. A real, official MAAS provider exists: canonical/maas, v2.7.2 (confirmed from the registry page metadata, published 2026-01-30) -- not a community/unofficial one. Moved from an older maas/maas namespace; use canonical/maas. (https://registry.terraform.io/providers/canonical/maas/latest)
  2. maas_vm_host (register) vs. maas_vm_host_machine (compose) are DIFFERENT operations -- confirmed by reading both resources' real schemas, not just their names. maas_vm_host_machine's arguments (cores, memory, storage_disks, network_interfaces as INPUTS) are MAAS's own "Compose machine" pod feature -- you specify desired specs and MAAS creates a new VM via the virsh connection itself. D-103 explicitly rules this out: "MAAS owns commission / deploy / power / release of those node VMs; it does NOT compose new ones." maas_vm_host (register the virsh chassis; MAAS's own discovery then enlists whatever domains already exist there) is the one that matches D-103's actual described flow -- modules/node-vm pre-creates the VMs, registering the host is what makes MAAS find them. Using vm_host_machine here would have MAAS and OpenTofu fighting over VM creation. (https://raw.githubusercontent.com/canonical/terraform-provider-maas/master/docs/resources/vm_host.md, .../vm_host_machine.md)
  3. power_address confirmed in the exact virsh URI format already used elsewhere in this repo -- the provider's own docs example is "qemu+ssh://172.16.99.2/system", the same shape as this module's own libvirt_uri variable. In practice these should point at the same vcloud host, kept as independent inputs rather than silently assumed identical.
  4. local-exec runs on the machine invoking tofu apply (Office1), NOT on the resource or the vcloud host -- confirmed from OpenTofu's own official docs. This matters directly: D-103 says OpenTofu itself runs from the Office1 operator VM, so a bare local-exec command applying tc qdisc would run on Office1, not touch the vcloud host's bridge interfaces at all. modules/netem-link wraps the command in an explicit SSH hop to the vcloud host because of this. (https://opentofu.org/docs/language/resources/provisioners/local-exec/)
  5. OpenTofu's own docs recommend terraform_data over the older null_resource for exactly the "run a provisioner with no logical resource to attach it to" case -- a built-in resource, no provider needed. Used in modules/netem-link, not null_resource. (https://opentofu.org/docs/language/resources/tf-data/)

Built from this research (2026-07-09): modules/maas-vm-host (registration only, deliberately no composition), modules/netem-link (terraform_data + SSH-wrapped local-exec, with a destroy-time provisioner to remove the qdisc on teardown). Neither instantiated: both need real inputs (a MAAS zone/pool, the vcloud host's SSH target, real netem parameters) that don't exist yet.

Schema notes (read before extending)

Verified this session against the provider's actual current docs (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):

  • 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.
  • 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 = <libvirt-network-name> } (attach to a libvirt-managed network, what this repo's planes/mesh-links are); .source.bridge takes { bridge = <os-bridge-name> } (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 <boot order='N'/>), 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.
  • libvirt_cloudinit_disk takes flat string arguments name, user_data, meta_data, network_config (cloud-init YAML content, no nested attributes) and exposes a .path -- feed that .path into a libvirt_volume's create.content.url to register it as an attachable cdrom volume. Confirmed directly from examples/alpine_cloudinit.tf.
  • A base image created in one module call and consumed via backing_store in a DIFFERENT module call: pass the base module's path OUTPUT straight through as a variable to the consuming module (modules/cloudinit-vm does this). Do not reconstruct a volume's path from its pool name + volume name -- libvirt's actual pool-relative path convention was not independently confirmed this session, and a real .path attribute reference is always available and unambiguous instead.
  • create.content.url accepts plain LOCAL filesystem paths, not just http(s) URLs -- inferred from examples/alpine_cloudinit.tf assigning libvirt_cloudinit_disk.alpine_seed.path (a local path, not a URL) directly to create.content.url, and corroborated by a second, independent source during the 2026-07-09 audit pass describing the same volume-creation mechanism as supporting "both local paths (relative to the machine running Terraform) and remote images" via an underlying go-getter-style fetch (a well-known library that treats bare/file:// local paths as one of its supported source schemes alongside http/https). modules/opnsense-edge relies on this for BOTH its base image (scripts/opnsense-prep-image.sh's output path) and its config-seed ISO (scripts/opnsense-build-config-iso.sh's output path) -- neither is fetched from a real URL. Reasonably high confidence now (two independent corroborating sources), though still not seen named explicitly in the provider's own prose docs -- confirm with tofu plan if it errors on a file://-less bare path.
  • maas_vm_host's type argument accepts "virsh" or "lxd" (confirmed from the provider's own docs); power_address is a flat string, no nested attributes; zone/pool are flat optional strings, not objects. Simpler, flatter schema than the libvirt provider's -- confirmed directly from docs/resources/vm_host.md in canonical/terraform-provider-maas.
  • provider "maas" { ... } takes flat top-level arguments (api_url, api_key, api_version [defaults `"2.0"`], installation_method [defaults `"snap"`], tls_ca_cert_path, tls_insecure_skip_verify) -- confirmed from the provider's own docs/index.md, including its exact example block.
  • terraform_data's schema is input (optional, stored + reflected in output) and triggers_replace (optional, forces replacement when changed) -- confirmed from OpenTofu's own official docs, including a complete example using it with a local-exec provisioner keyed on triggers_replace. modules/netem-link uses triggers_replace (not input) since nothing needs to be stored/reflected, just re-triggered when the bridge or netem args change.

Audit pass (2026-07-09) -- re-checked every module's flagged assumptions

Operator asked to pull documentation for anything drafted without it, and review every module for errors/assumptions, after 5 modules had already shipped across 4 changelogs. Went through every "UNVERIFIED"/"inferred"/ "assumed" note in the codebase and did further research rather than leaving them as-is. Outcomes:

  • node-vm's boot = { order = N } shape -- confidence UPGRADED, not resolved outright. Confirmed the provider code-generates its schema as a 1:1 mirror of libvirt's own domain XML (an architectural fact, not documented in the resource pages fetched earlier), and confirmed libvirt's own official docs show the native element is exactly <boot order='N'/> -- one attribute, matching the established single-attribute-element pattern already confirmed elsewhere in this schema (mtu.size, network.domain.name). Comment updated to reflect this is now well-supported reasoning, not a blind guess -- still recommend a real schema check before trusting it.
  • opnsense-edge's LAN/WAN interface-ordering claim -- GENUINE ERROR FOUND AND FIXED, not just re-confirmed. The original comment conflated two separate things: which vtnetN device number a NIC gets (plausibly controlled by devices.interfaces list order -- libvirt's own docs on PCI addressing say auto-assigned addresses "usually match" XML order for a simple topology like this one) and OPNsense's actual LAN/WAN ROLE assignment, which is a SEPARATE, explicit mapping made inside config.xml itself (or the interactive/API assignment step) -- confirmed via OPNsense's own interface-configuration model ("maps logical 'friendly' names... to physical or virtual system devices"). "vtnet0 = WAN" is a convention some setup guides choose, not a FreeBSD/OPNsense-enforced rule. Both main.tf's comments and variables.tf's lan_network_name/ wan_network_name descriptions were rewritten to state this correctly: the variables express INTENT for the network layer, but the real LAN/WAN role still has to be set correctly in config.xml against whichever vtnetN is confirmed (via a real boot) to correspond to which network -- this repo does not yet write that config.xml, so the correction changes documentation accuracy, not current behavior, but would have misled whoever writes it next.
  • create.content.url accepting local paths -- corroborated by a second, independent source (a go-getter-style fetch mechanism explicitly described as supporting local paths). Confidence upgraded; schema notes entry above updated.
  • genisoimage/xorriso flags (-V/-J/-R) -- CONFIRMED standard, correct usage, not just "reasonable." scripts/opnsense-build-config-iso.sh's header comment corrected to separate this (now confirmed) from the genuinely still-open question (whether OPNsense's Importer actually reads the result on a real boot).
  • maas_vm_host's zone/pool optionality -- CONFIRMED safe. Both are computed by MAAS when left unset, validating the default = null design choice as a supported no-op rather than an unhandled edge case.

Not re-litigated: items already backed by a primary, authoritative source the first time (OPNsense's Configuration Importer mechanism itself, the provider's syntax-bug fix, maas_vm_host vs. maas_vm_host_machine) -- re-researching well-sourced findings on every pass would be its own kind of waste. The audit targeted specifically the notes marked UNVERIFIED/inferred/ assumed, since those were the actual gaps.

Conventions carried from the rest of this repo

  • No hardcoded repo name (repo-lint L9 would need extending to cover .tf files if this becomes a recurring class of finding -- not yet done, flag if it comes up).
  • Every variable without a safe, already-ratified default requires explicit input (no invented fallbacks) -- same "never use an inferred value" discipline as everywhere else in this repo.
  • DC1's plane CIDRs are a second, hand-maintained copy of scripts/lib-net.sh's PLANE_CIDRS. Keep them in sync manually until a generator exists to derive one from the other (DOCFIX candidate, not actioned).