From a100bf8ea7099c46eadcd0d7d3e60eddbb8d8702 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Mon, 4 May 2026 13:20:42 -0700 Subject: [PATCH 1/3] feat(hosting-cli): add `reflex cloud gcp deploy` for Cloud Run Fetches a Dockerfile and bash deploy script from flexgen (`GET /api/v1/cli/gcp-cloud-run-manifest`), writes the Dockerfile into the user's source directory, prints the script, and runs it via bash after the user confirms. Pre-flights `bash`/`gcloud`/`docker` on PATH and an active gcloud account, and surfaces a clear message on 403 (Enterprise tier required). Deploy parameters (project, region, service name, AR repo, version) are passed via env vars to the script. Co-Authored-By: Claude Opus 4.7 --- .../src/reflex_cli/v2/deployments.py | 5 + .../src/reflex_cli/v2/gcp.py | 364 ++++++++++++++++++ tests/units/reflex_cli/v2/test_gcp.py | 299 ++++++++++++++ 3 files changed, 668 insertions(+) create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py create mode 100644 tests/units/reflex_cli/v2/test_gcp.py diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index 8042bf8f6c5..cca4f50cdb3 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -12,6 +12,7 @@ from reflex_cli import constants from reflex_cli.utils import console from reflex_cli.v2.apps import apps_cli +from reflex_cli.v2.gcp import gcp_cli from reflex_cli.v2.project import project_cli from reflex_cli.v2.secrets import secrets_cli from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli @@ -64,6 +65,10 @@ def hosting_cli(ctx: click.Context) -> None: secrets_cli, name="secrets", ) +hosting_cli.add_command( + gcp_cli, + name="gcp", +) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI hosting_cli.add_command(command, name=name) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py new file mode 100644 index 00000000000..950985cbc8f --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -0,0 +1,364 @@ +"""GCP Cloud Run deploy commands for the Reflex Cloud CLI. + +Fetches a Dockerfile + bash deploy script from flexgen, writes the Dockerfile +into the user's project, prints the script, and runs it via bash after the +user confirms. The script reads its parameters from environment variables +(GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION). +""" + +from __future__ import annotations + +import contextlib +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urljoin + +import click + +from reflex_cli import constants +from reflex_cli.utils import console + +GCP_MANIFEST_ENDPOINT = "/api/v1/cli/gcp-cloud-run-manifest" + +DOCKERFILE_NAME = "Dockerfile" + + +@click.group() +def gcp_cli(): + """Commands for deploying to GCP Cloud Run.""" + + +@gcp_cli.command(name="deploy") +@click.option( + "--gcp-project", + "gcp_project", + required=True, + help="The GCP project ID to deploy into (sets GCP_PROJECT).", +) +@click.option( + "--region", + default="us-central1", + show_default=True, + help="The GCP region for Cloud Run (sets GCP_REGION).", +) +@click.option( + "--service-name", + default="reflex-app", + show_default=True, + help="The Cloud Run service name (sets SERVICE_NAME).", +) +@click.option( + "--ar-repo", + default="reflex", + show_default=True, + help="The Artifact Registry repository name (sets AR_REPO).", +) +@click.option( + "--version", + "version_tag", + default=None, + help="The image version tag (sets VERSION). Defaults to a UTC timestamp.", +) +@click.option( + "--source", + "source_dir", + default=".", + show_default=True, + type=click.Path(file_okay=False, dir_okay=True), + help="The directory containing the Reflex app and into which the Dockerfile is written.", +) +@click.option( + "--overwrite-dockerfile/--no-overwrite-dockerfile", + default=False, + show_default=True, + help="Overwrite an existing Dockerfile without prompting.", +) +@click.option("--token", help="The Reflex authentication token.") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Print the manifest without writing the Dockerfile or running the script.", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def gcp_deploy( + gcp_project: str, + region: str, + service_name: str, + ar_repo: str, + version_tag: str | None, + source_dir: str, + overwrite_dockerfile: bool, + token: str | None, + dry_run: bool, + loglevel: str, +): + """Deploy a Reflex app to GCP Cloud Run. + + Fetches a Dockerfile and bash deploy script from flexgen, writes the Dockerfile + into the source directory, then asks before running the script. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=True + ) + + bash_path = shutil.which("bash") + if not bash_path: + console.error( + "`bash` was not found on PATH; required to run the deploy script." + ) + raise click.exceptions.Exit(1) + + gcloud_path = shutil.which("gcloud") + if not gcloud_path: + console.error( + "The `gcloud` CLI was not found on PATH. Install it from " + "https://cloud.google.com/sdk/docs/install and run `gcloud auth login` " + "and `gcloud auth application-default login` before retrying." + ) + raise click.exceptions.Exit(1) + + if not shutil.which("docker"): + console.error( + "The `docker` CLI was not found on PATH; required to build the image." + ) + raise click.exceptions.Exit(1) + + if not _get_active_gcp_account(gcloud_path): + console.error( + "No active GCP account found. Run `gcloud auth login` and " + "`gcloud auth application-default login`, then retry." + ) + raise click.exceptions.Exit(1) + + dockerfile, deploy_script = _request_manifest(authenticated_client.token) + + source_path = Path(source_dir).resolve() + if not source_path.is_dir(): + console.error(f"Source directory does not exist: {source_path}") + raise click.exceptions.Exit(1) + dockerfile_path = source_path / DOCKERFILE_NAME + + version_value = version_tag or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + deploy_env = { + "GCP_PROJECT": gcp_project, + "GCP_REGION": region, + "SERVICE_NAME": service_name, + "AR_REPO": ar_repo, + "VERSION": version_value, + } + + console.info("Received deploy manifest from flexgen.") + console.print("") + console.print(f"Dockerfile target: {dockerfile_path}") + console.print("Deploy environment:") + for key, value in deploy_env.items(): + console.print(f" {key}={value}") + console.print("") + console.print("Deploy script:") + console.print("─" * 60) + console.print(deploy_script) + console.print("─" * 60) + + if dry_run: + console.print("") + console.print("Dockerfile contents:") + console.print("─" * 60) + console.print(dockerfile) + console.print("─" * 60) + console.info("Dry run — nothing written or executed.") + return + + if not _write_dockerfile(dockerfile_path, dockerfile, overwrite_dockerfile): + raise click.exceptions.Exit(1) + + answer = console.ask("Run the deploy script now?", choices=["y", "n"], default="y") + if answer != "y": + console.warn("Aborted by user. The Dockerfile has been written for later use.") + raise click.exceptions.Exit(1) + + exit_code = _run_deploy_script( + bash_path=bash_path, + script=deploy_script, + cwd=source_path, + env_overrides=deploy_env, + ) + if exit_code != 0: + console.error(f"Deploy script exited with status {exit_code}.") + raise click.exceptions.Exit(exit_code) + console.success("Deployment finished.") + + +def _get_active_gcp_account(gcloud_path: str) -> str | None: + """Return the email of the active gcloud account, or None. + + Args: + gcloud_path: Resolved path to the gcloud executable. + + Returns: + The active account email or None if not logged in. + + """ + try: + result = subprocess.run( + [ + gcloud_path, + "auth", + "list", + "--filter=status:ACTIVE", + "--format=value(account)", + ], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError) as ex: + console.debug(f"Failed to query gcloud auth list: {ex}") + return None + account = result.stdout.strip().splitlines() + return account[0] if account else None + + +def _request_manifest(token: str) -> tuple[str, str]: + """Fetch the Dockerfile + deploy script from flexgen. + + Args: + token: The Reflex API token to authenticate with. + + Returns: + A `(dockerfile, deploy_command)` tuple. + + Raises: + Exit: If the request fails or the response shape is invalid. + + """ + import httpx + + from reflex_cli.utils import hosting + + url = urljoin(constants.Hosting.HOSTING_SERVICE, GCP_MANIFEST_ENDPOINT) + try: + response = httpx.get( + url, + headers=hosting.authorization_header(token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + except httpx.HTTPStatusError as ex: + detail = ex.response.text + with contextlib.suppress(ValueError): + detail = ex.response.json().get("detail", detail) + if ex.response.status_code == 403: + console.error( + "Flexgen denied the request (403). GCP Cloud Run deploys require an " + "Enterprise tier subscription." + ) + else: + console.error(f"Flexgen rejected the manifest request: {detail}") + raise click.exceptions.Exit(1) from ex + except httpx.HTTPError as ex: + console.error(f"Failed to reach flexgen at {url}: {ex}") + raise click.exceptions.Exit(1) from ex + + try: + body = response.json() + except ValueError as ex: + console.error("Flexgen returned a non-JSON response.") + raise click.exceptions.Exit(1) from ex + + if not isinstance(body, dict): + console.error("Flexgen returned an unexpected response shape.") + raise click.exceptions.Exit(1) + + dockerfile = body.get("dockerfile") + deploy_command = body.get("deploy_command") + if not isinstance(dockerfile, str) or not dockerfile.strip(): + console.error("Flexgen response is missing a non-empty 'dockerfile' field.") + raise click.exceptions.Exit(1) + if not isinstance(deploy_command, str) or not deploy_command.strip(): + console.error("Flexgen response is missing a non-empty 'deploy_command' field.") + raise click.exceptions.Exit(1) + + return dockerfile, deploy_command + + +def _write_dockerfile(path: Path, contents: str, overwrite: bool) -> bool: + """Write the Dockerfile to disk, prompting before overwriting. + + Args: + path: Where to write the Dockerfile. + contents: The Dockerfile body. + overwrite: If True, overwrite without prompting. + + Returns: + True on success, False if the user declined to overwrite or write failed. + + """ + if path.exists() and not overwrite: + answer = console.ask( + f"{path} already exists. Overwrite?", choices=["y", "n"], default="n" + ) + if answer != "y": + console.warn( + f"Keeping the existing {path.name}. Re-run with --overwrite-dockerfile " + "or move the file aside to use the flexgen Dockerfile." + ) + return False + try: + path.write_text(contents) + except OSError as ex: + console.error(f"Failed to write {path}: {ex}") + return False + console.info(f"Wrote {path}.") + return True + + +def _run_deploy_script( + bash_path: str, + script: str, + cwd: Path, + env_overrides: dict[str, str], +) -> int: + """Run the bash deploy script, streaming output to the user's terminal. + + Args: + bash_path: Resolved path to the bash executable. + script: The bash script body received from flexgen. + cwd: Working directory to run the script in. + env_overrides: Environment variables to layer on top of the parent env. + + Returns: + The exit code of the bash process. + + """ + env = os.environ.copy() + env.update(env_overrides) + try: + result = subprocess.run( + [bash_path, "-s"], + input=script, + text=True, + cwd=cwd, + env=env, + check=False, + stdout=sys.stdout, + stderr=sys.stderr, + ) + except OSError as ex: + console.error(f"Failed to launch bash: {ex}") + return 1 + return result.returncode diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py new file mode 100644 index 00000000000..ca04004b8e1 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from pathlib import Path +from unittest import mock + +import httpx +import pytest +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_cli.utils import hosting +from reflex_cli.v2.deployments import hosting_cli +from typer.main import Typer, get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + +DOCKERFILE = "FROM python:3.13-slim\nWORKDIR /app\n" +DEPLOY_SCRIPT = ( + "#!/usr/bin/env bash\nset -euo pipefail\necho deploying ${SERVICE_NAME}\n" +) +MANIFEST = {"dockerfile": DOCKERFILE, "deploy_command": DEPLOY_SCRIPT} + + +def _patch_environment( + mocker: MockFixture, account: str = "user@example.com" +) -> mock.MagicMock: + """Patch auth + tool detection. Returns the deploy-script subprocess mock.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient(token="fake-token", validated_data={}), + ) + + def fake_which(name: str) -> str | None: + return f"/usr/bin/{name}" + + mocker.patch("reflex_cli.v2.gcp.shutil.which", side_effect=fake_which) + mocker.patch("reflex_cli.v2.gcp._get_active_gcp_account", return_value=account) + return mocker.patch("reflex_cli.v2.gcp._run_deploy_script", return_value=0) + + +def _mock_manifest_response( + mocker: MockFixture, body=MANIFEST, status_code: int = 200 +) -> mock.MagicMock: + response = mock.MagicMock(spec=httpx.Response) + response.status_code = status_code + response.json.return_value = body + response.text = "ok" + if status_code >= 400: + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "boom", request=mock.MagicMock(), response=response + ) + else: + response.raise_for_status.return_value = None + return mocker.patch("httpx.get", return_value=response) + + +def test_gcp_deploy_writes_dockerfile_and_runs_script( + mocker: MockFixture, tmp_path: Path +): + run_mock = _patch_environment(mocker) + get_mock = _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "gcp", + "deploy", + "--gcp-project", + "my-gcp-project", + "--region", + "europe-west1", + "--service-name", + "myapp", + "--ar-repo", + "myrepo", + "--version", + "v1", + "--source", + str(tmp_path), + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + dockerfile = tmp_path / "Dockerfile" + assert dockerfile.read_text() == DOCKERFILE + assert run_mock.call_count == 1 + kwargs = run_mock.call_args.kwargs + assert kwargs["script"] == DEPLOY_SCRIPT + assert kwargs["cwd"] == tmp_path.resolve() + assert kwargs["env_overrides"] == { + "GCP_PROJECT": "my-gcp-project", + "GCP_REGION": "europe-west1", + "SERVICE_NAME": "myapp", + "AR_REPO": "myrepo", + "VERSION": "v1", + } + # X-API-Token header is sent. + assert get_mock.call_args.kwargs["headers"] == {"X-API-TOKEN": "fake-token"} + + +def test_gcp_deploy_aborts_on_no(mocker: MockFixture, tmp_path: Path): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + input="n\n", + ) + + assert result.exit_code == 1 + # Dockerfile is still written so the user can run it later. + assert (tmp_path / "Dockerfile").exists() + assert run_mock.call_count == 0 + + +def test_gcp_deploy_propagates_script_failure(mocker: MockFixture, tmp_path: Path): + run_mock = _patch_environment(mocker) + run_mock.return_value = 7 + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + input="y\n", + ) + + assert result.exit_code == 7 + + +def test_gcp_deploy_dry_run(mocker: MockFixture, tmp_path: Path): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "gcp", + "deploy", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert run_mock.call_count == 0 + assert not (tmp_path / "Dockerfile").exists() + assert "Dry run" in result.output + + +def test_gcp_deploy_prompts_before_overwriting_dockerfile( + mocker: MockFixture, tmp_path: Path +): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + existing = tmp_path / "Dockerfile" + existing.write_text("FROM existing\n") + + # User says no to overwrite -> abort with non-zero. + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + input="n\n", + ) + + assert result.exit_code == 1 + assert existing.read_text() == "FROM existing\n" + assert run_mock.call_count == 0 + + +def test_gcp_deploy_overwrite_flag_skips_prompt(mocker: MockFixture, tmp_path: Path): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + existing = tmp_path / "Dockerfile" + existing.write_text("FROM existing\n") + + result = runner.invoke( + hosting_cli, + [ + "gcp", + "deploy", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--overwrite-dockerfile", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert existing.read_text() == DOCKERFILE + assert run_mock.call_count == 1 + + +def test_gcp_deploy_requires_gcloud(mocker: MockFixture, tmp_path: Path): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient(token="t", validated_data={}), + ) + mocker.patch( + "reflex_cli.v2.gcp.shutil.which", + side_effect=lambda name: None if name == "gcloud" else f"/usr/bin/{name}", + ) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "gcloud" in result.output + + +def test_gcp_deploy_requires_docker(mocker: MockFixture, tmp_path: Path): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient(token="t", validated_data={}), + ) + mocker.patch( + "reflex_cli.v2.gcp.shutil.which", + side_effect=lambda name: None if name == "docker" else f"/usr/bin/{name}", + ) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "docker" in result.output.lower() + + +def test_gcp_deploy_requires_gcp_login(mocker: MockFixture, tmp_path: Path): + _patch_environment(mocker, account="") + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "gcloud auth login" in result.output + + +def test_gcp_deploy_403_mentions_enterprise_tier(mocker: MockFixture, tmp_path: Path): + _patch_environment(mocker) + _mock_manifest_response(mocker, body={"detail": "denied"}, status_code=403) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "Enterprise" in result.output + + +def test_gcp_deploy_rejects_missing_fields(mocker: MockFixture, tmp_path: Path): + _patch_environment(mocker) + _mock_manifest_response(mocker, body={"dockerfile": "FROM scratch"}) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "deploy_command" in result.output + + +def test_gcp_deploy_default_version_is_timestamp(mocker: MockFixture, tmp_path: Path): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + input="y\n", + ) + + assert result.exit_code == 0, result.output + version = run_mock.call_args.kwargs["env_overrides"]["VERSION"] + # YYYYMMDD-HHMMSS + assert len(version) == 15 + assert version[8] == "-" + assert version.replace("-", "").isdigit() + + +@pytest.fixture(autouse=True) +def _no_log_level_side_effects(mocker: MockFixture): + mocker.patch("reflex_cli.utils.console.set_log_level") From 2b884159664c7cfd80270949f293ac8019c3938e Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 7 May 2026 10:30:10 -0700 Subject: [PATCH 2/3] fix(hosting-cli): restrict gcp deploy env, add --no-interactive, extract constants Address PR feedback: - Restrict the deploy script's environment to an allowlist of host vars (PATH, HOME, gcloud/docker config, proxy/TLS) plus the explicit deploy overrides. Prevents a tampered or compromised flexgen manifest from exfiltrating unrelated host secrets like AWS_*/GITHUB_TOKEN. - Add --interactive/--no-interactive (default true) so the command works in CI. In non-interactive mode the run prompt is skipped, and an existing Dockerfile errors out unless --overwrite-dockerfile is set. - Extract env-var keys and manifest field names into module-level constants per project convention. Co-Authored-By: Claude Opus 4.7 --- .../src/reflex_cli/v2/gcp.py | 131 +++++++++++++++--- tests/units/reflex_cli/v2/test_gcp.py | 112 +++++++++++++++ 2 files changed, 224 insertions(+), 19 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 950985cbc8f..8e035ddc923 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -26,6 +26,60 @@ DOCKERFILE_NAME = "Dockerfile" +# Environment variables passed to the deploy script. +ENV_GCP_PROJECT = "GCP_PROJECT" +ENV_GCP_REGION = "GCP_REGION" +ENV_SERVICE_NAME = "SERVICE_NAME" +ENV_AR_REPO = "AR_REPO" +ENV_VERSION = "VERSION" + +# Manifest response field names from flexgen. +FIELD_DOCKERFILE = "dockerfile" +FIELD_DEPLOY_COMMAND = "deploy_command" + +# Allowlist of host environment variables forwarded to the deploy script. +# We deliberately exclude things like AWS_*/GITHUB_TOKEN/SSH agent sockets so a +# compromised or tampered manifest cannot exfiltrate unrelated credentials. +DEPLOY_ENV_ALLOWLIST = frozenset({ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TMPDIR", + "TEMP", + "TMP", + "XDG_CONFIG_HOME", + # gcloud configuration + "CLOUDSDK_CONFIG", + "CLOUDSDK_ACTIVE_CONFIG_NAME", + "CLOUDSDK_CORE_PROJECT", + "CLOUDSDK_CORE_ACCOUNT", + "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", + "GOOGLE_APPLICATION_CREDENTIALS", + # docker configuration + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_BUILDKIT", + # corporate proxy / TLS trust + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", +}) + @click.group() def gcp_cli(): @@ -78,6 +132,12 @@ def gcp_cli(): help="Overwrite an existing Dockerfile without prompting.", ) @click.option("--token", help="The Reflex authentication token.") +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to prompt before overwriting the Dockerfile and running the script.", +) @click.option( "--dry-run", is_flag=True, @@ -99,6 +159,7 @@ def gcp_deploy( source_dir: str, overwrite_dockerfile: bool, token: str | None, + interactive: bool, dry_run: bool, loglevel: str, ): @@ -112,7 +173,7 @@ def gcp_deploy( console.set_log_level(loglevel) authenticated_client = hosting.get_authenticated_client( - token=token, interactive=True + token=token, interactive=interactive ) bash_path = shutil.which("bash") @@ -154,11 +215,11 @@ def gcp_deploy( version_value = version_tag or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") deploy_env = { - "GCP_PROJECT": gcp_project, - "GCP_REGION": region, - "SERVICE_NAME": service_name, - "AR_REPO": ar_repo, - "VERSION": version_value, + ENV_GCP_PROJECT: gcp_project, + ENV_GCP_REGION: region, + ENV_SERVICE_NAME: service_name, + ENV_AR_REPO: ar_repo, + ENV_VERSION: version_value, } console.info("Received deploy manifest from flexgen.") @@ -172,6 +233,10 @@ def gcp_deploy( console.print("─" * 60) console.print(deploy_script) console.print("─" * 60) + console.info( + f"The script runs with a restricted env (only {len(DEPLOY_ENV_ALLOWLIST)} " + "allowlisted host variables forwarded plus the deploy variables above)." + ) if dry_run: console.print("") @@ -182,13 +247,20 @@ def gcp_deploy( console.info("Dry run — nothing written or executed.") return - if not _write_dockerfile(dockerfile_path, dockerfile, overwrite_dockerfile): + if not _write_dockerfile( + dockerfile_path, dockerfile, overwrite_dockerfile, interactive + ): raise click.exceptions.Exit(1) - answer = console.ask("Run the deploy script now?", choices=["y", "n"], default="y") - if answer != "y": - console.warn("Aborted by user. The Dockerfile has been written for later use.") - raise click.exceptions.Exit(1) + if interactive: + answer = console.ask( + "Run the deploy script now?", choices=["y", "n"], default="y" + ) + if answer != "y": + console.warn( + "Aborted by user. The Dockerfile has been written for later use." + ) + raise click.exceptions.Exit(1) exit_code = _run_deploy_script( bash_path=bash_path, @@ -284,31 +356,44 @@ def _request_manifest(token: str) -> tuple[str, str]: console.error("Flexgen returned an unexpected response shape.") raise click.exceptions.Exit(1) - dockerfile = body.get("dockerfile") - deploy_command = body.get("deploy_command") + dockerfile = body.get(FIELD_DOCKERFILE) + deploy_command = body.get(FIELD_DEPLOY_COMMAND) if not isinstance(dockerfile, str) or not dockerfile.strip(): - console.error("Flexgen response is missing a non-empty 'dockerfile' field.") + console.error( + f"Flexgen response is missing a non-empty {FIELD_DOCKERFILE!r} field." + ) raise click.exceptions.Exit(1) if not isinstance(deploy_command, str) or not deploy_command.strip(): - console.error("Flexgen response is missing a non-empty 'deploy_command' field.") + console.error( + f"Flexgen response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field." + ) raise click.exceptions.Exit(1) return dockerfile, deploy_command -def _write_dockerfile(path: Path, contents: str, overwrite: bool) -> bool: - """Write the Dockerfile to disk, prompting before overwriting. +def _write_dockerfile( + path: Path, contents: str, overwrite: bool, interactive: bool +) -> bool: + """Write the Dockerfile to disk, prompting before overwriting in interactive mode. Args: path: Where to write the Dockerfile. contents: The Dockerfile body. overwrite: If True, overwrite without prompting. + interactive: If False, never prompt; require `overwrite` when the file exists. Returns: True on success, False if the user declined to overwrite or write failed. """ if path.exists() and not overwrite: + if not interactive: + console.error( + f"{path} already exists. Pass --overwrite-dockerfile to replace it " + "in non-interactive mode." + ) + return False answer = console.ask( f"{path} already exists. Overwrite?", choices=["y", "n"], default="n" ) @@ -335,17 +420,25 @@ def _run_deploy_script( ) -> int: """Run the bash deploy script, streaming output to the user's terminal. + The script's environment is restricted to ``DEPLOY_ENV_ALLOWLIST`` (plus the + explicit ``env_overrides``) so unrelated host secrets like ``AWS_*`` or + ``GITHUB_TOKEN`` cannot be exfiltrated by a tampered or compromised manifest. + Args: bash_path: Resolved path to the bash executable. script: The bash script body received from flexgen. cwd: Working directory to run the script in. - env_overrides: Environment variables to layer on top of the parent env. + env_overrides: Environment variables required by the deploy script. Returns: The exit code of the bash process. """ - env = os.environ.copy() + env = { + name: value + for name, value in os.environ.items() + if name in DEPLOY_ENV_ALLOWLIST + } env.update(env_overrides) try: result = subprocess.run( diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index ca04004b8e1..44d1b20937c 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path from unittest import mock @@ -294,6 +295,117 @@ def test_gcp_deploy_default_version_is_timestamp(mocker: MockFixture, tmp_path: assert version.replace("-", "").isdigit() +def test_gcp_deploy_no_interactive_skips_run_prompt( + mocker: MockFixture, tmp_path: Path +): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + + result = runner.invoke( + hosting_cli, + [ + "gcp", + "deploy", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--no-interactive", + "--token", + "fake-token", + ], + ) + + assert result.exit_code == 0, result.output + assert (tmp_path / "Dockerfile").read_text() == DOCKERFILE + assert run_mock.call_count == 1 + + +def test_gcp_deploy_no_interactive_refuses_to_overwrite_without_flag( + mocker: MockFixture, tmp_path: Path +): + run_mock = _patch_environment(mocker) + _mock_manifest_response(mocker) + existing = tmp_path / "Dockerfile" + existing.write_text("FROM existing\n") + + result = runner.invoke( + hosting_cli, + [ + "gcp", + "deploy", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--no-interactive", + "--token", + "fake-token", + ], + ) + + assert result.exit_code == 1 + assert "--overwrite-dockerfile" in result.output + assert existing.read_text() == "FROM existing\n" + assert run_mock.call_count == 0 + + +def test_gcp_deploy_env_is_restricted_to_allowlist(mocker: MockFixture, tmp_path: Path): + """Verify the script env excludes host secrets and only includes allowlisted vars.""" + from reflex_cli.v2 import gcp as gcp_module + + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient(token="fake-token", validated_data={}), + ) + mocker.patch( + "reflex_cli.v2.gcp.shutil.which", side_effect=lambda n: f"/usr/bin/{n}" + ) + mocker.patch( + "reflex_cli.v2.gcp._get_active_gcp_account", return_value="u@example.com" + ) + _mock_manifest_response(mocker) + + captured: dict[str, dict[str, str]] = {} + + def fake_run(*args, **kwargs): + captured["env"] = kwargs["env"] + return mock.MagicMock(returncode=0) + + mocker.patch("reflex_cli.v2.gcp.subprocess.run", side_effect=fake_run) + mocker.patch.dict( + os.environ, + { + "PATH": "/usr/bin", + "HOME": "/home/test", + "AWS_SECRET_ACCESS_KEY": "should-not-leak", + "GITHUB_TOKEN": "also-secret", + "DOCKER_HOST": "unix:///var/run/docker.sock", + "MY_RANDOM_VAR": "should-not-leak", + }, + clear=True, + ) + + result = runner.invoke( + hosting_cli, + ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + input="y\n", + ) + + assert result.exit_code == 0, result.output + env = captured["env"] + # Allowlisted host vars are forwarded. + assert env["PATH"] == "/usr/bin" + assert env["HOME"] == "/home/test" + assert env["DOCKER_HOST"] == "unix:///var/run/docker.sock" + # Deploy overrides are present. + assert env[gcp_module.ENV_GCP_PROJECT] == "p" + # Host secrets are NOT forwarded. + assert "AWS_SECRET_ACCESS_KEY" not in env + assert "GITHUB_TOKEN" not in env + assert "MY_RANDOM_VAR" not in env + + @pytest.fixture(autouse=True) def _no_log_level_side_effects(mocker: MockFixture): mocker.patch("reflex_cli.utils.console.set_log_level") From 16a39ee08820b8fdbd22bd03b4c7ab4c5b960ffb Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 7 May 2026 18:01:32 -0700 Subject: [PATCH 3/3] refactor(hosting-cli): rename gcp deploy to `reflex cloud deploy --gcp`, add docs Flatten the `gcp` group into a single `deploy` command with a `--gcp` target flag so the surface can grow to other targets without nesting. `--gcp-project` becomes optional at the Click level and is validated in-function so the missing-target error fires first. Add a hosting doc (with Enterprise-only callout) covering prerequisites, options, what gets created in the GCP project, the env-allowlist security model, CI usage, and troubleshooting. Wire it into the sidebar's Self Hosting section and add `Gcp` -> `GCP` to the sidebar acronym map. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/app/reflex_docs/pages/docs/__init__.py | 1 + .../docpage/sidebar/sidebar_items/item.py | 9 +- .../docpage/sidebar/sidebar_items/learn.py | 1 + docs/hosting/deploy-to-gcp.md | 124 ++++++++++++++++++ .../src/reflex_cli/v2/deployments.py | 6 +- .../src/reflex_cli/v2/gcp.py | 39 ++++-- tests/units/reflex_cli/v2/test_gcp.py | 57 +++++--- 7 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 docs/hosting/deploy-to-gcp.md diff --git a/docs/app/reflex_docs/pages/docs/__init__.py b/docs/app/reflex_docs/pages/docs/__init__.py index 6ac16a0b29d..6dfe7283672 100644 --- a/docs/app/reflex_docs/pages/docs/__init__.py +++ b/docs/app/reflex_docs/pages/docs/__init__.py @@ -149,6 +149,7 @@ def get_previews_from_frontmatter(filepath: str) -> dict[str, str]: "docs/events/special_events.md": "Special Events Docs", "docs/library/graphing/general/tooltip.md": "Graphing Tooltip", "docs/recipes/content/grid.md": "Grid Recipe", + "docs/hosting/deploy-to-gcp.md": "Deploy to GCP", } diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/item.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/item.py index b4f16cb63ec..4c70332b5e3 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/item.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/item.py @@ -13,7 +13,14 @@ def create_item(route: Route, children=None): # For "Overview", we want to keep the qualifier prefix ("Components overview") alt_name_for_next_prev = name if name.endswith("Overview") else "" # Capitalize acronyms - acronyms = {"Api": "API", "Cli": "CLI", "Ide": "IDE", "Mcp": "MCP", "Ai": "AI"} + acronyms = { + "Api": "API", + "Cli": "CLI", + "Ide": "IDE", + "Mcp": "MCP", + "Ai": "AI", + "Gcp": "GCP", + } name = re.sub( r"\b(" + "|".join(acronyms.keys()) + r")\b", lambda m: acronyms[m.group(0)], diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py index 0f4d652eb5d..5e334ae4e44 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py @@ -250,6 +250,7 @@ def get_sidebar_items_hosting(): children=[ hosting.self_hosting, hosting.databricks, + hosting.deploy_to_gcp, ], ), ] diff --git a/docs/hosting/deploy-to-gcp.md b/docs/hosting/deploy-to-gcp.md new file mode 100644 index 00000000000..9a96bb89dab --- /dev/null +++ b/docs/hosting/deploy-to-gcp.md @@ -0,0 +1,124 @@ +```python exec +import reflex as rx +``` + +# Deploy to GCP Cloud Run + +The `reflex cloud deploy --gcp` command deploys a Reflex app to your own [Google Cloud Run](https://cloud.google.com/run) service. Reflex Cloud fetches a Cloud Run-ready Dockerfile and a `gcloud` deploy script, writes the Dockerfile into your project, and runs the script against the Google Cloud project you specify. The image is built on Cloud Build (so it works from any host OS, including Apple Silicon) and pushed to Artifact Registry. + +```md alert info +# Enterprise tier only. + +Self-deploying to GCP Cloud Run is part of the **Enterprise tier** of Reflex Cloud. The control plane will return `403` to non-Enterprise tokens, and the CLI surfaces a clear error pointing at this. Contact [sales@reflex.dev](mailto:sales@reflex.dev) to upgrade. +``` + +## Prerequisites + +Before running the command, install and authenticate the local tools the deploy script invokes: + +- `gcloud` — install from the [Google Cloud SDK docs](https://cloud.google.com/sdk/docs/install), then run: + - `gcloud auth login` + - `gcloud auth application-default login` +- `docker` — required by `gcloud builds submit` for source upload. +- `bash` — used to run the deploy script. + +You also need: + +- A GCP project with **billing enabled**. Without it, `gcloud services enable` fails with `UREQ_PROJECT_BILLING_NOT_FOUND`. +- An Enterprise-tier Reflex Cloud subscription and a logged-in Reflex CLI (`reflex login`). + +## Quick start + +From the root of your Reflex app: + +```bash +reflex cloud deploy --gcp \ + --gcp-project my-gcp-project-id \ + --service-name my-reflex-app +``` + +The CLI will: + +1. Authenticate against Reflex Cloud and fetch the deploy manifest (Dockerfile + `gcloud` script). +2. Print the manifest so you can review it. +3. Write a `Dockerfile` into your project (after asking, if one already exists). +4. Ask for confirmation, then run the `gcloud` script: enable the required APIs, create the Artifact Registry repository, build the image on Cloud Build, and deploy a public Cloud Run service. + +When it's done, you'll get a service URL like `https://my-reflex-app-.us-central1.run.app`. + +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `--gcp` | _(required)_ | Selects the GCP Cloud Run target. | +| `--gcp-project` | _(required)_ | The GCP **project ID** to deploy into. Project numbers are **not** accepted by `gcloud artifacts repositories`; use the project ID. | +| `--region` | `us-central1` | Cloud Run region. | +| `--service-name` | `reflex-app` | Cloud Run service name. | +| `--ar-repo` | `reflex` | Artifact Registry repository name (created on first deploy). | +| `--version` | UTC timestamp (`YYYYMMDD-HHMMSS`) | Image version tag. | +| `--source` | `.` | Directory containing the Reflex app and into which the Dockerfile is written. | +| `--overwrite-dockerfile` | _off_ | Overwrite an existing `Dockerfile` without prompting. | +| `--token` | _from `~/.reflex` config_ | Reflex authentication token. | +| `--interactive / --no-interactive` | `--interactive` | Whether to prompt before overwriting the Dockerfile and running the script. | +| `--dry-run` | _off_ | Print the manifest without writing the Dockerfile or running the script. | +| `--loglevel` | `info` | Log verbosity. | + +## What gets created in your GCP project + +The deploy script enables these APIs (if not already enabled): + +- `cloudbuild.googleapis.com` +- `run.googleapis.com` +- `artifactregistry.googleapis.com` + +It then creates (idempotently) and uses: + +- An Artifact Registry Docker repository at `${REGION}-docker.pkg.dev/${GCP_PROJECT}/${AR_REPO}`. +- A Cloud Build job that builds and pushes the image. +- A Cloud Run service named `${SERVICE_NAME}`, deployed with `--allow-unauthenticated`, port 8080, 1 vCPU, 1 GiB memory, `--min-instances 1`, and `--session-affinity`. + +Re-running the command pushes a new image tag and rolls the Cloud Run service forward. + +## Security model + +The CLI runs the deploy script under a **restricted environment**. Only an explicit allowlist of host variables is forwarded to `bash` — things like `PATH`, `HOME`, `CLOUDSDK_*`, `DOCKER_*`, and proxy/TLS variables. Unrelated host secrets such as `AWS_*`, `GITHUB_TOKEN`, or arbitrary user variables are **not** forwarded, so a tampered or compromised manifest cannot exfiltrate them. + +You can preview the exact script and Dockerfile before anything runs by using `--dry-run`: + +```bash +reflex cloud deploy --gcp \ + --gcp-project my-gcp-project-id \ + --dry-run +``` + +## Non-interactive use (CI) + +For automated pipelines, pass `--no-interactive`, an explicit `--token`, and `--overwrite-dockerfile`: + +```bash +reflex cloud deploy --gcp \ + --gcp-project "$GCP_PROJECT_ID" \ + --service-name my-reflex-app \ + --token "$REFLEX_TOKEN" \ + --no-interactive \ + --overwrite-dockerfile +``` + +In non-interactive mode the CLI will not prompt — it will refuse to overwrite an existing `Dockerfile` unless `--overwrite-dockerfile` is set, and it will exit non-zero if a token cannot be resolved. + +## Troubleshooting + +**`Flexgen denied the request (403). GCP Cloud Run deploys require an Enterprise tier subscription.`** +Your account is not on the Enterprise tier. Contact [sales@reflex.dev](mailto:sales@reflex.dev). + +**`Billing must be enabled for activation of service(s) ...` (`UREQ_PROJECT_BILLING_NOT_FOUND`)** +Attach a billing account to the GCP project, or use a different `--gcp-project`. + +**`The value of '--project' flag was set to Project number. To use this command, set it to PROJECT ID instead.`** +Pass the project ID (e.g. `my-app-123456`), not the numeric project number. + +**`No active GCP account found.`** +Run `gcloud auth login` and `gcloud auth application-default login`. + +**`The 'gcloud' / 'docker' / 'bash' CLI was not found on PATH.`** +Install the missing tool and ensure it's on `PATH` for the shell you're invoking the CLI from. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index cca4f50cdb3..37c680fd7ca 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -12,7 +12,7 @@ from reflex_cli import constants from reflex_cli.utils import console from reflex_cli.v2.apps import apps_cli -from reflex_cli.v2.gcp import gcp_cli +from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command from reflex_cli.v2.project import project_cli from reflex_cli.v2.secrets import secrets_cli from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli @@ -66,8 +66,8 @@ def hosting_cli(ctx: click.Context) -> None: name="secrets", ) hosting_cli.add_command( - gcp_cli, - name="gcp", + gcp_deploy_command, + name="deploy", ) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI 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 8e035ddc923..dd5c4c9df15 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -81,17 +81,19 @@ }) -@click.group() -def gcp_cli(): - """Commands for deploying to GCP Cloud Run.""" - - -@gcp_cli.command(name="deploy") +@click.command(name="deploy") +@click.option( + "--gcp", + "use_gcp", + is_flag=True, + default=False, + help="Deploy to GCP Cloud Run. Required (the only supported target today).", +) @click.option( "--gcp-project", "gcp_project", - required=True, - help="The GCP project ID to deploy into (sets GCP_PROJECT).", + default=None, + help="The GCP project ID to deploy into (sets GCP_PROJECT). Required with --gcp.", ) @click.option( "--region", @@ -150,8 +152,9 @@ def gcp_cli(): default=constants.LogLevel.INFO.value, help="The log level to use.", ) -def gcp_deploy( - gcp_project: str, +def deploy_command( + use_gcp: bool, + gcp_project: str | None, region: str, service_name: str, ar_repo: str, @@ -163,15 +166,25 @@ def gcp_deploy( dry_run: bool, loglevel: str, ): - """Deploy a Reflex app to GCP Cloud Run. + """Deploy a Reflex app to a cloud target. - Fetches a Dockerfile and bash deploy script from flexgen, writes the Dockerfile - into the source directory, then asks before running the script. + Currently the only supported target is GCP Cloud Run via --gcp. The command + fetches a Dockerfile and bash deploy script from flexgen, writes the + Dockerfile into the source directory, then asks before running the script. """ from reflex_cli.utils import hosting console.set_log_level(loglevel) + if not use_gcp: + console.error( + "Specify a deploy target. Currently supported: --gcp (GCP Cloud Run)." + ) + raise click.exceptions.Exit(2) + if not gcp_project: + console.error("--gcp-project is required when using --gcp.") + raise click.exceptions.Exit(2) + authenticated_client = hosting.get_authenticated_client( token=token, interactive=interactive ) diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 44d1b20937c..52ddbbc990a 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -67,8 +67,8 @@ def test_gcp_deploy_writes_dockerfile_and_runs_script( result = runner.invoke( hosting_cli, [ - "gcp", "deploy", + "--gcp", "--gcp-project", "my-gcp-project", "--region", @@ -109,7 +109,7 @@ def test_gcp_deploy_aborts_on_no(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], input="n\n", ) @@ -126,7 +126,7 @@ def test_gcp_deploy_propagates_script_failure(mocker: MockFixture, tmp_path: Pat result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], input="y\n", ) @@ -140,8 +140,8 @@ def test_gcp_deploy_dry_run(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, [ - "gcp", "deploy", + "--gcp", "--gcp-project", "p", "--source", @@ -167,7 +167,7 @@ def test_gcp_deploy_prompts_before_overwriting_dockerfile( # User says no to overwrite -> abort with non-zero. result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], input="n\n", ) @@ -185,8 +185,8 @@ def test_gcp_deploy_overwrite_flag_skips_prompt(mocker: MockFixture, tmp_path: P result = runner.invoke( hosting_cli, [ - "gcp", "deploy", + "--gcp", "--gcp-project", "p", "--source", @@ -213,7 +213,7 @@ def test_gcp_deploy_requires_gcloud(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], ) assert result.exit_code == 1 @@ -232,7 +232,7 @@ def test_gcp_deploy_requires_docker(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], ) assert result.exit_code == 1 @@ -244,7 +244,7 @@ def test_gcp_deploy_requires_gcp_login(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], ) assert result.exit_code == 1 @@ -257,7 +257,7 @@ def test_gcp_deploy_403_mentions_enterprise_tier(mocker: MockFixture, tmp_path: result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], ) assert result.exit_code == 1 @@ -270,7 +270,7 @@ def test_gcp_deploy_rejects_missing_fields(mocker: MockFixture, tmp_path: Path): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], ) assert result.exit_code == 1 @@ -283,7 +283,7 @@ def test_gcp_deploy_default_version_is_timestamp(mocker: MockFixture, tmp_path: result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], input="y\n", ) @@ -304,8 +304,8 @@ def test_gcp_deploy_no_interactive_skips_run_prompt( result = runner.invoke( hosting_cli, [ - "gcp", "deploy", + "--gcp", "--gcp-project", "p", "--source", @@ -332,8 +332,8 @@ def test_gcp_deploy_no_interactive_refuses_to_overwrite_without_flag( result = runner.invoke( hosting_cli, [ - "gcp", "deploy", + "--gcp", "--gcp-project", "p", "--source", @@ -388,7 +388,7 @@ def fake_run(*args, **kwargs): result = runner.invoke( hosting_cli, - ["gcp", "deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], input="y\n", ) @@ -406,6 +406,33 @@ def fake_run(*args, **kwargs): assert "MY_RANDOM_VAR" not in env +def test_deploy_requires_gcp_target_flag(tmp_path: Path): + """Without any target flag, the command errors with usage hint.""" + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 2 + assert "--gcp" in result.output + + +def test_deploy_gcp_requires_gcp_project(mocker: MockFixture, tmp_path: Path): + """With --gcp set but --gcp-project missing, errors before any auth/manifest call.""" + auth_mock = mocker.patch("reflex_cli.utils.hosting.get_authenticated_client") + get_mock = mocker.patch("httpx.get") + + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp", "--source", str(tmp_path)], + ) + + assert result.exit_code == 2 + assert "--gcp-project" in result.output + assert auth_mock.call_count == 0 + assert get_mock.call_count == 0 + + @pytest.fixture(autouse=True) def _no_log_level_side_effects(mocker: MockFixture): mocker.patch("reflex_cli.utils.console.set_log_level")