From 869e09d9d38ed78cedfbaf0ff0532c50004a513f Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 22 May 2026 09:44:25 -0700 Subject: [PATCH 1/3] hosting-cli(gcp): add --max-instances and --allow-unauthenticated toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new flags for `reflex deploy --gcp`: - --max-instances (IntRange(min=1), default 100): caps autoscaling so cost-conscious deploys don't run open-ended against Cloud Run's 100- instance default. CLI-level validation rejects max < min so users get a clear error instead of an opaque gcloud one. - --allow-unauthenticated / --no-allow-unauthenticated (default true): today the deploy script unconditionally publishes the service to allUsers. The negated form makes the service private — callers then need a roles/run.invoker IAM binding to reach it (or front it with IAP / a load balancer with IAM auth). Help text calls this out. Forwarded as CLOUD_RUN_MAX_INSTANCES (string int) and CLOUD_RUN_ALLOW_UNAUTHENTICATED ("true"/"false"). Requires the matching backend change so the deploy script honors them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/reflex_cli/v2/gcp.py | 27 +++++ tests/units/reflex_cli/v2/test_gcp.py | 110 ++++++++++++++++++ 2 files changed, 137 insertions(+) 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..425c416b50c 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -7,6 +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, +CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED, REFLEX_CLOUDBUILD_YAML). """ @@ -41,6 +42,8 @@ ENV_CPU = "CLOUD_RUN_CPU" ENV_MEMORY = "CLOUD_RUN_MEMORY" ENV_MIN_INSTANCES = "CLOUD_RUN_MIN_INSTANCES" +ENV_MAX_INSTANCES = "CLOUD_RUN_MAX_INSTANCES" +ENV_ALLOW_UNAUTHENTICATED = "CLOUD_RUN_ALLOW_UNAUTHENTICATED" # 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 +165,21 @@ 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( + "--max-instances", + "max_instances", + default=100, + show_default=True, + type=click.IntRange(min=1), + help="Maximum number of Cloud Run instances during autoscaling (sets CLOUD_RUN_MAX_INSTANCES). Caps cost under traffic spikes.", +) +@click.option( + "--allow-unauthenticated/--no-allow-unauthenticated", + "allow_unauthenticated", + default=True, + show_default=True, + help="Whether to make the Cloud Run service publicly reachable (sets CLOUD_RUN_ALLOW_UNAUTHENTICATED). Use --no-allow-unauthenticated for internal / IAP-fronted services; callers will then need a roles/run.invoker IAM binding.", +) @click.option( "--source", "source_dir", @@ -199,6 +217,8 @@ def deploy_command( cpu: str, memory: str, min_instances: int, + max_instances: int, + allow_unauthenticated: bool, source_dir: str, token: str | None, interactive: bool, @@ -226,6 +246,11 @@ def deploy_command( if not gcp_project: console.error("--gcp-project is required when using --gcp.") raise click.exceptions.Exit(2) + if max_instances < min_instances: + console.error( + f"--max-instances ({max_instances}) must be >= --min-instances ({min_instances})." + ) + raise click.exceptions.Exit(2) authenticated_client = hosting.get_authenticated_client( token=token, interactive=interactive @@ -284,6 +309,8 @@ def deploy_command( ENV_CPU: cpu, ENV_MEMORY: memory, ENV_MIN_INSTANCES: str(min_instances), + ENV_MAX_INSTANCES: str(max_instances), + ENV_ALLOW_UNAUTHENTICATED: "true" if allow_unauthenticated else "false", } console.info("Received deploy manifest from Reflex.") diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 8109504133f..dd9efcf5626 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -206,6 +206,116 @@ 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_max_instances(mocker: MockFixture, tmp_path: Path): + """--max-instances threads through to CLOUD_RUN_MAX_INSTANCES.""" + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--max-instances", + "42", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + env_overrides = run_mock.call_args.kwargs["env_overrides"] + assert env_overrides["CLOUD_RUN_MAX_INSTANCES"] == "42" + + +def test_gcp_deploy_max_instances_default(mocker: MockFixture, tmp_path: Path): + """Default --max-instances is 100, matching Cloud Run's own default.""" + 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 env_overrides["CLOUD_RUN_MAX_INSTANCES"] == "100" + + +def test_gcp_deploy_rejects_max_less_than_min(mocker: MockFixture, tmp_path: Path): + """--max-instances < --min-instances is caught at the CLI, not inside gcloud.""" + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--min-instances", + "5", + "--max-instances", + "3", + ], + ) + + assert result.exit_code == 2 + assert "max-instances" in result.output.lower() + assert "min-instances" in result.output.lower() + assert run_mock.call_count == 0 + + +def test_gcp_deploy_allow_unauthenticated_defaults_true( + mocker: MockFixture, tmp_path: Path +): + """Default is --allow-unauthenticated (public service), matching prior behavior.""" + 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 env_overrides["CLOUD_RUN_ALLOW_UNAUTHENTICATED"] == "true" + + +def test_gcp_deploy_no_allow_unauthenticated(mocker: MockFixture, tmp_path: Path): + """--no-allow-unauthenticated produces the 'false' value.""" + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--no-allow-unauthenticated", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + env_overrides = run_mock.call_args.kwargs["env_overrides"] + assert env_overrides["CLOUD_RUN_ALLOW_UNAUTHENTICATED"] == "false" + + 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) From 70ebba39984ca658a994c07eed4c34e8dbe2e5a6 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 22 May 2026 09:54:22 -0700 Subject: [PATCH 2/3] hosting-cli(gcp): add --env / --envfile matching existing CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user-supplied env vars to `reflex deploy --gcp`, mirroring the existing `reflex deploy` and `reflex secrets update` flows: - `--env KEY=VALUE` (multiple=True): repeatable; parsed by `hosting.process_envs` (validates key format). - `--envfile PATH`: reads a .env file via `dotenv_values`; lazy import with the same install-hint as secrets.py. - When both are passed, --envfile wins with a warning (same precedence as the existing flows). Implementation: the parsed dict is written to a YAML tempfile (via json.dumps per value, so any string round-trips safely) and the path is forwarded to the deploy script as REFLEX_ENV_VARS_FILE. The script hands it to `gcloud run deploy --env-vars-file=...`. The tempfile's lifecycle is bound to a contextlib.ExitStack so it's only created when envs are present and always cleaned up afterward. Dry-run output now shows the env-vars YAML body so users can preview what's about to ship to Cloud Run. Help text calls out that these become plain Cloud Run env vars (visible to roles/run.viewer) and points at Secret Manager for sensitive values — matches existing Reflex/Fly deploy semantics. Companion backend PR (the script-side --env-vars-file support): reflex-dev/flexgen#3748. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/reflex_cli/v2/gcp.py | 124 +++++++++++++- tests/units/reflex_cli/v2/test_gcp.py | 156 ++++++++++++++++++ 2 files changed, 274 insertions(+), 6 deletions(-) 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 425c416b50c..da153e088d6 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -8,12 +8,13 @@ variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION, CLOUD_RUN_CPU, CLOUD_RUN_MEMORY, CLOUD_RUN_MIN_INSTANCES, CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED, -REFLEX_CLOUDBUILD_YAML). +REFLEX_CLOUDBUILD_YAML, REFLEX_ENV_VARS_FILE). """ from __future__ import annotations import contextlib +import json import os import re import shutil @@ -47,6 +48,9 @@ # 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" +# Path to a YAML file with user-supplied env vars. When set, the deploy +# script passes it to ``gcloud run deploy --env-vars-file=...``. +ENV_REFLEX_ENV_VARS_FILE = "REFLEX_ENV_VARS_FILE" # Pattern for the start of the `gcloud builds submit` invocation in the # Reflex deploy script. We rewrite that whole multi-line command to use @@ -180,6 +184,17 @@ show_default=True, help="Whether to make the Cloud Run service publicly reachable (sets CLOUD_RUN_ALLOW_UNAUTHENTICATED). Use --no-allow-unauthenticated for internal / IAP-fronted services; callers will then need a roles/run.invoker IAM binding.", ) +@click.option( + "--envfile", + default=None, + help="Path to a .env file. Loaded into the Cloud Run service as env vars. Takes precedence over --env.", +) +@click.option( + "--env", + "envs", + multiple=True, + help="Environment variable to set on the Cloud Run service: =. Repeat for multiple, e.g. --env K1=V1 --env K2=V2. Plain Cloud Run env vars — visible to anyone with roles/run.viewer; for sensitive values use Secret Manager separately.", +) @click.option( "--source", "source_dir", @@ -219,6 +234,8 @@ def deploy_command( min_instances: int, max_instances: int, allow_unauthenticated: bool, + envfile: str | None, + envs: tuple[str, ...], source_dir: str, token: str | None, interactive: bool, @@ -252,6 +269,8 @@ def deploy_command( ) raise click.exceptions.Exit(2) + parsed_envs = _parse_envs(envfile=envfile, envs=envs) + authenticated_client = hosting.get_authenticated_client( token=token, interactive=interactive ) @@ -333,6 +352,8 @@ def deploy_command( "tempfile; your source directory is not modified." ) + env_vars_yaml = _format_env_vars_yaml(parsed_envs) if parsed_envs else None + if dry_run: console.print("") console.print("cloudbuild.yaml contents:") @@ -344,6 +365,12 @@ def deploy_command( console.print("─" * 60) console.print(dockerfile) console.print("─" * 60) + if env_vars_yaml is not None: + console.print("") + console.print(f"env-vars file contents ({len(parsed_envs)} variable(s)):") + console.print("─" * 60) + console.print(env_vars_yaml) + console.print("─" * 60) console.info("Dry run — nothing staged or executed.") return @@ -355,15 +382,21 @@ def deploy_command( console.warn("Aborted by user.") raise click.exceptions.Exit(1) - with _temp_cloudbuild_yaml(cloudbuild_yaml) as cloudbuild_path: + with contextlib.ExitStack() as stack: + cloudbuild_path = stack.enter_context(_temp_cloudbuild_yaml(cloudbuild_yaml)) + env_overrides = { + **deploy_env, + ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path), + } + if env_vars_yaml is not None: + env_vars_path = stack.enter_context(_temp_env_vars_yaml(env_vars_yaml)) + env_overrides[ENV_REFLEX_ENV_VARS_FILE] = str(env_vars_path) + exit_code = _run_deploy_script( bash_path=bash_path, script=deploy_script, cwd=source_path, - env_overrides={ - **deploy_env, - ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path), - }, + env_overrides=env_overrides, ) if exit_code != 0: console.error(f"Deploy script exited with status {exit_code}.") @@ -590,6 +623,85 @@ def _temp_cloudbuild_yaml(contents: str): path.unlink() +@contextlib.contextmanager +def _temp_env_vars_yaml(contents: str): + """Write the env-vars YAML to a tempfile and yield its path; always clean up. + + Args: + contents: The YAML body to write (one ``KEY: "value"`` line per env var). + + Yields: + The path to the written tempfile. + + """ + fd, path_str = tempfile.mkstemp(prefix="reflex-env-vars-", suffix=".yaml") + path = Path(path_str) + try: + with os.fdopen(fd, "w") as fh: + fh.write(contents) + yield path + finally: + with contextlib.suppress(FileNotFoundError): + path.unlink() + + +def _parse_envs(envfile: str | None, envs: tuple[str, ...]) -> dict[str, str]: + """Resolve --envfile + --env into a single dict of env vars. + + Mirrors the precedence of the existing `reflex deploy` / `reflex secrets + update` flow: when both are provided, --envfile wins and --env is + discarded with a warning. Empty values are preserved as empty strings; + keys defined without a value in the envfile (``FOO`` with no ``=``) + become empty strings rather than ``None``. + + Args: + envfile: Path to a .env file, or None. + envs: Tuple of ``KEY=VALUE`` strings from repeated --env flags. + + Returns: + Dict of env var name → string value. Empty when neither input is set. + + """ + from reflex_cli.utils import hosting + + if envfile and envs: + console.warn("--envfile is set; ignoring --env") + + if envfile: + try: + from dotenv import dotenv_values # pyright: ignore[reportMissingImports] + except ImportError: + console.error( + 'The `python-dotenv` package is required for --envfile. Run `pip install "python-dotenv>=1.0.1"`.' + ) + raise click.exceptions.Exit(1) from None + return { + k: (v if v is not None else "") for k, v in dotenv_values(envfile).items() + } + + if envs: + return hosting.process_envs(list(envs)) + + return {} + + +def _format_env_vars_yaml(envs: dict[str, str]) -> str: + """Format env vars as YAML for gcloud ``--env-vars-file``. + + Uses ``json.dumps`` per value so any string — quotes, backslashes, + newlines, unicode — is encoded safely. JSON strings are valid YAML, so + the output round-trips through gcloud's YAML loader. + + Args: + envs: Dict of env var name → string value. + + Returns: + YAML body with one ``KEY: "value"`` line per env var. + + """ + return "".join(f"{k}: {json.dumps(v)}\n" for k, v in envs.items()) + + def _run_deploy_script( bash_path: str, script: str, diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index dd9efcf5626..509f85a9159 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -316,6 +316,162 @@ def test_gcp_deploy_no_allow_unauthenticated(mocker: MockFixture, tmp_path: Path assert env_overrides["CLOUD_RUN_ALLOW_UNAUTHENTICATED"] == "false" +def test_gcp_deploy_no_env_vars_means_no_env_vars_file( + mocker: MockFixture, tmp_path: Path +): + """Without --env or --envfile, REFLEX_ENV_VARS_FILE is absent from env_overrides.""" + 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 "REFLEX_ENV_VARS_FILE" not in env_overrides + + +def test_gcp_deploy_forwards_env_flag(mocker: MockFixture, tmp_path: Path): + """--env KEY=VALUE writes a tempfile and forwards its path via REFLEX_ENV_VARS_FILE. + + Captures the file's contents during the run (the tempfile is unlinked + afterward) and verifies the YAML body uses json-encoded values. + """ + captured: dict = {} + + def capture(**kwargs): + path = Path(kwargs["env_overrides"]["REFLEX_ENV_VARS_FILE"]) + captured["existed_during_run"] = path.exists() + captured["path"] = path + captured["yaml"] = path.read_text() + return 0 + + run_mock = _patch_environment(mocker) + run_mock.side_effect = capture + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--env", + "DB_URL=postgres://u:p@h/d", + "--env", + "FEATURE_FLAG=on", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert captured["existed_during_run"] + assert not captured["path"].exists() # cleaned up after run + # YAML uses json.dumps per value, so embedded special chars are escaped. + assert captured["yaml"] == 'DB_URL: "postgres://u:p@h/d"\nFEATURE_FLAG: "on"\n' + + +def test_gcp_deploy_envfile_loads_dotenv(mocker: MockFixture, tmp_path: Path): + """--envfile reads a .env file via dotenv_values and forwards its contents.""" + envfile = tmp_path / ".env" + envfile.write_text('DB_URL="postgres://u:p@h/d"\nFEATURE_FLAG=on\n') + + captured: dict = {} + + def capture(**kwargs): + path = Path(kwargs["env_overrides"]["REFLEX_ENV_VARS_FILE"]) + captured["yaml"] = path.read_text() + return 0 + + run_mock = _patch_environment(mocker) + run_mock.side_effect = capture + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--envfile", + str(envfile), + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + yaml = captured["yaml"] + assert 'DB_URL: "postgres://u:p@h/d"' in yaml + assert 'FEATURE_FLAG: "on"' in yaml + + +def test_gcp_deploy_envfile_takes_precedence_over_env_with_warning( + mocker: MockFixture, tmp_path: Path +): + """When both --envfile and --env are passed, --envfile wins (matches existing flow).""" + envfile = tmp_path / ".env" + envfile.write_text("FROM_FILE=yes\n") + + captured: dict = {} + + def capture(**kwargs): + path = Path(kwargs["env_overrides"]["REFLEX_ENV_VARS_FILE"]) + captured["yaml"] = path.read_text() + return 0 + + run_mock = _patch_environment(mocker) + run_mock.side_effect = capture + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--envfile", + str(envfile), + "--env", + "FROM_FLAG=no", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert "FROM_FILE" in captured["yaml"] + assert "FROM_FLAG" not in captured["yaml"] + assert "envfile" in result.output.lower() + assert "ignoring --env" in result.output + + +def test_format_env_vars_yaml_escapes_specials(): + """Values with quotes, backslashes, and newlines round-trip via json.dumps.""" + from reflex_cli.v2 import gcp as gcp_module + + envs = { + "QUOTED": 'has "quotes" and \\ backslash', + "MULTILINE": "line1\nline2", + "EMPTY": "", + } + yaml = gcp_module._format_env_vars_yaml(envs) + # Each line is `KEY: `. + assert 'QUOTED: "has \\"quotes\\" and \\\\ backslash"' in yaml + assert 'MULTILINE: "line1\\nline2"' in yaml + assert 'EMPTY: ""' in yaml + + 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) From 15ceb22c174315bb8a11509f7d2eeb7bd1127b78 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 22 May 2026 11:21:56 -0700 Subject: [PATCH 3/3] review: guard --no-allow-unauthenticated against older backends Per Greptile review on #6557 (P1 + security): if a user upgrades the CLI before the matching flexgen backend ships, passing --no-allow-unauthenticated would be silently no-op'd by the older deploy script (which still hard-codes --allow-unauthenticated), producing a PUBLIC service when the user explicitly asked for a private one. That's exactly the fail-silent privacy flip we defended against on the script side. Add a CLI-side check: after fetching the manifest, if the user passed --no-allow-unauthenticated but the fetched deploy_script doesn't reference CLOUD_RUN_ALLOW_UNAUTHENTICATED, abort with a clear error naming the missing backend support. Declining the companion P2 (IntRange max=1000 on --max-instances): 1000 is a soft default per-service cap that customers can raise via quota request; hard-coding it client-side would lock out users with raised quotas. Let gcloud be the authority. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/reflex_cli/v2/gcp.py | 15 ++++++ tests/units/reflex_cli/v2/test_gcp.py | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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 da153e088d6..e93b6b14cae 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -306,6 +306,21 @@ def deploy_command( dockerfile, deploy_script = _request_manifest(authenticated_client.token) + # If the user asks for a private service, abort when the fetched script + # doesn't reference CLOUD_RUN_ALLOW_UNAUTHENTICATED. Without that backend + # support the deploy would silently use the script's hard-coded + # --allow-unauthenticated, producing a public service when the user + # explicitly asked for a private one — a silent privacy flip we'd rather + # fail loud on. + if not allow_unauthenticated and ENV_ALLOW_UNAUTHENTICATED not in deploy_script: + console.error( + "The Reflex backend's deploy script doesn't yet recognize " + f"{ENV_ALLOW_UNAUTHENTICATED} — without it, --no-allow-unauthenticated " + "would be silently ignored and the service would deploy as PUBLIC. " + "Upgrade the Reflex backend, or remove --no-allow-unauthenticated." + ) + raise click.exceptions.Exit(1) + source_path = Path(source_dir).resolve() if not source_path.is_dir(): console.error(f"Source directory does not exist: {source_path}") diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 509f85a9159..123d75bd472 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -25,6 +25,7 @@ "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'IMAGE="us-central1-docker.pkg.dev/${GCP_PROJECT}/reflex/${SERVICE_NAME}:${VERSION}"\n' + "AUTH=${CLOUD_RUN_ALLOW_UNAUTHENTICATED:-true}\n" "gcloud builds submit \\\n" ' --tag "${IMAGE}" \\\n' ' --project "${GCP_PROJECT}" \\\n' @@ -292,6 +293,53 @@ def test_gcp_deploy_allow_unauthenticated_defaults_true( assert env_overrides["CLOUD_RUN_ALLOW_UNAUTHENTICATED"] == "true" +def test_gcp_deploy_no_allow_unauthenticated_requires_backend_support( + mocker: MockFixture, tmp_path: Path +): + """--no-allow-unauthenticated aborts when the fetched script doesn't honor it. + + The deploy script's auth flag is read from CLOUD_RUN_ALLOW_UNAUTHENTICATED. + If we shipped a CLI build against an older backend that still hard-codes + --allow-unauthenticated, the user's --no-allow-unauthenticated would be + silently ignored and the service would deploy as PUBLIC. Catch the + mismatch at the CLI before we deploy anything. + """ + run_mock = _patch_environment(mocker) + # Manifest from an older backend that doesn't reference the env var — + # built from scratch so it doesn't inherit DEPLOY_SCRIPT's auth env var. + legacy_script = ( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'IMAGE="us-central1-docker.pkg.dev/${GCP_PROJECT}/reflex/${SERVICE_NAME}:${VERSION}"\n' + "gcloud builds submit \\\n" + ' --tag "${IMAGE}" \\\n' + ' --project "${GCP_PROJECT}" \\\n' + " .\n" + 'gcloud run deploy "${SERVICE_NAME}" --image "${IMAGE}" --allow-unauthenticated\n' + ) + _mock_manifest_response( + mocker, body={"dockerfile": DOCKERFILE, "deploy_command": legacy_script} + ) + + result = runner.invoke( + hosting_cli, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--no-allow-unauthenticated", + ], + ) + + assert result.exit_code == 1 + assert "CLOUD_RUN_ALLOW_UNAUTHENTICATED" in result.output + assert "PUBLIC" in result.output + assert run_mock.call_count == 0 + + def test_gcp_deploy_no_allow_unauthenticated(mocker: MockFixture, tmp_path: Path): """--no-allow-unauthenticated produces the 'false' value.""" run_mock = _patch_environment(mocker)