Newer
Older
openstack-caracal-dc-dc / runbooks / dc-dc-phase1-office1-standup.md

DC-DC Phase 1 -- Office1 site standup (Stage 2)

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's OPNsense edge (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.
  • NOT yet done: everything from Step 5 onward -- MAAS-region on 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.


Entry condition (Stage 1 gate + the Office1 edge -- confirm, do not re-derive)

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.


The D-114 model in one paragraph (read before Step 1)

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.


Open questions flagged here (not resolved by this runbook)

Per this repo's discipline of naming ambiguity explicitly rather than quietly picking an answer:

  1. RESOLVED 2026-07-13 by D-114. The Option A / Option B provisioning fork is CLOSED. The old fork ("OpenTofu 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.
  2. The exact MAAS/LXD install, LXD-KVM-host registration, and VM-compose command lines are NOT yet verified. See the PENDING VERIFICATION banner above. Steps 5-8 carry the placeholders; a separate verification pass against Canonical's current docs owns filling them in. Do not run those steps from a remembered flag set.
  3. NetBox's own address literals are not this stage's job, per D-101's own text ("the org ULA /48, the per-DC GUA carve... and DC2's v4 supernet are NetBox-authoritative... NOT hardcoded in this decision"). This runbook stands NetBox up and can run its import MECHANISM (Step 10), but the underlying ORG_ULA_48 / DC_GUA_PREFIX / DC2_V4_SUPERNET values do not exist yet -- see Step 10's own gate.
  4. GitBucket: CLOSED, NOT DEFERRED (D-116). There is NO Office1-local GitBucket. The operator ruled 2026-07-13 that the existing 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.)
  1. Tailscale ACL/tag scoping specifics are not decided. D-107 says the front door is "Office1 only" -- the actual tailnet ACL policy, tags, and which services (NetBox web UI? MAAS UI? SSH to the service machines?) are exposed through it versus reachable only from Office1's own local network, is real policy design left to Step 12 and the operator's own tailnet account -- not invented here. NOTE the placement change under D-114: Tailscale is now a MAAS-COMPOSED SERVICE MACHINE (a VM in 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.
  2. Exact MAAS / NetBox / LXD version pins are not decided (beyond D-114's hard LXD 5.21-LTS-track constraint, which IS decided). Each install step below names the well-known official install MECHANISM but does not pin an exact version/channel/tag -- consult each project's current official install docs at execution time and record whatever is ACTUALLY installed in the changelog entry for this runbook's real run, and in runbooks/appendix-B-asbuilt-version-lock.md.
  3. 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.

Sequence

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

Step 1 -- Confirm Stage 1 exit gate (READ-ONLY)

Covered above under "Entry condition." Do not proceed past this point until that CHECK is green.


Step 2 -- Confirm OpenTofu still reaches vcloud libvirt (READ-ONLY)

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).


Step 2b -- Office1's OPNsense edge: BUILT AND LIVE (READ-ONLY / historical)

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.xml and pushing it to 10.10.0.1 WOULD CLOBBER THAT LIVE STATE -- a full-config push replaces /conf/config.xml wholesale and drops ~667 migration-populated elements.

Two things are now permanently WRONG and must not be attempted:

  1. 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 -b probes for a read-only root, finds a writable one with a factory /conf/config.xml already present, and bootstrap_and_exit 0s without enumerating a single device. The ISO is INERT. Nothing was ever going to read it.
  2. Full-config.xml delivery 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.


Step 3 -- voffice1: the site containment VM [MUTATION: gated]

STATUS: APPLIED 2026-07-13. module.voffice1 is in opentofu/terraform.tfstate and 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-running tofu apply when voffice1 is 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.
  • Cloud-init is DELIBERATELY MINIMAL (identity + access + guest agent). MAAS and LXD are installed as separate GATED steps below so they are observable and individually approved, not buried in a first-boot script that either silently works or silently does not.

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).


Step 4 -- voffice1 first contact: lease, Kea reservation, SSH [MUTATION: gated]

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.


