tenant-lifecycle #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |