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/4] 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/4] 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/4] 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) From 001e0f123c72eac9d2d4d7d853b9b3cb22a612b9 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 28 May 2026 16:41:40 -0700 Subject: [PATCH 4/4] chore: regenerate pyi_hashes.json for packages/ components Co-Authored-By: Claude Opus 4.8 (1M context) --- pyi_hashes.json | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/pyi_hashes.json b/pyi_hashes.json index 33a6e1dc984..b4498b3581b 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,4 +1,123 @@ { + "packages/reflex-components-code/src/reflex_components_code/code.pyi": "6ef91a4a4976e66b2761539e16d4f28e", + "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "d3e0c33fdc34f5c154ac387d550c0d29", + "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", + "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", + "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "ecccfd8a9b0e8b2f4128ff13ff27a9da", + "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "2535814d409e5feaf57da63dcf0abeaf", + "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "a2e67a9814dc61853ca2299d9d9c698d", + "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "59170074a1a228ce58685f3f207954f2", + "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "e4cbfc46eabb904596be4372392add35", + "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "005866cf4d1cc8ac7693ed6baeca2289", + "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "0cfa2d8c52321ce7440e887d03007d5b", + "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "bfc7fb609b822f597d1141595f8090fe", + "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", + "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", + "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", + "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "58138b5f1d5901839729d839620ea4da", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", + "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6", + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", + "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "2dd6ba6e3a4d61fc1d79eb582a7cc548", + "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6", + "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", + "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", + "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "ff68d843c5987d3f0d773a6367eb9c63", + "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", + "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "a3ef8bcb5fe8e4bfb22a8f6d714611b8", + "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "21e51ccc7307c3c41f2556ffa7019f2c", + "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "9c1432e70e6b9349f44df04a244a4303", + "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f51120c31a1a8b79da9ecf58f19005b9", + "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "73d19f3d9e389447ad8bbb68e1b7d1c9", + "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "c86abf00384b5f15725a0daf2533848d", + "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "903432e316a781b342f2b8d334952da1", + "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "fbbe0bf222d4196c32c88d05cb077997", + "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "93a69aab9a6f519e3f293d439a39786b", + "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "2b434f2231d6f21b12d32995ac185e79", + "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", + "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", + "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "b692058e40b15da293fbf463ad300a83", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "e04f22f5d3d2b5dfd99f9fbedb2b4f3d", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", + "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", + "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "4d6121ccc963c64e33c49acd4295eb7a", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "b3b66ec57525c53ea741897e2bc8370e", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "c86bc8d4604e3d8c8d40baad2ac6dc17", + "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", + "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "e75cbf2a34620721432b1556f3c875cd", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "ed020269e4728cc6abe72354193146b7", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "d5e0419729df4ddf2caf214f40ae7845", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "613abb9870259547c99eb434a3a17512", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "1671e796449b236386d8f53d33e42b2f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "9fde9929ca5197e0e1880bce9a08e926", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "e5d6387a93c74dafaa0d6f1719e08bac", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "31da62c4d8c1d459089aab32cd232feb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "76afb58340c6be1f26b7b110473efa55", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "6cbf013e21d7280118dfd7383998b3bf", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "63b4134246f68f9f556896d6ce194462", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "ec3f89e7d187303344d4127a83522b22", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "99541ee46f112eb4096f903a99f5ffb8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "1dfd91741ff402b3ed93b6daca4939f3", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "ed9198da4a7950a8579e50ad970c34ef", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "15f9cee0584414f2d2e0fb82c167f216", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "3f328bb0ba5225e4478febf8c7623833", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "be8eed28e19221a406e554829809ff0d", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "9b2adf18f7d239b8e7431f39042ed301", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "4d5813a47b8f8b6ac317ca01d87d9afb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "1ab01f45a4c5ef4211eacc00cc99e4a5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "38a7412205a98617f98218a5b213ada1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "d84b16ac16083a534199fd23659aaa06", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "51fda6313f1ce86d5b1ffdfd68ae8b74", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "bba40e5eae75314157378c9e8b0eea73", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "44770b1f5eb91502bfef3aadd209d0b8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "0f2f7b6ea8d5e4d2e35ff2069364ab75", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "519781d33b99c675a12014d400e54d08", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "f4848f7d89abb4c78f6db52c624cdabf", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "61a08374fa19a0bb3f52b8654effc0f2", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "530c51742031389d4b2ae43548ff0f03", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "23b21bc11a0012e13ce9bb79b47ba146", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "8364f40600870bafa585528d9cadedf8", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "3a52910c327f55656eb59309f9362361", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "fcf562b2f61ecdcc2de6f70d2ebf9907", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "250b7e77b67e7d8cd3fff2b40526c04c", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "799acce0af81899a3a310bdcd43c403b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "9e452af27229b676ad0146e40f75bed5", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "5b262189e235cac17182e79188b1681a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "e06c8fd64132765d61b9edb87a48558b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "5aa934d7c6ba3889fa943eabee7dc05f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "c67fafd1aec105cb5a9927ff0e6d2071", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "aa68061a8e5dfd4adf336d1d1cb000fb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "06b92d31331c6f08b5083fcc811b754a", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "e7cd3a9cea1c34e21f731f1bd05c1ceb", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "8c968fead3155b2d51c687459811b5df", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "cfc8a927642e5b68feabc80080aeb8dc", + "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "cf88cf870eefaacaf765ead10fb4593b", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "92d5a2df77a69a28a4d591000ee46bd1", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "8a1e4376cadf4961212d39a5128a0e4f", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "34c7ed3fe1e5f702a98d72751b0052fa", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "619a9d8351748fffe76136002931e583", + "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "4919daa4483b7c12f6fafd02a2275e0f", + "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "0817c9232a6e4790cff8ea8aa6001950", + "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "6c1c26149d57c708fab04b82de0eb515", + "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "75207a9fe4f37ec2a2f1becbbbd5237b", + "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", + "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "277bbf09d72e0c450241f0b7d39ebb60", + "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "be20d1d71c3b16f7e973a0329c3d81d6", + "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "5a1a479924ad6184abafe4d796cb04c5", + "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", + "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482", + "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "12a863ddbcac050c702a3ec6092ae17c", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "5bfbbd60585132d7a76840a0dbacbdd2"