# Phase 01 -- Bundle Deploy

Deploy the hardened bundle + the octavia-pki overlay onto the freshly-prepped MAAS
machines, and verify it settles to the expected PRE-vault-init state (zero errors,
vault awaiting init, the TLS consumers awaiting vault certs). Vault init is phase-02.

Decisions: B5 (IP-only), D-019 (no designate), D-020 (dual provider+metal VIPs),
R14 (VIPs front-loaded to .50-.60), Section-G NIC bindings. Troubleshooting:
appendix-A -- R14 (VIP relocation), R15 (.10 phantom resolver), L1 (no `set -e` on
count-gate blocks), L3 (metal-side dual-VIP eyeball check), DOCFIX-016 (maas list leak).

---

## Prerequisites (must be true entering phase-01)
- phase-00 done: 4 machines Ready/power=off; MAAS carve applied (front-loaded VIP /26
  reserved, FIP pool reserved, stale iprange gone); enp8s0 data NIC linked on ALL four
  hosts; OSD `/dev/vdb` wiped blank.
- `overlays/octavia-pki.yaml` present (Step 1.0).
- Hardened `bundle.yaml` in the working dir (channels pinned; VIPs `.50-.60`;
  reserved-host-memory 8192; image-conversion; use-policyd-override).

**GATE (DOCFIX-073) -- run THE pre-deploy gate before add-model:**
```bash
bash scripts/preflight.sh
```
Expect `PREFLIGHT: PASS` (repo lint + bundle invariants + Charmhub channel
assert + live MAAS pre-flight, aggregated). WARN = review then decide; FAIL =
do not deploy. This is the single entry point that replaced the six separate
gate artifacts an operator previously had to remember.

## Constants and env-literals
- Host identity / system_ids: resolve LIVE via `scripts/lib-hosts.sh` (DOCFIX-040:
  system_ids are re-minted on every reenrollment -- a documented id is stale by
  definition; never paste one into a command).
- Planes / CIDRs / VIP bands: `scripts/lib-net.sh` is the single authority
  (D-052/D-053). Resolve MAAS subnets BY CIDR (PATTERN-1), never by numeric
  subnet id -- ids drift across MAAS lifecycles. (DOCFIX-068 removed a stale
  pre-D-052 literal map that sat here.)
- expected plan: 50 apps, 97 relations, 4 machines (bundle 8/9/10/11 -> juju 0/1/2/3), 24 LXD.

## Run-location legend
- `# RUN: jumphost` -- `juju` + `maas admin` (MAAS profile is `admin`; never `maas list` -- DOCFIX-016).

---

## Command-label convention
Every command block below is bracketed by bold labels, so a command line is never mistaken
for surrounding prose (these render in GitBucket and read clearly in a raw editor):
- **RUN -- LOC** -- the block CHANGES state; run it at LOC (e.g. `jumphost`, `vault/0`, `jumphost -> magnum/0`).
- **CHECK (read-only) -- LOC** -- a read-only verification; safe to re-run.
- **GATE:** -- a hard stop; do NOT proceed past the block unless the stated condition holds.
- **Expect:** -- what a passing result looks like.
- `> CAUTION:` -- marks a destructive, secret-handling, or irreversible step.


## Step 1.0 -- Octavia PKI overlay (secret-handling prereq)  DISCRETE
`overlays/octavia-pki.yaml` carries the 5 lb-mgmt-* PKI keys (controller CA/cert,
issuing CA key+passphrase+cert). It is the ONLY overlay in the deploy command and is
secret-safe + ASCII. PRIMARY path: reuse the existing validated overlay (the CAs are
10y, so it survives rebuilds). REGENERATION path (fresh CAs): run the discrete secret
procedure inlined as "Step 1.0-GEN" at the end of this phase. Either way, confirm the
overlay parses and contains exactly the 5 keys (sanity block below) before deploying.

**CHECK (read-only) -- jumphost**
```bash
# RUN: jumphost -- sanity only (does NOT print key material)
[ -f overlays/octavia-pki.yaml ] && grep -cE 'lb-mgmt-' overlays/octavia-pki.yaml   # expect 5 keys
LC_ALL=C grep -nP '[^\x00-\x7F]' overlays/octavia-pki.yaml && echo "NON-ASCII" || echo "ASCII clean"
```

