Newer
Older
openstack-caracal-dc-dc / opentofu / main.tf
# Root module: wires the network + storage-pool layer for VR1 (Stage 1-2 of
# docs/dc-dc-deployment-workflow.md). See opentofu/README.md for full scope,
# what is deliberately deferred, and why.

provider "libvirt" {
  uri = var.libvirt_uri
}

# ---- The provider "maas" CONFIG block is deliberately DEFERRED to Stage 3
# (DOCFIX-179, decided on the first real `tofu plan`). No module instantiated in
# Stage 1-2 uses the maas provider (modules/maas-vm-host is a Stage-3 module,
# commented out below), yet a configured provider "maas" block here forced
# `tofu plan` to demand maas_api_url AND the SENSITIVE maas_api_key for a plan
# that creates ZERO MAAS resources -- which would bake a fake key into
# plan/state (the DOCFIX-175 plaintext-secret surface). maas stays in
# versions.tf's required_providers (version pinned, lock-file complete); Stage 3
# re-adds this provider block + its two variables when it actually instantiates
# modules/maas-vm-host. modules/netem-link is likewise not instantiated yet
# (needs a real bridge/SSH target + netem params). See gap register items
# 2/4/11 and opentofu/README.md. ----

# ---- DC1: inherits the DC0 six-plane v4 layout unchanged (D-101) ----

module "vr1_dc0_planes" {
  source        = "./modules/dc-planes"
  dc_name       = "vr1-dc0"
  domain_suffix = var.domain_suffix
  mtu           = var.underlay_mtu
  planes        = var.vr1_dc0_planes
}

module "vr1_dc0_storage" {
  source      = "./modules/dc-storage-pool"
  dc_name     = "vr1-dc0"
  target_path = var.vr1_dc0_pool_path
}

# ---- vr1-dc1 (VR1's SECOND DC): PLANES deferred by SEQUENCING, not a missing
# literal. Its supernet IS assigned -- D-115 moved it to 10.12.64.0/19 (ADOPTED,
# imported to office1-netbox), superseding the old "wait for NetBox" premise. It
# stays out of scope until vr1-dc0 completes (operator ruling 2026-07-10, Option B:
# vr1-dc0-first). When vr1-dc1 is started, add a `vr1_dc1_planes` variable (same
# shape as `vr1_dc0_planes`, CIDRs derived per D-115) and uncomment this block.
# Do NOT uncomment it now. The storage pool + mesh legs are NOT address-dependent,
# so they ARE wired now.
#
# module "vr1_dc1_planes" {
#   source        = "./modules/dc-planes"
#   dc_name       = "vr1-dc1"
#   domain_suffix = var.domain_suffix
#   mtu           = var.underlay_mtu
#   planes        = var.vr1_dc1_planes
# }

module "vr1_dc1_storage" {
  source      = "./modules/dc-storage-pool"
  dc_name     = "vr1-dc1"
  target_path = var.vr1_dc1_pool_path
}

# ---- Office1 headend: its own storage pool for MAAS-region/NetBox/GitBucket
# service-VM disks (D-103). Office1 does not get a dc-planes module -- the
# six-plane template is per-OpenStack-DC, not per-headend. ----

module "office1_storage" {
  source      = "./modules/dc-storage-pool"
  dc_name     = "office1"
  target_path = var.office1_pool_path
}

# ---- Office1-local network (closes tooling gap register item #12): the
# shared L2 segment the three Office1 service VMs (MAAS-region, NetBox,
# GitBucket), the vcloud host's own management interface, and Office1's own
# OPNsense edge's LAN side (gap #16) all attach to. See
# modules/office1-network/main.tf's header for the design decision (a new
# module, not a reused host bridge) and opentofu/README.md for the full
# writeup. Instantiated for real here (unlike office1_opnsense below): it
# needs no value beyond domain_suffix/underlay_mtu, both already real,
# required inputs elsewhere in this file -- the same reasoning that already
# lets the three mesh-link legs be instantiated today. ----

module "office1_network" {
  source        = "./modules/office1-network"
  domain_suffix = var.domain_suffix
  mtu           = var.underlay_mtu
}

