# DC-DC Phase 2 -- OpenTofu builds each DC substrate (Stage 3)

From Office1, script each DC's "physical" layer into existence: the node-VM
libvirt domains MAAS will discover and deploy, the DC's plane networks, and
its OPNsense edge -- then register the DC's libvirt host to that DC's own
MAAS rack controller as a virsh VM-host. This runbook is written **DC1-first**.
DC2 is explicitly gated (see the callout below) and is NOT part of this
session's run until that gate clears -- do not duplicate these steps for DC2
with invented values in the meantime.

**Governing docs:** `docs/dc-dc-buildout-design.md` Section 4 "Phase 2" (the
goal/build/gate this runbook satisfies), Section 9 (shim register -- the
node-VM creation module has **NO Roosevelt analog**, see the callout below),
and Section 6 (WAN simulation / netem, same-metro lean).
`docs/dc-dc-deployment-workflow.md` Stage 3 row and the Tooling gap register
(items 1, 4, 11 govern this stage directly). `opentofu/README.md` and
`opentofu/templates/README.md` for exact module/token status -- read both
before touching any `.tf` file or the OPNsense template.

Decisions this runbook owns: **D-103** (the OpenTofu/MAAS/Juju lifecycle seam
-- OpenTofu creates, MAAS discovers/commissions, never composes), **D-100**
(the dark-fiber mesh + per-site OPNsense edge, and the netem WAN-simulation
mechanism riding on it).

---

!!! **Depends on Stage 2 (Office1 headend standup).** This stage's MAAS
    registration step needs a REACHABLE MAAS region controller (Stage 2's
    gate) and Stage 1's OpenTofu-reaches-vcloud-libvirt gate already closed.
    If Stage 2 has not completed when this session runs, STOP at Step 9 (MAAS
    registration) -- everything through Step 8 (planes/pool/edge/node-VM
    creation at the libvirt layer) does not itself require MAAS and may
    proceed, but do not fabricate a `maas_api_url`/`maas_api_key` pair to push
    past that dependency. Confirm Stage 2's actual state in
    `docs/dc-dc-deployment-workflow.md` before starting.

!!! **DC2 is explicitly gated, not merely "later."** D-101 has not assigned
    DC2's real, non-overlapping v4 supernet yet (open sub-item; Tooling gap
    register item 3's DATA half -- the import pipeline mechanism
    (`netbox/dc-dc-prefixes-import.py`) is built, but the literals themselves
    are not assigned). `opentofu/main.tf`'s `dc2_planes`/`dc2_storage` blocks
    are commented out for exactly this reason. Do NOT uncomment them, do NOT
    invent a placeholder DC2 CIDR, and do NOT run this runbook's steps against
    DC2 until NetBox has assigned the real supernet and `main.tf` reflects it
    for real. This is a hard gate, re-stated in the GATE section.

!!! **Node-VM creation has NO Roosevelt analog (Section 9 shim register).**
    `modules/node-vm` (the blank-disk, PXE-boot libvirt domain this stage
    calls) is a virtual-only stand-in for what, in Roosevelt, is physical
    racking plus BMC enlistment -- MAAS enlists hardware that already exists
    there. Do not treat this module, or the act of Terraform-creating "node
    hardware," as reusable production IaC. Everything from MAAS enlistment
    onward (commission/deploy, Juju bundle) IS Roosevelt-transferable; this
    one step is not.

!!! **Node sizing is not decided -- do not invent vCPU/RAM/disk.**
    `modules/node-vm`'s `memory_mib`, `vcpu`, and `disk_size_bytes` variables
    have no defaults by design (buildout-design Section 3's Phase-0 host/
    disk-budget decision has not been made). If that decision is still open
    when you reach Step 6, STOP there and flag it -- do not pick numbers that
    "seem reasonable" to get `tofu plan` to run.

