Skip to content

tenant-lifecycle

tenant-lifecycle #11

name: tenant-lifecycle
on:
workflow_dispatch:
inputs:
tenant_id:
description: "DNS-safe tenant slug (^[a-z0-9][a-z0-9-]{0,29}$)"
required: true
type: string
action:
description: "Lifecycle action"
required: true
type: choice
options:
- provision
- update
- status
- deprovision
- rotate-admin-key
config_path:
description: "Path in repo to a YAML/JSON tenant config (provision/update only)"
required: false
type: string
default: ""
config_json:
description: "Inline JSON tenant config (provision/update only, takes precedence over config_path)"
required: false
type: string
default: ""
proxy_instance_id:
description: "Basilica UUID of existing proxy (required for update; optional for status/deprovision)"
required: false
type: string
default: ""
dashboard_instance_id:
description: "Basilica UUID of existing dashboard (required for update; optional for status/deprovision)"
required: false
type: string
default: ""
strategy:
description: "Update strategy (update only): recreate (URL changes) or restart (URL stable)"
required: false
type: choice
default: recreate
options:
- recreate
- restart
new_admin_key:
description: "Optional explicit new admin key for rotate-admin-key (else auto-generated)"
required: false
type: string
default: ""
tenant_secrets_json:
description: >-
JSON object of per-tenant secrets to inject as env vars before the
CLI runs, e.g. {"OPENAI_API_KEY":"sk-...","LLMTRACE_UPSTREAM_URL":"https://..."}.
The CLI's ${VAR} substitution resolves these into the tenant config.
Values are masked in step logs via ::add-mask::, but workflow_dispatch
input VALUES are visible in the run UI to anyone with actions:read.
For production multi-tenant use, trigger via repository_dispatch with
client_payload (event_type: tenant-lifecycle) — those payloads are
NOT shown in the run UI. See README for the migration path.
required: false
type: string
default: "{}"
# Production trigger: client_payload is not visible in the run UI.
# POST /repos/{owner}/{repo}/dispatches with:
# {"event_type": "tenant-lifecycle",
# "client_payload": {"tenant_id": "...", "action": "provision",
# "config_json": "{...}",
# "tenant_secrets": {"OPENAI_API_KEY": "sk-..."}}}
repository_dispatch:
types:
- tenant-lifecycle
permissions:
contents: read
jobs:
lifecycle:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
# Secrets used both for the API client and for ${VAR} substitution
# inside the loaded tenant config. Add per-tenant secrets here as
# needed — they will be resolved at deploy time.
BASILICA_API_TOKEN: ${{ secrets.BASILICA_API_TOKEN }}
LLMTRACE_UPSTREAM_URL: ${{ secrets.LLMTRACE_UPSTREAM_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LLMTRACE_AUTH_ADMIN_KEY: ${{ secrets.LLMTRACE_AUTH_ADMIN_KEY }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install deps
run: |
python3 -m pip install --upgrade pip
python3 -m pip install basilica-sdk PyYAML
- name: Resolve inputs (workflow_dispatch or repository_dispatch)
env:
EVENT_NAME: ${{ github.event_name }}
WF_TENANT_ID: ${{ inputs.tenant_id }}
WF_ACTION: ${{ inputs.action }}
WF_CONFIG_PATH: ${{ inputs.config_path }}
WF_CONFIG_JSON: ${{ inputs.config_json }}
WF_PROXY_ID: ${{ inputs.proxy_instance_id }}
WF_DASHBOARD_ID: ${{ inputs.dashboard_instance_id }}
WF_STRATEGY: ${{ inputs.strategy }}
WF_NEW_ADMIN_KEY: ${{ inputs.new_admin_key }}
WF_SECRETS: ${{ inputs.tenant_secrets_json }}
RD_PAYLOAD: ${{ toJson(github.event.client_payload) }}
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, pathlib
event = os.environ.get("EVENT_NAME", "")
if event == "workflow_dispatch":
tenant_id = os.environ.get("WF_TENANT_ID", "")
action = os.environ.get("WF_ACTION", "")
cfg_path = os.environ.get("WF_CONFIG_PATH", "")
cfg_json = os.environ.get("WF_CONFIG_JSON", "")
proxy_id = os.environ.get("WF_PROXY_ID", "")
dash_id = os.environ.get("WF_DASHBOARD_ID", "")
strategy = os.environ.get("WF_STRATEGY", "")
new_admin_key = os.environ.get("WF_NEW_ADMIN_KEY", "")
secrets_json = os.environ.get("WF_SECRETS", "{}") or "{}"
try:
secrets = json.loads(secrets_json)
except json.JSONDecodeError as exc:
raise SystemExit(f"::error::tenant_secrets_json is invalid JSON: {exc}")
elif event == "repository_dispatch":
payload = json.loads(os.environ.get("RD_PAYLOAD", "{}") or "{}")
tenant_id = payload.get("tenant_id", "")
action = payload.get("action", "")
cfg_path = payload.get("config_path", "")
cfg_json = payload.get("config_json", "")
if cfg_json and not isinstance(cfg_json, str):
cfg_json = json.dumps(cfg_json)
proxy_id = payload.get("proxy_instance_id", "")
dash_id = payload.get("dashboard_instance_id", "")
strategy = payload.get("strategy", "recreate")
new_admin_key = payload.get("new_admin_key", "") or ""
secrets = payload.get("tenant_secrets", {}) or {}
else:
raise SystemExit(f"::error::unsupported event {event!r}")
if not isinstance(secrets, dict):
raise SystemExit("::error::tenant_secrets must be a JSON object")
# Mask the optional explicit new admin key from the workflow input
# surface — it ends up as a CLI arg downstream and must not show up
# in subsequent log lines.
if new_admin_key:
print(f"::add-mask::{new_admin_key}")
env_path = pathlib.Path(os.environ["GITHUB_ENV"])
with env_path.open("a") as fh:
fh.write(f"TENANT_ID={tenant_id}\n")
fh.write(f"ACTION={action}\n")
fh.write(f"CFG_PATH={cfg_path}\n")
fh.write(f"PROXY_ID={proxy_id}\n")
fh.write(f"DASHBOARD_ID={dash_id}\n")
fh.write(f"STRATEGY={strategy}\n")
fh.write(f"NEW_ADMIN_KEY={new_admin_key}\n")
# config_json may contain newlines; use heredoc form
if cfg_json:
fh.write(f"CFG_JSON<<__LIFECYCLE_EOF__\n{cfg_json}\n__LIFECYCLE_EOF__\n")
pathlib.Path("/tmp/tenant_secrets.json").write_text(json.dumps(secrets))
print(f"resolved: tenant_id={tenant_id} action={action} secret_keys={sorted(secrets.keys())}")
PY
- name: Inject tenant secrets
env:
SECRETS_PATH: /tmp/tenant_secrets.json
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, pathlib, secrets as _secrets
data = json.loads(pathlib.Path(os.environ["SECRETS_PATH"]).read_text() or "{}")
if not data:
print("no tenant secrets to inject")
raise SystemExit(0)
env_path = pathlib.Path(os.environ["GITHUB_ENV"])
with env_path.open("a") as fh:
for key, value in data.items():
if not isinstance(key, str) or not key:
raise SystemExit(f"::error::secret key must be a non-empty string, got {key!r}")
value = "" if value is None else str(value)
# Mask FIRST so any later log line containing the value is redacted.
print(f"::add-mask::{value}")
# Random per-value delimiter — paranoid guard against a secret
# that happens to contain a static EOF token.
delim = f"__SEC_{_secrets.token_hex(8)}__"
if delim in value:
raise SystemExit(f"::error::generated delimiter collided with secret value for {key}")
fh.write(f"{key}<<{delim}\n{value}\n{delim}\n")
print(f"injected {key} ({len(value)} chars)")
PY
- name: Validate inputs
run: |
set -euo pipefail
if [[ -z "${BASILICA_API_TOKEN:-}" ]]; then
echo "::error::BASILICA_API_TOKEN secret is not configured"
exit 2
fi
if [[ -z "${TENANT_ID:-}" ]]; then
echo "::error::tenant_id is required"
exit 2
fi
case "${ACTION:-}" in
provision)
if [[ -z "${CFG_PATH:-}" && -z "${CFG_JSON:-}" ]]; then
echo "::error::action=provision requires config_path or config_json"
exit 2
fi
;;
update)
if [[ -z "${CFG_PATH:-}" && -z "${CFG_JSON:-}" ]]; then
echo "::error::action=update requires config_path or config_json"
exit 2
fi
if [[ -z "${PROXY_ID:-}" || -z "${DASHBOARD_ID:-}" ]]; then
echo "::error::action=update requires proxy_instance_id and dashboard_instance_id"
exit 2
fi
;;
status|deprovision)
:
;;
rotate-admin-key)
if [[ -z "${CFG_PATH:-}" && -z "${CFG_JSON:-}" ]]; then
echo "::error::action=rotate-admin-key requires config_path or config_json"
exit 2
fi
if [[ -z "${PROXY_ID:-}" ]]; then
echo "::error::action=rotate-admin-key requires proxy_instance_id"
exit 2
fi
;;
*)
echo "::error::unknown or missing action: ${ACTION:-<empty>}"
exit 2
;;
esac
- name: Run lifecycle action
id: run
run: |
set -euo pipefail
args=("--tenant-id" "${TENANT_ID}")
case "${ACTION}" in
provision)
if [[ -n "${CFG_JSON:-}" ]]; then
args+=("--config-json" "${CFG_JSON}")
else
args+=("--config" "${CFG_PATH}")
fi
;;
update)
args+=("--strategy" "${STRATEGY}")
args+=("--proxy-instance-id" "${PROXY_ID}")
args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
if [[ -n "${CFG_JSON:-}" ]]; then
args+=("--config-json" "${CFG_JSON}")
else
args+=("--config" "${CFG_PATH}")
fi
;;
status|deprovision)
[[ -n "${PROXY_ID:-}" ]] && args+=("--proxy-instance-id" "${PROXY_ID}")
[[ -n "${DASHBOARD_ID:-}" ]] && args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
;;
rotate-admin-key)
args+=("--proxy-instance-id" "${PROXY_ID}")
if [[ -n "${CFG_JSON:-}" ]]; then
args+=("--config-json" "${CFG_JSON}")
else
args+=("--config" "${CFG_PATH}")
fi
if [[ -n "${NEW_ADMIN_KEY:-}" ]]; then
args+=("--new-key" "${NEW_ADMIN_KEY}")
fi
;;
esac
set +e
python3 -m deployments.basilica.cli "${ACTION}" "${args[@]}" > result.json
code=$?
set -e
# Mask BOTH api_key and admin_key BEFORE any log line (cat /
# step summary) touches result.json. ::add-mask:: is per-job
# and applies to subsequent log lines, so registering them
# here covers the cat below + the summary step + any
# downstream consumers that print step outputs.
python3 - <<'PY'
import json, pathlib
data = json.loads(pathlib.Path("result.json").read_text() or "{}")
for field in ("api_key", "admin_key"):
value = data.get(field)
if value:
print(f"::add-mask::{value}")
PY
cat result.json
echo "exit_code=${code}" >> "${GITHUB_OUTPUT}"
python3 - <<'PY' >> "${GITHUB_OUTPUT}"
import json, pathlib
data = json.loads(pathlib.Path("result.json").read_text() or "{}")
print(f"proxy_instance_id={data.get('proxy_instance_id') or ''}")
print(f"dashboard_instance_id={data.get('dashboard_instance_id') or ''}")
print(f"proxy_url={data.get('proxy_url') or ''}")
print(f"dashboard_url={data.get('dashboard_url') or ''}")
# api_key (operator-scoped, given to the tenant) and admin_key
# (bootstrap-scoped, retained by the caller for self-service /
# admin pages) are masked above. Emitting them as step outputs
# is fine because downstream consumers (the caller's app via
# the Actions API) need both: api_key for the tenant's bearer,
# admin_key for the platform's own admin pages.
print(f"api_key={data.get('api_key') or ''}")
print(f"admin_key={data.get('admin_key') or ''}")
PY
if [[ "${code}" -ne 0 ]]; then
exit "${code}"
fi
- name: Write step summary
if: always()
run: |
set -euo pipefail
{
echo "## tenant-lifecycle: ${ACTION} ${TENANT_ID}"
echo ""
echo '```json'
cat result.json 2>/dev/null || echo "{}"
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"