REWRITTEN 2026-07-13 to the D-114 model. The previous version of this runbook built THREE SIBLING SERVICE VMs on the vcloud host (MAAS-region, NetBox, GitBucket), each behind an "Option A (OpenTofu, blocked) / Option B [GitBucket is since REMOVED from scope entirely -- D-116.] (manual virt-install)" fork, with Tailscale on the headend host. That model is SUPERSEDED. D-114 (ADOPTED 2026-07-13) replaces it with ONE Office1 site containment VM (
voffice1) that IS the facility, with MAAS + LXD running ON it and the non-stack service machines COMPOSED BY MAAS into that LXD. Do not work from a cached memory of the old shape -- it will build the wrong thing.
Stand up the Office1 SITE: the containment VM that simulates the facility, the MAAS region controller that runs inside it, the LXD VM host MAAS composes into, and the non-stack service machines (NetBox, Tailscale -- GitBucket dropped, D-116) as MAAS-visible VMs -- BEFORE either DC substrate exists. This is the second DC-DC runbook; it follows runbooks/dc-dc-phase0-vcloud-prep.md (Stage 1), whose exit gate is this runbook's entry condition. Per this repo's session contract, do NOT re-derive anything Stage 1 already settled (nested KVM, disk budget, MTU, the DC1/Office1 libvirt planes and pools, the mesh-link networks) -- confirm Stage 1's gate passed and move on.
Execution status (measured 2026-07-13, not inferred):
office1-opnsense) is BUILT, RUNNING, ROUTING, and SERVING DHCP (Kea). Its config is REST-API-managed per D-113(a2).office1-local and office1-wan libvirt networks exist and are active.voffice1 EXISTS and is RUNNING (virsh list --all -> voffice1 running; module.voffice1 is in opentofu/terraform.tfstate). It was applied by the main session's tofu apply, not by this runbook's authoring pass.voffice1, LXD on voffice1, the LXD-KVM-host registration, and every MAAS-composed service machine. Those are this runbook's remaining work.Governing docs: docs/dc-dc-buildout-design.md Section 4 Phase 1 (goal/build/gate) and Section 5 (OpenTofu/MAAS/NetBox/Juju boundary); docs/dc-dc-deployment-workflow.md Stage 2 (tracker row -- update its **State:** line when this runbook completes a real run) and the Tooling gap register; opentofu/README.md (module scope/status for modules/cloudinit-vm / modules/base-image / modules/office1-network / modules/opnsense-edge); netbox/dc-dc-prefixes-import.py (the NetBox multi-DC/dual-stack importer -- MECHANISM only, see Step 10).
Decisions this runbook owns: D-114 (site containment VMs + MAAS-composed LXD VMs for the non-stack machines -- the model this runbook now implements, including the LXD 5.21 LTS pin), D-103 as AMENDED by D-114 (OpenTofu owns the site containment VM and the site's networks; INSIDE a site, MAAS composes the service machines -- MAAS still owns the provisioning lifecycle and still does NOT compose the OpenStack node VMs), D-107 (airgap posture -- Office1 is explicitly OUT of the core-service path: no node artifacts, no NTP served from Office1; Tailscale is a narrowly-scoped front door, not a general egress path), D-106 (naming convention -- Office1 services use a region-level subdomain outside the per-cloud convention, e.g. maas.office1.vr1...; exact scheme is this stage's job to fix, not yet fixed), and D-101 (referenced only -- NetBox is the IPAM apex this stage stands up, but the address literals D-101 requires are NOT this stage's job to assign).
!!! PENDING VERIFICATION -- the MAAS/LXD install-and-compose sequence (Steps 5, 6, 7, 8) is the NEW heart of this runbook and its exact command lines are NOT yet confirmed against Canonical's current documentation. Those steps name the MECHANISM and the GATE, and mark every unconfirmed flag, channel, and subcommand as <PENDING VERIFICATION: ...>. A separate verification pass owns filling them in. Per this repo's cardinal rule, a fabricated flag is worse than an honest placeholder -- do NOT "reconstruct" these from memory, from a different MAAS version's docs, or from this repo's own scripts/phase-00-maas-standup.sh / scripts/reenroll-hosts.sh (those are the PXE-enlisted METAL-NODE path, a DIFFERENT mechanism from D-114's COMPOSE path -- see Step 8's own note).
!!! LXD IS VERSION-PINNED to the 5.21 LTS track (D-114). MAAS 3.6/3.7 is INCOMPATIBLE with LXD >= 6.7: LXD 6.7 consolidated API endpoints and MAAS's pinned pylxd 2.3.5 cannot speak to them. Canonical's guidance is "use LXD <= 6.6 or the 5.21 LTS release until further notice" (https://discourse.maas.io/t/maas-incompatibility-with-lxd-6-7/15749). VR0 runs 5.21.4. Installing LXD from the default latest/stable channel would silently break the MAAS-LXD seam this entire stage depends on. The pin belongs in runbooks/appendix-B-asbuilt-version-lock.md -- record it there when the real installed version is measured.
!!! DHCP HAZARD (D-114). OPNsense/Kea is AUTHORITATIVE for DHCP on office1-local (10.10.0.0/24, pool .100-.199). LXD's own lxdbr0 runs a SECOND dnsmasq. That is FINE as long as lxdbr0 stays on LXD's internal NAT bridge and is NEVER bridged onto office1-local. Two DHCP servers on one L2 is an intermittent, genuinely unpleasant failure to diagnose. Any LXD networking change in Step 6 must not put lxdbr0 on the site LAN.
CHECK -- read-only, before starting anything in this runbook
virsh net-list --all virsh pool-list --all virsh list --all
Expect: the six DC1 plane networks, the three mesh-link networks (dc1<->dc2, dc1<->office1, dc2<->office1), the office1-local network (opentofu/modules/office1-network), the office1-wan network (the Office1 ISP uplink -- gap #17 CLOSED for Office1), and the dc1/office1/dc2 storage pools, all active. Expect the office1-opnsense domain running. If any of this is missing or inactive, STOP -- Stage 1 (or the Office1 edge build) is not actually done; go complete it rather than starting the rest of Stage 2 on an unfinished substrate. Do not re-measure nested KVM, disk budget, or MTU here -- Stage 1 already recorded those; this runbook only reads their OUTCOME (the networks and the edge existing) as its gate.
Office1 is a SITE, not three peer VMs on a host. OpenTofu creates ONE containment VM -- voffice1, Ubuntu 24.04, on office1-local, taking a DHCP lease from the OPNsense edge's Kea, reaching the internet through that edge like a real server behind a real site router. voffice1 IS the facility. The MAAS REGION CONTROLLER runs ON it. LXD (5.21 LTS) also runs ON it, and that LXD is REGISTERED BACK INTO MAAS as an LXD KVM host. The non-stack service machines -- NetBox and Tailscale -- are then VMs composed by MAAS into that LXD host: MAAS-visible, enlisted, commissioned, deployed, powered, releasable. This is not a new pattern; it is VR0's own already-proven lxd + tailscale pair, applied per site. Explicitly NOT in scope: the Juju-deployed OpenStack services on the DC nodes -- those stay in Juju-created LXD CONTAINERS (bundle lxd:N placements), invisible to MAAS, exactly as in VR0. Sequencing is an operator ruling: Office1 FIRST, fully up. DC1 is GATED behind it. DC2 follows DC1.
expose_nested_virt = true on voffice1 is LOAD-BEARING, not a nicety. LXD virtual machines are qemu/KVM guests, so composing NetBox/Tailscale as LXD VMs inside voffice1 requires nested KVM at L3. (Only LXD containers would be free of that requirement -- and MAAS does not enlist containers.) Office1 is therefore the CHEAP nesting probe for the whole D-114 model: no OpenStack, no Ceph, small VMs. If an LXD VM boots inside voffice1, the pod model is proven to L3 and DC1 inherits that proof (D-114's own DC1 entry gate). If it does not, the containment-VM model is abandoned early having cost one VM -- a scoped retreat, by design, not a redesign.
Per this repo's discipline of naming ambiguity explicitly rather than quietly picking an answer:
cloudinit-vm, blocked pending user_data design" vs "manual virt-install, logged as debt") existed because no cloudinit-vm instantiation had ever been designed. OpenTofu (Option A) is the path, and it is DONE: module "voffice1" in opentofu/main.tf is a real, applied cloudinit-vm instantiation -- real image source (modules/base-image, the official Ubuntu 24.04 noble cloud image), real user_data (identity + access + guest agent), real meta_data, real network_config (DHCP on office1-local, matched by interface GLOB rather than a guessed kernel NIC name), and the new expose_nested_virt variable. There is nothing left to fork on: the three service VMs are no longer OpenTofu VMs at all -- they are MAAS-composed LXD VMs (Step 8). Do not re-introduce a manual virt-install path for them.ORG_ULA_48 / DC_GUA_PREFIX / DC2_V4_SUPERNET values do not exist yet -- see Step 10's own gate.git.baldurkeep.com remains the git service of record, with new repos created there as deployment tasks require. This also makes the old open question -- what a second, Office1-local GitBucket would mirror and under what repo path -- MOOT. One git service, and it is authoritative. (D-107's per-DC ARTIFACT mirror is a separate concern and is NOT affected.)voffice1's LXD, exactly like VR0's own tailscale machine), NOT a package installed on the headend host. A pre-existing subnet route already reaches the jumphost; that is a different node and does not satisfy this gate.runbooks/appendix-B-asbuilt-version-lock.md.voffice1's stable address is not yet fixed. It takes a DHCP lease from the edge's Kea (pool .100-.199). A Kea RESERVATION (added over the OPNsense REST API, scripts/opnsense-api.sh) is what makes it stable -- Step 4. Until that reservation exists, do NOT hardcode voffice1's address anywhere, including in MAAS's own --maas-url.1. Confirm Stage 1 exit gate + Office1 edge live (read-only, above)
2. Confirm OpenTofu still reaches vcloud libvirt (read-only)
2b. Office1 OPNsense edge -- BUILT AND LIVE (historical +
DANGER banner; do NOT re-run against the live edge) (read-only)
3. voffice1 -- the site containment VM (tofu apply) [MUTATION, gated]
-- APPLIED 2026-07-13; domain running (measured)
4. voffice1 first contact -- lease, Kea reservation, SSH [MUTATION, gated]
5. MAAS region controller ON voffice1 [MUTATION, gated] PENDING VERIF.
6. LXD ON voffice1, pinned to the 5.21 LTS track
[MUTATION, gated] PENDING VERIF.
7. Register that LXD BACK INTO MAAS as an LXD KVM host
[MUTATION, gated] PENDING VERIF.
8. Compose the non-stack service machines into it (MAAS)
-- THE L3 NESTING PROOF; gates DC1 [MUTATION, gated] PENDING VERIF.
9. NetBox -- install on its composed machine [MUTATION, gated]
10. NetBox -- run dc-dc-prefixes-import.py (MECHANISM only;
literals pending -- PARTIAL) [MUTATION, gated]
12. Tailscale -- install on its composed machine, scoped [MUTATION, gated]
13. Post-standup verify against the Stage 2 gate (read-only)
-> EXIT GATE -> Stage 3 (DC1), which D-114 GATES behind this
Covered above under "Entry condition." Do not proceed past this point until that CHECK is green.
Stage 1 proved OpenTofu -> vcloud-host libvirt reachability and has since APPLIED for real (the state file holds the DC1 planes, the pools, the three mesh links, office1-network, the OPNsense edge, and voffice1). This step is a re-verification, not a re-derivation.
CHECK -- from the Office1 operator session
tofu -chdir="$REPO/opentofu" version virsh -c "<the libvirt_uri value recorded in Stage 1's tfvars>" list --all
($REPO is set once per session -- see runbooks/README.md Conventions.) Expect: the connection succeeds, and the domain list shows office1-opnsense and voffice1. If the connection fails, STOP -- do not proceed into any mutation without confirmed libvirt reach.
CHECK -- the tree still validates
bash scripts/opentofu-validate.sh
Expect: root + every module PASS (10/10 as of DOCFIX-194 -- the gate now validates every module STANDALONE, because root-only validation silently skipped the two modules root does not call).
The Office1 edge is no longer work this runbook does -- it is a satisfied PREREQUISITE. It was built 2026-07-12/13 and is routing, NAT-ing, providing egress, and serving Kea DHCP on office1-local. voffice1 depends on all four of those. This section is retained as the historical record of how the image is prepped, and for its safety banner, which is still live.
!!! DANGER -- DO NOT RUN A CONFIG PUSH AGAINST THE LIVE OFFICE1 EDGE (2026-07-13) !!!
The Office1 edge is BUILT, ROUTING, and SERVING DHCP. Its DHCP config is now API-MANAGED (D-113(a2), proven live 2026-07-13). Rendering a full
config.xmland pushing it to10.10.0.1WOULD CLOBBER THAT LIVE STATE -- a full-config push replaces/conf/config.xmlwholesale and drops ~667 migration-populated elements.Two things are now permanently WRONG and must not be attempted:
- The config ISO cannot work AT ALL. Per D-112, the OPNsense Configuration Importer can NEVER fire on a pre-installed nano image --
opnsense-importer -bprobes for a read-only root, finds a writable one with a factory/conf/config.xmlalready present, andbootstrap_and_exit 0s without enumerating a single device. The ISO is INERT. Nothing was ever going to read it.- Full-
config.xmldelivery is SUPERSEDED by D-113(a2): edge configuration is done over the REST API (scripts/opnsense-api.sh), not by hand-authoring the appliance's GUI-owned XML. Hand-authoring that XML was the single root cause of DOCFIX-191 (lockout), DOCFIX-192 (dead console) and DOCFIX-193 (no DHCP).What is actually true today: Office1 was brought up by the D-112(c) console bootstrap, and is now managed over SSH + the REST API.
Image prep (host-local files only -- the one part still runnable, and only for a from-scratch re-standup):
bash scripts/opnsense-prep-image.sh # see the script's own header for its exact args/output path
GATE (Stage 2 Gate bullet): Office1 OPNsense edge up -- MET 2026-07-12/13 (routing, NAT, egress, serial console, SSH-key managed, Kea DHCP). Gap #17 is CLOSED for Office1 via the dedicated office1-wan ISP-uplink network. DC1/DC2 still have no uplink network -- that is Stage 3's problem, not this one's.
STATUS: APPLIED 2026-07-13.
module.voffice1is inopentofu/terraform.tfstateand the domain is RUNNING (measured this session:virsh list --all->voffice1 running). The apply was performed by the main session, not by this runbook's authoring pass. This step is retained as the procedure -- for a re-standup, and so the gate it satisfies is auditable. Re-runningtofu applywhenvoffice1is already up is NOT a no-op to be taken casually: an apply that reports "updated in-place" can still BOUNCE a domain (measured 2026-07-13 -- an apply restarted the live edge). Read the plan before approving it.
This is the D-114 site containment VM. It IS the Office1 facility: MAAS-region runs on it, LXD runs on it, and the non-stack service machines are composed into that LXD by MAAS. It sits on office1-local ONLY -- it reaches the internet THROUGH the OPNsense edge, like a real server behind a real site router.
CHECK -- confirm the current state before any apply
virsh -c "<libvirt_uri>" list --all | grep -i voffice1 tofu -chdir="$REPO/opentofu" plan
Expect either no voffice1 domain (fresh standup -- the apply will create it) or the domain present AND a plan showing no changes to module.voffice1. An unexplained diff against a running containment VM is a STOP.
MUTATION -- create voffice1 (OpenTofu; Option A, and now the ONLY path)
tofu -chdir="$REPO/opentofu" apply
Approve only after reading the plan. The module is already fully specified in opentofu/main.tf -- do not re-derive its inputs here. Its load-bearing properties, for the record:
modules/cloudinit-vm off modules/base-image (the official Ubuntu 24.04 noble cloud image -- 24.04 is what VR0 deploys for its own lxd/juju/ tailscale machines, so the image choice carries no new delta).network_names = [office1-local] ONLY. No mesh leg, no direct WAN.network_config takes DHCP from the edge's Kea, matching the NIC by GLOB (en*) rather than a guessed kernel name -- the NIC's name is not knowable before first boot, and naming it would be an inferred value.expose_nested_virt = true -- LOAD-BEARING (see "The D-114 model" above). Without the host CPU feature passed through, no LXD VM can ever boot inside voffice1 and D-114's model fails at its first step.SECURE THE STATE FILE after any apply (standing discipline from DOCFIX-175): confirm opentofu/**/*.tfstate* is gitignored, permissions are tight, and an out-of-band backup exists. The state stores sensitive = true values (e.g. var.maas_api_key) in PLAINTEXT.
GATE (Stage 2, first Gate bullet): voffice1 exists and is running. MET 2026-07-13 (measured).
office1-local is an isolated libvirt network with NO libvirt-managed DHCP -- virsh net-dhcp-leases office1-local is EMPTY BY DESIGN and is NOT evidence that voffice1 failed to get an address. Kea, on the OPNsense edge, is the authoritative DHCP server for that L2. Look for the lease THERE.
Per D-114, voffice1 booting on office1-local takes the first real DHCP lease the Kea path has ever served -- a path proven so far only at the daemon level, never end to end. Treat a successful lease as a real result worth recording, and a failed one as a real finding (not a nuisance to work around by hand-assigning a static address).
CHECK -- read the lease from the edge (read-only)
bash scripts/opnsense-api.sh <PENDING VERIFICATION: the read endpoint for Kea leases -- see the script's own --help/usage; do not guess an endpoint path>
Expect: one lease, in the .100-.199 pool, whose MAC matches voffice1's interface (virsh -c "<libvirt_uri>" domiflist voffice1). MEASURE both -- do not assume the lease belongs to voffice1 because it is the only one.
MUTATION -- pin the address with a Kea RESERVATION (gated)
Add a Kea host reservation binding voffice1's measured MAC to a stable address, over the OPNsense REST API (scripts/opnsense-api.sh). Do NOT hand-edit /conf/config.xml -- see Step 2b's DANGER banner. Do NOT hardcode an address anywhere (MAAS --maas-url included) until this reservation exists and has been re-measured after a voffice1 reboot.
CHECK -- SSH reach
ssh -i <the PRIVATE half of the Office1 service key -- jumphost-local, never read into a session> jessea123@<voffice1's measured address> 'hostnamectl; systemd-detect-virt; cloud-init status --long'
Expect: hostname voffice1, kvm, and cloud-init status: done. A cloud-init that is still running or error is a STOP -- read its logs before layering MAAS on top of a half-configured machine.
CHECK -- nested virt is actually exposed (the L3 precondition)
ssh <voffice1> 'grep -c -w -E "svm|vmx" /proc/cpuinfo; ls -l /dev/kvm'
Expect: a non-zero count and /dev/kvm present. If this fails, STOP -- expose_nested_virt did not take effect, and every LXD VM in Step 8 will fail to boot. That is D-114's own early-abandon signal; do not paper over it.
GATE (Stage 2 Gate bullet, NEW): voffice1 holds a Kea RESERVATION, is reachable over SSH, and exposes nested KVM.
PENDING VERIFICATION. The exact snap channel and the exact
maas initflag set (--maas-url, database backend selection, admin creation) VARY BY MAAS RELEASE. They are NOT reproduced from memory here. A separate verification pass against MAAS's current official install docs owns filling these in. Do not substitute this repo'sscripts/phase-00-maas-standup.sh-- that script drives an EXISTING MAAS, it does not install one.
Under D-114 the MAAS region controller runs INSIDE voffice1, not on a sibling VM of its own. It is the site's provisioning authority: it will own the LXD KVM host (Step 7) and every service machine composed into it (Step 8).
CHECK -- nothing pre-existing
ssh <voffice1> 'snap list maas 2>&1 || echo "not installed"'
MUTATION -- install MAAS (snap, the official method)
ssh <voffice1> 'sudo snap install maas --channel=<PENDING VERIFICATION: the current MAAS LTS/stable channel per MAAS official docs -- 3.6 and 3.7 are both in play in D-114; pick and RECORD, do not guess>'
MUTATION -- initialize it as a region controller
Per MAAS's own current maas init documentation for the installed version. <PENDING VERIFICATION: the exact init mode and flags, including the database backend -- these differ materially between MAAS releases; consult the official docs for the version actually installed, not a remembered flag set.>
Use voffice1's RESERVED address (Step 4) for the MAAS URL -- never the raw lease, and never a guessed address.
CHECK -- the region controller answers
curl -sI "http://<voffice1's reserved address>:5240/MAAS/" | head -1
Expect an HTTP response, not a connection failure. Naming per D-106: this service's FQDN, once DNS exists for Office1, follows the region-level subdomain scheme D-106 names as an example (maas.office1.vr1...) -- record whatever real hostname convention you actually use; do not assume it.
MUTATION -- create the MAAS API key for later steps
Steps 7 and 8 (and opentofu/modules/maas-vm-host, if it is ever wired for Office1) need a real MAAS API key. Generate it per MAAS's own docs and handle it per this repo's secrets discipline: never printed, never committed, passed as an environment variable or read from a jumphost-local file only. Note DOCFIX-175: var.maas_api_key lands in terraform.tfstate in PLAINTEXT if OpenTofu is given it -- factor that into where the key is allowed to go.
GATE (Stage 2 Gate bullet): MAAS region reachable, running ON voffice1.
PENDING VERIFICATION. The
lxd initanswers (storage backend and size, bridge configuration, whether the LXD HTTPS API is exposed and on what address/port, and how MAAS is expected to authenticate to it -- trust password vs. certificate) are NOT reproduced from memory. They are precisely what the MAAS-LXD seam depends on. A separate verification pass against Canonical's current MAAS + LXD docs owns filling them in.
MUTATION -- install LXD, PINNED (D-114)
ssh <voffice1> 'sudo snap install lxd --channel=5.21/stable'
The 5.21 track is a HARD D-114 constraint, not a preference: MAAS 3.6/3.7 cannot talk to LXD >= 6.7 (pylxd 2.3.5 vs LXD's consolidated API endpoints). latest/stable WILL eventually hand you a broken seam. If LXD is already present from the image at a different track, refresh it onto 5.21 rather than proceeding -- and record the measured version in appendix-B.
CHECK -- confirm the pin actually took
ssh <voffice1> 'snap list lxd; lxd --version'
Expect a 5.21.x version. A 6.x version here is a STOP -- it is the exact incompatibility D-114 pins against.
MUTATION -- initialize LXD
ssh <voffice1> 'sudo lxd init <PENDING VERIFICATION: interactive vs --preseed, and the actual answers -- storage backend/size, bridge, HTTPS listen address, and the auth mechanism MAAS requires. Consult LXD + MAAS official docs; do not invent a preseed.>'
!!! lxdbr0 MUST STAY on LXD's internal NAT bridge. Do NOT bridge it onto office1-local. Kea (on the OPNsense edge) is the authoritative DHCP server for that L2; lxdbr0's own dnsmasq on the same L2 would be a second one. See the DHCP HAZARD banner at the top of this runbook.
CHECK -- LXD is up and its bridge is where it belongs
ssh <voffice1> 'lxc network list; ip -br addr show lxdbr0'
Expect lxdbr0 on its own private subnet, NOT on 10.10.0.0/24.
GATE (Stage 2 Gate bullet, NEW): LXD installed on voffice1, version on the 5.21 LTS track (MEASURED), lxdbr0 isolated from office1-local.
PENDING VERIFICATION. The exact MAAS CLI/API call that adds an LXD KVM host (its subcommand, its power/auth parameters, the project name, and how the LXD certificate trust is established) is NOT reproduced from memory. A separate verification pass owns it. VR0's as-built shows the TARGET STATE: an LXD KVM host in project
default, LXD 5.21.4.
This is the seam D-114 turns on. voffice1 is a MAAS-managed machine that runs LXD; that LXD is then registered as a VM HOST inside the same MAAS. It is what makes the service machines MAAS-visible instead of hand-run containers.
Note the D-103/D-114 boundary, precisely: the composition right granted here is scoped to THIS LXD VM host and the NON-STACK machines. MAAS still does NOT compose the OpenStack NODE VMs -- modules/node-vm pre-creates those and modules/maas-vm-host registers the VIRSH host so MAAS merely DISCOVERS them. maas_vm_host_machine remains ruled out for nodes.
CHECK -- read the current VM-host inventory (read-only)
ssh <voffice1> 'maas <PENDING VERIFICATION: profile> vm-hosts read'
MUTATION -- add the LXD KVM host
PENDING VERIFICATION: the exact MAAS "add a VM host of type LXD" invocation -- its subcommand, power address, project name, and credentials/certificate trust. Consult MAAS's own current LXD-VM-host documentation. Do NOT adapt this from scripts/phase-00-maas-standup.sh or scripts/reenroll-hosts.sh: those are the PXE-enlisted metal-node path, a different mechanism.
CHECK -- MAAS sees the LXD host, with real capacity
ssh <voffice1> 'maas <profile> vm-hosts read'
Expect the LXD host present, with its cores/memory/storage reported -- MAAS reading real numbers back out of LXD is the evidence the seam works, not merely that the record was created.
GATE (Stage 2 Gate bullet, NEW): voffice1's LXD is registered in MAAS as an LXD KVM host and MAAS reads its capacity.
PENDING VERIFICATION. The exact
maas ... vm-host composeparameters (cores, memory, storage, and how the composed machine is attached to the right network) are NOT reproduced from memory. A separate verification pass owns them. VR0's as-built gives the SIZING PRECEDENT, measured, not invented: itstailscalemachine is 2 cores / 2 GiB / 25 GB, Ubuntu 24.04 -- D-114's own figure for what a composed service machine costs.
This step is the L3 NESTING PROOF, and it GATES DC1. The first LXD virtual machine that boots inside voffice1 proves the whole D-114 pod model to L3. D-114's DC1 entry gate depends on it. If no LXD VM will boot here, STOP and report -- do not "work around" it by falling back to LXD containers (MAAS does not enlist containers) or by hand-running the services on voffice1 itself (that reconstructs the flat model D-114 replaced, without saying so).
MUTATION -- compose the machines, ONE AT A TIME, individually gated
Compose in this order, gating each: Tailscale first (it is the smallest, it is VR0's proven exemplar, and if it boots the model is proven at minimum cost), then NetBox, then Tailscale.
ssh <voffice1> 'maas <profile> vm-host compose <PENDING VERIFICATION: the exact compose parameters -- cores/memory/storage/interfaces. Consult MAAS official docs.>'
Each composed machine then goes through MAAS's OWN provisioning lifecycle -- this is the entire point of D-114's ruling (they are MAAS-VISIBLE machines, not hand-run VMs):
<PENDING VERIFICATION: the commission/deploy invocations for a COMPOSED machine. Note that a composed machine may land in a different MAAS status than a PXE-enlisted one -- that is an explicitly UNCONFIRMED item in the Stage 4 runbook's own known-gaps list. Confirm it here, on real output, rather than assuming the PXE flow's statuses.>
CHECK -- the machines are real, MAAS-visible, and DEPLOYED
ssh <voffice1> 'maas <profile> machines read | <a jq/grep that prints hostname + status + power type>'
Expect each service machine Deployed, powered via the LXD VM host. This is VR0's lxd + tailscale shape, reproduced per site.
GATE (Stage 2 Gate bullet, NEW -- and D-114's DC1 entry gate, half (b)): at least one LXD virtual machine BOOTS inside voffice1, and the non-stack service machines are MAAS-composed, deployed, and powered. L3 nesting PROVEN. Record this explicitly -- DC1 is gated on it.
The VM is now a MAAS-DEPLOYED machine (Step 8), not a hand-built one. This step only installs the application onto it.
MUTATION -- install NetBox (official netbox-docker Compose method)
Over SSH to the composed NetBox machine, follow NetBox's own official Docker-based install (the netbox-community/netbox-docker repo's documented Compose workflow: clone it, configure its .env / docker-compose.override.yml per its own current docs, docker compose up -d). Do not hand-roll a from-source install -- Docker Compose is NetBox's own recommended path and the lowest-delta one to keep reproducible. Consult NetBox's current official docs for the exact compose file / image tag to pin; do not fabricate a version tag.
CHECK -- verify NetBox is reachable
curl -sI "http://<the composed NetBox machine's address>:8080/" | head -1
Expect an HTTP response. Create/record a NetBox API token per NetBox's own docs (needed by Step 10) -- handle it per this repo's secrets discipline: never printed, never committed, passed as an environment variable only (NETBOX_TOKEN, matching netbox/dc-dc-prefixes-import.py's own documented usage).
GATE (Stage 2 Gate, partial): NetBox reachable and running. NOT yet "authoritative and populated" -- see Step 10.
netbox/dc-dc-prefixes-import.py (DOCFIX-152) extends the v1 single-site import to VR1's per-DC, dual-stack model per D-101. It is idempotent and --verify-only-capable. It does NOT, and cannot, close the DATA half of tooling gap #3: the real org ULA /48, the per-DC GUA carve, and DC2's v4 supernet do not exist yet as assigned values anywhere in this repo or session. Running it for DC1 requires the two v6 literals (ORG_ULA_48, DC_GUA_PREFIX); running it for DC2 additionally requires DC2_V4_SUPERNET -- none of the three has a default, by the script's own design, and none is invented here.
CHECK -- dry run, no writes
NETBOX_URL="http://<the composed NetBox machine's address>:8080" \ NETBOX_TOKEN="<the token from Step 9, passed as an env var, never printed>" \ ORG_ULA_48="<MEASURE/ASSIGN -- a real RFC 4193 random ULA /48; not generated by this runbook>" \ DC_GUA_PREFIX="<MEASURE/ASSIGN -- the real per-DC GUA carve, per whoever administers that block>" \ python3 netbox/dc-dc-prefixes-import.py --dc dc1 --verify-only
If ORG_ULA_48 / DC_GUA_PREFIX are not yet assigned, this command CANNOT be run for real yet -- that is the honest state of this stage, not a missing step in this runbook. Record that as the blocking condition rather than inventing placeholder-looking values to "get past" it (this script is designed to fail loud on exactly that pattern, per its own header). See docs/dc-dc-netem-and-ula-gua-proposal.md for the drafted ULA-generation guidance -- a PROPOSAL, not a ruling.
MUTATION -- once the literals above are real (a later session/step)
NETBOX_URL="..." NETBOX_TOKEN="..." ORG_ULA_48="..." DC_GUA_PREFIX="..." \ python3 netbox/dc-dc-prefixes-import.py --dc dc1
DC1's v4 is hardcoded in the script itself (D-101: "DC1 equals the validated template," the explicit text of an ADOPTED decision, not an inferred value). DC2 additionally needs DC2_V4_SUPERNET, not assigned yet either.
GATE (Stage 2 Gate, HONEST partial): NetBox is reachable/running (Step 9). "Authoritative and populated (planes, per-DC v4, ULA/GUA carve)" is only PARTIALLY achievable: the IMPORT MECHANISM exists and is runnable the moment the literals are assigned, but the literals themselves are not assigned, so the DATA half of this gate bullet is NOT met. Do not mark this bullet green in the tracker until a real ORG_ULA_48 / DC_GUA_PREFIX (and, for DC2, DC2_V4_SUPERNET) exist and this script has actually run against this real NetBox instance.
DO NOT BUILD AN OFFICE1-LOCAL GITBUCKET. Operator ruling 2026-07-13 (D-116): the existing git.baldurkeep.com remains the git service of record for VR1, and new repos are created there as deployment tasks require. No third service machine is composed.
This step previously walked an Office1-local GitBucket install. It is retained as a tombstone rather than deleted because the runbook's step numbering is referenced elsewhere, and because someone WILL come looking for it after reading D-103 (whose GitBucket half this supersedes).
The old open question this closes -- what a second, Office1-local GitBucket would mirror, and under what repo path, given git.baldurkeep.com already exists -- is MOOT, not deferred.
NOTE: D-107's per-DC ARTIFACT mirror (OS/package artifacts for node provisioning) is a SEPARATE concern and is NOT affected by this ruling.
Proceed directly to Step 12.
Per D-114, Tailscale is a MAAS-COMPOSED SERVICE MACHINE (VR0's own tailscale machine, reproduced per site) -- NOT a package on the headend host, which is what the superseded model did. Per D-107, Office1 is explicitly OUT of the core-service path (no node artifacts, no NTP served from Office1) -- Tailscale is a narrowly-scoped OPERATOR front door to Office1's own services, not a general egress/access path for anything DC-side. Do not wire it into any DC-facing plane.
A pre-existing subnet route already reaches the jumphost. That is a DIFFERENT node and does NOT satisfy this gate -- do not mistake it for one.
CHECK -- confirm nothing conflicting on the composed machine
ssh <the composed Tailscale machine> 'tailscale version 2>&1 || echo "not installed"'
MUTATION -- install (official method)
ssh <the composed Tailscale machine> 'curl -fsSL https://tailscale.com/install.sh | sh'
Consult Tailscale's own current docs if this machine's distro needs a different install path (the install script itself detects and branches on distro).
MUTATION -- bring up, scoped
ssh <the composed Tailscale machine> 'sudo tailscale up --advertise-tags=<MEASURE/ASSIGN -- your own tailnet ACL tag for "Office1", per Open question #5 above>'
The actual ACL policy restricting reachability to Office1's services only (and NOT to any DC-side network) is configured in the operator's own Tailscale admin console/ACL file -- real policy design (Open question #5), not invented here. Do not advertise routes into any DC plane from this node.
CHECK -- verify scope
ssh <the composed Tailscale machine> 'tailscale status'
Confirm only the expected Office1 node(s) appear, and that no route advertisement reaches into DC1/DC2 plane CIDRs.
GATE (Stage 2 Gate bullet): Tailscale access confirmed to Office1 only, from a MAAS-composed machine.
virsh -c "<libvirt_uri>" list --all ssh <voffice1> 'snap list maas lxd; lxd --version' ssh <voffice1> 'maas <profile> vm-hosts read' ssh <voffice1> 'maas <profile> machines read' curl -sI "http://<voffice1's reserved address>:5240/MAAS/" | head -1 curl -sI "http://<the composed NetBox machine's address>:8080/" | head -1 curl -sI "http://<the composed GitBucket machine's address>:<port>/" | head -1 ssh <the composed Tailscale machine> 'tailscale status'
Cross-check each against the GATE section below before declaring this stage done or partially done.
Per docs/dc-dc-deployment-workflow.md Stage 2's Gate row, as reshaped by D-114:
voffice1 site containment VM exists and runs: MET 2026-07-13 (measured).voffice1 has a stable (reserved) address and SSH reach, with nested KVM exposed: Step 4 -- NOT yet confirmed.voffice1: Step 5 -- NOT yet done.voffice1, on the 5.21 LTS track, lxdbr0 off office1-local: Step 6 -- NOT yet done.netbox/dc-dc-prefixes-import.py has actually run against the real instance.This runbook's honest exit state is therefore: Stage 2 PARTIALLY complete -- the substrate half (networks, edge, voffice1) is DONE and measured; the SITE half (MAAS, LXD, the LXD KVM host, and every composed service machine) is the remaining work, and its command lines are PENDING VERIFICATION. The NetBox DATA bullet closes its mechanism half only, pending the D-101 literal assignment that is explicitly out of scope for this stage to invent. Update docs/dc-dc-deployment-workflow.md's Stage 2 row to reflect whichever of these is actually true after a real run, not to "DONE" wholesale.
-> Stage 3 (DC1) is GATED behind this stage. D-114's sequencing is an operator ruling: Office1 FIRST, fully up on this model; DC1 is not started until Office1 is complete; DC2 follows DC1. DC1's own entry gate additionally requires proving nested KVM inside vdc1 (which also proves L4 for tenant instances -- the one depth VR0 has never exercised). If L3 fails at Step 8, the containment-VM model is abandoned early having cost one VM -- that is the designed cheap retreat, and it is the correct outcome to report, not a failure to engineer around.
bash scripts/repo-lint.sh clean (0 fail) before committing any repo
changes made while executing this runbook.
opentofu/ change was written this run, `bash
scripts/opentofu-validate.sh` green (root + EVERY module -- DOCFIX-194).
scripts/ledger-scan.sh`), noting: the MAAS/LXD versions ACTUALLY installed, `voffice1`'s reserved address, whether the L3 nesting proof passed, which service machines were composed, and whether NetBox's literal-assignment gap was closed or remains open.
runbooks/appendix-B-asbuilt-version-lock.md updated with the LXD 5.21-LTS
pin (D-114 requires it be recorded there) and the measured MAAS version.
docs/session-ledger.md updated with the outcome.docs/dc-dc-deployment-workflow.md Stage 2 row and tracker table updated to
the ACTUAL state reached -- do not mark NetBox's gate bullet closed unless the D-101 literals were genuinely assigned and imported this run.
<PENDING VERIFICATION: ...> placeholder in Steps 4-8 replaced with a
command confirmed against Canonical's current docs, and the confirmed sequence folded back into this runbook -- so the next site (DC1, then DC2) inherits a verified procedure rather than repeating the verification.
D-114 -- OpenTofu is the path, `module "voffice1"` is instantiated and applied, and the three service VMs are no longer OpenTofu VMs at all.
opentofu/modules/office1-network,
gap #12 CLOSED).
ownership; gap #17 CLOSED for Office1 via `office1-wan`).