# ---- Office1's own OPNsense edge (closes tooling gap register item #16 --
# decision: YES, Office1 gets a fourth modules/opnsense-edge call alongside
# DC1/DC2, and Stage 2's runbook (runbooks/dc-dc-phase1-office1-standup.md)
# owns creating it -- see that runbook's new Step 4b and its "Open questions"
# section for the full reasoning). NOT instantiated below -- commented out on
# purpose, mirroring the identical NOT-YET-REAL status of module
# "vr1_dc0_opnsense"/"vr1_dc1_opnsense" (see runbooks/dc-dc-phase2-tofu-dc-substrate.md
# Step 5's own template, which is ALSO still all placeholders, nothing
# committed here either). Every value below needs to be real before this is
# uncommented -- do not fill in invented specs to wire it in sooner:
#
# Office1's own OPNsense simulated-ISP edge (gap #16 CLOSED). gap #17 resolved
# for Office1 via the `office1-wan` NAT network (created via virsh 2026-07-12 --
# a documented D-103 debt to formalize into an OpenTofu module later, same class
# as the retained `wan`). LAN = the Office1-local segment; WAN = office1-wan (its
# own simulated ISP -> internet). Config is the DOCFIX-185 real-ISP-router config
# (no egress-airgap), rendered to office1-opnsense-config.iso.
module "office1_opnsense" {
  source           = "./modules/opnsense-edge"
  vm_name          = "office1-opnsense"
  memory_mib       = 2048
  vcpu             = 2
  pool_name        = module.office1_storage.pool_name
  base_volume_path = "/var/lib/libvirt/vr1/office1/opnsense-26.1-nano.qcow2"
  # No disk_size_bytes: the disk is a direct copy of the prepped nano, so its size comes
  # from scripts/opnsense-prep-image.sh's GROW (default +8G -> 11 GiB). The old
  # `disk_size_bytes = 16 GiB` here was SILENTLY IGNORED -- measured: the live volume is
  # 11 GiB. Removed in DOCFIX-189.
  lan_network_name = module.office1_network.network_name
  wan_network_name = "office1-wan"
}

# ---- The D-100 dark-fiber mesh triangle: DC1<->DC2, DC1<->Office1,
# DC2<->Office1 -- mesh, not star. All three legs created now even though DC2
# itself is not yet wired above: the LINK is independent of whether DC2's
# plane networks exist yet, and Phase 0's gate wants "virtual networks present
# and isolated as designed" before Phase 2 needs them. ----

module "mesh_vr1_dc0_vr1_dc1" {
  source    = "./modules/mesh-link"
  link_name = "vr1-dc0-vr1-dc1"
  mtu       = var.underlay_mtu
}

module "mesh_vr1_dc0_office1" {
  source    = "./modules/mesh-link"
  link_name = "vr1-dc0-office1"
  mtu       = var.underlay_mtu
}

module "mesh_vr1_dc1_office1" {
  source    = "./modules/mesh-link"
  link_name = "vr1-dc1-office1"
  mtu       = var.underlay_mtu
}

# =====================================================================
# D-114 -- Office1 site containment VM + its base image.
#
# voffice1 IS the Office1 "facility": MAAS-region runs on it, LXD runs on it, and the
# non-stack service machines (NetBox, GitBucket, Tailscale) are VMs MAAS COMPOSES into
# that LXD host -- VR0's proven `lxd` + `tailscale` pattern, applied per site.
#
# expose_nested_virt = true is LOAD-BEARING, not a nicety: LXD virtual machines are
# qemu/KVM guests, so without the host `svm` feature passed through NO service VM can
# ever boot inside voffice1 and D-114's model fails at its first step. (cloudinit-vm had
# NO cpu block at all before 2026-07-13, which silently gave every guest an emulated CPU
# with no svm -- measured: host is an EPYC 9965, a default-CPU guest sees "Opteron_G3".)
#
# Only network is office1-local: voffice1 reaches the internet THROUGH the OPNsense edge,
# like a real server behind a real site router. It takes its address by DHCP from the
# edge's Kea -- which also makes it the FIRST REAL LEASE that path has ever served.
#
# Cloud-init here is DELIBERATELY MINIMAL (identity + access + guest agent). MAAS and LXD
# are installed as separate GATED steps so they are observable and individually approved,
# not buried in a first-boot script that either silently works or silently does not.
# =====================================================================

