|
| 1 | +# KAN-260 — settle the RESP2 pin against a real Valkey |
| 2 | + |
| 3 | +> **Status:** not started · **Blocks:** KAN-209 (can't close on evidence without this) |
| 4 | +> **Written:** 2026-08-28, at the end of the Sprint 9 close-out session. |
| 5 | +> **Related:** KAN-209, KAN-182 (no staging env), PR #3448 (merged — scoped the pin's justification) |
| 6 | +
|
| 7 | +--- |
| 8 | + |
| 9 | +## What this is |
| 10 | + |
| 11 | +`server/valkey.ts` pins the Valkey wire protocol to RESP2: |
| 12 | + |
| 13 | +```ts |
| 14 | +protocol: 2, |
| 15 | +``` |
| 16 | + |
| 17 | +That pin is **conservative, not proven necessary**. This document is the procedure |
| 18 | +to find out which, so KAN-209 can close on evidence instead of on a guess. |
| 19 | + |
| 20 | +**Do not remove the pin without running this.** And do not run the first attempt |
| 21 | +against production — see §0. |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## The actual question (one sentence) |
| 26 | + |
| 27 | +> Does Memorystore, with IAM auth enabled, accept ioredis 6's RESP3 handshake |
| 28 | +> `HELLO 3 AUTH default <iam-access-token>` — which sends a **username** — given |
| 29 | +> that Google documents IAM auth as password-only with **no** username? |
| 30 | +
|
| 31 | +### What is already established |
| 32 | + |
| 33 | +- ioredis 6 defaults to `protocol: 3`. |
| 34 | +- Reply shapes are **not** the risk. ioredis' `replyMapping: "legacy"` default keeps |
| 35 | + replies identical to RESP2, and we only consume two: `rate-limit-redis`'s EVAL |
| 36 | + array and AUTH's simple string. |
| 37 | +- Under RESP3 ioredis authenticates via `HELLO`, and with a password-only |
| 38 | + credential it injects the username `default` |
| 39 | + (`ioredis/built/redis/event_handler.js`). |
| 40 | +- `refreshTokenInPlace()` in `server/valkey.ts` deliberately sends password-only |
| 41 | + `await client.call('AUTH', token)` — the form Google documents for Memorystore |
| 42 | + IAM auth ("the authentication uses the access token directly"; the CLI example |
| 43 | + is `valkey-cli -h HOST -p PORT -a ACCESS_TOKEN`, no `--user`). |
| 44 | +- A rejected AUTH is **not** a protocol-negotiation error, so ioredis' automatic |
| 45 | + RESP2 fallback (which only catches `NOPROTO` / unknown-command) would not |
| 46 | + rescue it. The client would simply fail to connect, and `getValkeyClient()` |
| 47 | + would silently degrade **every replica** to in-memory rate limiting — on the |
| 48 | + limiter metering paid Gemini and Imagen calls. |
| 49 | + |
| 50 | +### What is NOT established |
| 51 | + |
| 52 | +That Memorystore actually **rejects** the RESP3 shape. Google documents no |
| 53 | +required username; it documents sending none. Those are different claims, and |
| 54 | +only the test below tells them apart. |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## §0 — Read this before you start |
| 59 | + |
| 60 | +**The decisive test is ~15 minutes and needs no app deploy and no staging |
| 61 | +environment.** It is a `valkey-cli` handshake against a throwaway instance. |
| 62 | +Do that first (§1–§4). Only do the full app-level verification (§6) if you want |
| 63 | +belt-and-braces before shipping. |
| 64 | + |
| 65 | +**Why a throwaway and not production:** `HELLO` mutates no data, so the test is |
| 66 | +read-safe. But a failed IAM handshake against prod is still an auth event on the |
| 67 | +instance the production limiter depends on, and if you fat-finger a `CONFIG` or |
| 68 | +`FLUSHALL` while poking around you have taken out the rate limiter. Spend the |
| 69 | +~$0.30 on a scratch instance. |
| 70 | + |
| 71 | +**Memorystore is private-IP only.** You cannot reach it from your laptop. You |
| 72 | +need a VM inside the same VPC (§3). This is the same constraint that makes |
| 73 | +Cloud SQL Studio necessary for the database. |
| 74 | + |
| 75 | +--- |
| 76 | + |
| 77 | +## §1 — Discover the real production config |
| 78 | + |
| 79 | +`gcloud` auth had expired when this file was written, so the exact instance name |
| 80 | +and API surface below are **unverified**. Establish them first — everything else |
| 81 | +depends on it. |
| 82 | + |
| 83 | +```bash |
| 84 | +gcloud auth login # run this yourself; it needs a browser |
| 85 | + |
| 86 | +PROJECT=comdottasteslikegood |
| 87 | +REGION=us-central1 |
| 88 | + |
| 89 | +# Which API is this instance on? Try both — one will return nothing. |
| 90 | +gcloud memorystore instances list --project="$PROJECT" --location="$REGION" |
| 91 | +gcloud redis instances list --project="$PROJECT" --region="$REGION" |
| 92 | +``` |
| 93 | + |
| 94 | +Docs in this repo (`docs/MCP_GCP_MONITORING.md`) name the deployed instance |
| 95 | +**`veganchef-valkeymem`**, and explicitly note it differs from the |
| 96 | +`vegangenius-valkey` that appears in the planning doc. Trust the list output over |
| 97 | +both. |
| 98 | + |
| 99 | +Dump the full config — this is what you will clone: |
| 100 | + |
| 101 | +```bash |
| 102 | +INSTANCE=<name from the list above> |
| 103 | + |
| 104 | +# Whichever API answered: |
| 105 | +gcloud memorystore instances describe "$INSTANCE" \ |
| 106 | + --project="$PROJECT" --location="$REGION" --format=yaml > /tmp/valkey-prod.yaml |
| 107 | +# ...or: |
| 108 | +gcloud redis instances describe "$INSTANCE" \ |
| 109 | + --project="$PROJECT" --region="$REGION" --format=yaml > /tmp/valkey-prod.yaml |
| 110 | + |
| 111 | +cat /tmp/valkey-prod.yaml |
| 112 | +``` |
| 113 | + |
| 114 | +**Fields that must match on the clone**, because they are the ones under test: |
| 115 | + |
| 116 | +| Field | Why it matters | |
| 117 | +| -------------------------------------------- | ----------------------------------------------------------- | |
| 118 | +| auth / authorization mode (IAM) | The whole question is about the IAM handshake | |
| 119 | +| transit encryption / TLS mode | TLS changes the `valkey-cli` invocation and the CA handling | |
| 120 | +| engine + version (Valkey vs Redis, x.y) | RESP3 support is version-dependent | |
| 121 | +| authorized network / VPC | Determines which VPC your test VM must live in | |
| 122 | +| connect mode (private service access vs PSC) | Determines whether a plain VM in the VPC can reach it | |
| 123 | + |
| 124 | +Node type / shard count / size do **not** matter — use the smallest available. |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## §2 — Create the throwaway instance |
| 129 | + |
| 130 | +Fill the flags from `/tmp/valkey-prod.yaml`. Exact flag names differ between the |
| 131 | +`memorystore` and `redis` surfaces and between engine versions, so check: |
| 132 | + |
| 133 | +```bash |
| 134 | +gcloud memorystore instances create --help # or: gcloud redis instances create --help |
| 135 | +``` |
| 136 | + |
| 137 | +Skeleton (Memorystore for Valkey): |
| 138 | + |
| 139 | +```bash |
| 140 | +SCRATCH=valkey-resp3-scratch |
| 141 | + |
| 142 | +gcloud memorystore instances create "$SCRATCH" \ |
| 143 | + --project="$PROJECT" \ |
| 144 | + --location="$REGION" \ |
| 145 | + --node-type=<smallest available> \ |
| 146 | + --shard-count=1 \ |
| 147 | + --replica-count=0 \ |
| 148 | + --engine-version=<match prod> \ |
| 149 | + --authorization-mode=<match prod: IAM> \ |
| 150 | + --transit-encryption-mode=<match prod> \ |
| 151 | + --network=<authorized network from prod> |
| 152 | +``` |
| 153 | + |
| 154 | +Then capture the endpoint: |
| 155 | + |
| 156 | +```bash |
| 157 | +gcloud memorystore instances describe "$SCRATCH" \ |
| 158 | + --project="$PROJECT" --location="$REGION" \ |
| 159 | + --format='value(discoveryEndpoints[0].address,discoveryEndpoints[0].port)' |
| 160 | +``` |
| 161 | + |
| 162 | +> ⚠️ **Set a teardown reminder right now.** An idle Memorystore instance bills |
| 163 | +> continuously. §5 is not optional. |
| 164 | +
|
| 165 | +--- |
| 166 | + |
| 167 | +## §3 — A VM inside the VPC to test from |
| 168 | + |
| 169 | +```bash |
| 170 | +NETWORK=<authorized network from prod> |
| 171 | +SUBNET=<a subnet of that network in $REGION> |
| 172 | + |
| 173 | +gcloud compute instances create valkey-probe \ |
| 174 | + --project="$PROJECT" \ |
| 175 | + --zone="${REGION}-a" \ |
| 176 | + --machine-type=e2-micro \ |
| 177 | + --network="$NETWORK" \ |
| 178 | + --subnet="$SUBNET" \ |
| 179 | + --no-address \ |
| 180 | + --scopes=https://www.googleapis.com/auth/cloud-platform \ |
| 181 | + --image-family=debian-12 --image-project=debian-cloud |
| 182 | +``` |
| 183 | + |
| 184 | +`--no-address` (no public IP) means you SSH via IAP: |
| 185 | + |
| 186 | +```bash |
| 187 | +gcloud compute ssh valkey-probe --project="$PROJECT" --zone="${REGION}-a" --tunnel-through-iap |
| 188 | +``` |
| 189 | + |
| 190 | +If IAP is not enabled, either enable it or drop `--no-address` for the life of |
| 191 | +the probe. `--scopes=cloud-platform` is what lets the VM mint its own IAM access |
| 192 | +token in §4. |
| 193 | + |
| 194 | +On the VM: |
| 195 | + |
| 196 | +```bash |
| 197 | +sudo apt-get update && sudo apt-get install -y redis-tools |
| 198 | +# redis-cli speaks the Valkey protocol; if you want the real thing, build |
| 199 | +# valkey-cli from the valkey-io/valkey release tarball instead. |
| 200 | +``` |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## §4 — THE TEST (this is the whole point) |
| 205 | + |
| 206 | +On the probe VM: |
| 207 | + |
| 208 | +```bash |
| 209 | +HOST=<scratch instance address> |
| 210 | +PORT=<scratch instance port> |
| 211 | + |
| 212 | +# The IAM access token IS the password for Memorystore IAM auth. |
| 213 | +TOKEN=$(gcloud auth print-access-token) |
| 214 | +``` |
| 215 | + |
| 216 | +If prod uses TLS, download the server CA into `ca.pem` first (the describe output |
| 217 | +tells you where it comes from) and add `--tls --cacert ca.pem` to every command |
| 218 | +below. |
| 219 | + |
| 220 | +### Test A — the form we ship today (RESP2, password-only). Expect success. |
| 221 | + |
| 222 | +```bash |
| 223 | +redis-cli -h "$HOST" -p "$PORT" -a "$TOKEN" --no-auth-warning PING |
| 224 | +# expect: PONG |
| 225 | +``` |
| 226 | + |
| 227 | +If this fails, stop — your instance config or token is wrong, not the protocol. |
| 228 | + |
| 229 | +### Test B — the form ioredis 6 would send under RESP3. **This is the answer.** |
| 230 | + |
| 231 | +```bash |
| 232 | +redis-cli -h "$HOST" -p "$PORT" --no-auth-warning |
| 233 | +# then, at the prompt: |
| 234 | +HELLO 3 AUTH default <paste $TOKEN> |
| 235 | +``` |
| 236 | + |
| 237 | +| Result | Meaning | Action | |
| 238 | +| --------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- | |
| 239 | +| Returns a map (`server`, `version`, `proto: 3`, …) | Memorystore accepts a `default` username with an IAM token under RESP3 | **The pin is unnecessary.** Go to §6, then remove it. | |
| 240 | +| `WRONGPASS` / `invalid username-password pair` / any auth error | The username breaks IAM auth, exactly as feared | **The pin is load-bearing.** Keep it, and record this as the proof. | |
| 241 | +| `NOPROTO` / unknown command `HELLO` | The engine predates RESP3 entirely | Pin stays; note the engine version. | |
| 242 | + |
| 243 | +### Test C — token refresh under RESP3 (only if B succeeded) |
| 244 | + |
| 245 | +`refreshTokenInPlace()` re-authenticates a live connection with password-only |
| 246 | +`AUTH <token>`. Confirm that still works on a connection that negotiated RESP3: |
| 247 | + |
| 248 | +```bash |
| 249 | +# in the same redis-cli session that ran HELLO 3 successfully: |
| 250 | +AUTH <a freshly minted $TOKEN> |
| 251 | +# expect: OK |
| 252 | +PING |
| 253 | +# expect: PONG |
| 254 | +``` |
| 255 | + |
| 256 | +If B passes but C fails, the pin still comes out **only** if |
| 257 | +`refreshTokenInPlace()` is changed to match — that is a code change, not just a |
| 258 | +flag flip. Record it. |
| 259 | + |
| 260 | +**Copy the raw terminal output of B and C into KAN-209.** The whole reason this |
| 261 | +ticket is open is that the current justification is reasoning, not a capture. |
| 262 | + |
| 263 | +--- |
| 264 | + |
| 265 | +## §5 — TEAR DOWN (do not skip) |
| 266 | + |
| 267 | +```bash |
| 268 | +gcloud compute instances delete valkey-probe \ |
| 269 | + --project="$PROJECT" --zone="${REGION}-a" --quiet |
| 270 | + |
| 271 | +gcloud memorystore instances delete "$SCRATCH" \ |
| 272 | + --project="$PROJECT" --location="$REGION" --quiet |
| 273 | +# ...or the `gcloud redis instances delete` equivalent. |
| 274 | +``` |
| 275 | + |
| 276 | +Verify both are gone: |
| 277 | + |
| 278 | +```bash |
| 279 | +gcloud memorystore instances list --project="$PROJECT" --location="$REGION" |
| 280 | +gcloud compute instances list --project="$PROJECT" --filter='name:valkey-probe' |
| 281 | +``` |
| 282 | + |
| 283 | +--- |
| 284 | + |
| 285 | +## §6 — Optional app-level verification (only if §4B passed) |
| 286 | + |
| 287 | +Only worth doing if you intend to actually remove the pin. |
| 288 | + |
| 289 | +1. In `server/valkey.ts`, change `protocol: 2` → `protocol: 3` (or delete the |
| 290 | + line, since 3 is the ioredis 6 default). |
| 291 | +2. Point a staging Express at the scratch instance: |
| 292 | + `VALKEY_HOST`, `VALKEY_PORT`, `VALKEY_AUTH_MODE`, and `VALKEY_CA_CERT` if TLS. |
| 293 | + (`VALKEY_TLS_INSECURE=true` exists but is dev-only — do not use it here; it |
| 294 | + would hide precisely the TLS/CA problems you are testing for.) |
| 295 | +3. Confirm in the logs that you get **neither** |
| 296 | + `[Valkey] VALKEY_HOST not set — using in-memory rate limiting` **nor** a |
| 297 | + connection failure. Silent degradation to in-memory is the failure mode that |
| 298 | + makes this dangerous, and it does not announce itself as an error. |
| 299 | +4. Drive enough requests to trip the limiter and confirm the count is shared |
| 300 | + across replicas (that is what proves Valkey is actually backing it, not the |
| 301 | + in-memory fallback). |
| 302 | + |
| 303 | +--- |
| 304 | + |
| 305 | +## §7 — Closing out |
| 306 | + |
| 307 | +**If the pin is unnecessary (§4B returned a map):** |
| 308 | + |
| 309 | +- Remove `protocol: 2` and the now-obsolete half of the comment block. |
| 310 | +- Keep the part documenting that IAM auth is password-only — that is still true |
| 311 | + and still explains `refreshTokenInPlace()`. |
| 312 | +- Paste the §4 capture into KAN-209 and close it. |
| 313 | + |
| 314 | +**If the pin is load-bearing (§4B errored):** |
| 315 | + |
| 316 | +- Leave the code exactly as it is. |
| 317 | +- Replace the comment's hedged "this is a deviation from documented guidance, |
| 318 | + NOT evidence that Memorystore rejects the RESP3 shape" with the actual error |
| 319 | + string and the date it was captured. |
| 320 | +- Close KAN-209 as "verified necessary" — that is a real outcome, not a |
| 321 | + non-result. |
| 322 | + |
| 323 | +Either way KAN-260 closes when the capture is on the ticket. |
| 324 | + |
| 325 | +--- |
| 326 | + |
| 327 | +## Cost note |
| 328 | + |
| 329 | +An `e2-micro` plus the smallest Memorystore node for under an hour is roughly |
| 330 | +pocket change. The thing that costs money is **forgetting §5**. Set the reminder |
| 331 | +when you create the instance, not after. |
0 commit comments