Skip to content
27 changes: 27 additions & 0 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Comment thread
Kastier1 marked this conversation as resolved.
)
@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.",
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
@click.option(
"--source",
"source_dir",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
110 changes: 110 additions & 0 deletions tests/units/reflex_cli/v2/test_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading