|
| 1 | +--- |
| 2 | +name: create-rpc-api-key |
| 3 | +description: Manage public RPC API key consumers for an Aztec RPC environment (mainnet or testnet) — add a new consumer end-to-end (mint the GCP Secret Manager secret, wire it into Kong via Terraform, apply, verify, open the PR), list existing consumers and their real owners, and check or change a consumer's per-minute rate limit. Use when asked to add/create an RPC API key, onboard a new RPC client/consumer, "give <someone> a mainnet/testnet rpc key", or to list/inspect/raise/lower an RPC consumer's rate limit. |
| 4 | +--- |
| 5 | + |
| 6 | +# Manage public RPC API keys |
| 7 | + |
| 8 | +This runbook covers the full lifecycle of a public RPC gateway consumer: **creating** a key (Steps 1–6), **listing** who has keys, and **checking / changing** a consumer's rate limit. |
| 9 | + |
| 10 | +A key is a value stored in GCP Secret Manager; Terraform turns it into a Kong consumer that can call every keyed route in the environment. Externally the consumer is just `clientN`; the real recipient is recorded only as a private `client_name` annotation on the secret. |
| 11 | + |
| 12 | +The two RPC environments live in `spartan/terraform/deploy-rpc/environments/`: |
| 13 | + |
| 14 | +| Environment | dir | secret prefix | namespace / release prefix | |
| 15 | +|---|---|---|---| |
| 16 | +| mainnet | `environments/mainnet` | `mainnet-rpc-consumer` | `mainnet-rpc` / `mainnet` | |
| 17 | +| testnet | `environments/testnet` | `testnet-rpc-consumer` | `testnet-rpc` / `testnet` | |
| 18 | + |
| 19 | +The examples below use **mainnet**. For testnet, swap the dir, prefix, namespace, and context. Always read the target env's existing consumer entries and copy their exact prefix rather than assuming. |
| 20 | + |
| 21 | +## Two consumer-config shapes (mainnet vs testnet differ) |
| 22 | + |
| 23 | +The `CONSUMERS` map passed to the environment module is written differently in each env — read the target env's `main.tf` before editing: |
| 24 | + |
| 25 | +- **mainnet** — an **explicit map**, one `clientN` block per consumer, each with its own `rate_limit_minute`. This is where you set an individual mainnet consumer's limit. |
| 26 | +- **testnet** — **generated** from a `consumer_secret_names` list via a `for` comprehension that hardcodes `rate_limit_minute = 0` for every entry. So every testnet key is unlimited by construction, and there is no per-consumer limit knob unless you break a consumer out of the loop into its own explicit entry. |
| 27 | + |
| 28 | +**Rate-limit tiers.** `rate_limit_minute = 0` means **unlimited** (no rate-limit plugin is generated; see the `consumers_with_rate_limit` filter in `spartan/terraform/modules/rpc-gateway/main.tf`). A positive value generates a `rate-limiting` KongPlugin with `policy = "local"` and `limit_by = "consumer"` — **`local` means the limit is enforced per Kong pod**, so the effective ceiling is `rate_limit_minute × (number of Kong gateway pods)`. When `ALLOW_ANONYMOUS = true` (testnet), keyless callers fall back to an anonymous consumer capped at `ANONYMOUS_RATE_LIMIT_MINUTE` (default 300) **per IP, per pod**; mainnet sets `ALLOW_ANONYMOUS = false` so a key is mandatory there. |
| 29 | + |
| 30 | +## Before you start — access and tooling |
| 31 | + |
| 32 | +- **Working directory**: agents in this repo often run with `yarn-project` as the shell CWD, but the script and Terraform live under the git root's `spartan/`. Use absolute paths (or the correct git-root-relative path). Do **not** `cd` — the Bash working directory persists across calls and a stray `cd` will break later relative paths. |
| 33 | +- **gcloud**: authenticated, project `testnet-440309` (`gcloud config get-value project`). Terraform's GCS backend and google provider need Application Default Credentials — if you hit auth errors, `gcloud auth application-default login`. |
| 34 | +- **kubectl**: the GKE context `gke_testnet-440309_us-west1-a_aztec-gke-public` must exist (`kubectl config get-contexts`). |
| 35 | +- **Terraform version**: the remote state may have been written by a newer Terraform than your local binary, and Terraform refuses to operate on newer state. Check and match: |
| 36 | + ```bash |
| 37 | + # state version (mainnet) |
| 38 | + gcloud storage cat gs://aztec-terraform/aztec-gke-public/mainnet-rpc/deploy-rpc/terraform.tfstate/default.tfstate | jq -r .terraform_version |
| 39 | + terraform version |
| 40 | + ``` |
| 41 | + If local `< state`, install a matching-or-newer Terraform before continuing. |
| 42 | + |
| 43 | +## Step 1 — mint the secret |
| 44 | + |
| 45 | +`spartan/scripts/create_api_key.sh <prefix> <internal-owner-name>` |
| 46 | + |
| 47 | +- `<prefix>` = the environment's consumer prefix, e.g. `mainnet-rpc-consumer`. |
| 48 | +- `<internal-owner-name>` = a private annotation naming the real recipient (e.g. `vitalik`, `fairies`). Kept for our records only — **do not put it in the public PR.** Match the lowercase style of existing annotations. |
| 49 | +- The script lists existing `<prefix>-client*` secrets, takes the most recently created, and creates the next index (`clientN+1`). If none exist it starts at `client1`. |
| 50 | + |
| 51 | +```bash |
| 52 | +/abs/path/to/spartan/scripts/create_api_key.sh mainnet-rpc-consumer fairies |
| 53 | +# -> Created version [1] of the secret [mainnet-rpc-consumer-client9]. |
| 54 | +``` |
| 55 | + |
| 56 | +Verify (note the index and annotation): |
| 57 | +```bash |
| 58 | +gcloud secrets describe mainnet-rpc-consumer-client9 --format="value(name.basename(), annotations)" |
| 59 | +``` |
| 60 | + |
| 61 | +Caveat: the "next index" is derived from the most-recently-*created* secret, not the max number. This is only a problem if secrets were created out of order — normally fine. |
| 62 | + |
| 63 | +## Step 2 — add the consumer to Terraform |
| 64 | + |
| 65 | +Edit `spartan/terraform/deploy-rpc/environments/<env>/main.tf` and append a `clientN` entry to the `CONSUMERS` map, matching the existing entries exactly: |
| 66 | + |
| 67 | +```hcl |
| 68 | + client9 = { |
| 69 | + username = "mainnet-rpc-consumer-client9" |
| 70 | + gcp_secret_manager_secret_name = "mainnet-rpc-consumer-client9" |
| 71 | + rate_limit_minute = 0 |
| 72 | + } |
| 73 | +``` |
| 74 | + |
| 75 | +`rate_limit_minute = 0` means unlimited (no rate-limit plugin is generated). Use a positive value only if a cap is intended. |
| 76 | + |
| 77 | +**testnet variant.** testnet does not use an explicit map — it builds `CONSUMERS` from a `consumer_secret_names` list. Add the new secret name to that list instead: |
| 78 | + |
| 79 | +```hcl |
| 80 | + consumer_secret_names = [ |
| 81 | + "testnet-rpc-consumer-client1", |
| 82 | + "testnet-rpc-consumer-client2", # new |
| 83 | + ] |
| 84 | +``` |
| 85 | + |
| 86 | +Every entry inherits `rate_limit_minute = 0` (unlimited) from the comprehension. To give one testnet consumer a finite cap, it cannot stay in the uniform loop — break it out into its own explicit `CONSUMERS` entry (or merge an override) with the desired `rate_limit_minute`. |
| 87 | + |
| 88 | +## Step 3 — plan and verify (the guardrail) |
| 89 | + |
| 90 | +```bash |
| 91 | +TF=/abs/path/to/spartan/terraform/deploy-rpc/environments/mainnet |
| 92 | +terraform -chdir="$TF" init -reconfigure -input=false |
| 93 | +terraform -chdir="$TF" plan -input=false -out=tfplan |
| 94 | +``` |
| 95 | + |
| 96 | +The plan **must** be exactly: |
| 97 | + |
| 98 | +``` |
| 99 | +Plan: 2 to add, 0 to change, 0 to destroy. |
| 100 | +``` |
| 101 | + |
| 102 | +The two additions are the new consumer's resources: |
| 103 | +- `module.environment.module.rpc_gateway.kubernetes_manifest.consumer["clientN"]` — the KongConsumer |
| 104 | +- `module.environment.module.rpc_gateway.kubernetes_manifest.consumer_key_external_secret["clientN"]` — the ExternalSecret that pulls the key from Secret Manager into Kong |
| 105 | + |
| 106 | +If the plan shows **anything else** — a change/replace/destroy on an RPC node, helm release, domain, or another consumer — STOP and find out why. Common cause: a deployment change (e.g. a version promotion) has been merged to the branch but not yet applied to live, so applying now would also push that change. You do **not** need to pass image `-var`s if the env's image defaults match the live pods (`kubectl -n <ns> get pods -o jsonpath=... <node>`). If you must add a key without triggering such a pending deployment, temporarily revert that change in your working tree so the plan is clean — but do not commit the revert (see Step 6). |
| 107 | + |
| 108 | +## Step 4 — apply |
| 109 | + |
| 110 | +```bash |
| 111 | +terraform -chdir="$TF" apply -input=false tfplan |
| 112 | +``` |
| 113 | + |
| 114 | +The ExternalSecret has a `wait { Ready }` block, so the GCP secret from Step 1 must already exist or the apply will block waiting for it to sync. |
| 115 | + |
| 116 | +## Step 5 — verify it is live |
| 117 | + |
| 118 | +```bash |
| 119 | +CTX=gke_testnet-440309_us-west1-a_aztec-gke-public |
| 120 | +kubectl --context "$CTX" -n mainnet-rpc get kongconsumer mainnet-client9 # PROGRAMMED=True |
| 121 | +kubectl --context "$CTX" -n mainnet-rpc get externalsecret mainnet-client9-rpc-key-auth # Ready/SecretSynced=True |
| 122 | +``` |
| 123 | + |
| 124 | +(Resource names are `<release-prefix>-clientN` and `<release-prefix>-clientN-rpc-key-auth`.) |
| 125 | + |
| 126 | +## Step 6 — open the PR |
| 127 | + |
| 128 | +The committed Terraform is a record of what is already applied. Keep the PR to a single-purpose, one-line addition. |
| 129 | + |
| 130 | +- **Commit only the `clientN` addition.** If you made a temporary revert in Step 3 to get a clean plan, undo it so it is not committed — the diff must be the `clientN` block and nothing else. Verify with `git diff <base>...HEAD -- <main.tf path>`. |
| 131 | +- Commit message: `chore: add new client` (the established convention). Author is the git author — no Claude attribution. |
| 132 | +- **Base branch: `merge-train/spartan`** for these `spartan/` deploy changes (not `next`). Branch off it, and if the base has moved, rebase the single commit onto it so the PR diff stays client-only. |
| 133 | + |
| 134 | +```bash |
| 135 | +git checkout -b stack/chore-add-new-client-N origin/merge-train/spartan |
| 136 | +# edit main.tf to add clientN |
| 137 | +git add /abs/path/.../environments/mainnet/main.tf |
| 138 | +git commit -m "chore: add new client" |
| 139 | +git push -u origin stack/chore-add-new-client-N |
| 140 | +gh pr create --base merge-train/spartan --head stack/chore-add-new-client-N \ |
| 141 | + --title "chore: add new client" \ |
| 142 | + --body "Adds \`clientN\` to the mainnet RPC consumers. Secret created and already \`terraform apply\`-ed; the plan was the two expected resources (KongConsumer + ExternalSecret). Rate limit 0 = unlimited." |
| 143 | +``` |
| 144 | + |
| 145 | +**Print the PR URL back to the user.** Do not name the private owner in the PR title or body. |
| 146 | + |
| 147 | +If the previous client-add PR merged while you were working (and its branch was deleted), target `merge-train/spartan` directly and rebase your commit onto it so the diff is only the new client. |
| 148 | + |
| 149 | +## Handing the key to the recipient |
| 150 | + |
| 151 | +The key value was never printed (the script piped it straight into Secret Manager). Retrieve it with: |
| 152 | + |
| 153 | +```bash |
| 154 | +gcloud secrets versions access latest --secret=mainnet-rpc-consumer-client9 |
| 155 | +``` |
| 156 | + |
| 157 | +It is a bearer credential — share it over a secure channel, not plain chat/email. The recipient uses it either way: |
| 158 | + |
| 159 | +- Path form: `https://canonical.mainnet.rpc.aztec-labs.com/<KEY>` |
| 160 | +- Header form: `x-aztec-api-key: <KEY>` against `https://canonical.mainnet.rpc.aztec-labs.com/` |
| 161 | + |
| 162 | +The consumer is defined at the environment level, so the key authenticates on **every** keyed route in that environment (e.g. mainnet `v4`, `canonical`, and `v5` hosts) — one key, all routes. |
| 163 | + |
| 164 | +## Listing consumers and their owners |
| 165 | + |
| 166 | +The `clientN` → real-recipient mapping lives **only** in the `client_name` annotation on each GCP secret — it is never in the committed Terraform or in the cluster. To find who owns which key, list the secrets with their annotation: |
| 167 | + |
| 168 | +```bash |
| 169 | +# one env |
| 170 | +gcloud secrets list --filter="name:mainnet-rpc-consumer" \ |
| 171 | + --format="table(name.basename(), createTime, annotations.client_name)" |
| 172 | + |
| 173 | +# every RPC consumer across all envs (mainnet, testnet, staging, testnet-prover, eth-sepolia, …) |
| 174 | +gcloud secrets list --filter="name~rpc-consumer" \ |
| 175 | + --format="value(name.basename(), annotations.client_name)" | sort |
| 176 | +``` |
| 177 | + |
| 178 | +To find a specific person's key, grep the annotation column — the owner may not have a key at all (in which case, on testnet, they are just using the anonymous tier and "bumping" them means creating a key): |
| 179 | + |
| 180 | +```bash |
| 181 | +gcloud secrets list --filter="name~rpc-consumer" --format="value(name.basename(), annotations.client_name)" | grep -i <name> |
| 182 | +``` |
| 183 | + |
| 184 | +Cross-check against what is actually live in the cluster (the committed `main.tf` can lag behind live — always reconcile against `origin/merge-train/spartan`, not a possibly-stale checkout): |
| 185 | + |
| 186 | +```bash |
| 187 | +CTX=gke_testnet-440309_us-west1-a_aztec-gke-public |
| 188 | +kubectl --context "$CTX" -n <ns>-rpc get kongconsumer |
| 189 | +``` |
| 190 | + |
| 191 | +## Checking a consumer's rate limit |
| 192 | + |
| 193 | +The declared limit is the consumer's `rate_limit_minute` in the env `main.tf` (`0` = unlimited). To read the **effective live** limit instead of the declared one: |
| 194 | + |
| 195 | +```bash |
| 196 | +CTX=gke_testnet-440309_us-west1-a_aztec-gke-public |
| 197 | +NS=testnet-rpc |
| 198 | + |
| 199 | +# does the consumer carry a rate-limit plugin annotation? (absent ⇒ unlimited) |
| 200 | +kubectl --context "$CTX" -n "$NS" get kongconsumer <release-prefix>-<key> -o jsonpath='{.metadata.annotations}'; echo |
| 201 | + |
| 202 | +# the per-consumer rate-limit plugin config, if any |
| 203 | +kubectl --context "$CTX" -n "$NS" get kongplugin <release-prefix>-<key>-rpc-rate-limit -o jsonpath='{.config}'; echo |
| 204 | + |
| 205 | +# the anonymous (keyless) limit, per route — testnet only |
| 206 | +kubectl --context "$CTX" -n "$NS" get kongplugin <release-prefix>-<route>-anonymous-rpc-rate-limit -o jsonpath='{.config}'; echo |
| 207 | + |
| 208 | +# Kong pod count — the local policy multiplies the limit by this |
| 209 | +kubectl --context "$CTX" -n "$NS" get pods | grep kong-gateway |
| 210 | +``` |
| 211 | + |
| 212 | +Remember `policy = "local"`: the real ceiling is `minute × (Kong gateway pod count)`. With a single gateway pod, the number in the plugin is the number the client sees. |
| 213 | + |
| 214 | +## Changing a consumer's rate limit |
| 215 | + |
| 216 | +Editing `rate_limit_minute` for an existing consumer, then `plan`/`apply` exactly as in Steps 3–4. **The Step 3 guardrail still applies**: the plan must touch only that one consumer's rate-limit plugin and its `KongConsumer` annotation — nothing else. Expected plan deltas by transition: |
| 217 | + |
| 218 | +| Change | Expected plan | |
| 219 | +|---|---| |
| 220 | +| `0` → `N` (add a cap) | `1 to add, 1 to change` — creates the rate-limit `KongPlugin`, annotates the consumer to use it | |
| 221 | +| `N` → `M` (both > 0) | `1 to change` — just the plugin's `minute` | |
| 222 | +| `N` → `0` (remove the cap) | `1 to change, 1 to destroy` — drops the plugin, removes the consumer annotation | |
| 223 | + |
| 224 | +Notes: |
| 225 | + |
| 226 | +- **testnet caveat**: a testnet consumer generated by the `consumer_secret_names` loop is uniformly `rate_limit_minute = 0`. You cannot raise *one* testnet consumer above/below the others without pulling it out of the loop into an explicit `CONSUMERS` entry (see the testnet variant under Step 2). If the intent is simply "more than the 300/min anonymous tier", giving them a key already makes them unlimited — no per-consumer plugin needed. |
| 227 | +- **Raising the anonymous tier** (testnet, keyless users) is a different lever: set `ANONYMOUS_RATE_LIMIT_MINUTE` on the testnet env module (default 300). This affects **every** keyless caller, not one person. |
| 228 | +- Commit message convention for a limit change is a normal `chore:`/`fix:` describing the change (not `chore: add new client`), on base `merge-train/spartan`. Do not name the private owner in the PR. |
0 commit comments