!!! **netem parameters are still an unruled D-100 sub-item (gap #11).** This
    stage's own gate text says "netem parameters applied and measured," but
    the exact latency/jitter/loss/rate values do not exist as a ruled
    decision -- only the qualitative buildout-design Section 6 lean exists:
    "same-metro dark fiber... low single-digit ms, jitter-capable, jumbo-
    capable." Step 11 below applies ONLY that qualitative lean as an
    explicitly-labeled PLACEHOLDER profile (recorded as such, with a citation
    to Section 6), pending a firmer numeric ruling. Do not write a specific
    number (e.g. "delay 3ms") into `netem_args` and represent it as measured
    or ruled -- if you pick a number to instantiate the mechanism for real,
    log it as exactly what it is: a placeholder pending D-100's still-open
    sub-item, not a final value.

!!! **OPNsense prep tools not confirmed present.** `scripts/opnsense-prep-
    image.sh` requires `bunzip2` + `qemu-img` on PATH; `scripts/opnsense-
    build-config-iso.sh` requires `genisoimage` OR `xorriso`. Neither has
    been confirmed present on the vcloud host as of this writing
    (`opentofu/README.md`). Step 2 below checks for all four before anything
    else in this runbook proceeds -- treat a missing tool as a blocking
    finding (install it, or escalate), not something to route around.

---

## Sequence (DC1; DC2 remains gated -- see callout above)

```
1.  Pre-flight: confirm Stage 1 + Stage 2 gates closed       (read-only)
2.  Confirm OPNsense prep-tool prereqs on the vcloud host    (read-only)
3.  Confirm/record still-open decisions this stage depends
    on (node sizing, config.xml tokens, MAAS zone/pool,
    netem parameters) -- STOP on any that are unresolved      (read-only)
4.  Prepare DC1's OPNsense base image + render config.xml
    + build the config ISO                                   [MUTATION: host-local files, gated]
5.  Wire modules/opnsense-edge for DC1 into main.tf           [repo change, gated]
6.  Wire modules/node-vm calls for DC1's node VMs into
    main.tf (ONLY once sizing is real)                        [repo change, gated]
7.  tofu init / validate / plan -- review before apply         (read-only)
8.  tofu apply -- DC1 OPNsense edge + DC1 node-VM domains      [MUTATION: creates libvirt objects, gated]
9.  Wire + apply modules/maas-vm-host -- register DC1's
    libvirt host with DC1's MAAS rack controller                [MUTATION: MAAS + repo change, gated]
10. Verify MAAS discovers the node VMs                          (read-only)
11. Wire + apply modules/netem-link on DC1's mesh legs,
    using the Section-6 placeholder lean, explicitly flagged     [MUTATION: gated]
12. Verify edge routing + simulated ISP uplink + netem applied   (read-only)
    -> EXIT GATE -> Stage 4 (per-DC, once DC2 clears its own gate)
```

---

## Step 1 -- Pre-flight: confirm Stage 1 + Stage 2 gates closed (READ-ONLY)

**CHECK -- Stage 1 (vcloud host prep) closed?**
```bash
virsh net-list --all
virsh pool-list --all
```
Expect: DC1's six plane networks, the three mesh-link networks (`dc1-dc2`,
`dc1-office1`, `dc2-office1`), and the `dc1`/`office1` storage pools, all
`active` -- Stage 1's Step 11 verification. If any are missing, Stage 1 is
not actually done regardless of what `docs/dc-dc-deployment-workflow.md`
says -- stop and reconcile there first.

**CHECK -- Stage 2 (Office1 headend) closed?**
Confirm against `docs/dc-dc-deployment-workflow.md`'s Stage 2 row and, if a
Stage 2 runbook exists by the time you run this, its own exit gate. At
minimum, confirm:
```bash
# from Office1 (or wherever this session's tofu apply will run from)
tofu -chdir=opentofu version
curl -sI "$MAAS_API_URL" 2>/dev/null | head -1   # MAAS_API_URL: this session's real value, not invented
```
Expect a real MAAS region controller reachable at a real URL. If Stage 2 is
not done, everything through Step 8 below (libvirt-layer objects only) may
still proceed since it needs no MAAS reach; Step 9 onward (MAAS registration)
is a hard stop until Stage 2 closes -- do not substitute a placeholder MAAS
endpoint to push past it.

