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..e93b6b14cae 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -7,12 +7,14 @@ 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_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED, +REFLEX_CLOUDBUILD_YAML, REFLEX_ENV_VARS_FILE). """ from __future__ import annotations import contextlib +import json import os import re import shutil @@ -41,9 +43,14 @@ 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" +# 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 @@ -162,6 +169,32 @@ 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( + "--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", @@ -199,6 +232,10 @@ def deploy_command( cpu: str, memory: str, min_instances: int, + max_instances: int, + allow_unauthenticated: bool, + envfile: str | None, + envs: tuple[str, ...], source_dir: str, token: str | None, interactive: bool, @@ -226,6 +263,13 @@ 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) + + parsed_envs = _parse_envs(envfile=envfile, envs=envs) authenticated_client = hosting.get_authenticated_client( token=token, interactive=interactive @@ -262,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}") @@ -284,6 +343,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.") @@ -306,6 +367,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:") @@ -317,6 +380,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 @@ -328,15 +397,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}.") @@ -563,6 +638,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 8109504133f..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' @@ -206,6 +207,319 @@ 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_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) + _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_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)