Under a CI burst (a batch merge fires ~13 workflows x ~4 jobs, each doing an AppRole login = a raft write), the vault nodes return nginx 504s and the rate-limit quota returns 429s. Both are non-JSON, which crashed jq and hard-failed the job with a misleading "parse error". Add a bao_req helper that retries connect-failures/429/5xx with backoff+jitter (~60s over 6 tries) and surfaces real 4xx bodies unchanged. Transient throttling no longer fails CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
5.1 KiB
YAML
109 lines
5.1 KiB
YAML
name: "Fetch OpenBao secrets"
|
|
description: "AppRole-login to OpenBao and export requested KV v2 secrets into the job env (masked). Phase 1, spikersoft-issues#545."
|
|
|
|
inputs:
|
|
role-id:
|
|
description: "AppRole role_id — the repo's BAO_ROLE_ID Actions secret."
|
|
required: true
|
|
secret-id:
|
|
description: "AppRole secret_id — the repo's BAO_SECRET_ID Actions secret."
|
|
required: true
|
|
bao-addr:
|
|
description: "OpenBao address."
|
|
required: false
|
|
default: "https://bao.spikersoft.com"
|
|
secrets:
|
|
description: |
|
|
One mapping per line: ENV_NAME <kv-v2-path> <field>
|
|
The path is exactly what you'd pass to `bao kv get` (mount + logical path).
|
|
Blank lines and `#` comments are ignored. Example:
|
|
DOCKER_PASSWORD secret/ci/shared/registry password
|
|
VIDEOS_S3_KEY secret/ci/backend/minio/videos secret_key
|
|
required: true
|
|
|
|
runs:
|
|
using: "composite"
|
|
steps:
|
|
- shell: bash
|
|
env:
|
|
BAO_ADDR: ${{ inputs.bao-addr }}
|
|
BAO_ROLE_ID: ${{ inputs.role-id }}
|
|
BAO_SECRET_ID: ${{ inputs.secret-id }}
|
|
BAO_SECRETS: ${{ inputs.secrets }}
|
|
run: |
|
|
set -euo pipefail
|
|
command -v curl >/dev/null || { echo "::error::bao-secrets needs 'curl' on the runner"; exit 1; }
|
|
command -v jq >/dev/null || { echo "::error::bao-secrets needs 'jq' on the runner"; exit 1; }
|
|
|
|
TOKEN=""
|
|
# HTTP with retry + backoff. When many CI jobs log in at once the vault
|
|
# nodes can return an nginx 504 (login is a raft write) or a 429 from the
|
|
# rate-limit quota — both are transient and non-JSON, which used to crash
|
|
# jq and hard-fail the job. Retry those; surface real 4xx bodies as-is.
|
|
# Prints the response BODY on stdout.
|
|
bao_req() { # METHOD URL [DATA]
|
|
local method="$1" url="$2" data="${3:-}" attempt=1 max=6 code tmp secs
|
|
tmp="$(mktemp)"
|
|
local -a args=(-s -o "$tmp" -w '%{http_code}' --max-time 15 -X "$method")
|
|
[ -n "${TOKEN:-}" ] && args+=(-H "X-Vault-Token: ${TOKEN}")
|
|
[ -n "$data" ] && args+=(--data "$data")
|
|
args+=("$url")
|
|
while :; do
|
|
code=$(curl "${args[@]}" 2>/dev/null || echo 000)
|
|
case "$code" in
|
|
200|204) cat "$tmp"; rm -f "$tmp"; return 0 ;;
|
|
000|429|500|502|503|504)
|
|
if [ "$attempt" -ge "$max" ]; then
|
|
echo "::warning::bao $method failed after ${attempt} tries (last HTTP ${code})" >&2
|
|
cat "$tmp"; rm -f "$tmp"; return 1
|
|
fi
|
|
secs=$(( attempt * 4 + RANDOM % 4 )) # ~4,8,12,16,20s + jitter
|
|
echo " bao ${method} -> HTTP ${code}; retry ${attempt}/${max} in ${secs}s (cluster busy/rate-limited)" >&2
|
|
sleep "$secs"; attempt=$((attempt+1)) ;;
|
|
*) cat "$tmp"; rm -f "$tmp"; return 1 ;; # 4xx etc — surface to caller
|
|
esac
|
|
done
|
|
}
|
|
|
|
# 1) AppRole login -> short-lived token (batch token if the role is set so).
|
|
login="$(bao_req POST "${BAO_ADDR}/v1/auth/approle/login" \
|
|
"{\"role_id\":\"${BAO_ROLE_ID}\",\"secret_id\":\"${BAO_SECRET_ID}\"}")" || true
|
|
TOKEN=$(printf '%s' "$login" | jq -r '.auth.client_token // empty' 2>/dev/null || true)
|
|
if [ -z "$TOKEN" ]; then
|
|
echo "::error::OpenBao AppRole login failed (after retries): $(printf '%s' "$login" | jq -rc '.errors // "non-JSON/timeout — cluster busy or rate-limited"' 2>/dev/null || echo 'non-JSON response')"
|
|
exit 1
|
|
fi
|
|
echo "::add-mask::$TOKEN"
|
|
|
|
# 2) fetch each requested secret into $GITHUB_ENV (masked, multiline-safe)
|
|
while IFS= read -r line; do
|
|
line="${line%%#*}"
|
|
# shellcheck disable=SC2086
|
|
set -- $line
|
|
[ "$#" -eq 0 ] && continue
|
|
if [ "$#" -ne 3 ]; then
|
|
echo "::error::bad 'secrets' line (need: ENV_NAME <kv-v2-path> <field>): ${line}"
|
|
exit 1
|
|
fi
|
|
env_name="$1"; path="$2"; field="$3"
|
|
mount="${path%%/*}"; rest="${path#*/}" # KV v2: <mount>/data/<rest>
|
|
resp="$(bao_req GET "${BAO_ADDR}/v1/${mount}/data/${rest}")" || true
|
|
val=$(printf '%s' "$resp" | jq -r --arg f "$field" '.data.data[$f] // empty' 2>/dev/null || true)
|
|
if [ -z "$val" ]; then
|
|
echo "::error::no value at '${path}' field '${field}' (wrong path/field, this role's policy denies it, or cluster busy): $(printf '%s' "$resp" | jq -rc '.errors // "empty/non-JSON"' 2>/dev/null || echo 'non-JSON')"
|
|
exit 1
|
|
fi
|
|
# mask every line of the value (handles multiline secrets like keys)
|
|
while IFS= read -r vline; do [ -n "$vline" ] && echo "::add-mask::$vline"; done <<< "$val"
|
|
{
|
|
echo "${env_name}<<__BAO_EOF__"
|
|
printf '%s\n' "$val"
|
|
echo "__BAO_EOF__"
|
|
} >> "$GITHUB_ENV"
|
|
echo " ✓ ${env_name} <- ${path}#${field}"
|
|
done <<< "$BAO_SECRETS"
|
|
|
|
# 3) drop the token (batch tokens can't be revoked; ignore errors)
|
|
curl -s --max-time 10 -H "X-Vault-Token: $TOKEN" \
|
|
-X POST "${BAO_ADDR}/v1/auth/token/revoke-self" >/dev/null 2>&1 || true
|