## Step 1.1 -- Pre-deploy verify (read-only; 4 checks)
One consolidated read-only block. NO `set -e` (a guarded count of
0 is a valid answer, not a failure -- appendix-A: L1); count greps are `|| true`.

**CHECK (read-only) -- jumphost**
```bash
( {
  echo "=== CHECK 1: bundle VIPs (quote-tolerant, octet-anchored) ==="
  grep -nE '^[[:space:]]+vip:' bundle.yaml
  TOT=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.' bundle.yaml || true)
  HI=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.(5[0-9]|60)("|$|[[:space:]])' bundle.yaml || true)
  LO=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.(1[0-9]|20)("|$|[[:space:]])' bundle.yaml || true)
  echo "  provider VIPs total=$TOT  in .50-.60=$HI  in .10-.20(stale)=$LO   (want 11/11/0)"
  # metal side is the second token of each dual vip; eyeball that all 11 are .8.50-.60,
  # clear of metal infra .8.10(maas)/.8.20(lxd)/.8.21(capi)/.8.30(juju) -- appendix-A: L3.

  echo "=== CHECK 2: enp8s0 data NIC linked on ALL FOUR hosts (10.12.12.0/22) ==="
  for SID in 4na83t qdbqd6 h8frng tmsafc; do
    echo -n "  $SID: "
    maas admin interfaces read "$SID" | jq -r '.[] | select(.name=="enp8s0")
      | [.links[]? | select(.subnet.cidr=="10.12.12.0/22") | .ip_address] | join(",")'
  done   # expect 10.12.12.40 / .41 / .42 / .43 (select by .subnet.cidr -> robust to id drift)

  echo "=== CHECK 3: subnet DNS resolvers ==="
  for ID in 1 2 6 7 8 9; do maas admin subnet read "$ID" | jq -c '{id,cidr,dns_servers}'; done
  # expect subnet 1 (provider) -> [10.12.4.1]; 2/6/7/8/9 -> [10.12.8.1]

  echo "=== CHECK 4a: nodes Ready / power off ==="
  maas admin machines read | jq -r '.[] | select(.system_id|IN("4na83t","qdbqd6","h8frng","tmsafc"))
    | "\(.hostname) \(.status_name) power=\(.power_state)"'
} )
```

**CHECK (read-only) -- jumphost**
```bash
# CHECK 4b: OSD /dev/vdb blank (DOCFIX-027 -- LOCAL libvirt-host loop, NOT ssh: the four
# hosts are Released/powered-off entering phase-01, and /var/lib/libvirt/images is a
# hypervisor (jumphost) path that does not exist on the hosts. RUN: jumphost (libvirt host; sudo).
for h in openstack0 openstack1 openstack2 openstack3; do
  echo "== $h =="
  sudo qemu-img info "/var/lib/libvirt/images/${h}-1.qcow2" | grep -E 'virtual size|disk size'
done   # expect virtual 512 GiB, disk ~200 KiB (sparse/blank)
```
**GATE:** VIPs 11/11/0; enp8s0 linked on all 4; subnet DNS as above; 4 nodes Ready; OSD blank.

## Step 1.2 -- Dry-run (guarded)
Refuse to add a model if `openstack` already exists; require the overlay.

**RUN -- jumphost**
```bash
( {
  juju models 2>&1 | tee /tmp/jmodels.txt
  if grep -qE '(^|[[:space:]]|/)openstack([[:space:]*]|$)' /tmp/jmodels.txt; then
    echo "ABORT: an 'openstack' model already exists (teardown is phase-00)"; 
  elif [ ! -f overlays/octavia-pki.yaml ]; then
    echo "ABORT: overlays/octavia-pki.yaml missing (Step 1.0)";
  else
    juju add-model openstack
    juju deploy ./bundle.yaml --overlay overlays/octavia-pki.yaml -m openstack --dry-run
  fi
} )
```
**GATE:** (from the plan): 50 apps, 97 relations, 4 machines (8/9/10/11 -> 0/1/2/3), 24 LXD;
ceph-osd/0-3 one per node; nova-compute/0-2 on machines 1/2/3 ONLY (machine 0 =
OSD+LXD host, no compute); channels match the matrix; relations include
`octavia:certificates - vault:certificates`, `vault:shared-db - vault-mysql-router`,
`mysql-innodb-cluster:certificates - vault:certificates`; NO `vault:ha`, NO designate
(D-019). Only the two benign R11 warnings (L34 `name`, L55 `variables`).

