# DC-DC Phase 1 -- Office1 headend standup (Stage 2)

Stand up the operator's deployment headend -- MAAS region controller, confirmed
OpenTofu reach, NetBox, GitBucket, Tailscale -- 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.

**Authoring context (read before running):** this runbook was authored PREP-ONLY,
with no live vcloud host, MAAS, NetBox, GitBucket, or Tailscale reachable in the
authoring session. Every command below is written to be run for real, later,
by the operator -- it has not been executed against real infrastructure. Per
`opentofu/README.md` and `docs/dc-dc-deployment-workflow.md` Stage 2 (tooling
gap register items #2, #3, #9): **none of the three Office1 service VMs
(MAAS-region, NetBox, GitBucket) have an OpenTofu module instantiation yet** --
`opentofu/modules/cloudinit-vm` exists as a mechanism, but no image source has
been chosen and no `user_data`/`meta_data`/`network_config` content has been
designed for any of the three. This runbook does not invent that content. It
gives each VM-creation step two explicit paths (Section "Provisioning-path
decision" below) and flags the OpenTofu path as blocked-pending-design rather
than silently choosing the manual path for the operator.

**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 (items #2, #3, #9 above all bear directly on this stage; items #12
and #16 were found and CLOSED during this runbook's own authoring/update
pass -- see Open questions #1/#2 below; item #17 was found while closing
#16 and remains OPEN, a cross-site gap, not specific to Office1);
`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,
built 2026-07-09 -- MECHANISM only, see Step 7).

Decisions this runbook owns: **D-103** (OpenTofu/MAAS/Juju lifecycle seam --
Office1 service VMs are OpenTofu-created, eventually; MAAS owns their
provisioning lifecycle once enlisted the same way DC node VMs are), **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).

!!! PREP-ONLY / NOT YET EXECUTED. No live infrastructure exists or was
    reachable in the authoring session. Every MUTATION below is written for
    the operator to run and individually gate later, per this repo's
    verify-before-mutate discipline -- none of it has been run.

!!! STRUCTURAL BLOCKER, flagged not fixed here: the OpenTofu path for all
    three Office1 service VMs needs `user_data`/`meta_data`/`network_config`
    designed against `modules/cloudinit-vm` before it can be used -- that
    design work has NOT been done. Step 3 below presents the resulting
    Option A (blocked) / Option B (manual, interim) fork explicitly for each
    VM. Do not silently pick one without reading Step 3 first.

---

## Entry condition (Stage 1 gate -- confirm, do not re-derive)

**CHECK -- read-only, before starting anything in this runbook**
```bash
virsh net-list --all
virsh pool-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`, added by this delivery -- 2026-07-09,
gap #12 CLOSED; picked up by whichever `tofu apply` run of
`dc-dc-phase0-vcloud-prep.md`'s Step 10/11 executes against this repo state,
same root `main.tf` Stage 1 already applies), and the `dc1`/`office1`
storage pools, all `active` -- this is `dc-dc-phase0-vcloud-prep.md`'s Step
11 output. If any of this is missing or `inactive`, STOP -- Stage 1 is not
actually done; go re-run/complete that runbook rather than starting Stage 2
on an unfinished Stage 1. Do not re-measure nested KVM, disk budget, or MTU
here -- Stage 1 already recorded those; this runbook only reads their OUTCOME (the networks
existing) as its gate.

---

## 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-09 (tooling gap register item #12 CLOSED). What
   virtual network do the three Office1 service VMs themselves attach to?**
   `opentofu/main.tf` (per Stage 1) previously wired only `office1_pool_path`
   (storage only) and the three mesh-link networks (Office1<->DC1,
   Office1<->DC2, inter-site only, "management traffic only" per D-100's own
   sub-item ruling) -- no Office1-LOCAL network (something the three service
   VMs and the vcloud host's own management interface could all share) was
   modeled anywhere in `opentofu/`. **Decision: a NEW module
   (`opentofu/modules/office1-network`), not a reused host bridge.** A
   dc-planes-shaped isolated `libvirt_network` sized for ONE network (Office1
   is a headend, not a six-plane OpenStack DC). Reusing an existing host
   bridge was explicitly considered and rejected: it would be the one network
   in this whole topology living outside OpenTofu's D-103-mandated inventory,
   and would silently break this repo's own `virsh net-list --all`
   completeness assumption (used as a CHECK step in this runbook's own Entry
   condition, above) -- exactly the kind of untracked, unreproducible state
   D-103 exists to prevent, and the same class of debt this runbook already
   logs explicitly when Option B (manual VM creation) is used. Instantiated
   for real in root `main.tf` as `module "office1_network"` (needs no
   unmeasured value beyond `domain_suffix`/`underlay_mtu`, both already real
   required inputs). See `opentofu/README.md` and
   `docs/changelog-20260709-office1-network-edge.md` for the full writeup.
   Use `module.office1_network.network_name` (`"office1-local"`) as the
   network for each service VM's `cloudinit-vm` `network_config` and for
   Step 4b's OPNsense edge LAN side below -- do not invent a different
   network name.
2. **RESOLVED 2026-07-09 (tooling gap register item #16 CLOSED). Does
   Office1 get its own OPNsense simulated-ISP edge, and if so, which stage
   creates it?** The buildout design's topology (Section 1) is explicit:
   "full dark-fiber triangle... plus a **per-site** OPNsense simulated ISP
   edge" -- three sites, three edges. Stage 3's Build list already covered
   DC1/DC2's edges; neither Stage 2 nor Stage 3 previously instantiated one
   for Office1. **Decision: YES, Office1 gets its own `modules/opnsense-edge`
   call, and Stage 2 (this runbook) owns creating it** -- new Step 4b below.
   Rationale: this runbook's entire scope is already "Office1 headend
   standup," and the OPNsense edge is part of that headend's own boundary,
   not DC substrate; Stage 3 (`runbooks/dc-dc-phase2-tofu-dc-substrate.md`)
   has no reason to reach back into Office1's own infrastructure to build a
   piece of it. This mirrors Stage 3's own `dc1_opnsense`/`dc2_opnsense`
   shape exactly (same module, same variable names) -- see Step 4b. Its LAN
   side resolves cleanly to open question #1's new network
   (`module.office1_network.network_name`); its WAN side does NOT -- no
   dedicated per-site ISP-uplink/WAN network exists for ANY site yet
   (DC1/DC2 included -- confirmed by reading Stage 3's own `dc1_opnsense`
   template, which carries the identical unresolved `wan_network_name`
   placeholder). This is now tracked as tooling gap register item #17 (NEW),
   a genuinely deeper, cross-site gap -- not invented here as an Office1-only
   workaround. Step 4b's module call therefore stays commented out, same as
   DC1/DC2's own not-yet-instantiated edges, until gap #17 closes and real
   `memory_mib`/`vcpu`/`disk_size_bytes`/`base_volume_path`/`config_iso_path`
   values exist.
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 7), but the
   underlying `ORG_ULA_48` / `DC_GUA_PREFIX` / `DC2_V4_SUPERNET` values do not
   exist yet -- see Step 7's own gate.
4. **GitBucket's VR1 repo path/name is not decided.** The historical D-014 path `jesse.austin/openstack-caracal-ipv4` names a DIFFERENT,
   pre-existing `git.baldurkeep.com` instance from the v1 rehearsal -- this
   stage stands up a SEPARATE, vcloud-local GitBucket instance (per the
   buildout design's own "GitBucket (vcloud-local mirror)" wording) -- what
   it mirrors, and under what repo path, is not decided here. Flag as an
   open item before Step 8's mutation.
5. **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? GitBucket web UI? MAAS UI? SSH to the
   service VMs?) are exposed through it versus reachable only from Office1's
   own local network, is real policy design left to Step 9 and the operator's
   own tailnet account -- not invented here.
6. **Exact MAAS / NetBox / GitBucket version/channel pins are not decided.**
   Each install step below names the well-known official install MECHANISM
   (snap for MAAS, Docker Compose for NetBox, WAR/Docker for GitBucket) 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.

---

## Provisioning-path decision (applies to all three service VMs)

Per the structural blocker above, EVERY VM-creation step in this runbook (5,
6, 8) has two paths. Do not silently default to one -- read both, pick
deliberately, and log which path was used in the changelog entry.

- **Option A (preferred, long-term, BLOCKED pending design):** create the VM
  via `opentofu/modules/cloudinit-vm`, now that (a) Open question #1 above is
  RESOLVED (attaches to `module.office1_network.network_name`, i.e.
  `"office1-local"`) but (b) real `user_data`/`meta_data`/`network_config`
  content still needs to be designed for that specific VM (what packages,
  what static address, what hostname). This is the D-103-correct path (`OpenTofu
  creates... the Office1 service VMs`) and is NOT available tonight -- doing
  it right requires a design pass this session did not do and this runbook
  will not invent.
- **Option B (interim, manual):** create the VM directly against the vcloud
  host's libvirt (e.g. `virt-install` with a real official cloud image ISO/
  qcow2, or an interactive install), attach it to whatever network Open
  question #1 resolves to, then run the install steps below directly over SSH
  once the VM has a reachable address. This gets Office1 stood up now, at the
  cost of NOT being reproducible/versioned in OpenTofu -- log it explicitly as
  a known debt (a DOCFIX candidate: "Office1 service VM `<name>` was
  hand-created via Option B on `<date>`; migrate to `modules/cloudinit-vm`
  once its cloud-init content is designed").

---

## Sequence
```
1.  Confirm Stage 1 exit gate                                (read-only, above)
2.  Confirm OpenTofu still reaches vcloud libvirt from Office1 (read-only)
3.  Read the provisioning-path decision + open questions       (above; no cmds)
4.  MAAS region controller -- create VM (A or B) + install    [MUTATION, gated]
4b. Office1 OPNsense edge -- wire module (gap #16 CLOSED;
    instantiation still BLOCKED on gap #17 + real specs)      [repo change, gated]
5.  NetBox -- create VM (A or B) + install (official Docker)  [MUTATION, gated]
6.  NetBox -- run dc-dc-prefixes-import.py (MECHANISM only;
    literals pending -- PARTIAL)                              [MUTATION, gated]
7.  GitBucket -- create VM (A or B) + install (WAR or Docker)  [MUTATION, gated]
8.  Tailscale -- install + scope to Office1 only               [MUTATION, gated]
9.  Post-standup verify against the Stage 2 gate                (read-only)
    -> EXIT GATE (PARTIAL, see GATE section) -> Stage 3
```

---

## 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 from Office1 (READ-ONLY)

Stage 1 already proved OpenTofu -> vcloud-host libvirt reachability (its Step
1 CHECK + Step 10 apply). This step is a re-verification, not a re-derivation
-- if this session is running some time after Stage 1, confirm nothing
regressed rather than assuming it still holds.

**CHECK -- from the Office1 operator VM/session**
```bash
cd opentofu
tofu version
virsh -c "$(grep -A1 'variable "libvirt_uri"' -m1 /dev/null 2>/dev/null; true)" list --all 2>/dev/null || true
```
More directly -- use the exact `libvirt_uri` value recorded in Stage 1's
`opentofu/dc-dc-phase0.auto.tfvars`:
```bash
virsh -c "<the libvirt_uri value recorded in Stage 1's tfvars>" list --all
```
Expect: the domain list (the DC1 plane/pool objects have no domains yet, so
this may be empty, but the CONNECTION must succeed with no error). If it
fails, STOP -- this is the same blocking condition Stage 1's Step 1 described;
do not proceed into VM creation without confirmed libvirt reach.

**CHECK -- MAAS provider not yet needed but flagged (Stage 1's Known Gap)**
```bash
grep -n 'provider "maas"' opentofu/main.tf
```
Confirm whether Stage 1 worked around the unconditional `maas` provider block
with placeholder values or whether a DOCFIX split it out -- read whatever
Stage 1's changelog entry recorded, and use the same resolved answer here
rather than re-deciding it.

---

## Step 3 -- Read the provisioning-path decision and open questions

No commands. Confirm with the operator (or record your own reasoned choice,
logged) which path -- A or B -- is being used for each of the three VMs
below, and how Open question #1 (Office1-local network) is being resolved for
this run. Do not proceed to Step 4 silently.

---

## Step 4 -- MAAS region controller: create VM + install [MUTATION: gated]

**CHECK -- confirm no pre-existing MAAS-region VM under this name**
```bash
virsh -c "<libvirt_uri>" list --all | grep -i maas-region
```
Expect: no match (fresh standup) or a clear, understood match (re-run/repair
case) -- do not proceed if an unexplained existing domain shares the name.

**MUTATION -- create the VM (Option A or B per Step 3)**

Option A: not available tonight (blocked, see above) -- instantiate
`module "office1_maas_region"` using `modules/cloudinit-vm` once its
`user_data`/`meta_data`/`network_config` are designed, then `tofu apply`.

Option B (interim manual path):
```bash
# Illustrative shape only -- fill every <ANGLE-BRACKET> with a value you
# measured/assigned this session; none of these are literals to copy verbatim.
virt-install \
  --connect "<libvirt_uri>" \
  --name maas-region-office1 \
  --memory <MEASURE/ASSIGN -- RAM budget from Stage 1 host measurement> \
  --vcpus <MEASURE/ASSIGN> \
  --disk pool=office1,size=<MEASURE/ASSIGN> \
  --network network=office1-local \
  --os-variant <the guest OS you are installing, per its own docs> \
  --cdrom <path to the official install ISO you fetched, per that OS's docs> \
  --graphics none --console pty,target_type=serial
```
Boot it, complete the OS install per that OS's own docs, and record the
resulting static/DHCP address this session assigns it -- MEASURE/ASSIGN, never
guessed. This is the exact "known debt" Option B commits to logging (see
Provisioning-path decision above).

**MUTATION -- install MAAS region controller (official snap-based method)**

Once the VM is up and reachable over SSH:
```bash
ssh <the address you just assigned> 'snap info maas'
```
Consult MAAS's current official install docs for the exact channel to pin
(this repo does not hardcode a MAAS snap channel anywhere else to copy from --
pick and record the current LTS/stable channel per those docs, not a guessed
one) and run the equivalent of:
```bash
sudo snap install maas --channel=<consult official docs for current channel>
```
Then initialize it as a region controller per MAAS's own current `maas init`
documentation for that installed version -- the exact flags (`--maas-url`,
database backend selection, admin creation) vary by MAAS release; consult the
official docs rather than reusing a flag set from memory or from a different
MAAS version's docs.

**CHECK -- verify the region controller is reachable**
```bash
curl -sI "http://<the address you assigned>:5240/MAAS/" | head -1
```
Expect an HTTP response, not a connection failure. Naming per D-106: this
service's FQDN, once Designate/DNS exists for Office1, would follow the
region-level subdomain scheme D-106 names as an example
(`maas.office1.vr1...`) -- exact scheme is confirmed here as part of this
stage, not assumed; record whatever real hostname convention you actually use.

**GATE (Stage 2, first Gate bullet):** MAAS region reachable.

---

## Step 4b -- Office1's own OPNsense edge: wire the module [repo change, gated] (NEW -- gap #16 CLOSED)

Per Open question #2 above (RESOLVED): Office1 gets its own
`modules/opnsense-edge` call, and this runbook (Stage 2) owns creating it --
not Stage 3, whose runbook only ever covered DC1/DC2. This step mirrors
`runbooks/dc-dc-phase2-tofu-dc-substrate.md` Step 4/5 exactly (same module,
same prep scripts, same variable names), just for the `office1` site instead
of `dc1`/`dc2`.

**CHECK -- confirm no pre-existing Office1 OPNsense VM under this name**
```bash
virsh -c "<libvirt_uri>" list --all | grep -i office1-opnsense
```
Expect no match (fresh standup) or a clear, understood match (re-run/repair
case).

**MUTATION -- prepare Office1's OPNsense base image + config.xml + config ISO
(host-local files, gated)**, same mechanism as Stage 3's Step 4:
```bash
bash scripts/opnsense-prep-image.sh   # see the script's own header for its exact args/output path
```
```bash
export OPNSENSE_HOSTNAME=... DOMAIN=... ROOT_PASSWORD_HASH=...   # plus every other token templates/README.md's legend marks REQUIRED for this site
bash scripts/opnsense-render-config.sh office1-opnsense-config.xml
```
```bash
bash scripts/opnsense-build-config-iso.sh office1-opnsense-config.xml office1-opnsense-config.iso
```
Every token filled above must be a real, measured/decided value for Office1
specifically -- no copy-paste of DC1/DC2's config.xml content.

**Repo change -- wire `module "office1_opnsense"` in `opentofu/main.tf`**

A commented-out skeleton already exists in root `main.tf` (this delivery,
2026-07-09) with `lan_network_name` already resolved
(`module.office1_network.network_name`). Uncommenting it for a real run
additionally requires:
- `memory_mib` / `vcpu` / `disk_size_bytes` -- real values, OPNsense's own
  sizing guidance, not invented (same "no invented specs" rule as every
  other VM module in this repo).
- `base_volume_path` / `config_iso_path` -- the real output paths from the
  two commands above.
- `wan_network_name` -- **BLOCKED on tooling gap register item #17** (no
  dedicated per-site ISP-uplink/WAN network exists anywhere in this repo
  yet, for ANY site -- confirmed against Stage 3's own `dc1_opnsense`
  template, which carries the identical open placeholder). Do NOT wire this
  to any `mesh-link` network: D-100's own sub-item ruling confirms those
  three legs carry management traffic only, not a simulated ISP uplink.
  **STOP here if gap #17 is still open** -- this step cannot be fully
  completed, and that is the honest state to record, not a placeholder to
  paper over.

**GATE:** `opentofu/main.tf` diff (once gap #17 closes and every value above
is real) shows exactly one new `module "office1_opnsense"` block, every
argument a real value, nothing else changed -- same GATE shape as Stage 3's
Step 5.

**GATE (Stage 2 Gate bullet, NEW):** Office1's own OPNsense edge module
wired with a real LAN side (achievable this stage, gap #12 resolved it) --
full instantiation (and therefore the running edge VM itself) remains
BLOCKED pending gap #17's resolution, same as DC1/DC2's edges. Do not mark
this bullet fully green until gap #17 closes and `tofu apply` has actually
run for `module.office1_opnsense`.

---

## Step 5 -- NetBox: create VM + install (official Docker method) [MUTATION: gated]

**CHECK -- confirm no pre-existing NetBox VM under this name**
```bash
virsh -c "<libvirt_uri>" list --all | grep -i netbox
```

**MUTATION -- create the VM (Option A or B per Step 3)**, same shape and same
caveats as Step 4's MAAS-region VM creation (Option A blocked pending
`cloudinit-vm` content design for THIS specific VM; Option B manual, logged as
debt).

**MUTATION -- install NetBox (official netbox-docker Compose method)**

Once the VM is up and reachable over SSH, 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**
```bash
curl -sI "http://<the address you assigned>:8080/" | head -1
```
Expect an HTTP response. Create/record a NetBox API token per NetBox's own
docs (needed by Step 6) -- 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 6.

---

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

`netbox/dc-dc-prefixes-import.py` (built 2026-07-09, closes tooling gap #3's
MECHANISM) 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 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 only 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**
```bash
NETBOX_URL="http://<the address you assigned>:8080" \
NETBOX_TOKEN="<the token from Step 5, 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 from the org's ARIN 2602:f3e2::/32, 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 tonight, 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
itself is designed to fail loud on exactly that pattern, per its own header).

**MUTATION -- once the literals above are real (a later session/step, not
necessarily tonight)**
```bash
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," not an inferred value -- it is the explicit text of an ADOPTED
decision). DC2 additionally needs `DC2_V4_SUPERNET`, not assigned yet either.

**GATE (Stage 2 Gate, HONEST partial):** NetBox is reachable/running (Step 5).
"Authoritative and populated (planes, per-DC v4, ULA/GUA carve)" is only
PARTIALLY achievable right now: the IMPORT MECHANISM exists and is runnable
the moment the literals are assigned, but the literals themselves are not
assigned this session, so the DATA half of this gate bullet is NOT met yet.
Do not mark this gate 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 been run against this real NetBox instance.

---

## Step 7 -- GitBucket: create VM + install (official WAR or Docker method) [MUTATION: gated]

**CHECK -- confirm no pre-existing GitBucket VM under this name**
```bash
virsh -c "<libvirt_uri>" list --all | grep -i gitbucket
```

**MUTATION -- create the VM (Option A or B per Step 3)**, same shape/caveats
as Steps 4 and 5.

**MUTATION -- install GitBucket (official method)**

GitBucket ships as a single runnable WAR (`java -jar gitbucket.war`, its own
documented standalone-Jetty mode) or as an official Docker image
(`gitbucket/gitbucket`). Either is GitBucket's own supported path; pick one
and record which. Consult GitBucket's current official docs/release page for
the exact WAR filename/version or image tag to pin -- do not fabricate one.
Open question #3 above (this instance's purpose/repo-path scheme, distinct
from the existing `git.baldurkeep.com` v1 instance per D-014) must be resolved
before deciding what this instance actually mirrors.

**CHECK -- verify GitBucket is reachable**
```bash
curl -sI "http://<the address you assigned>:8080/" | head -1
```
Expect an HTTP response.

**GATE (Stage 2 Gate bullet):** GitBucket serving.

---

## Step 8 -- Tailscale: install + scope to Office1 only [MUTATION: gated]

Per D-107, Office1 is explicitly OUT of the core-service path (no node
artifacts, no NTP served from Office1) -- Tailscale here 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 Tailscale into any DC-facing plane.

**CHECK -- confirm Tailscale is not already installed/conflicting**
```bash
ssh <office1-headend-host-or-VM> 'tailscale version 2>&1 || echo "not installed"'
```

**MUTATION -- install (official method)**
```bash
ssh <office1-headend-host-or-VM> 'curl -fsSL https://tailscale.com/install.sh | sh'
```
Consult Tailscale's own current docs if this host's distro needs a different
install path (the install script itself detects and branches on distro).

**MUTATION -- bring up, scoped**
```bash
ssh <office1-headend-host-or-VM> 'sudo tailscale up --advertise-tags=<MEASURE/ASSIGN -- your own tailnet ACL tag for "Office1 headend", per Open question #4 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 -- this is real policy design (Open question #4), not
invented here. Do not advertise routes into any DC plane from this node.

**CHECK -- verify scope**
```bash
ssh <office1-headend-host-or-VM> 'tailscale status'
```
Confirm only the expected Office1 headend 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.

---

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

```bash
curl -sI "http://<maas-region-address>:5240/MAAS/" | head -1
curl -sI "http://<netbox-address>:8080/" | head -1
curl -sI "http://<gitbucket-address>:<port>/" | head -1
ssh <office1-headend-host-or-VM> 'tailscale status'
virsh -c "<libvirt_uri>" list --all
```
Cross-check each against the GATE section below before declaring this stage
done or partially done.

---

## GATE (Stage 2 exit condition -- honest partial state)

Per `docs/dc-dc-deployment-workflow.md` Stage 2's Gate row: "MAAS region
reachable; NetBox authoritative + populated (planes, per-DC v4, ULA/GUA
carve); GitBucket serving; OpenTofu reaches vcloud host libvirt; Tailscale
confirmed to Office1 only; Office1-local network created; Office1 OPNsense
edge module wired."

- **MAAS region reachable:** achievable this stage (Step 4).
- **OpenTofu reaches vcloud host libvirt:** already proven in Stage 1; Step 2
  here only re-verifies it still holds.
- **Office1-local network created:** ACHIEVED by this delivery (gap #12
  CLOSED) -- `module "office1_network"` is instantiated for real in root
  `main.tf`, no longer pending a per-run decision.
- **Office1 OPNsense edge module wired:** PARTIALLY achievable (Step 4b, gap
  #16 CLOSED for ownership/LAN-side). Full instantiation and a running edge
  VM remain BLOCKED on tooling gap register item #17 (no per-site
  ISP-uplink/WAN network exists yet, for any of the three sites) plus real
  `memory_mib`/`vcpu`/`disk_size_bytes`/`base_volume_path`/`config_iso_path`
  values -- do not mark this bullet fully green until gap #17 closes and
  `tofu apply` has actually run for `module.office1_opnsense`.
- **GitBucket serving:** achievable this stage (Step 7).
- **Tailscale confirmed to Office1 only:** achievable this stage (Step 8),
  contingent on the operator's own ACL policy design (Open question #5).
- **NetBox authoritative and populated:** ONLY PARTIALLY achievable right now.
  The instance can be stood up and reachable (Step 5), and the import
  MECHANISM exists and is runnable (Step 6) -- but the real address literals
  (org ULA /48, per-DC GUA carve, DC2 v4 supernet) are not assigned this
  session, so NetBox cannot yet be truly "populated" per D-101. Do not mark
  this bullet green in `docs/dc-dc-deployment-workflow.md`'s Stage 2 row until
  those literals exist and `netbox/dc-dc-prefixes-import.py` has actually run
  against this real instance.

This runbook's honest exit state is therefore: **Stage 2 PARTIALLY complete**
-- MAAS/GitBucket/Tailscale/OpenTofu-reach/Office1-network bullets closeable
in full tonight's sequence; the Office1-OPNsense-edge bullet closes its
ownership/LAN-side half only (gap #16), pending gap #17's cross-site
resolution; the NetBox 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.

-> Proceed to Stage 3 (OpenTofu builds each DC substrate) only once the
achievable bullets above are actually green; the NetBox DATA gap does not
block Stage 3's OWN gate (Stage 3 depends on MAAS reachability and OpenTofu
reach, not on NetBox's DC2/GUA literals), but should be tracked as a standing
gap until closed.

---

## 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 (e.g. any real `opentofu/`
      instantiation once Option A becomes available; any real tfvars, minus
      secrets).
- [ ] If any Option A `cloudinit-vm` instantiation was written this run,
      `bash scripts/opentofu-validate.sh` green.
- [ ] Changelog entry for this runbook's first real execution (next DOCFIX
      number via `bash scripts/ledger-scan.sh`), noting: which provisioning
      path (A or B) was used for each of the three VMs, the real addresses/
      hostnames assigned, and whether NetBox's literal-assignment gap was
      closed or remains open.
- [ ] `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 (likely "PARTIAL," not "DONE," per
      the GATE section above) -- do not mark NetBox's gate bullet closed
      unless the D-101 literals were genuinely assigned and imported this run.
- [ ] If Option B (manual VM creation) was used for any service VM, log the
      resulting debt explicitly (a DOCFIX candidate: migrate that VM to
      `opentofu/modules/cloudinit-vm` once its cloud-init content is
      designed) rather than letting it go unrecorded.
- [x] Open question #1 (Office1-local network) RESOLVED 2026-07-09 --
      `opentofu/modules/office1-network` built and instantiated for real in
      root `main.tf` (gap #12 CLOSED). See
      `docs/changelog-20260709-office1-network-edge.md`.
- [x] Open question #2 (Office1 OPNsense edge ownership) RESOLVED 2026-07-09
      -- Stage 2 owns it (this runbook's new Step 4b); gap #16 CLOSED. Module
      skeleton wired (commented) in root `main.tf`; full instantiation
      remains blocked on gap #17 (per-site ISP-uplink/WAN network, not yet
      resolved for any of the three sites) plus real VM specs -- do not close
      Step 4b's own GATE bullet until that instantiation actually runs.
