Status: Second execution document of Batch A. Generates local-only crypto material (no cloud touched). Runs after v1-do-doc-01-prep.md (state check). Runs before runbooks/01-destroy-model.md re-verification (Batch B doc 03 will point to that runbook).
Replaces: runbooks/deprecated/01a-octavia-pki-generation.md (which had a buggy VIP-count grep pattern, an obsolete §12 bundle-housekeeping block, a self-reference off-by-one, and exit 1 blocks in operator-facing context that violate the no-exit-in-pasted-shell rule).
Cross-references:
octavia.options PKI material sectionoverlays/octavia-pki.yaml (gitignored — output of this document)Generate a complete two-tier PKI for Charmed Octavia's amphora load-balancer trust domain. The output is a single overlay file (overlays/octavia-pki.yaml) that the v1-do-doc-04 deploy step will pass to juju deploy --overlay.
Octavia uses two CAs:
Plus one controller certificate (cert + key bundled) signed by the Controller CA.
Five charm options on the octavia application consume the artifacts:
| Charm option | Content | Format |
|---|---|---|
lb-mgmt-issuing-cacert |
Issuing CA certificate | base64-encoded PEM |
lb-mgmt-issuing-ca-private-key |
Issuing CA encrypted private key | base64-encoded PEM (already encrypted with passphrase) |
lb-mgmt-issuing-ca-key-passphrase |
Issuing CA key passphrase | plain string (NOT base64) |
lb-mgmt-controller-cacert |
Controller CA certificate | base64-encoded PEM |
lb-mgmt-controller-cert |
Controller cert + key, concatenated | base64-encoded PEM bundle |
Scope: v1 testcloud (VR0 DC0 Omega Cloud). Roosevelt deltas in §14.
Out of scope:
octavia:certificates relation in the bundle; separate concern)Per workstream 3a sign-off (2026-05-22):
| Decision | Choice | Roosevelt parallel |
|---|---|---|
| Cert provenance | Generate fresh (no Bobcat-backup copy) | Vault PKI engine |
| CA key algorithm | EC P-384 | EC P-384 (Vault root) |
| Controller cert algorithm | EC P-256 | EC P-256 |
| CA validity | 10 years | 5-year intermediate, Vault-rotated |
| Controller cert validity | 2 years | 90 days, auto-rotated |
| Distribution method | Juju overlay file (gitignored) | Vault-injected at deploy |
| Storage path on jumphost | $HOME/octavia-pki/ |
Vault PKI mounts |
| Passphrase strength | 32 random bytes, base64-encoded (44 chars) | Vault-generated |
Naming convention:
VR0 DC0 Omega Cloud Octavia Issuing CAVR0 DC0 Omega Cloud Octavia Controller CAoctavia-controller.omega.dc0.vr0.cloud.neumatrix.localoctavia.omega.dc0.vr0.cloud.neumatrix.local, plus 10.12.4.233 (the Octavia API VIP)Neumatrix| Prereq | Verification |
|---|---|
v1-do-doc-01-prep.md completed cleanly |
Manual confirmation; all §5 acceptance items checked |
Executor on jumphost vopenstack-jesse as jessea123 |
hostname && id -un |
openssl version 3.x or later |
openssl version |
$HOME writable |
test -w "$HOME" && echo OK |
Repository cloned at $HOME/openstack-caracal-ipv4 |
Verified in v1-do-doc-01 §4.2 |
Repository on main, clean, up to date |
Verified in v1-do-doc-01 §4.2 |
| Pre-deploy fixes commits all landed | Verified in v1-do-doc-01 §4.3 |
Shell context — paste once at start:
export REPO="$HOME/openstack-caracal-ipv4" echo "REPO=$REPO" test -d "$REPO/.git" && echo "[OK] repo present" || echo "[FAIL] repo missing" cd "$REPO"
Verify pre-deploy fixes are present (smoketest against the corrected grep pattern):
echo "VIP grep (corrected pattern, expect 12):" grep -cE "^[[:space:]]+vip: 10\.12\.4\." "$REPO/bundle.yaml"
If this returns anything other than 12, the bundle pre-deploy fix did not land — stop and reconcile before proceeding.
Note on the grep pattern: the deprecated runbook 01a used
^ vip: 10.12.4.with a single literal space, which matches zero lines because the bundle'svip:entries live insideoptions:blocks indented six spaces deep. The corrected pattern^[[:space:]]+vip: 10\.12\.4\.matches any leading whitespace and escapes the dot literals.
Critical: the .gitignore patch must be in main BEFORE any private key material exists on disk in the workspace. This minimizes the race window for an accidental commit.
The current .gitignore already catches *.key, *.crt, *.pem via wildcards, but does NOT catch overlays/octavia-pki.yaml (a .yaml file) or passphrase.txt (a .txt file). This step adds the missing patterns.
cd "$REPO" # Idempotent patch — only add the block if the overlay path is not already protected if ! grep -q "^overlays/octavia-pki.yaml" .gitignore; then cat >> .gitignore <<'EOF' # Octavia PKI artifacts — never commit overlays/octavia-pki.yaml octavia-pki/ passphrase.txt EOF echo "[OK] .gitignore patched" else echo "[OK] .gitignore already has overlay protection (no change)" fi # Review the diff (will be empty if already patched) git diff .gitignore
If the diff shows the new block, commit and push it before generating any keys:
git add .gitignore git commit -m "gitignore: octavia PKI artifacts and overlay (v1-do-doc-02 §4)" git push origin main
Verify the gitignore is effective (this is a safety smoketest — touch a fake overlay file and ensure git ignores it):
touch overlays/octavia-pki.yaml STATUS=$(git status --short overlays/octavia-pki.yaml) rm overlays/octavia-pki.yaml if [ -z "$STATUS" ]; then echo "[OK] gitignore working — overlay file does not show as untracked" else echo "[FAIL] gitignore not effective — git sees:" echo " $STATUS" echo "Stop here. Fix .gitignore syntax before generating any secrets." fi
If [FAIL], do not proceed. The error means a generated PKI overlay could be accidentally committed.
WORKDIR="$HOME/octavia-pki"
mkdir -p "$WORKDIR"/{issuing-ca,controller-ca,controller,overlay-build}
chmod 700 "$WORKDIR"
cd "$WORKDIR"
echo "Working in: $WORKDIR"
ls -la "$WORKDIR"
Resulting layout:
$HOME/octavia-pki/ ├── issuing-ca/ # passphrase.txt, .key.enc, .cert.pem ├── controller-ca/ # passphrase.txt, .key.enc, .cert.pem ├── controller/ # .key, .csr, .cert.pem, .bundle.pem, .cnf └── overlay-build/ # base64 intermediates → consumed by §10
EC P-384 key encrypted with random 32-byte passphrase. Self-signed cert, 10-year validity.
cd "$WORKDIR/issuing-ca" # Generate passphrase (no trailing newline — required for clean YAML embedding) openssl rand -base64 32 | tr -d '\n' > passphrase.txt chmod 600 passphrase.txt # Sanity-check length (no exit; operator-decided) PASS_LEN=$(wc -c < passphrase.txt) if [ "$PASS_LEN" -ne 44 ]; then echo "[FAIL] passphrase length is $PASS_LEN bytes, expected 44 — investigate before continuing" else echo "[OK] passphrase length: $PASS_LEN bytes" fi
On the length check:
openssl rand -base64 32produces 32 random bytes encoded as base64. Base64 encoding of 32 bytes is 44 characters (including two=padding chars).tr -d '\n'strips the trailing newline that openssl adds. The resulting file is exactly 44 bytes — verified bywc -c.
If the length is wrong, stop and re-run the openssl rand line before proceeding. Do NOT continue with a wrong-length passphrase — it will silently break the overlay parsing later.
# Generate EC P-384 private key, encrypted with passphrase 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 # Self-sign cert (10 years, SHA-384) 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 # Verify (no exit; visible output for operator) echo "=== Issuing CA verification ===" openssl x509 -in issuing-ca.cert.pem -noout -dates -subject openssl verify -CAfile issuing-ca.cert.pem issuing-ca.cert.pem # Expect: issuing-ca.cert.pem: OK ls -la
Identical pattern; different CN.
cd "$WORKDIR/controller-ca" openssl rand -base64 32 | tr -d '\n' > passphrase.txt chmod 600 passphrase.txt PASS_LEN=$(wc -c < passphrase.txt) if [ "$PASS_LEN" -ne 44 ]; then echo "[FAIL] passphrase length is $PASS_LEN bytes, expected 44 — investigate before continuing" else echo "[OK] passphrase length: $PASS_LEN bytes" fi 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 echo "=== Controller CA verification ===" openssl x509 -in controller-ca.cert.pem -noout -dates -subject openssl verify -CAfile controller-ca.cert.pem controller-ca.cert.pem # Expect: controller-ca.cert.pem: OK ls -la
Why Controller CA's key is encrypted even though Octavia never uses it: the Controller CA key is needed for future rotations of the controller cert. Encrypting it (with its own passphrase, separate from Issuing CA's) is defense in depth — if the jumphost is compromised, the key still requires the passphrase to be useful for forging controller certs.
EC P-256 key (no encryption — Octavia must read it at startup), CSR with SAN extensions, signed by Controller CA, 2-year validity.
cd "$WORKDIR/controller" # Generate unencrypted EC P-256 key openssl genpkey -algorithm EC \ -pkeyopt ec_paramgen_curve:P-256 \ -out controller.key chmod 600 controller.key # CSR config with SAN extensions cat > controller.cnf <<'EOF' [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 IP.1 = 10.12.4.233 EOF # Generate CSR openssl req -new -sha256 \ -key controller.key \ -config controller.cnf \ -out controller.csr # Sign with Controller CA (2 years) openssl x509 -req -sha256 \ -in controller.csr \ -CA "$WORKDIR/controller-ca/controller-ca.cert.pem" \ -CAkey "$WORKDIR/controller-ca/controller-ca.key.enc" \ -passin file:"$WORKDIR/controller-ca/passphrase.txt" \ -CAcreateserial \ -days 730 \ -extfile controller.cnf \ -extensions v3_req \ -out controller.cert.pem # Bundle cert + key (the lb-mgmt-controller-cert option expects both in one PEM) cat controller.cert.pem controller.key > controller.bundle.pem chmod 600 controller.bundle.pem
Verify the chain and SAN:
echo "=== Chain verification ===" openssl verify -CAfile "$WORKDIR/controller-ca/controller-ca.cert.pem" controller.cert.pem # Expect: controller.cert.pem: OK echo "" echo "=== SAN extensions ===" openssl x509 -in controller.cert.pem -noout -ext subjectAltName # Expect: # DNS:octavia-controller.omega.dc0.vr0.cloud.neumatrix.local, # DNS:octavia.omega.dc0.vr0.cloud.neumatrix.local, # IP Address:10.12.4.233 echo "" echo "=== Validity ===" openssl x509 -in controller.cert.pem -noout -dates # Expect: notAfter ~2 years from today echo "" echo "=== Bundle integrity (cert and key match) ===" # Use $HOME paths (not /tmp) per snap-confinement convention used elsewhere in repo openssl x509 -in controller.bundle.pem -noout -pubkey > "$WORKDIR/controller/.cert.pub" openssl pkey -in controller.bundle.pem -pubout > "$WORKDIR/controller/.key.pub" if diff -q "$WORKDIR/controller/.cert.pub" "$WORKDIR/controller/.key.pub" >/dev/null; then echo "[OK] bundle cert/key match" else echo "[FAIL] bundle cert/key DO NOT match — investigate before continuing" fi rm -f "$WORKDIR/controller/.cert.pub" "$WORKDIR/controller/.key.pub"
A standalone block to confirm the full chain is sound before consuming for Octavia. All three "verify" lines must show : OK. If any do not, stop and investigate before proceeding.
cd "$WORKDIR" echo "=== Issuing CA ===" openssl x509 -in issuing-ca/issuing-ca.cert.pem -noout -subject -dates openssl verify -CAfile issuing-ca/issuing-ca.cert.pem issuing-ca/issuing-ca.cert.pem echo "" echo "=== Controller CA ===" openssl x509 -in controller-ca/controller-ca.cert.pem -noout -subject -dates openssl verify -CAfile controller-ca/controller-ca.cert.pem controller-ca/controller-ca.cert.pem echo "" echo "=== Controller cert ===" openssl x509 -in controller/controller.cert.pem -noout -subject -dates openssl verify -CAfile controller-ca/controller-ca.cert.pem controller/controller.cert.pem
Operator-visible check: the three verify lines must all end with : OK.
Each base64 file is a single line (no wrapping); each becomes one YAML value.
cd "$WORKDIR/overlay-build" # Issuing CA cert (base64) base64 -w0 "$WORKDIR/issuing-ca/issuing-ca.cert.pem" > issuing-cacert.b64 # Issuing CA private key (already encrypted PEM → base64) base64 -w0 "$WORKDIR/issuing-ca/issuing-ca.key.enc" > issuing-ca-private-key.b64 # Controller CA cert base64 -w0 "$WORKDIR/controller-ca/controller-ca.cert.pem" > controller-cacert.b64 # Controller cert + key bundle base64 -w0 "$WORKDIR/controller/controller.bundle.pem" > controller-cert.b64 # Sanity-check sizes (expect 500-2000 chars each) wc -c *.b64
# Read each artifact into shell variables
ISSUING_CACERT=$(cat "$WORKDIR/overlay-build/issuing-cacert.b64")
ISSUING_CA_KEY=$(cat "$WORKDIR/overlay-build/issuing-ca-private-key.b64")
ISSUING_CA_PASS=$(cat "$WORKDIR/issuing-ca/passphrase.txt")
CONTROLLER_CACERT=$(cat "$WORKDIR/overlay-build/controller-cacert.b64")
CONTROLLER_CERT=$(cat "$WORKDIR/overlay-build/controller-cert.b64")
# Assemble overlay (passphrase is YAML-quoted; cert blobs are not — they're
# guaranteed-safe base64 without special chars)
mkdir -p "$REPO/overlays"
cat > "$REPO/overlays/octavia-pki.yaml" <<EOF
# Octavia LBaaS PKI overlay — SENSITIVE — NEVER COMMIT
# Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ) UTC
# Source: docs/v1-do-doc-02-pki.md
# Issuing CA, Controller CA, Controller cert all generated fresh per workstream 3a.
#
# This file is gitignored. If you see it staged or committed, .gitignore is broken.
applications:
octavia:
options:
lb-mgmt-issuing-cacert: ${ISSUING_CACERT}
lb-mgmt-issuing-ca-private-key: ${ISSUING_CA_KEY}
lb-mgmt-issuing-ca-key-passphrase: "${ISSUING_CA_PASS}"
lb-mgmt-controller-cacert: ${CONTROLLER_CACERT}
lb-mgmt-controller-cert: ${CONTROLLER_CERT}
EOF
chmod 600 "$REPO/overlays/octavia-pki.yaml"
# Unset the shell variables (they held key material)
unset ISSUING_CACERT ISSUING_CA_KEY ISSUING_CA_PASS CONTROLLER_CACERT CONTROLLER_CERT
Validate the overlay parses as YAML:
python3 - <<PY
import yaml
with open("$REPO/overlays/octavia-pki.yaml") as f:
d = yaml.safe_load(f)
o = d["applications"]["octavia"]["options"]
print("Keys present:", sorted(o.keys()))
print("All values non-empty:", all(v for v in o.values()))
PY
Expected: 5 keys listed; All values non-empty: True.
Confirm gitignore is doing its job:
cd "$REPO" git status --short echo "" echo "If overlays/octavia-pki.yaml appears above as ?? (untracked), STOP." echo "Shred the file with: shred -uvz overlays/octavia-pki.yaml" echo "Fix .gitignore and regenerate (§4 + §6-11)."
The overlay file must NOT show up in git status --short. If it does, the gitignore patch in §4 did not stick.
The Issuing CA private key plus its passphrase are the crown jewels of the LB trust domain. Loss → cannot sign new amphora certs (LBs gradually break). Exposure → attacker can forge amphora identities and intercept tenant LB traffic.
Minimum backup for testcloud:
cd "$HOME"
BACKUP_NAME="octavia-pki-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$BACKUP_NAME" -C "$HOME" octavia-pki/
# Encrypt with strong symmetric cipher (will prompt for passphrase interactively)
gpg --symmetric --cipher-algo AES256 --output "${BACKUP_NAME}.gpg" "$BACKUP_NAME"
# Shred the unencrypted tar (whether gpg succeeded or failed — gpg output is the asset of record)
if [ -f "${BACKUP_NAME}.gpg" ]; then
shred -uvz "$BACKUP_NAME"
ls -la "${BACKUP_NAME}.gpg"
echo "[OK] backup created and unencrypted tar shredded"
else
echo "[FAIL] gpg encryption did not produce ${BACKUP_NAME}.gpg"
echo " Unencrypted tar still present at: $BACKUP_NAME"
echo " Investigate gpg failure before continuing."
fi
Move ${BACKUP_NAME}.gpg off-host to your chosen secrets store (admin workstation encrypted drive, password-manager attachment, dedicated secrets vault). Do not leave it on the jumphost long-term — single point of compromise.
Roosevelt note: Vault PKI engine stores all of this; no manual backup required. This procedure is testcloud-only.
After successful deploy and post-deploy verification (§14), shred files that are not needed for future rotation:
# Optional: shred the base64 intermediates (regeneratable from PEM sources)
shred -uvz "$WORKDIR/overlay-build/"*.b64
rmdir "$WORKDIR/overlay-build"
# Optional: shred the CSR (regeneratable if needed)
shred -uvz "$WORKDIR/controller/controller.csr"
# DO NOT shred any of the following — they are needed for future operations:
# - issuing-ca/{issuing-ca.cert.pem, issuing-ca.key.enc, passphrase.txt}
# - controller-ca/{controller-ca.cert.pem, controller-ca.key.enc, passphrase.txt}
# - controller/{controller.key, controller.cert.pem, controller.bundle.pem, controller.cnf}
#
# Specifically:
# - Issuing CA artifacts: required for signing new amphoras (Octavia uses them at runtime)
# - Controller CA artifacts: required for signing new controller certs (rotation)
# - Controller cert/key: required to repopulate the overlay if jumphost is rebuilt
This step runs AFTER §14 verification has confirmed the overlay was consumed correctly.
After v1-do-doc-04-deploy.md completes (juju deploy with the overlay), verify Octavia is healthy and the PKI plumbing works. This section is referenced from §13 above as the verification gate.
# Octavia charm active/idle juju status octavia # Expect: octavia/0 active idle # Octavia services running juju ssh octavia/0 -- sudo systemctl is-active octavia-api octavia-worker octavia-housekeeping # Expect: 3x "active" # Confirm PKI files landed on the unit juju ssh octavia/0 -- sudo ls -la /etc/octavia/certs/ # Expect: server_ca.cert.pem, server_ca.key.pem, client_ca.cert.pem, client.cert-and-key.pem # (filenames are charm-controlled; presence is what matters) # Confirm Octavia can use them — verbose health-check from the API juju ssh octavia/0 -- sudo journalctl -u octavia-api --since "5 minutes ago" \ | grep -iE "(cert|ssl|tls|amphora)" | head -20 # Expect: no errors related to cert loading
Smoketest — create a test LB once amphora image is available:
# After octavia-diskimage-retrofit has populated Glance with the amphora image, # and the LBaaS Mgmt network is wired (these are downstream deploy steps), # a test LB creation exercises the full PKI chain: source "$HOME/admin-openrc" openstack loadbalancer create --name pki-smoketest --vip-subnet-id <provider-subnet> # Watch for amphora spawn (3-5 minutes typical) watch -n5 'openstack loadbalancer show pki-smoketest' # Wait for: provisioning_status=ACTIVE, operating_status=ONLINE # Octavia-worker log should show successful amphora handshake (signed by Issuing CA, # trusted via Controller CA): juju ssh octavia/0 -- sudo journalctl -u octavia-worker --since "10 minutes ago" \ | grep -iE "(amphora|cert)" | tail -20 # Expect: "amphora <UUID> connection established" or similar # Expect: no TLS handshake errors, no cert validation errors # Cleanup the smoketest LB openstack loadbalancer delete pki-smoketest --cascade
If amphora handshake fails with cert errors, the most likely causes are:
lb-mgmt-controller-cert bundle should contain BOTH the cert and the matching private key; if they're for different keys, handshake fails. (Verified in §8 with the pubkey diff.)When this procedure is adapted for Roosevelt bare-metal deploy:
| Aspect | Testcloud (v1) | Roosevelt |
|---|---|---|
| Issuing CA root | Self-signed | Intermediate signed by Vault root CA |
| CA storage | Filesystem on jumphost | Vault PKI engine, encrypted at rest |
| Controller cert validity | 2 years | 90 days |
| Rotation | Manual (this document re-run) | Automated via Vault + cron + bundle redeploy |
| Backup | gpg tarball, off-host | Vault's own backup mechanism |
| Amphora image signing | Out of scope for v1 | Image signed by Vault PKI as well |
| Procedure file | runbooks/v1-do-doc-02-pki.md |
New runbook in Roosevelt repo |
The procedure structure (generate Issuing CA → Controller CA → Controller cert → encode → overlay → backup → deploy) remains identical. Roosevelt just sources the CA root from Vault instead of self-signing.
For testcloud, the 2-year controller cert and 10-year CAs are intentionally "set and forget" — they will outlive the cloud at this scale.
If rotation IS needed before testcloud teardown (e.g., a key leak event), the re-run procedure is:
juju config octavia lb-mgmt-controller-cert=<new-base64> (single-option update; does not require full bundle redeploy).juju ssh octavia/0 -- sudo systemctl restart octavia-api octavia-worker octavia-housekeeping.For Roosevelt, this whole procedure is replaced by Vault automated rotation.
Before proceeding to Batch B (v1-do-doc-03-destroy.md):
verify lines show : OK$REPO/overlays/octavia-pki.yaml; parses as YAML; 5 non-empty option valuesgit status --short does NOT show the overlay fileIf all checked, the overlay is ready for v1-do-doc-04-deploy.md (Batch B). The overlay is consumed by the deploy command:
juju deploy ./bundle.yaml --overlay overlays/octavia-pki.yaml --trust
(Note: only one overlay reference. The deprecated overlays/vr0-dc0-testcloud.yaml placeholder is not used; the bundle has its testcloud values inline.)
| Date | Change | Reference |
|---|---|---|
| 2026-05-27 | Document created from runbooks/deprecated/01a-octavia-pki-generation.md with the following fixes: $REPO path corrected to $HOME/openstack-caracal-ipv4; §3 VIP-count grep corrected to ^[[:space:]]+vip: pattern; old §12 (bundle housekeeping — already done) removed; §13/§14 self-reference fixed; operator-facing exit 1 blocks replaced with non-exiting [FAIL] reports; intermediate diff files moved out of /tmp and into $WORKDIR. |
Batch A drafting |