## Step 1.3 -- Deploy (VIP-guarded)
Re-run the VIP guard inline (the dry-run never echoes vip values),
then deploy only if 11/11/0.

**RUN -- jumphost**
```bash
( {
  TOT=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.' bundle.yaml || true)
  HI=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.(5[0-9]|60)("|$|[[:space:]])' bundle.yaml || true)
  LO=$(grep -cE '^[[:space:]]+vip:[[:space:]]*"?10\.12\.4\.(1[0-9]|20)("|$|[[:space:]])' bundle.yaml || true)
  if [ "$TOT" = 11 ] && [ "$HI" = 11 ] && [ "$LO" = 0 ]; then
    juju deploy ./bundle.yaml --overlay overlays/octavia-pki.yaml -m openstack
  else
    echo "ABORT: VIP guard failed (total=$TOT hi=$HI lo=$LO; want 11/11/0)"
  fi
} )
```

CONVERGENCE WATCH (ENHANCEMENT-1): keep two windows open for the whole deploy arc.
- Window 1 (detail): `juju status -m openstack --watch 5s` (always explicit -m; on a 50-app
  model the table exceeds one screen, so this is a slice -- optionally filter, e.g. `... 'mysql*' vault`).
- Window 2 (signal): `scripts/deploy-watch.sh openstack 15` -- compact machine/unit state counts
  + named error/blocked units. Health-at-a-glance: the error/blocked section stays EMPTY until
  the expected late blocks (vault needs-init, octavia awaiting-configure). Neither window
  descends into subordinates; neither replaces the phase gates.

## Step 1.4 -- DNS gate during deploy (as machines come up)
Run when machine 0 reaches `started`, then per LXD unit as they
appear (flag BEFORE the target; logic inside the remote quotes; no outer 2>/dev/null):

**CHECK (read-only) -- jumphost**
```bash
juju ssh -m openstack 0 -- 'resolvectl status | grep -i "DNS Server"; getent hosts api.snapcraft.io && echo OK || echo FAIL'
# repeat for ceph-mon/0, mysql-innodb-cluster/0 as they appear
```
**GATE:** each returns OK (api.snapcraft.io resolves -> the snap install storm proceeds
clean). FINDING (non-blocking, R15): the unreachable region resolver `10.12.8.10`
(MAAS region/rack controller, advertised on the metal VLAN independent of the subnet
field) may still appear in a node's resolver list -- resolution succeeds because
systemd-resolved deprioritizes `.10` and falls through to `.1`. Latent fragility if
`.1` ever drops; understand/eliminate for Roosevelt. (appendix-A: R15.)

---

## EXIT GATE (phase-01 complete)
- Deploy settled to the PRE-vault-init end state:
  * ZERO units in `error`.
  * mysql-innodb-cluster x3 ACTIVE ("Cluster is ONLINE").
  * vault/0 BLOCKED "Vault needs to be initialized" (the phase-02 trigger, not a fault).
  * Waiting on vault certs (expected pre-init): ovn-central x3, ovn-chassis x3
    (incl nova-compute subordinates), ovn-chassis-octavia, neutron-api-plugin-ovn, barbican-vault.
  * octavia BLOCKED "Awaiting configure-resources" (D-021); gss unknown (pre-run).
  * magnum/0 BLOCKED "Ports which should be open, but are not: 9501" -- pre-vault posture:
    magnum-api is loopback-bound ([api] host not yet templated) and haproxy backends target
    unit IPs. EXPECTED phase-01 end-state; self-resolves at the phase-02 cert rollout (apache2
    takes *:9501). Confirmed self-resolving 2026-06-12 (FINDING-2); verify in the phase-02 post-init sweep.
  * keystone/0 "PO (broken): Unit is ready" -- expected while use-policyd-override=true with
    no policy zip attached (FINDING-1); keystone runs the DEFAULT policy. No mutation this arc.