module "ubuntu_noble_base" {
  source     = "./modules/base-image"
  image_name = "ubuntu-24.04-base.qcow2"
  pool_name  = module.office1_storage.pool_name
  # Official Ubuntu 24.04 LTS (noble) cloud image. VERIFIED reachable 2026-07-13 (HTTP 200,
  # Last-Modified 2026-07-05). 24.04 matches what VR0 deploys for its lxd/juju/tailscale
  # machines, so the image choice carries no new delta.
  source_url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}

module "voffice1" {
  source             = "./modules/cloudinit-vm"
  vm_name            = "voffice1"
  vcpu               = var.voffice1_vcpu
  memory_mib         = var.voffice1_memory_mib
  disk_size_bytes    = var.voffice1_disk_bytes
  pool_name          = module.office1_storage.pool_name
  base_volume_path   = module.ubuntu_noble_base.path
  network_names      = [module.office1_network.network_name]
  expose_nested_virt = true

  user_data = <<-EOT
    #cloud-config
    hostname: voffice1
    fqdn: voffice1.${var.domain_suffix}
    manage_etc_hosts: true
    users:
      - name: jessea123
        groups: [adm, sudo]
        shell: /bin/bash
        sudo: "ALL=(ALL) NOPASSWD:ALL"
        ssh_authorized_keys:
          - ${trimspace(file(var.office1_ssh_pubkey_path))}
    package_update: true
    packages:
      - qemu-guest-agent
    runcmd:
      - [systemctl, enable, --now, qemu-guest-agent]
  EOT

  meta_data = <<-EOT
    instance-id: voffice1-d114
    local-hostname: voffice1
  EOT

  # Match by GLOB, not a guessed interface name: the NIC's kernel name (ens3 / enp1s0 /
  # ...) depends on the machine type and PCI topology and is NOT known until first boot.
  # Naming it would be an inferred value. dhcp4 is a DELIBERATE choice here, not the
  # module's refused default: office1-local genuinely has an authoritative DHCP server
  # (Kea on the OPNsense edge, pool .100-.199). A Kea RESERVATION is added afterwards,
  # via the OPNsense REST API, to make the address stable.
  network_config = <<-EOT
    version: 2
    ethernets:
      lan:
        match:
          name: "en*"
        dhcp4: true
  EOT
}

# -----------------------------------------------------------------------------
# D-119 state migration. `moved` rewrites the STATE ADDRESS only -- it cannot
# suppress a ForceNew driven by an attribute, and `dc_name`/`link_name` are
# interpolated straight into each libvirt object's `name` (which IS ForceNew).
# So the libvirt networks/pools ARE replaced; these blocks exist so `tofu plan`
# reads as N clean "must be replaced" lines instead of N unrelated destroy+create
# pairs -- i.e. so the plan is REVIEWABLE.
#
# SAFE TO REPLACE, MEASURED 2026-07-14 BEFORE THE RENAME: all of these objects are
# EMPTY -- no guests are attached (Stage 3 has not run, so no DC node VMs exist)
# and no volumes live in either DC pool (the only volumes in state belong to
# office1_opnsense, ubuntu_noble_base and voffice1, none of which sit in a DC pool).
# Re-verify with `tofu plan` before applying; if a plan shows anything ATTACHED,
# STOP -- standing lesson 2: an apply touching a libvirt_domain's devices is an
# OUTAGE, and "updated in-place" does NOT mean "no restart".
# -----------------------------------------------------------------------------
moved {
  from = module.dc1_planes
  to   = module.vr1_dc0_planes
}

moved {
  from = module.dc1_storage
  to   = module.vr1_dc0_storage
}

moved {
  from = module.dc2_storage
  to   = module.vr1_dc1_storage
}

moved {
  from = module.mesh_dc1_dc2
  to   = module.mesh_vr1_dc0_vr1_dc1
}

moved {
  from = module.mesh_dc1_office1
  to   = module.mesh_vr1_dc0_office1
}

moved {
  from = module.mesh_dc2_office1
  to   = module.mesh_vr1_dc1_office1
}

