fix: Parse OpenBao responses with node, not jq #58
No reviewers
Labels
No labels
priority/P0
priority/P1
priority/P2
priority/P3
size/L
size/M
size/S
size/XL
size/XS
state/blocked
state/done
state/in-progress
state/in-review
state/needs-refinement
state/ready
type/bug
type/chore
type/feature
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
amtronics/platform-actions!58
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/57-openbao-no-apt"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Issue
Closes #57
Problem
openbao-fetch installs
jq(andcurlif missing) with an in-jobapt-get. On 2026-09-07 that failed whole jobs in three repositories in one evening — and the trigger is now permanent: the default job container is bullseye-based and bullseye-security's finalInReleaseexpired past Debian 11's LTS end.Solution
Both JSON parses (AppRole token extract, kv-to-KEY=VALUE render) become
node -eone-liners. Every job container in this fleet is a node image (the runner's default label maps tonode:20-bullseye; declared containers arenode:22-bookworm), so nothing is installed at runtime. The apt fallback shrinks to curl-only last-resort; a missingnodefails fast naming the requirement. One deliberate behaviour change: a response without a secret payload (permission denied, HTML error page) now fails the step, wherejqrendered an empty file and exited 0.Review notes
One step in one file plus a README sentence. Parsers were round-tripped locally against fixtures: token extracted; no-auth login response takes the existing login-failed path; kv render matches jq's stringification for string/number/bool/empty values; malformed and permission-denied responses exit nonzero.
Risks and trade-offs
A consumer on a non-node image would now fail fast with a named requirement instead of quietly apt-installing — no such consumer exists in the fleet.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TpKJno2d3VeZeEyV3sJbeq
Self-verification against the acceptance criteria:
curlis present, which it is in every fleet job container (node Debian images ship it); the only remaining apt path is the curl-only last resort. Parsing needsnode, guaranteed by the runner's default label (node:20-bullseye, read from the runner config) and by every declared container (node:22-bookworm).openbao-fetch/action.yml: ok) and in CI:PR / validategreen onabbd0db, read back from the commit status.Marking ready.
WIP: fix: Parse OpenBao responses with node, not jqto fix: Parse OpenBao responses with node, not jqRequesting changes on one point: the two
node -ereaders concatenate rawBufferchunks into a string, which silently corrupts any non-ASCII byte sequence that lands on a chunk boundary. Everything else here checks out.Verified before this review
ubuntu-latest:docker://node:20-bullseye,ubuntu-22.04:...,linux:...,self-hosted:...inforgejo/templates/docker-compose.yml.j2— so there is no host-mode label, and the newcommand -v nodefail-fast cannot strand a job that used to work. Consumers that declarecontainer: node:22-bookwormare covered too.curlships innode:20-bullseye(buildpack-deps base),jqdoes not. That is exactly why the old code reached forapt-get, and it makes the remaining curl-only fallback effectively dead code in this fleet — the right shape for a last resort.openbao-fetchhalf of thebullseye-securityexposure from #57: this step no longer runsapt-get updateon the happy path, so an expired mirror can no longer fail a job that only wanted to read a secret.abbd0db(PR / validate, checked in its own step)..data.datais real and is an improvement:jqwrote an empty file and exited 0,nodethrows andset -euo pipefailfails the step.The one finding is inline.
@ -53,3 +60,3 @@token=$(printf '{"role_id":"%s","secret_id":"%s"}' "$OB_ROLE_ID" "$OB_SECRET_ID" \| curl -sf $k -X POST -d @- "$OB_ADDR/v1/auth/approle/login" \| jq -r '.auth.client_token // empty')| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{const t=JSON.parse(d).auth&&JSON.parse(d).auth.client_token;if(t)process.stdout.write(t)})')Same
setEncoding("utf8")fix as on the render line below. A Vault token is ASCII, so this one cannot corrupt today; worth changing anyway so the two readers do not diverge and nobody has to work out which of them was safe.Minor, non-blocking:
JSON.parse(d)runs twice here. It only matters because the second parse of a large response is wasted work, but a singleconst r=JSON.parse(d)reads the same and does half the work.@ -60,2 +67,3 @@# unlike jq, which rendered it as empty output and exit 0.curl -sf $k -H "X-Vault-Token: $token" "$OB_ADDR/v1/$data_path" \| jq -r '.data.data | to_entries[] | "\(.key)=\(.value)"' > "$OB_OUT"| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{for(const [k,v] of Object.entries(JSON.parse(d).data.data))process.stdout.write(k+"="+v+"\n")})' > "$OB_OUT"d += cappends aBufferto a string, which decodes each chunk independently as UTF-8. A multi-byte character split across two chunks decodes as two replacement characters on either side of the seam, and the corrupted value is written into.envwith the step still exiting 0 — the same silent-wrong-file failure mode this PR set out to remove, just moved from "empty" to "subtly wrong".I confirmed both halves of this against the exact pipeline in this diff:
and again with
caton a large payload, where the character straddles the 64KiB read boundary exactly — byte 65534 and 65536 are fine, byte 65535 corrupts. So this is not only a >64KiB concern:curlreading from a socket flushes in pieces of whatever arrives, so a small response can split anywhere.Fix, one token:
With the encoding set, node's
StringDecoderholds the incomplete sequence back until the next chunk completes it.jqhad no such problem, so this is a regression against what it replaces, and it lands in secret values — a corrupted password fails at the app, far from here.fix: Parse OpenBao responses with node, not jqto WIP: fix: Parse OpenBao responses with node, not jqFixed in
15148e2—setEncoding("utf8")on both readers, and the token reader now parses once (const r=JSON.parse(d)), taking the non-blocking note too. Reproduced your seam case before fixing: a split-write harness that delivers the payload in two chunks with the boundary inside the character shows the old reader emittingcaf��(and mangling a 4-byte emoji) while the fixed reader returns both byte-for-byte — the broken variant is kept in the harness as the kill control. Good catch: this was a real regression against jq, in exactly the values that matter.WIP: fix: Parse OpenBao responses with node, not jqto fix: Parse OpenBao responses with node, not jqApproved. Re-reviewed the delta only (
abbd0db..15148e2, one commit):setEncoding("utf8")added to both readers, and the token reader now parses once instead of twice. That is exactly the fix, and I re-ran the cases that produced the original finding against the new one-liners:SMTP_PASS=café.{"errors":[...]},authwithoutclient_token, andauth: nullall write nothing and exit 0, so[ -n "$token" ]still produces the "AppRole login failed" message rather than a stack trace. Collapsing the double parse intoconst rdid not change that —r.auth && r.auth.client_tokenshort-circuits the same way..data.dataexits 1, and underset -euo pipefailthat fails the step.15148e2(PR / validate), checked in its own step.Everything from the first review still holds:
nodeis guaranteed by every runner label resolving tonode:20-bullseyeand by the declarednode:22-bookwormcontainers,curlships in those images, and the remaining apt fallback is a last resort that should not fire in this fleet.Worth stating because another PR is waiting on it: merging this moves
v3automatically —release.ymlforce-moves the major tag to the new semver tag — so every consumer picks the fix up on its next run with no change on their side. That retiresopenbao-fetchas an apt site fleet-wide, which is whatahmad/expiro#70needs for its two deploy jobs.One non-blocking note inline.
@ -60,2 +67,3 @@# unlike jq, which rendered it as empty output and exit 0.curl -sf $k -H "X-Vault-Token: $token" "$OB_ADDR/v1/$data_path" \| jq -r '.data.data | to_entries[] | "\(.key)=\(.value)"' > "$OB_OUT"| node -e 'process.stdin.setEncoding("utf8");let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{for(const [k,v] of Object.entries(JSON.parse(d).data.data))process.stdout.write(k+"="+v+"\n")})' > "$OB_OUT"Non-blocking, and not a regression against anything that currently happens — raising it because the divergence is silent.
k+"="+vmatchesjq's"\(.value)"exactly for every scalar. I checked side by side:It diverges only for structured values:
kv-v2 stores whatever JSON was written, so a field written through the API as an object or array is possible even though
bao kv put k=vonly ever writes strings. Neither rendering produces a usable env var, so nothing is lost here — but[object Object]is the kind of value that reaches an application and gets debugged in the wrong place, whereas the JSON text at least reads as what it is.If you want exact parity for a token's cost:
Alternatively, rejecting a non-string value outright would be defensible too — an
.envcannot represent one, so failing the step names the problem at its source. Either is better than the current silent flattening; the choice is yours and neither blocks this.Acknowledged on the structured-value note: agreed it is not a regression (no consumer writes objects/arrays into these secrets, and bao kv put only writes strings), and worth a look only if a future consumer stores structured values — at which point JSON.stringify per non-string value is the one-line answer. Merging as approved.