- Section-G NIC payoff confirmed (no subset/binding errors): ceph-mon -> storage 10.12.16.x;
  octavia -> data 10.12.12.1; nova-compute -> data 10.12.12.4x; vault -> metal 10.12.8.x.
- Proceed to phase-02 (vault init).

## As-built reference (2026-06-03 second redeploy -- audit trail)
- `juju deploy ./bundle.yaml --overlay overlays/octavia-pki.yaml -m openstack` on maas/default (cred maas-api).
- Plan: 50 apps / 97 relations / 4 machines / 24 LXD; placement as above.
- Pre-deploy verify: VIPs 11/11/0; enp8s0 -> 10.12.12.40-43 (all 4); subnet DNS as above; nodes Ready; OSD blank.
- Settled: zero errors; mysql /0 R/W (10.12.8.173), /1 (.179) /2 (.185) R/O; vault blocked needs-init.

## Balance / stability observations (Roosevelt rebalancing inputs -- post-deploy item 6)
- Quorum triads (mysql-innodb-cluster, ovn-central, ceph-mon) all on machines 0/1/2: correct
  anti-affinity; machine 3 loss breaks no quorum; any single loss of 0/1/2 leaves a 2-of-3 majority.
- Machine 0 = no-compute control host, largest container count: prefigures the Roosevelt role split.
- FLAG: machine 3 concentrates six singletons (vault, glance, nova-cloud-controller, octavia,
  placement, barbican) + compute + OSD. Acceptable on testcloud; Roosevelt answer is role split + HA,
  informed by measured footprints at the 32 GiB envelope (DEVIATION-2 caveat).
- rabbitmq-server single unit: messaging SPOF, as designed for v1.

## PATTERN-1 (standing convention) -- dynamic lookup vs. pinned identifiers
READ/VERIFY ops discover values at runtime (never hardcode what resolves: hostname->system_id via
`maas admin machines read | jq`; subnet id by CIDR). DESTRUCTIVE/IRREVERSIBLE ops discover
dynamically, ASSERT against a pinned EXPECTED set, ABORT on mismatch, then operate on the pinned
values (a filter bug or an unexpected new machine must not become collateral damage). Retrofit
candidates (apply as fixture-tested gated blocks, NOT bulk edits): phase-00 release/host loops ->
discover-assert-pin; subnet ids -> resolve by CIDR; octet maps -> derive from hostname index.
Canonical statement in runbooks/README.md.

## Next
phase-02 -- vault bring-up.

---

## Step 1.0-GEN -- Octavia management-PKI generation (regeneration path)  DISCRETE / SECRET

> CAUTION: SECRET step -- generates Octavia CA private keys + passphrases. Do NOT echo or log key
> material (only cert dates/subjects + verify-OK are printed). The overlay it writes is gitignored.

Run ONLY if you are not reusing an existing `overlays/octavia-pki.yaml`. Produces the
two-tier EC PKI for Charmed Octavia's amphora trust domain and writes the overlay.
Decisions (Workstream 3a, 2026-05-22): fresh generation; EC P-384 CAs (SHA-384, 10y);
EC P-256 controller cert (2y); overlay-file distribution (gitignored); artifacts under
`$HOME/octavia-pki/`; passphrases = 32 random bytes base64 (44 chars). SECRET step --
do NOT echo key material; the only printed values are cert dates/subjects and verify OK.

The five `octavia` charm options the overlay sets:
- `lb-mgmt-issuing-cacert`            = base64(issuing CA cert)
- `lb-mgmt-issuing-ca-private-key`    = base64(issuing CA ENCRYPTED key)
- `lb-mgmt-issuing-ca-key-passphrase` = the issuing CA passphrase (PLAIN string, NOT base64)
- `lb-mgmt-controller-cacert`         = base64(controller CA cert)
- `lb-mgmt-controller-cert`           = base64(controller cert + key, concatenated)

