|
| 1 | +# PR 4 Stage 4 Runbook: Provision the Load Balancer |
| 2 | + |
| 3 | +> Per-stage record: gcloud equivalents of every console action, committed **before** |
| 4 | +> manual execution (the plan's infra rule). Current operational behavior: |
| 5 | +> [`../cloud-run-operations.md`](../cloud-run-operations.md). Source of truth for stage |
| 6 | +> gates: the Stage 4 section of `api-v1-pr4-cloud-run-host-cutover-execution-plan.md` |
| 7 | +> in the planning folder. |
| 8 | +
|
| 9 | +Builds the global external Application Load Balancer (`EXTERNAL_MANAGED`) that will |
| 10 | +eventually front `api.policyengine.org`, with two serverless NEG backends (App Engine, |
| 11 | +Cloud Run) and a weighted URL map pinned to **app_engine=100 / cloud_run=0**. This stage |
| 12 | +creates infrastructure only — **no DNS change to `api.policyengine.org`** (the only DNS |
| 13 | +edits are the two certificate-authorization CNAMEs; the `api-lb` validation hostname is |
| 14 | +Stage 5, the real cutover is Stage 8). |
| 15 | + |
| 16 | +Facts baked in from pre-checks (2026-07-08): |
| 17 | + |
| 18 | +- `api.policyengine.org` currently CNAMEs to `ghs.googlehosted.com`, which serves **both |
| 19 | + A and AAAA** — IPv6 clients exist today, so the LB gets dual (IPv4 + IPv6) frontends. |
| 20 | +- The App Engine app's `locationId` is `us-central` → serverless NEGs go in compute |
| 21 | + region **`us-central1`** (same region as the Cloud Run service). |
| 22 | +- Executor needs `roles/compute.loadBalancerAdmin` + `roles/certificatemanager.editor` |
| 23 | + (or owner) on `policyengine-api`; the CI deploy service account has neither and never |
| 24 | + will — this stage is manual by design. |
| 25 | + |
| 26 | +## 0. Shell variables (paste first) |
| 27 | + |
| 28 | +```bash |
| 29 | +export PROJECT=policyengine-api REGION=us-central1 |
| 30 | +export IP4=policyengine-api-lb-ip IP6=policyengine-api-lb-ipv6 |
| 31 | +export NEG_GAE=neg-app-engine NEG_CR=neg-cloud-run |
| 32 | +export BS_GAE=bs-app-engine BS_CR=bs-cloud-run |
| 33 | +export MAP=lb-api REDIRECT_MAP=lb-api-redirect |
| 34 | +export PROXY_HTTPS=proxy-api-https PROXY_HTTP=proxy-api-http |
| 35 | +export CERT=cert-api-lb CERT_MAP=map-api |
| 36 | +``` |
| 37 | + |
| 38 | +## 1. Static IPs |
| 39 | + |
| 40 | +```bash |
| 41 | +gcloud compute addresses create "$IP4" --project "$PROJECT" --global --ip-version=IPV4 |
| 42 | +gcloud compute addresses create "$IP6" --project "$PROJECT" --global --ip-version=IPV6 |
| 43 | +gcloud compute addresses list --project "$PROJECT" --global \ |
| 44 | + --format 'table(name, address)' # record BOTH in this file when executed |
| 45 | +``` |
| 46 | + |
| 47 | +Executed values: `IP4 = ______` / `IP6 = ______` (fill in at execution). |
| 48 | + |
| 49 | +## 2. Certificate via DNS authorization (both hostnames) |
| 50 | + |
| 51 | +```bash |
| 52 | +for D in api api-lb; do |
| 53 | + gcloud certificate-manager dns-authorizations create "auth-${D}" \ |
| 54 | + --project "$PROJECT" --domain "${D}.policyengine.org" |
| 55 | +done |
| 56 | +gcloud certificate-manager dns-authorizations list --project "$PROJECT" \ |
| 57 | + --format 'table(name, dnsResourceRecord.name, dnsResourceRecord.data)' |
| 58 | +``` |
| 59 | + |
| 60 | +Add the two CNAME records printed above **at Squarespace** (they look like |
| 61 | +`_acme-challenge.api.policyengine.org → xxxx.authorize.certificatemanager.goog`). |
| 62 | +These are additive records — they do not affect serving DNS. Then: |
| 63 | + |
| 64 | +```bash |
| 65 | +gcloud certificate-manager certificates create "$CERT" --project "$PROJECT" \ |
| 66 | + --domains="api.policyengine.org,api-lb.policyengine.org" \ |
| 67 | + --dns-authorizations="auth-api,auth-api-lb" |
| 68 | +gcloud certificate-manager maps create "$CERT_MAP" --project "$PROJECT" |
| 69 | +for D in api api-lb; do |
| 70 | + gcloud certificate-manager maps entries create "entry-${D}" --project "$PROJECT" \ |
| 71 | + --map "$CERT_MAP" --hostname "${D}.policyengine.org" --certificates "$CERT" |
| 72 | +done |
| 73 | +# GATE (may take minutes after the CNAMEs propagate): |
| 74 | +gcloud certificate-manager certificates describe "$CERT" --project "$PROJECT" \ |
| 75 | + --format 'value(managed.state, managed.authorizationAttemptInfo)' |
| 76 | +# expect state ACTIVE and both domains AUTHORIZED before proceeding to step 5. |
| 77 | +``` |
| 78 | + |
| 79 | +## 3. Serverless NEGs |
| 80 | + |
| 81 | +```bash |
| 82 | +gcloud compute network-endpoint-groups create "$NEG_GAE" --project "$PROJECT" \ |
| 83 | + --region "$REGION" --network-endpoint-type=serverless --app-engine-service=default |
| 84 | +gcloud compute network-endpoint-groups create "$NEG_CR" --project "$PROJECT" \ |
| 85 | + --region "$REGION" --network-endpoint-type=serverless --cloud-run-service=policyengine-api |
| 86 | +``` |
| 87 | + |
| 88 | +Pinning `--app-engine-service=default` is deliberate: the NEG routes straight to the |
| 89 | +service, **bypassing `gcp/dispatch.yaml`** — the dispatch file stays as the rollback |
| 90 | +path for the direct App Engine domain mapping, not part of the LB path. |
| 91 | + |
| 92 | +## 4. Backend services (timeout 600, full request logging) |
| 93 | + |
| 94 | +```bash |
| 95 | +for PAIR in "$BS_GAE:$NEG_GAE" "$BS_CR:$NEG_CR"; do |
| 96 | + BS="${PAIR%%:*}"; NEG="${PAIR##*:}" |
| 97 | + gcloud compute backend-services create "$BS" --project "$PROJECT" --global \ |
| 98 | + --load-balancing-scheme=EXTERNAL_MANAGED --protocol=HTTPS \ |
| 99 | + --timeout=600 --enable-logging --logging-sample-rate=1.0 |
| 100 | + gcloud compute backend-services add-backend "$BS" --project "$PROJECT" --global \ |
| 101 | + --network-endpoint-group="$NEG" --network-endpoint-group-region="$REGION" |
| 102 | +done |
| 103 | +``` |
| 104 | + |
| 105 | +`--timeout=600`: the default 30s would kill long `/calculate` requests; Cloud Run's own |
| 106 | +request cap is 300s and App Engine's is longer, so 600 makes the LB never the binding |
| 107 | +constraint. **Validated empirically in Stage 5's long-request proof** — if the timeout |
| 108 | +turns out not to be honored for serverless NEGs, that gate catches it before any DNS |
| 109 | +change. Reminder: serverless NEGs have **no health checks** — alerting (Stage 6) is the |
| 110 | +failover mechanism. |
| 111 | + |
| 112 | +## 5. URL map at 100/0, proxies, forwarding rules |
| 113 | + |
| 114 | +```bash |
| 115 | +gcloud compute url-maps create "$MAP" --project "$PROJECT" \ |
| 116 | + --default-service "$BS_GAE" |
| 117 | +gcloud compute url-maps export "$MAP" --project "$PROJECT" \ |
| 118 | + --destination /tmp/lb-api.yaml |
| 119 | +``` |
| 120 | + |
| 121 | +Edit `/tmp/lb-api.yaml`: replace the top-level `defaultService: ...` line with the |
| 122 | +weighted action (weights are the ramp lever for Stages 9–11): |
| 123 | + |
| 124 | +```yaml |
| 125 | +defaultRouteAction: |
| 126 | + weightedBackendServices: |
| 127 | + - backendService: https://www.googleapis.com/compute/v1/projects/policyengine-api/global/backendServices/bs-app-engine |
| 128 | + weight: 100 |
| 129 | + - backendService: https://www.googleapis.com/compute/v1/projects/policyengine-api/global/backendServices/bs-cloud-run |
| 130 | + weight: 0 |
| 131 | +``` |
| 132 | +
|
| 133 | +```bash |
| 134 | +gcloud compute url-maps import "$MAP" --project "$PROJECT" --source /tmp/lb-api.yaml --quiet |
| 135 | +``` |
| 136 | + |
| 137 | +Then the HTTPS side (certificate map attaches to the proxy — no `--ssl-certificates`): |
| 138 | + |
| 139 | +```bash |
| 140 | +gcloud compute target-https-proxies create "$PROXY_HTTPS" --project "$PROJECT" \ |
| 141 | + --url-map "$MAP" --certificate-map "$CERT_MAP" |
| 142 | +for PAIR in "fr-api-https:$IP4" "fr-api-https-v6:$IP6"; do |
| 143 | + FR="${PAIR%%:*}"; ADDR="${PAIR##*:}" |
| 144 | + gcloud compute forwarding-rules create "$FR" --project "$PROJECT" --global \ |
| 145 | + --load-balancing-scheme=EXTERNAL_MANAGED --address="$ADDR" \ |
| 146 | + --target-https-proxy="$PROXY_HTTPS" --ports=443 |
| 147 | +done |
| 148 | +``` |
| 149 | + |
| 150 | +HTTP→HTTPS 301 (separate redirect map; `url-maps create` has no redirect flags, so |
| 151 | +import it): |
| 152 | + |
| 153 | +```bash |
| 154 | +cat > /tmp/lb-api-redirect.yaml <<'YAML' |
| 155 | +name: lb-api-redirect |
| 156 | +defaultUrlRedirect: |
| 157 | + httpsRedirect: true |
| 158 | + redirectResponseCode: MOVED_PERMANENTLY_DEFAULT |
| 159 | + stripQuery: false |
| 160 | +YAML |
| 161 | +gcloud compute url-maps import "$REDIRECT_MAP" --project "$PROJECT" \ |
| 162 | + --source /tmp/lb-api-redirect.yaml --quiet |
| 163 | +gcloud compute target-http-proxies create "$PROXY_HTTP" --project "$PROJECT" \ |
| 164 | + --url-map "$REDIRECT_MAP" |
| 165 | +for PAIR in "fr-api-http:$IP4" "fr-api-http-v6:$IP6"; do |
| 166 | + FR="${PAIR%%:*}"; ADDR="${PAIR##*:}" |
| 167 | + gcloud compute forwarding-rules create "$FR" --project "$PROJECT" --global \ |
| 168 | + --load-balancing-scheme=EXTERNAL_MANAGED --address="$ADDR" \ |
| 169 | + --target-http-proxy="$PROXY_HTTP" --ports=80 |
| 170 | +done |
| 171 | +``` |
| 172 | + |
| 173 | +## 6. Snapshot the URL map (audit trail for every future weight change) |
| 174 | + |
| 175 | +```bash |
| 176 | +mkdir -p docs/migration/urlmap |
| 177 | +gcloud compute url-maps export "$MAP" --project "$PROJECT" \ |
| 178 | + --destination "docs/migration/urlmap/$(date -u +%Y%m%dT%H%M%SZ)-lb-api.yaml" |
| 179 | +git add docs/migration/urlmap/ && git commit -m "Snapshot LB URL map (Stage 4 initial, 100/0)" |
| 180 | +``` |
| 181 | + |
| 182 | +All future weight changes follow the procedure in |
| 183 | +[`../cloud-run-operations.md`](../cloud-run-operations.md) ("URL map weight changes"): |
| 184 | +export → diff → edit → import → commit. Console clicks leave no audit trail, and |
| 185 | +`url-maps import` **replaces the whole map** — always diff first. |
| 186 | + |
| 187 | +## Exit gates (from the plan; evaluate before Stage 5) |
| 188 | + |
| 189 | +```bash |
| 190 | +LB_IP=$(gcloud compute addresses describe "$IP4" --project "$PROJECT" --global --format 'value(address)') |
| 191 | +curl -sv --resolve "api.policyengine.org:443:${LB_IP}" \ |
| 192 | + https://api.policyengine.org/liveness-check -o /dev/null 2>&1 \ |
| 193 | + | grep -E "HTTP/|x-policyengine-backend" |
| 194 | +# expect: 200 + X-PolicyEngine-Backend: app_engine (traffic reaches GAE through the LB) |
| 195 | +curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://${LB_IP}/liveness-check" \ |
| 196 | + -H 'Host: api.policyengine.org' |
| 197 | +# expect: 301 → https://api.policyengine.org/liveness-check |
| 198 | +``` |
| 199 | + |
| 200 | +- Cert `state: ACTIVE`, both hostnames `AUTHORIZED` (step 2). |
| 201 | +- LB request logs visible in Logging with `resource.type="http_load_balancer"` and |
| 202 | + `backend_service_name` populated (allow a few minutes after first traffic). |
| 203 | +- IPv6 spot check: repeat the `--resolve` curl against the IPv6 address. |
| 204 | + |
| 205 | +## Teardown (full rollback of this stage; reverse order) |
| 206 | + |
| 207 | +```bash |
| 208 | +gcloud compute forwarding-rules delete fr-api-https fr-api-https-v6 fr-api-http fr-api-http-v6 --global --project "$PROJECT" --quiet |
| 209 | +gcloud compute target-https-proxies delete "$PROXY_HTTPS" --project "$PROJECT" --quiet |
| 210 | +gcloud compute target-http-proxies delete "$PROXY_HTTP" --project "$PROJECT" --quiet |
| 211 | +gcloud compute url-maps delete "$MAP" "$REDIRECT_MAP" --project "$PROJECT" --quiet |
| 212 | +gcloud compute backend-services delete "$BS_GAE" "$BS_CR" --global --project "$PROJECT" --quiet |
| 213 | +gcloud compute network-endpoint-groups delete "$NEG_GAE" "$NEG_CR" --region "$REGION" --project "$PROJECT" --quiet |
| 214 | +gcloud compute addresses delete "$IP4" "$IP6" --global --project "$PROJECT" --quiet |
| 215 | +# certificate-manager resources can stay (no cost, reusable) or be deleted last. |
| 216 | +``` |
| 217 | + |
| 218 | +No serving-path risk at any point in this stage: nothing resolves to the LB until the |
| 219 | +Stage 5 `api-lb` record, and `api.policyengine.org` continues to point at App Engine |
| 220 | +until Stage 8. Standing cost while provisioned: ~$0.025/h per forwarding rule |
| 221 | +(4 rules ≈ $73/month) plus egress — starts as soon as step 5 completes. |
0 commit comments