# =====================================================================
# STAGE 3 -- vr1-dc0 substrate (runbooks/dc-dc-phase2-tofu-dc-substrate.md).
# Authored 2026-07-15. NOT YET APPLIED -- every block below is a gated Stage-3
# mutation; `tofu apply` is the operator's step (Steps 8/11 of the runbook).
# Governing rulings: D-100 (edge/fabric), D-121 (Option C node layout + sizing),
# D-122 (2-NIC fabric-routed edge; dedicated per-site ISP uplink).
#
# D-123 Model A + MAAS model RULED (region on Office1 + rack per DC) + D-124
# ADOPTED (Scheme A: office1<->dc0 mesh transit; rack sizing 4/8192/80). The
# `vvr1-dc0` rack controller IS wired below -- its two static IPs come from
# office1-netbox via variables (tfvars), NOT invented here. The Step-9
# `maas-vm-host` registration is STILL DEFERRED (DOCFIX-179): adding the
# `provider "maas"` block it needs would force EVERY plan (incl. the Steps 1-8
# substrate apply) to demand MAAS creds, and vr1-dc0's MAAS does not exist until
# vvr1-dc0 is up and its rack is installed. Wire maas-vm-host + the maas provider
# as its own step once the rack MAAS is reachable (runbook Step 9). The blocks
# below are the D-123-independent substrate (D-103/D-114 as-built) plus the rack.
# =====================================================================

# D-122: the dedicated per-site simulated-ISP uplink (172.30.2.0/24, D-115).
module "vr1_dc0_wan" {
  source       = "./modules/site-wan"
  network_name = "vr1-dc0-wan"
  cidr         = "172.30.2.0/24"
  # mtu defaults to 1500 (ISP-uplink domain; NOT the jumbo planes/mesh).
}

# D-122: the DC edge -- 2-NIC (WAN + LAN), Office1 pattern (2048/2/nano). The six
# planes are fabric-routed (OVN/OpenStack), so the edge is only the external
# boundary: LAN = provider-public (edge is its external gateway, D-100), WAN =
# the dedicated uplink above.
module "vr1_dc0_opnsense" {
  source     = "./modules/opnsense-edge"
  vm_name    = "vr1-dc0-opnsense"
  memory_mib = 2048
  vcpu       = 2
  pool_name  = module.vr1_dc0_storage.pool_name
  # No disk_size_bytes: nano direct-copy, sized by opnsense-prep-image.sh (DOCFIX-189).
  # PREREQUISITE (runbook Step 4): the prepped nano image must exist at this path.
  base_volume_path = "/var/lib/libvirt/vr1/vr1-dc0/opnsense-26.1-nano.qcow2"
  lan_network_name = module.vr1_dc0_planes.network_names["provider-public"]
  wan_network_name = module.vr1_dc0_wan.network_name
}

# D-121 Option C node layout: 3 control + 2 compute + 3 storage (16/12/8 vCPU,
# 64/48/24 GiB, 150/100/550 GiB). Six NICs each (one per plane, baremetal-matched
# D-122); metal-admin FIRST = the PXE/boot plane (D-052 default binding).
locals {
  vr1_dc0_node_nics = [
    module.vr1_dc0_planes.network_names["metal-admin"], # PXE / boot fabric first
    module.vr1_dc0_planes.network_names["provider-public"],
    module.vr1_dc0_planes.network_names["metal-internal"],
    module.vr1_dc0_planes.network_names["data-tenant"],
    module.vr1_dc0_planes.network_names["storage"],
    module.vr1_dc0_planes.network_names["replication"],
  ]

  vr1_dc0_nodes = {
    "vr1-dc0-control-01" = { vcpu = 16, mem = 65536, disk_gib = 150 }
    "vr1-dc0-control-02" = { vcpu = 16, mem = 65536, disk_gib = 150 }
    "vr1-dc0-control-03" = { vcpu = 16, mem = 65536, disk_gib = 150 }
    "vr1-dc0-compute-01" = { vcpu = 12, mem = 49152, disk_gib = 100 }
    "vr1-dc0-compute-02" = { vcpu = 12, mem = 49152, disk_gib = 100 }
    "vr1-dc0-storage-01" = { vcpu = 8, mem = 24576, disk_gib = 550 }
    "vr1-dc0-storage-02" = { vcpu = 8, mem = 24576, disk_gib = 550 }
    "vr1-dc0-storage-03" = { vcpu = 8, mem = 24576, disk_gib = 550 }
  }
}

module "vr1_dc0_node" {
  for_each = local.vr1_dc0_nodes

  source          = "./modules/node-vm"
  vm_name         = each.key
  vcpu            = each.value.vcpu
  memory_mib      = each.value.mem
  disk_size_bytes = each.value.disk_gib * 1024 * 1024 * 1024
  pool_name       = module.vr1_dc0_storage.pool_name
  network_names   = local.vr1_dc0_node_nics
}