### 1.0-GEN.0 -- workspace (openssl 3.x; $HOME only -- snap home-confinement, never /tmp)

**RUN -- jumphost**
```bash
# RUN: jumphost
WORKDIR="$HOME/octavia-pki"
mkdir -p "$WORKDIR"/issuing-ca "$WORKDIR"/controller-ca "$WORKDIR"/controller
chmod 700 "$WORKDIR"
openssl version    # expect OpenSSL 3.x
```

### 1.0-GEN.a -- Issuing CA (EC P-384, AES-256 encrypted key, self-signed 10y)

**RUN -- jumphost**
```bash
( {
  WORKDIR="$HOME/octavia-pki"; cd "$WORKDIR/issuing-ca" || exit 1   # dir from 1.0-GEN.a
  openssl rand -base64 32 | tr -d '\n' > passphrase.txt
  chmod 600 passphrase.txt
  test "$(wc -c < passphrase.txt)" -eq 44 || { echo "ABORT: issuing passphrase length != 44"; exit 1; }
  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 \
    -aes-256-cbc -pass file:passphrase.txt -out issuing-ca.key.enc
  chmod 600 issuing-ca.key.enc
  openssl req -new -x509 -sha384 -key issuing-ca.key.enc -passin file:passphrase.txt \
    -days 3650 -subj "/CN=VR0 DC0 Omega Cloud Octavia Issuing CA/O=Neumatrix" \
    -out issuing-ca.cert.pem
  openssl x509 -in issuing-ca.cert.pem -noout -dates -subject
  openssl verify -CAfile issuing-ca.cert.pem issuing-ca.cert.pem    # expect: OK
} )
```

### 1.0-GEN.b -- Controller CA (EC P-384, AES-256 encrypted key, self-signed 10y; own passphrase)
The controller CA key is encrypted (its own passphrase) for future controller-cert
rotation -- Octavia never receives this key, only the controller CA cert.

**RUN -- jumphost**
```bash
( {
  WORKDIR="$HOME/octavia-pki"; cd "$WORKDIR/controller-ca" || exit 1   # dir from 1.0-GEN.a
  openssl rand -base64 32 | tr -d '\n' > passphrase.txt
  chmod 600 passphrase.txt
  test "$(wc -c < passphrase.txt)" -eq 44 || { echo "ABORT: controller passphrase length != 44"; exit 1; }
  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 \
    -aes-256-cbc -pass file:passphrase.txt -out controller-ca.key.enc
  chmod 600 controller-ca.key.enc
  openssl req -new -x509 -sha384 -key controller-ca.key.enc -passin file:passphrase.txt \
    -days 3650 -subj "/CN=VR0 DC0 Omega Cloud Octavia Controller CA/O=Neumatrix" \
    -out controller-ca.cert.pem
  openssl x509 -in controller-ca.cert.pem -noout -dates -subject
  openssl verify -CAfile controller-ca.cert.pem controller-ca.cert.pem    # expect: OK
} )
```

### 1.0-GEN.c -- Controller cert (EC P-256 UNENCRYPTED, SAN, signed by Controller CA, 2y)
The P-256 key is unencrypted -- Octavia reads it at startup. SAN carries the controller
FQDN, the octavia API FQDN, and the Octavia API VIP (derived from the bundle at generation time -- DOCFIX-067; never a baked literal).

