tenant-lifecycle #1
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 | |
| 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 | |
| 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_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", "") | |
| 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") | |
| 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") | |
| 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") | |
| # 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) | |
| : | |
| ;; | |
| *) | |
| 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}") | |
| ;; | |
| esac | |
| set +e | |
| python3 -m deployments.basilica.cli "${ACTION}" "${args[@]}" > result.json | |
| code=$? | |
| set -e | |
| 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 ''}") | |
| 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}" |