# netem (runbook Step 11) -- HELD as a comment. It is a POST-apply step that runs
# SSH-wrapped provisioners from Office1 to the vcloud host, so it needs a real
# `vcloud_host_ssh_target` (a runtime value) and the netem params are an unruled
# D-100 sub-item (gap #11, placeholder only). Measured input ready: the
# vr1-dc0<->office1 mesh leg is bridge `virbr7`. Wire at Step 11, not before.
# module "netem_vr1_dc0_office1" {
#   source                 = "./modules/netem-link"
#   link_name              = "vr1-dc0-office1"
#   bridge_name            = "virbr7"                  # measured 2026-07-15
#   vcloud_host_ssh_target = "<real ssh target -- runtime>"
#   netem_args             = "<PLACEHOLDER: buildout-design S6 same-metro lean; D-100 gap #11 unruled>"
# }

# =====================================================================
# D-123 / D-124: the vr1-dc0 MAAS RACK CONTROLLER (vvr1-dc0). An Ubuntu VM (reusing
# the noble base) that will run a MAAS rack controller enrolled to the Office1 MAAS
# REGION. Two legs: metal-admin (serves PXE/DHCP to the vr1-dc0 nodes) + the
# office1<->dc0 mesh transit (reaches the region, D-124 Scheme A). It PROXIES OS
# images from the region, so it needs no direct DC egress (D-107). NOT YET APPLIED.
# The MAAS rack INSTALL itself is a gated post-boot step (an adapted
# site-headend-install.sh rack-only mode -- follow-up), like voffice1's install.
# =====================================================================
module "vvr1_dc0" {
  source             = "./modules/cloudinit-vm"
  vm_name            = "vvr1-dc0"
  vcpu               = 4                       # D-124 (rack controller: lightweight)
  memory_mib         = 8192                    # D-124
  disk_size_bytes    = 80 * 1024 * 1024 * 1024 # D-124: 80 GiB
  pool_name          = module.vr1_dc0_storage.pool_name
  base_volume_path   = module.ubuntu_noble_base.path
  expose_nested_virt = false # rack-only: the region + its LXD host compose VMs, not the rack

  network_names = [
    module.vr1_dc0_planes.network_names["metal-admin"], # leg 1: node-facing PXE/DHCP
    module.mesh_vr1_dc0_office1.network_name,           # leg 2: office1<->dc0 transit (region)
  ]

  user_data = <<-EOT
    #cloud-config
    hostname: vvr1-dc0
    fqdn: vvr1-dc0.${var.domain_suffix}
    manage_etc_hosts: true
    users:
      - name: jessea123
        groups: [adm, sudo]
        shell: /bin/bash
        sudo: "ALL=(ALL) NOPASSWD:ALL"
        ssh_authorized_keys:
          - ${trimspace(file(var.office1_ssh_pubkey_path))}
    package_update: true
    packages:
      - qemu-guest-agent
    runcmd:
      - [systemctl, enable, --now, qemu-guest-agent]
  EOT

  meta_data = <<-EOT
    instance-id: vvr1-dc0-d123
    local-hostname: vvr1-dc0
  EOT

  # STATIC on both legs (planes + mesh have NO DHCP). IPs are NetBox-assigned
  # (D-124), supplied via tfvars -- no literal here. INTERFACE-NAMING TRAP: the
  # guest NIC names (enp1s0/enp2s0 vs ens3/...) depend on machine type and are NOT
  # known until first boot -- CONFIRM on boot and adjust `match` before relying on
  # this (documented trap; modules/opnsense-edge + the runbook). metal-admin gives
  # the rack no default route; the region route rides the transit toward Office1
  # (10.10.0.0/22, as-built D-115).
  network_config = <<-EOT
    version: 2
    ethernets:
      admin:
        match:
          name: "enp1s0"
        set-name: admin
        addresses: ["${var.vr1_dc0_rack_metal_admin_ip}/22"]
      mgmt:
        match:
          name: "enp2s0"
        set-name: mgmt
        addresses: ["${var.vr1_dc0_rack_transit_ip}/${var.vr1_dc0_rack_transit_prefix}"]
        routes:
          - to: "10.10.0.0/22"
            via: "${var.vr1_dc0_rack_transit_peer_ip}"
  EOT
}