Files
ci/bin/kms
zeekay 2fe813158a
Hanzo CI/CD / cicd (push) Waiting to run
CI/CD / gate (push) Waiting to run
one way to read a secret
Three copies of "log in to KMS and read one name": this repo's site action,
this repo's build.yml, and hanzoai/gui's npm-token action. Written three times
they were wrong in three different ways at once — two piped the body straight
into jq, so a gateway timeout died as "Invalid numeric literal at line 1,
column 6" and another reported "NPM_TOKEN absent from KMS" about a token that
was sealed and read clean a minute later; the third retried only a lost
connection and not a 5xx. All three looked alike in a log, which is the whole
cost: an outage, a refusal and an unsealed secret were one sentence.

bin/kms is that read, once, for the same reason bin/imgver is one number once.
The site action already clones this repo for bin/site, so it costs nothing
there.

THE SCHEDULE IS SHORTER THAN WHAT IT REPLACED — 17 seconds, not 65. A retry
buys a dropped response, a 502, a pod coming back. It does not buy a wedged
store: that ran fifteen minutes and no schedule a build step can afford covers
it. The long version only made every failing build slower, and the test that
pins the behaviour slower still — 3m05s against 17s.

build.yml still has its own kms(). It reads six names plus a loop behind one
login and carries its own required-vs-warning policy, so it is not a drop-in,
and it is the file every repo in the fleet builds through. Left deliberately,
not missed.

Pinned by bin/kms_test.sh, in hanzo.yml's gate beside the other bin suites.
2026-08-24 11:18:34 -07:00

83 lines
3.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# kms — read one secret from Hanzo KMS. One implementation, every caller.
#
# kms <NAME> [env] # prints the value, or exits non-zero
#
# Needs KMS_CLIENT_ID and KMS_CLIENT_SECRET in the environment. KMS_ENDPOINT
# defaults to https://kms.hanzo.ai; both it and api.hanzo.ai are Ingresses onto
# the same cloud service, so the host is a preference and not a fallback.
#
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL, which is the same reason imgver is:
# three callers each carried their own copy — this repo's site action, this
# repo's build.yml, and hanzoai/gui's npm-token action. Written three times it
# was wrong in three different ways at once. Two of them piped the body straight
# into jq, so a gateway timeout died as "Invalid numeric literal at line 1,
# column 6" and one reported "NPM_TOKEN absent from KMS" about a sealed secret;
# the third retried only a lost connection, not a 5xx.
#
# THE READ IS FLAT. KMS resolves a name at the credential's own scope root, so
# `HANZO_DEPLOY_TOKEN` finds the value sealed for this org, while a
# path-qualified `deploy/HANZO_DEPLOY_TOKEN` finds nothing and reads exactly like
# an absent secret.
#
# ASK AGAIN ONLY WHERE ASKING AGAIN CAN HELP: nothing arrived, 429, or any 5xx —
# the network saying "not now". 401, 403 and 404 are KMS itself answering and are
# taken on the first try.
#
# THE SCHEDULE IS SHORT ON PURPOSE — 17 seconds over four tries. It exists to
# survive a dropped response, a 502 or a pod coming back, which are seconds. It
# does NOT exist to ride out a wedged store: that lasted fifteen minutes on
# 2026-08-24 and no schedule a build step can afford would have covered it. A
# minute of waiting before the same failure only makes the build slower and the
# test that pins this behaviour slower still.
#
# EVERY FAILURE NAMES WHICH OF THE THREE IT WAS — an outage, a refusal, or an
# unsealed secret — because the whole cost of the old copies was that they all
# looked alike in a log.
set -uo pipefail
name=${1:-}
env=${2:-prod}
endpoint=${KMS_ENDPOINT:-https://kms.hanzo.ai}
[ -n "$name" ] || { echo "::error::kms: usage: kms <NAME> [env]" >&2; exit 2; }
[ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ] || {
echo "::error::kms: KMS_CLIENT_ID and KMS_CLIENT_SECRET are unset — set them from the org-level secrets on git.hanzo.ai" >&2
exit 1
}
body=$(mktemp); trap 'rm -f "$body"' EXIT
# ask <what> <url> [curl args...] -> body on stdout when 2xx.
#
# `|| code=000` on the ASSIGNMENT, never `|| echo 000` inside the substitution:
# curl's -w already prints 000 when the request fails, so echoing a second one
# yields "000000", which matches no case below and turns an outage into a
# refusal that never retries.
ask() {
local what=$1 url=$2; shift 2
local code
for wait in 0 2 5 10; do
[ "$wait" = 0 ] || sleep "$wait"
code=$(curl -sS -m 30 -o "$body" -w '%{http_code}' "$url" "$@") || code=000
case "$code" in
2*) cat "$body"; return 0 ;;
000|429|5*) continue ;;
*) echo "::error::kms: $endpoint refused the $what: HTTP $code — $(head -c 300 "$body")" >&2; return 1 ;;
esac
done
echo "::error::kms: $endpoint did not answer the $what — HTTP $code over four tries: $(head -c 300 "$body"). An outage, not a missing secret." >&2
return 1
}
token=$(ask login "$endpoint/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" \
| jq -r '.accessToken // empty' 2>/dev/null) || exit 1
[ -n "$token" ] || { echo "::error::kms: login returned no accessToken at $endpoint/v1/kms/auth/login" >&2; exit 1; }
value=$(ask "secret $name" "$endpoint/v1/kms/secrets/$name?env=$env" \
-H "Authorization: Bearer $token" | jq -r '.value // empty' 2>/dev/null) || exit 1
[ -n "$value" ] || { echo "::error::kms: no value for $name at $endpoint/v1/kms/secrets/$name?env=$env — seal it there" >&2; exit 1; }
printf '%s' "$value"