diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 41cf5069f..d3659db9a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -254,7 +254,10 @@ repo-tracked operator command that composes the hosted signup/billing eval pack with the hosted mobile onboarding bootstrap helpers under `tests/integration/scripts/`, and those helpers must fail closed onto explicit target cloud host and control-plane URL input instead of silently defaulting to -production infrastructure. +production infrastructure. When the operator does not pin +`PULSE_E2E_HOSTED_TENANT_ID`, that entrypoint may auto-select the newest active +tenant exposed by the authenticated `/admin/tenants?state=active` control-plane +view, but it must still fail closed when no active tenant is available. Those same governed release workflows also own the operator-facing wording for that promotion metadata. Human-visible workflow inputs, summaries, and error messages must describe the path as a prerelease or preview flow rather than diff --git a/scripts/run_hosted_staging_smoke.sh b/scripts/run_hosted_staging_smoke.sh index 6cb97c56a..cf82ead05 100755 --- a/scripts/run_hosted_staging_smoke.sh +++ b/scripts/run_hosted_staging_smoke.sh @@ -33,6 +33,47 @@ require_env() { fi } +resolve_hosted_tenant_id() { + if [[ -n "${PULSE_E2E_HOSTED_TENANT_ID}" ]]; then + return 0 + fi + + local tenants_payload + if ! tenants_payload="$( + curl -fsS \ + -H "X-Admin-Key: ${PULSE_CP_ADMIN_KEY}" \ + -H 'Accept: application/json' \ + "${PULSE_CLOUD_BASE_URL%/}/admin/tenants?state=active" + )"; then + echo "Failed to list active hosted tenants from ${PULSE_CLOUD_BASE_URL}" >&2 + exit 1 + fi + + if ! PULSE_E2E_HOSTED_TENANT_ID="$( + printf '%s' "${tenants_payload}" | node -e ' + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + input += chunk; + }); + process.stdin.on("end", () => { + const parsed = JSON.parse(input); + const tenants = Array.isArray(parsed?.tenants) ? parsed.tenants : []; + const selected = tenants.find((entry) => typeof entry?.id === "string" && entry.id.trim() !== ""); + if (!selected) { + process.exit(2); + } + process.stdout.write(selected.id.trim()); + }); + ' + )"; then + echo "No active hosted tenant found; set PULSE_E2E_HOSTED_TENANT_ID explicitly." >&2 + exit 1 + fi + + echo "Auto-selected active hosted tenant ${PULSE_E2E_HOSTED_TENANT_ID}" +} + require_command curl require_command go require_command node @@ -50,7 +91,6 @@ require_env "PULSE_CLOUD_SSH_TARGET" "${PULSE_CLOUD_SSH_TARGET}" require_env "PULSE_CP_ADMIN_KEY" "${PULSE_CP_ADMIN_KEY}" require_env "PULSE_E2E_STRIPE_API_KEY" "${PULSE_E2E_STRIPE_API_KEY}" require_env "PULSE_E2E_STRIPE_WEBHOOK_SECRET" "${PULSE_E2E_STRIPE_WEBHOOK_SECRET}" -require_env "PULSE_E2E_HOSTED_TENANT_ID" "${PULSE_E2E_HOSTED_TENANT_ID}" echo "[1/2] Running hosted signup and billing smoke against ${PULSE_CLOUD_BASE_URL}" ( @@ -64,6 +104,8 @@ echo "[1/2] Running hosted signup and billing smoke against ${PULSE_CLOUD_BASE_U node ./scripts/run-evals.mjs --mode deterministic --scenario "${HOSTED_SCENARIOS}" ) +resolve_hosted_tenant_id + echo "[2/2] Running hosted mobile onboarding smoke against tenant ${PULSE_E2E_HOSTED_TENANT_ID}" bootstrap_args=( "${INTEGRATION_ROOT}/scripts/bootstrap-hosted-mobile-onboarding.mjs" diff --git a/tests/integration/tests/09-cloud-billing-lifecycle.spec.ts b/tests/integration/tests/09-cloud-billing-lifecycle.spec.ts index fcce21d5f..df075f1b5 100644 --- a/tests/integration/tests/09-cloud-billing-lifecycle.spec.ts +++ b/tests/integration/tests/09-cloud-billing-lifecycle.spec.ts @@ -79,8 +79,10 @@ async function listTenants( request: import('@playwright/test').APIRequestContext, baseURL: string, adminKey: string, + state?: string, ): Promise { - const response = await request.get(`${baseURL}/admin/tenants`, { + const suffix = state ? `?state=${encodeURIComponent(state)}` : ''; + const response = await request.get(`${baseURL}/admin/tenants${suffix}`, { headers: { 'X-Admin-Key': adminKey, Accept: 'application/json', @@ -174,6 +176,11 @@ test.describe.serial('Cloud billing lifecycle (post-checkout)', () => { }, { timeout: 60_000 }).toBe('active'); expect(tenantID).toBeTruthy(); + const activeTenants = await listTenants(page.request, baseURL, adminKey, 'active'); + const activeTenant = activeTenants.find((entry) => entry.id === tenantID); + expect(activeTenant?.id).toBe(tenantID); + expect(activeTenant?.state).toBe('active'); + await stripeRequest( stripeSecretKey, `/v1/subscriptions/${encodeURIComponent(subscriptionID)}`,