diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py index 755b3410831..68687f8dcc9 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -7,7 +7,7 @@ tree is never modified. The script reads its parameters from environment variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION, CLOUD_RUN_CPU, CLOUD_RUN_MEMORY, CLOUD_RUN_MIN_INSTANCES, -REFLEX_CLOUDBUILD_YAML). +CLOUD_RUN_SERVICE_ACCOUNT, REFLEX_CLOUDBUILD_YAML). """ from __future__ import annotations @@ -41,6 +41,7 @@ ENV_CPU = "CLOUD_RUN_CPU" ENV_MEMORY = "CLOUD_RUN_MEMORY" ENV_MIN_INSTANCES = "CLOUD_RUN_MIN_INSTANCES" +ENV_SERVICE_ACCOUNT = "CLOUD_RUN_SERVICE_ACCOUNT" # Path to the Cloud Build config file written by the CLI. The rewritten # deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``. ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML" @@ -162,6 +163,12 @@ type=click.IntRange(min=0), help="Minimum number of Cloud Run instances to keep warm (sets CLOUD_RUN_MIN_INSTANCES). Set to 0 to scale to zero.", ) +@click.option( + "--service-account", + "service_account", + default=None, + help="IAM service account email the Cloud Run service runs as (sets CLOUD_RUN_SERVICE_ACCOUNT). If omitted, Cloud Run uses the project's default compute SA. The deploying principal needs roles/iam.serviceAccountUser on the target SA.", +) @click.option( "--source", "source_dir", @@ -199,6 +206,7 @@ def deploy_command( cpu: str, memory: str, min_instances: int, + service_account: str | None, source_dir: str, token: str | None, interactive: bool, @@ -285,6 +293,8 @@ def deploy_command( ENV_MEMORY: memory, ENV_MIN_INSTANCES: str(min_instances), } + if service_account: + deploy_env[ENV_SERVICE_ACCOUNT] = service_account console.info("Received deploy manifest from Reflex.") console.print("") diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 8109504133f..da84f523b01 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -206,6 +206,56 @@ def test_gcp_deploy_resource_flags_have_defaults(mocker: MockFixture, tmp_path: assert env_overrides["CLOUD_RUN_MIN_INSTANCES"] == "1" +def test_gcp_deploy_forwards_service_account(mocker: MockFixture, tmp_path: Path): + """--service-account threads through to CLOUD_RUN_SERVICE_ACCOUNT.""" + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--service-account", + "my-sa@p.iam.gserviceaccount.com", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + env_overrides = run_mock.call_args.kwargs["env_overrides"] + assert ( + env_overrides["CLOUD_RUN_SERVICE_ACCOUNT"] == "my-sa@p.iam.gserviceaccount.com" + ) + + +def test_gcp_deploy_omits_service_account_when_unset( + mocker: MockFixture, tmp_path: Path +): + """Without --service-account, CLOUD_RUN_SERVICE_ACCOUNT is not in env_overrides. + + The deploy script defaults it to empty, so Cloud Run falls back to the + project's default compute SA. Leaving the key out of env_overrides (rather + than sending an empty string) keeps the dry-run output tidy. + """ + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], + input="y\n", + ) + + assert result.exit_code == 0, result.output + env_overrides = run_mock.call_args.kwargs["env_overrides"] + assert "CLOUD_RUN_SERVICE_ACCOUNT" not in env_overrides + + def test_gcp_deploy_rejects_negative_min_instances(mocker: MockFixture, tmp_path: Path): """--min-instances is IntRange(min=0); negative values fail at the CLI layer.""" run_mock = _patch_environment(mocker)