Step 5 -- MAAS region controller ON voffice1 [MUTATION: gated] (PENDING VERIFICATION)

PENDING VERIFICATION. The exact snap channel and the exact maas init flag 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's scripts/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.


Step 6 -- LXD ON voffice1, pinned to the 5.21 LTS track [MUTATION: gated] (PENDING VERIFICATION)

PENDING VERIFICATION. The lxd init answers (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.


Step 7 -- Register that LXD BACK INTO MAAS as an LXD KVM host [MUTATION: gated] (PENDING VERIFICATION)

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.


Step 8 -- Compose the non-stack service machines (NetBox, Tailscale) [MUTATION: gated]

PENDING VERIFICATION. The exact maas ... vm-host compose parameters (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: its tailscale machine 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):

  1. Enlist -- MAAS registers the composed VM.
  2. Commission -- MAAS boots it and inventories it.
  3. Deploy -- MAAS installs the OS (Ubuntu 24.04, matching VR0's own service machines).
  4. Power -- MAAS controls it via the LXD VM host.

<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.


Step 9 -- NetBox: install on its composed machine [MUTATION: gated]

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.


Step 10 -- NetBox: run the multi-DC/dual-stack import (MECHANISM only -- PARTIAL) [MUTATION: gated]

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.


Step 11 -- GitBucket: REMOVED FROM SCOPE (D-116)

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.

Step 12 -- Tailscale: install on its composed machine, scoped to Office1 [MUTATION: gated]

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.


Step 13 -- Post-standup verify against the Stage 2 gate (READ-ONLY)

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.


GATE (Stage 2 exit condition)

Per docs/dc-dc-deployment-workflow.md Stage 2's Gate row, as reshaped by D-114:

  • Office1 OPNsense edge up (routing, NAT, egress, DHCP): MET 2026-07-12/13.
  • Office1-local + office1-wan networks created: MET (gaps #12 and #17-for-Office1 CLOSED).
  • OpenTofu reaches vcloud host libvirt: MET (Stage 1; re-verified in Step 2).
  • 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.
  • MAAS region reachable, running ON voffice1: Step 5 -- NOT yet done.
  • LXD on voffice1, on the 5.21 LTS track, lxdbr0 off office1-local: Step 6 -- NOT yet done.
  • That LXD registered in MAAS as an LXD KVM host: Step 7 -- NOT yet done.
  • Non-stack service machines COMPOSED BY MAAS into it, deployed and powered -- i.e. an LXD VM BOOTS at L3: Step 8 -- NOT yet done. This is the D-114 DC1 entry gate.
  • GitBucket serving: Step 11 -- NOT yet done.
  • Tailscale confirmed to Office1 only, from a composed machine: Step 12 -- NOT yet done, and contingent on the operator's own ACL policy (Open question #5).
  • NetBox authoritative and populated: ONLY PARTIALLY achievable. The instance can be stood up and reachable (Step 9), and the import MECHANISM exists and is runnable (Step 10) -- but the real address literals (org ULA /48, per-DC GUA carve, DC2 v4 supernet) are not assigned, so NetBox cannot yet be truly "populated" per D-101. Do not mark this bullet green until those literals exist and 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.


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.
  • If any opentofu/ change was written this run, `bash
    scripts/opentofu-validate.sh` green (root + EVERY module -- DOCFIX-194).
  • Changelog entry for this runbook's real execution (next number via `bash
    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.
  • Every <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.
  • Provisioning-path fork (old Option A / Option B) RESOLVED 2026-07-13 by
    D-114 -- OpenTofu is the path, `module "voffice1"` is instantiated and
    applied, and the three service VMs are no longer OpenTofu VMs at all.
  • Office1-local network RESOLVED 2026-07-09 (opentofu/modules/office1-network,
    gap #12 CLOSED).
  • Office1 OPNsense edge RESOLVED and BUILT 2026-07-12/13 (gap #16 CLOSED for
    ownership; gap #17 CLOSED for Office1 via `office1-wan`).