**GATE:** Stage 1 objects present and active; Stage 2's MAAS reachability
either confirmed, or explicitly recorded as NOT YET (in which case this
session's scope for today is Steps 1-8 only).

---

## Step 2 -- Confirm OPNsense prep-tool prerequisites on the vcloud host (READ-ONLY)

**CHECK**
```bash
for bin in bunzip2 qemu-img genisoimage xorriso curl wget; do
  command -v "$bin" >/dev/null 2>&1 && echo "present: $bin" || echo "absent:  $bin"
done
```
Required: `bunzip2` + `qemu-img` (for `scripts/opnsense-prep-image.sh`), and
EITHER `genisoimage` OR `xorriso` (for `scripts/opnsense-build-config-iso.sh`),
plus `curl` or `wget` for the image fetch. If any required tool is absent,
install it on the vcloud host (an OS-specific package install, out of scope
to prescribe here) before Step 4 -- do not skip the tool and hand-build the
ISO/qcow2 by some improvised method not covered by this repo's tested
scripts.

**GATE:** all required tools present (or installed and re-checked).

---

## Step 3 -- Confirm/record still-open decisions this stage depends on (READ-ONLY)

Before wiring anything into `main.tf`, walk this list. Any "NOT YET" item is a
STOP for the corresponding later step -- record it plainly rather than
inventing a value to keep moving:

- **Node sizing** (`memory_mib`/`vcpu`/`disk_size_bytes` for `modules/node-vm`,
  and how many node VMs DC1 needs) -- buildout-design Section 3's Phase-0
  host/disk-budget decision. Blocks Step 6.
- **MAAS zone/pool for DC1's VM-host registration** (`modules/maas-vm-host`'s
  `zone`/`pool` variables) -- both are optional (MAAS computes them if left
  `null`, confirmed safe per `opentofu/README.md`'s audit pass), so this is
  NOT necessarily a blocker; record whichever real zone/pool name the
  operator wants, or confirm the `null` no-op is intentional. Affects Step 9.
- **DC1's real `power_address`** (the virsh URI MAAS uses to reach the vcloud
  host) -- must be measured this session (or carried from Stage 1's
  `libvirt_uri` tfvar, if it is the SAME endpoint -- confirm, do not assume
  identical per `modules/maas-vm-host`'s own variable note). Blocks Step 9.
- **OPNsense `config.xml` tokens** -- per `opentofu/templates/README.md`'s
  token legend, several tokens are pending Stage-0-adjacent decisions or a
  real boot measurement: `{{WAN_IF}}`/`{{LAN_IF}}` (only measurable by
  booting the domain and running `ifconfig` -- genuinely cannot be filled in
  before Step 8 creates the domain), `{{WAN_IPADDR}}`/`{{WAN_SUBNET_BITS}}`/
  `{{WAN_GATEWAY}}` (pending NetBox), `{{LAN_IPADDR}}`/`{{LAN_SUBNET_BITS}}`
  (pending which plane(s) OPNsense's LAN interface actually serves --
  flagged in the template README as not fully specified in the buildout
  design), `{{MIRROR_SYNC_PROTOCOL}}`/`{{MIRROR_UPSTREAM_NET}}`/
  `{{MIRROR_SYNC_PORT}}` (pending mirror-software choice, gap #3-adjacent),
  and `{{ROOT_PASSWORD_HASH}}` (must be freshly generated per site, never
  reused from the shipped sample). Blocks Step 4's render sub-step for
  everything except `{{OPNSENSE_HOSTNAME}}`/`{{DOMAIN}}`/NTP defaults.
- **netem parameters** -- still an unruled D-100 sub-item (gap #11); Step 11
  uses the Section-6 qualitative placeholder only, explicitly labeled as
  such. Not a hard blocker for Step 11 (the mechanism can be exercised with a
  labeled placeholder), but IS a blocker for calling this stage's netem gate
  "final" -- see the GATE section.

**GATE:** every item above is either resolved with a real, measured/ruled
value, or explicitly recorded as NOT YET with the corresponding downstream
step named as blocked. Do not proceed past a step whose input is still
"NOT YET" by inventing one.

---

## Step 4 -- Prepare DC1's OPNsense base image + config.xml + config ISO [MUTATION: host-local files, gated]

This step produces plain files on the vcloud host filesystem -- no libvirt or
MAAS object is created yet. Run from the vcloud host (or wherever these
scripts execute with reach to write there).

**MUTATION -- prep the base image (needs Step 2's tools)**
```bash
bash scripts/opnsense-prep-image.sh   # see the script's own header for its exact args/output path
```
**Expect:** a decompressed, qcow2-converted, resized OPNsense nano image at a
real output path -- record that path, it feeds Step 5's `base_volume_path`.

**MUTATION -- render config.xml (only if every non-boot-measured token from
Step 3 is real)**
```bash
export OPNSENSE_HOSTNAME=... DOMAIN=... ROOT_PASSWORD_HASH=...
export WAN_IPADDR=... WAN_SUBNET_BITS=... WAN_GATEWAY=...
export LAN_IPADDR=... LAN_SUBNET_BITS=...
export MIRROR_SYNC_PROTOCOL=... MIRROR_UPSTREAM_NET=... MIRROR_SYNC_PORT=...
# WAN_IF / LAN_IF: cannot be set for real yet -- see the note below.
bash scripts/opnsense-render-config.sh dc1-opnsense-config.xml
```
`WAN_IF`/`LAN_IF` are the one pair the script's own `REQUIRED_VARS` list
demands that genuinely cannot be measured before the domain exists (Step 8
creates it). If Step 3 flagged these as not yet measurable, this render
sub-step cannot complete for real this session -- STOP here for DC1's config
render, record it as blocked pending a real boot, and do not substitute a
guessed `vtnetN` name. (One documented path forward once the domain exists:
boot the domain with a MINIMAL/placeholder config good enough to reach a
console, measure `ifconfig`'s real `vtnetN` mapping, then re-render and
re-seed -- that re-seed mechanic is not designed in this delivery; treat it
as a real follow-up need if it comes to that, not an invented shortcut here.)

**MUTATION -- build the config ISO (only once config.xml render succeeded)**
```bash
bash scripts/opnsense-build-config-iso.sh dc1-opnsense-config.xml dc1-opnsense-config.iso
```
**Expect:** a plain ISO9660 image containing `/conf/config.xml` at a real
output path -- record it, it feeds Step 5's `config_iso_path`.

**GATE:** base image path and (if unblocked) config ISO path recorded as real
host-local file paths. If the config.xml render step is blocked on
`WAN_IF`/`LAN_IF`, record that explicitly and treat Step 5's `config_iso_path`
input as still pending -- do not wire Step 5 with a placeholder ISO.

---

## Step 5 -- Wire `modules/opnsense-edge` for DC1 into `main.tf` [repo change, gated]

Only proceed once Step 4 produced real `base_volume_path` and
`config_iso_path` values (or an explicit decision to proceed with a
genuinely-final, if minimal, config.xml -- not a placeholder).

**MUTATION -- edit `opentofu/main.tf`**, adding a block shaped like:
```hcl
module "dc1_opnsense" {
  source           = "./modules/opnsense-edge"
  vm_name          = "dc1-opnsense"
  memory_mib       = <REAL VALUE -- OPNsense's own sizing guidance, not invented>
  vcpu             = <REAL VALUE>
  pool_name        = module.dc1_storage.pool_name   # confirm real output name in modules/dc-storage-pool
  disk_size_bytes  = <REAL VALUE>
  base_volume_path = "<Step 4's real base image path>"
  config_iso_path  = "<Step 4's real config ISO path>"
  lan_network_name = <the dc1_planes output this edge's LAN side attaches to -- confirm which plane per Step 3's open LAN-role item>
  wan_network_name = module.mesh_dc1_office1... # or a dedicated per-site ISP-uplink segment -- confirm which, do not assume
}
```
Every placeholder above (`<...>`) must be a real, measured/decided value
before this is committed -- this template line exists to show shape, not to
be copy-pasted with invented numbers. `memory_mib`/`vcpu`/`disk_size_bytes`
here have no ratified default in the module either (same "no invented specs"
discipline as `modules/node-vm`).

**GATE:** `opentofu/main.tf` diff shows exactly one new `module "dc1_opnsense"`
block, every argument a real value, nothing else changed.

---

## Step 6 -- Wire `modules/node-vm` calls for DC1's node VMs into `main.tf` [repo change, gated]

**ONLY if Step 3 confirmed node sizing is real.** If sizing is still "NOT
YET," STOP here -- this step does not proceed, and this session's DC1 pass
ends at whatever the last unblocked step was (Step 5 or wherever Step 4 left
off). Re-run from here once sizing exists; do not invent numbers to close
this out early.

**MUTATION -- edit `opentofu/main.tf`**, one `module "dc1_node_XX"` block per
node VM, shaped like:
```hcl
module "dc1_node_01" {
  source          = "./modules/node-vm"
  vm_name         = "dc1-node-01"
  memory_mib      = <REAL VALUE from the Phase-0 sizing decision>
  vcpu            = <REAL VALUE>
  pool_name       = module.dc1_storage.pool_name
  disk_size_bytes = <REAL VALUE>
  network_names   = [ <ordered list of module.dc1_planes outputs -- confirm which plane goes first for PXE priority, per D-103> ]
}
```
Repeat per node, per the real node count from the sizing decision.

**GATE:** every `module "dc1_node_*"` block uses only real, decided values;
node count matches the recorded sizing decision exactly.

---

## Step 7 -- `tofu init` / `validate` / `plan` (READ-ONLY against providers)

```bash
cd opentofu
tofu fmt -check -recursive -diff .
tofu init -backend=false -input=false
tofu validate
tofu plan -out=phase2-dc1.tfplan
```
Review the plan line by line: expect creates for `module.dc1_opnsense`
(the edge VM's boot volume, config-seed volume, and domain) and each
`module.dc1_node_*` (a blank boot volume + domain per node). Confirm nothing
else is planned -- in particular, confirm no DC2 resource appears (its
module blocks should still be commented out per the DC2 gate above).

**GATE:** plan matches this expectation exactly; no unexpected creates,
updates, or destroys. Do not apply a plan you have not read.

---

## Step 8 -- `tofu apply` -- DC1 OPNsense edge + node-VM domains [MUTATION: gated]

```bash
cd opentofu
tofu apply phase2-dc1.tfplan
```
Confirm this is the exact reviewed plan file from Step 7 (not re-planned)
before running. This is the first live mutation THIS stage performs.

**VERIFY**
```bash
virsh list --all
virsh domblklist dc1-opnsense
virsh domblklist dc1-node-01   # per real node name
```
Expect the new domains present (likely `shut off` until first boot/PXE), the
OPNsense domain showing both the base overlay disk and the config ISO
attached as a secondary cdrom, each node VM showing a single blank boot disk.

**GATE:** all new domains defined as planned; disk attachments match Step
5/6's wiring.

---

## Step 9 -- Wire + apply `modules/maas-vm-host` -- register DC1's libvirt host with DC1's MAAS rack controller [MUTATION: gated]

**Hard dependency on Stage 2.** This step requires DC1's MAAS rack controller
(under the Stage-2-stood-up MAAS region) to actually exist and be reachable.
If Stage 2 (or the DC1 rack-controller enrollment specifically, however that
ends up sequenced relative to Stage 2's own runbook) is not done, STOP here
-- this stage's remaining steps (9-12) do not proceed, and DC1's Phase 2 work
is INCOMPLETE, not silently skippable.

**MUTATION -- edit `opentofu/main.tf`**
```hcl
module "dc1_maas_vm_host" {
  source         = "./modules/maas-vm-host"
  vm_host_name   = "dc1-vcloud"
  power_address  = "<Step 3's real, measured virsh URI -- confirm same as var.libvirt_uri or a distinct value>"
  zone           = <real zone name, or leave unset for MAAS's computed default -- per Step 3>
  pool           = <real pool name, or leave unset -- per Step 3>
}
```

```bash
cd opentofu
tofu plan -out=phase2-dc1-maashost.tfplan   # review: exactly one new maas_vm_host resource
tofu apply phase2-dc1-maashost.tfplan
```

**GATE:** `maas_vm_host` resource created; no other resource touched by this
plan.

---

## Step 10 -- Verify MAAS discovers the node VMs (READ-ONLY)

**CHECK**
```bash
maas admin vm-hosts read | jq -r '.[] | select(.name=="dc1-vcloud") | {id, name, resources}'
maas admin machines read | jq -r '.[] | select(.hostname|test("^dc1-node")) | "\(.hostname)\t\(.status_name)"'
```
Expect DC1's node VMs listed as MAAS machines (status likely `New` --
enlistment discovered them; commissioning is Stage 4's job, not this
stage's). If the node VMs do not appear, do not proceed to declare this
stage's MAAS-discovery gate met -- troubleshoot the VM-host registration
(power_address correctness, virsh reachability from the MAAS rack
controller) before moving on.

**GATE:** DC1's OpenTofu-created node VMs visible in MAAS, associated with
the `dc1-vcloud` VM host.

---

## Step 11 -- Wire + apply `modules/netem-link` on DC1's mesh legs, placeholder profile [MUTATION: gated]

Per the netem callout above: this step applies ONLY the buildout design's
qualitative Section 6 lean ("same-metro dark fiber... low single-digit ms,
jitter-capable, jumbo-capable"), explicitly recorded as a PLACEHOLDER, not a
ruled numeric value. If the operator has since ruled a specific numeric
profile, use that instead and cite the ruling; do not use this placeholder
language once a real ruling exists.

**MUTATION -- edit `opentofu/main.tf`**, one block per relevant mesh leg
(at minimum DC1<->DC2 and DC1<->Office1; DC2<->Office1 is out of scope while
DC2 remains gated):
```hcl
module "netem_dc1_office1" {
  source                  = "./modules/netem-link"
  link_name               = "dc1-office1"
  vcloud_host_ssh_target  = "<real SSH target for the vcloud host -- measured, not invented>"
  bridge_name             = "<real OS bridge name for this mesh-link network -- confirm via `virsh net-info dc1-office1` or `tofu show`, not assumed>"
  netem_args              = "<PLACEHOLDER pending D-100 sub-item ruling -- record explicitly as provisional, e.g. a low single-digit-ms delay with modest jitter and negligible loss, cited to buildout-design Section 6, NOT presented as a measured or ruled value>"
}
```
(A DC1<->DC2 block follows the same shape once DC2 itself is ungated; until
then, applying netem on that specific leg has no DC2-side substrate to
matter for, so it is naturally deferred alongside the rest of DC2's work.)

```bash
cd opentofu
tofu plan -out=phase2-dc1-netem.tfplan
tofu apply phase2-dc1-netem.tfplan
```

**GATE:** the `terraform_data`/`local-exec` resource applies without error;
record the exact `netem_args` string used and its PLACEHOLDER status in the
as-executed log, alongside a note that D-100's exact parameters remain open
(gap #11).

---

## Step 12 -- Verify edge routing + simulated ISP uplink + netem applied (READ-ONLY)

**CHECK -- from the vcloud host**
```bash
sudo tc qdisc show dev <bridge_name>   # per Step 11's real bridge name
```
Expect a `netem` qdisc present with the applied (placeholder) parameters.

**CHECK -- OPNsense edge reachability** (only meaningful once WAN_IF/LAN_IF
have been measured on a real boot per Step 4's note, and the domain has
actually been booted/configured)
```bash
virsh domstate dc1-opnsense
```
If the config.xml render was blocked in Step 4 (pending `WAN_IF`/`LAN_IF`),
this verification is necessarily partial this session -- record that
honestly rather than declaring edge routing verified when it is not.

**GATE (this step):** netem qdisc present and matching Step 11's recorded
placeholder profile; OPNsense domain running IF its config.xml was
successfully completed, otherwise recorded as blocked pending a real-boot
`vtnetN` measurement and re-seed.

---

## GATE -- Stage 3 exit condition (honest state, not aspirational)

Buildout-design Section 4 / deployment-workflow Stage 3 states the gate as:
"MAAS rack controller per DC up; MAAS discovers the OpenTofu-created node
VMs; edge routing + simulated ISP uplink up; netem parameters applied and
measured." As of what this runbook can actually close this session:

- **MAAS rack controller per DC up:** achievable ONLY once Stage 2 (or
  whatever runbook stands up DC1's specific rack controller) is done --
  Step 1/9 depend on it. Record actual state, do not assume it.
- **MAAS discovers the OpenTofu-created node VMs:** achievable through Step
  10, CONDITIONAL on node sizing being real (Step 3/6) and Stage 2's MAAS
  region being reachable (Step 9's dependency).
- **Edge routing + simulated ISP uplink up:** PARTIALLY achievable. The
  OPNsense domain and its network attachments can be created (Step 8), but
  full edge routing verification depends on `WAN_IF`/`LAN_IF` being measured
  on a real boot (Step 4's documented blocker) and on the WAN/LAN addressing
  tokens (pending NetBox) -- do not mark this sub-gate fully closed unless
  those are genuinely resolved.
- **netem parameters applied and measured:** applied, YES (Step 11), using
  an explicitly-labeled PLACEHOLDER same-metro-lean profile per
  buildout-design Section 6 -- MEASURED in the sense of "the qdisc is
  present and its effect can be observed" (e.g. via `ping`/`iperf` once the
  domains are reachable), but NOT measured against a ruled, final numeric
  target, because that target does not exist yet (D-100 gap #11). Do not
  represent this sub-gate as "done" in the same sense as a stage with no
  open decisions -- it is done AS FAR AS A PLACEHOLDER ALLOWS, with the
  remaining gap named.

**This stage's exit gate is therefore CONDITIONALLY MET at best** on any run
before (a) Stage 2 is complete, (b) node sizing is decided, (c) DC1's
OPNsense WAN/LAN addressing and `vtnetN` mapping are measured, and (d) D-100's
exact netem parameters are ruled. Update `docs/dc-dc-deployment-workflow.md`'s
Stage 3 row to reflect the REAL state after running this (e.g. "PARTIAL --
libvirt objects created, MAAS registration blocked on Stage 2" or similar),
never to a blanket DONE unless every sub-condition above is genuinely true.

**DC2:** none of the above applies to DC2 until NetBox assigns its real
supernet (D-101 open item) and `main.tf`'s DC2 blocks are uncommented with
real values -- re-run this entire runbook for DC2 at that point, do not
attempt a partial DC2 pass now.

-> Proceed to Stage 4 (MAAS enlist/commission/deploy) for DC1 once this
gate's applicable sub-conditions are genuinely met; DC2 remains blocked at
Stage 3 until its own gate (NetBox supernet) clears.

---

## Delivery checklist (this repo's standard discipline)

- [ ] `bash scripts/repo-lint.sh` clean (0 fail) before committing any repo
      changes made while executing this runbook (`main.tf` edits, any new
      tfvars, minus secrets).
- [ ] `bash scripts/opentofu-validate.sh` green (the harness for `opentofu/`,
      per its own README) -- re-run after every `main.tf` change in Steps
      5/6/9/11, not just once at the end.
- [ ] Changelog entry for this runbook's first real execution (next DOCFIX
      number via `bash scripts/ledger-scan.sh`), noting the ACTUAL measured/
      decided values used (node sizing, MAAS zone/pool, power_address, the
      netem placeholder profile and its provisional status) -- redact
      nothing that isn't a secret.
- [ ] `docs/session-ledger.md` updated with the outcome, including which
      sub-steps were blocked and why (Stage 2 dependency, node sizing,
      WAN/LAN token gaps, D-100 netem sub-item).
- [ ] `docs/dc-dc-deployment-workflow.md` Stage 3 row updated to the REAL,
      honest state (PARTIAL/DONE/NOT STARTED per the GATE section above),
      never rounded up to DONE with open sub-conditions.
- [ ] `opentofu/README.md` updated to reflect DC1's OPNsense edge / node-VM /
      MAAS-vm-host / netem-link modules moving from "not instantiated" to
      instantiated (or partially so) once this runbook actually applies
      them, including any real bug found while doing so (matching this
      repo's practice of logging schema surprises as they're found).
- [ ] DC2 gate re-confirmed as still closed (NetBox supernet not yet
      assigned) if this session did not clear it -- do not let this
      runbook's DC1 completion be misread as covering DC2.
