mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
a8cc0d2465
Four subcommands: list-orgs, create, wait, pooler-url. Built against
the verified Supabase Management API shape (Pre-Impl Gate 1):
- POST /v1/projects with {name, db_pass, organization_slug, region}
— not the original plan's /v1/organizations/{ref}/projects
- No `plan` field; subscription tier is org-level per the OpenAPI
description ("Subscription Plan is now set on organization level
and is ignored in this request")
- GET /v1/projects/{ref}/config/database/pooler for pooler config
— not /config/database
Secrets discipline: SUPABASE_ACCESS_TOKEN (PAT) and DB_PASS read from
env only, never from argv (D8 grep test enforces this). `set +x` at
the top as a defensive default so debug tracing never leaks secrets.
Management API hostname hardcoded to SUPABASE_API_BASE env override —
no user-controlled URL portion (SSRF guard).
HTTP error paths: 401/403 → exit 3 (auth), 402 → 4 (quota), 409 → 5
(conflict), 429 + 5xx → exponential-backoff retry up to 3 attempts,
then exit 8. Wait subcommand polls every 5s until ACTIVE_HEALTHY
with a configurable timeout; terminal states (INIT_FAILED, REMOVED,
etc.) exit 7 immediately with a clear message. Timeout emits the
--resume-provision hint so the skill can recover.
Pooler-url constructs the URL locally from db_user/host/port/name +
DB_PASS rather than trusting the API response's connection_string
field, which is templated with [PASSWORD] rather than the real value.
Handles both object and array response shapes, preferring session
pool_mode when Supabase returns multiple pooler configs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
12 KiB
Bash
Executable File
365 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-gbrain-supabase-provision — Supabase Management API wrapper for
|
|
# /setup-gbrain path 2a (auto-provision).
|
|
#
|
|
# Subcommands:
|
|
# list-orgs
|
|
# GET /v1/organizations. Output: {"orgs": [{"slug","name"}, ...]}
|
|
#
|
|
# create <name> <region> <org-slug>
|
|
# POST /v1/projects with {name, db_pass, organization_slug, region}.
|
|
# db_pass must be in the DB_PASS env var (never argv — D8 grep test
|
|
# enforces this). Output: {"ref","name","region","organization_slug","status"}.
|
|
#
|
|
# NOTE: does NOT send a `plan` field. Per verified Supabase Management
|
|
# API OpenAPI, the `plan` field is now deprecated at the project level
|
|
# — subscription tier is an org-level decision (D17 updated).
|
|
#
|
|
# wait <ref> [--timeout <seconds>]
|
|
# Poll GET /v1/projects/{ref} every 5s until status=ACTIVE_HEALTHY,
|
|
# or fail on terminal states (INIT_FAILED, REMOVED). Default timeout
|
|
# 180s. Output on success: {"ref","status","elapsed_s"}.
|
|
#
|
|
# pooler-url <ref>
|
|
# GET /v1/projects/{ref}/config/database/pooler, construct the full
|
|
# Session Pooler URL using DB_PASS from env (the API response's
|
|
# connection_string is typically templated [PASSWORD] rather than the
|
|
# real value — we build from db_user/db_host/db_port/db_name instead).
|
|
# Output: {"ref","pooler_url"}.
|
|
#
|
|
# Secrets discipline (D8, D10, D11):
|
|
# - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv.
|
|
# - DB_PASS (for `create` and `pooler-url`) is read from env; never argv.
|
|
# - Forbidden strings (enforced by skill-validation grep test):
|
|
# --insecure, -k (curl), NODE_TLS_REJECT_UNAUTHORIZED
|
|
# - `set +x` default — debug mode requires explicit opt-in around
|
|
# non-secret lines.
|
|
#
|
|
# Env:
|
|
# SUPABASE_ACCESS_TOKEN — PAT for auth (required on all subcommands)
|
|
# DB_PASS — database password (required for create + pooler-url)
|
|
# SUPABASE_API_BASE — override the API host (tests point this at a
|
|
# local mock server). Default: https://api.supabase.com
|
|
#
|
|
# Exit codes:
|
|
# 0 — success
|
|
# 2 — usage / invalid input
|
|
# 3 — auth failure (401/403) — retry with fresh PAT
|
|
# 4 — quota / billing (402) — user action needed
|
|
# 5 — conflict (409) — duplicate name, user action needed
|
|
# 6 — timeout (wait subcommand hit its deadline)
|
|
# 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED)
|
|
# 8 — network / 5xx after retries
|
|
set +x # Defensive: never trace secrets in this helper.
|
|
set -euo pipefail
|
|
|
|
SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}"
|
|
API_VERSION="v1"
|
|
DEFAULT_WAIT_TIMEOUT=180
|
|
POLL_INTERVAL=5
|
|
CURL_TIMEOUT=30
|
|
|
|
die() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 2; }
|
|
die_auth() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 3; }
|
|
die_quota(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 4; }
|
|
die_conflict(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 5; }
|
|
die_net() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 8; }
|
|
|
|
require_jq() {
|
|
command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq"
|
|
}
|
|
require_curl() {
|
|
command -v curl >/dev/null 2>&1 || die "curl is required"
|
|
}
|
|
|
|
require_pat() {
|
|
if [ -z "${SUPABASE_ACCESS_TOKEN:-}" ]; then
|
|
die_auth "SUPABASE_ACCESS_TOKEN is not set. Generate a PAT at https://supabase.com/dashboard/account/tokens"
|
|
fi
|
|
}
|
|
|
|
require_db_pass() {
|
|
if [ -z "${DB_PASS:-}" ]; then
|
|
die "DB_PASS env var is required (never passed as argv — that leaks via ps/history)"
|
|
fi
|
|
}
|
|
|
|
# api_call <method> <path> [<json-body-file>]
|
|
# Handles: 401/403 → exit 3, 402 → 4, 409 → 5, 429 + 5xx → retry w/
|
|
# exponential backoff up to 3 attempts. Returns the response body on
|
|
# stdout and HTTP status on an internal variable via a pipe trick.
|
|
#
|
|
# Because bash lacks multi-value returns, we write response body to a
|
|
# tmpfile + status to another tmpfile and the caller reads them.
|
|
api_call() {
|
|
local method="$1"
|
|
local apipath="$2"
|
|
local body_file="${3:-}"
|
|
|
|
local url="$SUPABASE_API_BASE/$API_VERSION/$apipath"
|
|
local body_tmp
|
|
body_tmp=$(mktemp)
|
|
local status_tmp
|
|
status_tmp=$(mktemp)
|
|
# shellcheck disable=SC2064
|
|
trap "rm -f '$body_tmp' '$status_tmp'" RETURN
|
|
|
|
local attempt=0
|
|
local max_attempts=3
|
|
local backoff=2
|
|
while : ; do
|
|
attempt=$((attempt + 1))
|
|
local curl_args=(
|
|
--silent
|
|
--show-error
|
|
--max-time "$CURL_TIMEOUT"
|
|
-o "$body_tmp"
|
|
-w "%{http_code}"
|
|
-X "$method"
|
|
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
|
|
-H "Accept: application/json"
|
|
-H "Content-Type: application/json"
|
|
-H "User-Agent: gstack-gbrain-supabase-provision"
|
|
)
|
|
if [ -n "$body_file" ]; then
|
|
curl_args+=(--data-binary "@$body_file")
|
|
fi
|
|
local status
|
|
if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); then
|
|
# curl itself failed (network, timeout, etc.). Retry.
|
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
die_net "network failure calling $method $apipath after $attempt attempts"
|
|
fi
|
|
sleep "$backoff"
|
|
backoff=$((backoff * 2))
|
|
continue
|
|
fi
|
|
|
|
case "$status" in
|
|
2??)
|
|
cat "$body_tmp"
|
|
printf '%s' "$status" > "$status_tmp"
|
|
return 0
|
|
;;
|
|
401)
|
|
die_auth "401 Unauthorized — your PAT is invalid or expired. Re-generate at https://supabase.com/dashboard/account/tokens"
|
|
;;
|
|
403)
|
|
die_auth "403 Forbidden — your PAT lacks permission for $method $apipath. Regenerate with All Access scope."
|
|
;;
|
|
402)
|
|
die_quota "402 Payment Required — Supabase project/organization quota exceeded. See https://supabase.com/dashboard"
|
|
;;
|
|
409)
|
|
die_conflict "409 Conflict on $method $apipath — likely a duplicate project name. Pick a different name and re-run."
|
|
;;
|
|
429|5??)
|
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
die_net "$status after $attempt attempts on $method $apipath"
|
|
fi
|
|
sleep "$backoff"
|
|
backoff=$((backoff * 2))
|
|
continue
|
|
;;
|
|
*)
|
|
# 400, 404, etc. — surface the error body for debugging.
|
|
local err
|
|
err=$(jq -r '.message // .error // empty' "$body_tmp" 2>/dev/null || true)
|
|
if [ -n "$err" ]; then
|
|
die "HTTP $status from $method $apipath: $err"
|
|
else
|
|
die "HTTP $status from $method $apipath (no error message in response)"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
cmd_list_orgs() {
|
|
local json_mode=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--json) json_mode=true; shift ;;
|
|
*) die "list-orgs: unknown flag: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_jq; require_curl; require_pat
|
|
local resp
|
|
resp=$(api_call GET organizations)
|
|
if $json_mode; then
|
|
printf '%s' "$resp" | jq '{orgs: map({slug: .slug, name: .name})}'
|
|
else
|
|
printf '%s' "$resp" | jq -r '.[] | "\(.slug)\t\(.name)"'
|
|
fi
|
|
}
|
|
|
|
cmd_create() {
|
|
local name="" region="" org_slug=""
|
|
local json_mode=false
|
|
local instance_size=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--json) json_mode=true; shift ;;
|
|
--instance-size) instance_size="$2"; shift 2 ;;
|
|
--*) die "create: unknown flag: $1" ;;
|
|
*)
|
|
if [ -z "$name" ]; then name="$1"
|
|
elif [ -z "$region" ]; then region="$1"
|
|
elif [ -z "$org_slug" ]; then org_slug="$1"
|
|
else die "create: too many positional arguments"
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
[ -z "$name" ] && die "create: missing <name>"
|
|
[ -z "$region" ] && die "create: missing <region>"
|
|
[ -z "$org_slug" ] && die "create: missing <org-slug>"
|
|
|
|
require_jq; require_curl; require_pat; require_db_pass
|
|
|
|
local body_file
|
|
body_file=$(mktemp)
|
|
# shellcheck disable=SC2064
|
|
trap "rm -f '$body_file'" RETURN
|
|
if [ -n "$instance_size" ]; then
|
|
jq -n \
|
|
--arg name "$name" \
|
|
--arg db_pass "$DB_PASS" \
|
|
--arg organization_slug "$org_slug" \
|
|
--arg region "$region" \
|
|
--arg desired_instance_size "$instance_size" \
|
|
'{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region, desired_instance_size: $desired_instance_size}' \
|
|
> "$body_file"
|
|
else
|
|
jq -n \
|
|
--arg name "$name" \
|
|
--arg db_pass "$DB_PASS" \
|
|
--arg organization_slug "$org_slug" \
|
|
--arg region "$region" \
|
|
'{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region}' \
|
|
> "$body_file"
|
|
fi
|
|
|
|
local resp
|
|
resp=$(api_call POST projects "$body_file")
|
|
if $json_mode; then
|
|
printf '%s' "$resp" | jq '{ref, name, region, organization_slug, status}'
|
|
else
|
|
printf '%s' "$resp" | jq -r '"ref=\(.ref) status=\(.status) region=\(.region)"'
|
|
fi
|
|
}
|
|
|
|
cmd_wait() {
|
|
local ref="" timeout="$DEFAULT_WAIT_TIMEOUT"
|
|
local json_mode=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--timeout) timeout="$2"; shift 2 ;;
|
|
--json) json_mode=true; shift ;;
|
|
--*) die "wait: unknown flag: $1" ;;
|
|
*) ref="$1"; shift ;;
|
|
esac
|
|
done
|
|
[ -z "$ref" ] && die "wait: missing <ref>"
|
|
|
|
require_jq; require_curl; require_pat
|
|
|
|
local elapsed=0
|
|
while : ; do
|
|
local resp
|
|
resp=$(api_call GET "projects/$ref")
|
|
local status
|
|
status=$(printf '%s' "$resp" | jq -r '.status // "UNKNOWN"')
|
|
case "$status" in
|
|
ACTIVE_HEALTHY)
|
|
if $json_mode; then
|
|
jq -n --arg ref "$ref" --arg status "$status" --argjson elapsed "$elapsed" \
|
|
'{ref: $ref, status: $status, elapsed_s: $elapsed}'
|
|
else
|
|
echo "ready ref=$ref status=$status elapsed_s=$elapsed"
|
|
fi
|
|
return 0
|
|
;;
|
|
INIT_FAILED|REMOVED|RESTORE_FAILED|PAUSE_FAILED)
|
|
echo "gstack-gbrain-supabase-provision: project $ref reached terminal failure state '$status'" >&2
|
|
exit 7
|
|
;;
|
|
COMING_UP|INACTIVE|ACTIVE_UNHEALTHY|UNKNOWN|RESTORING|UPGRADING|PAUSING|RESTARTING|RESIZING|GOING_DOWN)
|
|
# Still provisioning — keep polling.
|
|
;;
|
|
*)
|
|
# Unexpected status from Supabase. Log but keep polling.
|
|
echo "gstack-gbrain-supabase-provision: unexpected status '$status' — continuing to poll" >&2
|
|
;;
|
|
esac
|
|
|
|
if [ "$elapsed" -ge "$timeout" ]; then
|
|
echo "gstack-gbrain-supabase-provision: wait timed out after ${timeout}s (last status: $status)" >&2
|
|
echo "gstack-gbrain-supabase-provision: re-run with /setup-gbrain --resume-provision $ref" >&2
|
|
exit 6
|
|
fi
|
|
sleep "$POLL_INTERVAL"
|
|
elapsed=$((elapsed + POLL_INTERVAL))
|
|
done
|
|
}
|
|
|
|
cmd_pooler_url() {
|
|
local ref=""
|
|
local json_mode=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--json) json_mode=true; shift ;;
|
|
--*) die "pooler-url: unknown flag: $1" ;;
|
|
*) ref="$1"; shift ;;
|
|
esac
|
|
done
|
|
[ -z "$ref" ] && die "pooler-url: missing <ref>"
|
|
|
|
require_jq; require_curl; require_pat; require_db_pass
|
|
|
|
local resp
|
|
resp=$(api_call GET "projects/$ref/config/database/pooler")
|
|
|
|
# Prefer the singular Session Pooler config when Supabase returns an
|
|
# array (response shape can vary by project state). Fall back to the
|
|
# first PRIMARY entry if no "session" pool_mode is present.
|
|
local db_user db_host db_port db_name
|
|
local first_or_session
|
|
if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
|
first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]')
|
|
else
|
|
first_or_session="$resp"
|
|
fi
|
|
|
|
db_user=$(printf '%s' "$first_or_session" | jq -r '.db_user // empty')
|
|
db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty')
|
|
db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty')
|
|
db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty')
|
|
|
|
if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then
|
|
die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state"
|
|
fi
|
|
|
|
local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}"
|
|
|
|
if $json_mode; then
|
|
jq -n --arg ref "$ref" --arg pooler_url "$url" '{ref: $ref, pooler_url: $pooler_url}'
|
|
else
|
|
# Non-JSON mode prints the URL; callers capturing it into a variable
|
|
# keep it in process memory only.
|
|
echo "$url"
|
|
fi
|
|
}
|
|
|
|
case "${1:-}" in
|
|
list-orgs) shift; cmd_list_orgs "$@" ;;
|
|
create) shift; cmd_create "$@" ;;
|
|
wait) shift; cmd_wait "$@" ;;
|
|
pooler-url) shift; cmd_pooler_url "$@" ;;
|
|
--help|-h|help) sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
"") die "usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|--help}" ;;
|
|
*) die "unknown subcommand: $1" ;;
|
|
esac
|