**RUN -- jumphost**
```bash
( {
  WORKDIR="$HOME/octavia-pki"; cd "$WORKDIR/controller" || exit 1   # dir from 1.0-GEN.a
  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out controller.key
  chmod 600 controller.key
  cat > controller.cnf <<'CNF'
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no

[req_distinguished_name]
CN = octavia-controller.omega.dc0.vr0.cloud.neumatrix.local
O = Neumatrix

[v3_req]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = octavia-controller.omega.dc0.vr0.cloud.neumatrix.local
DNS.2 = octavia.omega.dc0.vr0.cloud.neumatrix.local
CNF
  # DOCFIX-067: the SAN IP is DERIVED from the bundle (provider leg of octavia's
  # vip triple), never a literal -- the old baked 10.12.4.233 went stale when R14
  # relocated the VIPs to .50-.60. Measured value is echoed; confirm it before
  # the sign. BUNDLE may be overridden if the hardened bundle lives elsewhere.
  REPO="${REPO:?REPO not set -- export REPO=<path to your clone> (see runbooks/README.md Conventions)}"
  BUNDLE="${BUNDLE:-$REPO/bundle.yaml}"
  [ -r "$BUNDLE" ] || { echo "FAIL: bundle not readable at $BUNDLE (set BUNDLE=)"; exit 1; }
  OCTAVIA_VIP=$(python3 -c "import yaml;print(yaml.safe_load(open('$BUNDLE'))['applications']['octavia']['options']['vip'].split()[0])" 2>/dev/null || true)
  echo "measured octavia provider VIP: ${OCTAVIA_VIP:-<none>}"
  echo "$OCTAVIA_VIP" | grep -qE '^10\.12\.4\.[0-9]{1,3}$' || { echo "FAIL: implausible VIP -- stop"; exit 1; }
  printf 'IP.1 = %s\n' "$OCTAVIA_VIP" >> controller.cnf
  openssl req -new -sha256 -key controller.key -config controller.cnf -out controller.csr
  openssl x509 -req -sha256 -in controller.csr \
    -CA ../controller-ca/controller-ca.cert.pem \
    -CAkey ../controller-ca/controller-ca.key.enc \
    -passin file:../controller-ca/passphrase.txt \
    -CAcreateserial -days 730 \
    -extfile controller.cnf -extensions v3_req \
    -out controller.cert.pem
  openssl verify -CAfile ../controller-ca/controller-ca.cert.pem controller.cert.pem  # expect: OK
  openssl x509 -in controller.cert.pem -noout -ext subjectAltName     # DNS x2 + IP present
  openssl x509 -in controller.cert.pem -noout -dates
  cat controller.cert.pem controller.key > controller.bundle.pem
  chmod 600 controller.bundle.pem
} )
```

### 1.0-GEN.d -- Write overlays/octavia-pki.yaml (base64 blobs + plaintext passphrase)
Four values are base64(PEM); the issuing-CA passphrase is a PLAIN string. The file is
gitignored. Set `$REPO` to the jumphost clone (the dir holding bundle.yaml + overlays/).

**RUN -- jumphost**
```bash
( {
  WORKDIR="$HOME/octavia-pki"; cd "$WORKDIR" || exit 1   # dir from 1.0-GEN.a
  REPO="${REPO:?REPO not set -- export REPO=<path to your clone> (see runbooks/README.md Conventions)}"
  mkdir -p "$REPO/overlays"
  ISS_CERT=$(base64 -w0 issuing-ca/issuing-ca.cert.pem)
  ISS_KEY=$(base64 -w0 issuing-ca/issuing-ca.key.enc)
  ISS_PASS=$(cat issuing-ca/passphrase.txt)
  CON_CACERT=$(base64 -w0 controller-ca/controller-ca.cert.pem)
  CON_CERT=$(base64 -w0 controller/controller.bundle.pem)
  cat > "$REPO/overlays/octavia-pki.yaml" <<OVL
applications:
  octavia:
    options:
      lb-mgmt-issuing-cacert: "$ISS_CERT"
      lb-mgmt-issuing-ca-private-key: "$ISS_KEY"
      lb-mgmt-issuing-ca-key-passphrase: "$ISS_PASS"
      lb-mgmt-controller-cacert: "$CON_CACERT"
      lb-mgmt-controller-cert: "$CON_CERT"
OVL
  chmod 600 "$REPO/overlays/octavia-pki.yaml"
  echo "wrote $REPO/overlays/octavia-pki.yaml"
} )
```
Then run the Step 1.0 sanity block (5 keys present; ASCII clean) before deploying. Keep
`$HOME/octavia-pki/` (the CA keys + passphrases) OFF the repo and backed up securely; the
10y CAs are reused across rebuilds -- regenerate only on key compromise or CA expiry.
