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/7] 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/7] 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/7] 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") From 5bc985161d87a9e826b628647bc6bc8f12decec0 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Mon, 11 May 2026 13:41:33 -0700 Subject: [PATCH 4/7] refactor(hosting-cli): use cloudbuild.yaml instead of symlinks for gcp deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile no longer touches disk anywhere near the user's source — it's embedded (base64) as an inline `docker build` step inside a Cloud Build config written to a tempfile, and the flexgen script's `gcloud builds submit --tag X .` invocation is rewritten in-memory to `--config="${REFLEX_CLOUDBUILD_YAML}" --substitutions=_IMAGE="${IMAGE}"`. The script runs with cwd = the user's source dir, so the user's tree is the Cloud Build upload context. No temp dir of symlinks, no source-tree mutation. The cloudbuild.yaml tempfile is removed after the deploy. If `gcloud builds submit` can't be located in the manifest's script (format drift on flexgen's side), the rewrite errors out clearly so the breakage surfaces immediately rather than half-running. Verified end-to-end against a real GCP project: 1m53s Cloud Build, new Cloud Run revision deployed and serving traffic, source Dockerfile timestamp unchanged, tempfile cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/hosting/deploy-to-gcp.md | 27 +-- .../src/reflex_cli/v2/gcp.py | 218 +++++++++++------ tests/units/reflex_cli/v2/test_gcp.py | 221 ++++++++++++------ 3 files changed, 313 insertions(+), 153 deletions(-) diff --git a/docs/hosting/deploy-to-gcp.md b/docs/hosting/deploy-to-gcp.md index 9a96bb89dab..845bb2a1895 100644 --- a/docs/hosting/deploy-to-gcp.md +++ b/docs/hosting/deploy-to-gcp.md @@ -4,7 +4,7 @@ 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. +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, wraps the Dockerfile inside a [Cloud Build config (`cloudbuild.yaml`)](https://cloud.google.com/build/docs/build-config-file-schema), 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. Your project tree is never modified — the Dockerfile lives only inside the build config that's submitted to Cloud Build. ```md alert info # Enterprise tier only. @@ -40,9 +40,12 @@ reflex cloud deploy --gcp \ 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. +2. Generate a `cloudbuild.yaml` that embeds the Dockerfile as a build step, write it to a tempfile, and rewrite the script's `gcloud builds submit` invocation to use `--config="$REFLEX_CLOUDBUILD_YAML"`. +3. Print the (rewritten) script so you can review it. +4. Ask for confirmation, then run the script with `cwd=` your source directory: enable the required APIs, create the Artifact Registry repository, build the image on Cloud Build (which materializes the Dockerfile inside the build step from the `cloudbuild.yaml`), and deploy a public Cloud Run service. +5. Delete the tempfile after the script finishes. + +Your source tree is never written to — if you have an existing `Dockerfile` in `--source`, it's left in place and ignored. The flexgen Dockerfile only exists inside the `cloudbuild.yaml` tempfile (and inside the Cloud Build job). When it's done, you'll get a service URL like `https://my-reflex-app-.us-central1.run.app`. @@ -56,11 +59,10 @@ When it's done, you'll get a service URL like `https://my-reflex-app-^[ \t]*)gcloud[ \t]+builds[ \t]+submit\b", + re.MULTILINE, +) # Manifest response field names from flexgen. FIELD_DOCKERFILE = "dockerfile" @@ -125,26 +143,20 @@ 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.", + help="The directory containing the Reflex app. Staged into an ephemeral build context; the source tree itself is not modified.", ) @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.", + help="Whether to prompt before running the deploy script.", ) @click.option( "--dry-run", is_flag=True, default=False, - help="Print the manifest without writing the Dockerfile or running the script.", + help="Print the manifest without staging the build context or running the script.", ) @click.option( "--loglevel", @@ -160,7 +172,6 @@ def deploy_command( ar_repo: str, version_tag: str | None, source_dir: str, - overwrite_dockerfile: bool, token: str | None, interactive: bool, dry_run: bool, @@ -168,9 +179,10 @@ def deploy_command( ): """Deploy a Reflex app to a cloud target. - 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. + Currently the only supported target is GCP Cloud Run via --gcp. The + command fetches a Dockerfile and bash deploy script from flexgen, stages + them in an ephemeral build context alongside symlinked source entries + (your project tree is never modified), and runs the script from there. """ from reflex_cli.utils import hosting @@ -224,7 +236,13 @@ def deploy_command( 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 + + cloudbuild_yaml = _build_cloudbuild_yaml(dockerfile) + try: + deploy_script = _rewrite_builds_submit(deploy_script) + except ValueError as ex: + console.error(str(ex)) + raise click.exceptions.Exit(1) from ex version_value = version_tag or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") deploy_env = { @@ -237,12 +255,12 @@ def deploy_command( console.info("Received deploy manifest from flexgen.") console.print("") - console.print(f"Dockerfile target: {dockerfile_path}") + console.print(f"Source: {source_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("Deploy script (rewritten to use cloudbuild.yaml):") console.print("─" * 60) console.print(deploy_script) console.print("─" * 60) @@ -250,37 +268,43 @@ def deploy_command( f"The script runs with a restricted env (only {len(DEPLOY_ENV_ALLOWLIST)} " "allowlisted host variables forwarded plus the deploy variables above)." ) + console.info( + "The Dockerfile is embedded in a Cloud Build config written to a " + "tempfile; your source directory is not modified." + ) if dry_run: console.print("") - console.print("Dockerfile contents:") + console.print("cloudbuild.yaml contents:") + console.print("─" * 60) + console.print(cloudbuild_yaml) + console.print("─" * 60) + console.print("") + console.print("Dockerfile contents (embedded in the build step):") console.print("─" * 60) console.print(dockerfile) console.print("─" * 60) - console.info("Dry run — nothing written or executed.") + console.info("Dry run — nothing staged or executed.") return - if not _write_dockerfile( - dockerfile_path, dockerfile, overwrite_dockerfile, interactive - ): - 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." - ) + console.warn("Aborted by user.") raise click.exceptions.Exit(1) - exit_code = _run_deploy_script( - bash_path=bash_path, - script=deploy_script, - cwd=source_path, - env_overrides=deploy_env, - ) + with _temp_cloudbuild_yaml(cloudbuild_yaml) as cloudbuild_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), + }, + ) if exit_code != 0: console.error(f"Deploy script exited with status {exit_code}.") raise click.exceptions.Exit(exit_code) @@ -385,44 +409,106 @@ def _request_manifest(token: str) -> tuple[str, str]: return dockerfile, deploy_command -def _write_dockerfile( - path: Path, contents: str, overwrite: bool, interactive: bool -) -> bool: - """Write the Dockerfile to disk, prompting before overwriting in interactive mode. +def _build_cloudbuild_yaml(dockerfile_contents: str) -> str: + """Generate a Cloud Build config that materializes the Dockerfile inline. + + The Dockerfile body is embedded as a single base64 line so we don't have + to worry about YAML literal-block indentation, bash here-doc markers, or + shell-meta characters in the Dockerfile leaking into the config. The + resulting build does ``docker build`` + ``docker push`` against the + user's source as the build context. 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. + dockerfile_contents: The Dockerfile body from flexgen. Returns: - True on success, False if the user declined to overwrite or write failed. + A complete ``cloudbuild.yaml`` body, ready to write to disk. """ - 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" + b64 = base64.b64encode(dockerfile_contents.encode("utf-8")).decode("ascii") + return ( + "steps:\n" + "- name: gcr.io/cloud-builders/docker\n" + " entrypoint: bash\n" + " args:\n" + " - -c\n" + " - |\n" + f" printf '%s' '{b64}' | base64 -d > Dockerfile\n" + ' docker build -t "$_IMAGE" .\n' + ' docker push "$_IMAGE"\n' + "images:\n" + " - $_IMAGE\n" + ) + + +def _rewrite_builds_submit(script: str) -> str: + """Rewrite the flexgen script's `gcloud builds submit` invocation to use --config=. + + Replaces the (possibly multi-line) ``gcloud builds submit --tag X .`` + command with one that references our generated cloudbuild.yaml via the + ``REFLEX_CLOUDBUILD_YAML`` environment variable and passes the image tag + through ``--substitutions=_IMAGE=...``. + + Args: + script: The flexgen deploy script body. + + Returns: + The script with the build-submit step rewritten. + + Raises: + ValueError: If `gcloud builds submit` cannot be located in the script. + + """ + match = _BUILDS_SUBMIT_PATTERN.search(script) + if not match: + raise ValueError( + "Couldn't find `gcloud builds submit` in the deploy script. The " + "flexgen manifest format may have changed; the CLI needs updating." ) - 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 + indent = match.group("indent") + line_start = script.rfind("\n", 0, match.start()) + 1 + # Consume continuation lines (trailing backslash) until we hit a final line. + cursor = match.end() + while True: + nl = script.find("\n", cursor) + if nl == -1: + cmd_end = len(script) + break + if not script[cursor:nl].rstrip().endswith("\\"): + cmd_end = nl + break + cursor = nl + 1 + + replacement = ( + f"{indent}gcloud builds submit \\\n" + f'{indent} --config="${{{ENV_REFLEX_CLOUDBUILD_YAML}}}" \\\n' + f'{indent} --substitutions=_IMAGE="${{IMAGE}}" \\\n' + f'{indent} --project "${{GCP_PROJECT}}" \\\n' + f"{indent} ." + ) + return script[:line_start] + replacement + script[cmd_end:] + + +@contextlib.contextmanager +def _temp_cloudbuild_yaml(contents: str): + """Write a cloudbuild.yaml to a tempfile and yield its path; always clean up. + + Args: + contents: The cloudbuild.yaml body to write. + + Yields: + The path to the written tempfile. + + """ + fd, path_str = tempfile.mkstemp(prefix="reflex-cloudbuild-", suffix=".yaml") + path = Path(path_str) 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 + with os.fdopen(fd, "w") as fh: + fh.write(contents) + yield path + finally: + with contextlib.suppress(FileNotFoundError): + path.unlink() def _run_deploy_script( diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 52ddbbc990a..bb21399c75b 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -19,8 +19,17 @@ runner = CliRunner() DOCKERFILE = "FROM python:3.13-slim\nWORKDIR /app\n" +# A realistic-shaped flexgen script — the rewrite logic targets the +# `gcloud builds submit ... .` block in here. DEPLOY_SCRIPT = ( - "#!/usr/bin/env bash\nset -euo pipefail\necho deploying ${SERVICE_NAME}\n" + "#!/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}"\n' ) MANIFEST = {"dockerfile": DOCKERFILE, "deploy_command": DEPLOY_SCRIPT} @@ -58,12 +67,36 @@ def _mock_manifest_response( return mocker.patch("httpx.get", return_value=response) -def test_gcp_deploy_writes_dockerfile_and_runs_script( +def test_gcp_deploy_runs_script_from_source_with_cloudbuild_yaml( mocker: MockFixture, tmp_path: Path ): + """Happy path: script runs with cwd=source, REFLEX_CLOUDBUILD_YAML points at a + tempfile that contains the generated cloudbuild.yaml, the script is rewritten + to use --config=, and the source tree is never written to. + """ + import base64 as _b64 + + captured: dict = {} + + def capture(**kwargs): + cloudbuild_path = Path(kwargs["env_overrides"]["REFLEX_CLOUDBUILD_YAML"]) + captured["cloudbuild_existed_during_run"] = cloudbuild_path.exists() + captured["cloudbuild_path"] = cloudbuild_path + captured["cloudbuild_yaml"] = cloudbuild_path.read_text() + captured["script"] = kwargs["script"] + captured["cwd"] = Path(kwargs["cwd"]) + captured["env_overrides"] = kwargs["env_overrides"] + return 0 + run_mock = _patch_environment(mocker) + run_mock.side_effect = capture get_mock = _mock_manifest_response(mocker) + # Pre-populate the source with a file and an existing Dockerfile that + # must NOT be touched. + (tmp_path / "app.py").write_text("print('hi')\n") + (tmp_path / "Dockerfile").write_text("FROM existing\n") + result = runner.invoke( hosting_cli, [ @@ -86,24 +119,46 @@ def test_gcp_deploy_writes_dockerfile_and_runs_script( ) assert result.exit_code == 0, result.output - dockerfile = tmp_path / "Dockerfile" - assert dockerfile.read_text() == DOCKERFILE + # Source tree is untouched. + assert (tmp_path / "Dockerfile").read_text() == "FROM existing\n" + assert sorted(p.name for p in tmp_path.iterdir()) == ["Dockerfile", "app.py"] + + # cwd is the user's source dir — no temp build context. + assert captured["cwd"] == tmp_path.resolve() + + # cloudbuild.yaml existed during the run and is removed after. + assert captured["cloudbuild_existed_during_run"] + assert not captured["cloudbuild_path"].exists() + + # cloudbuild.yaml embeds the flexgen Dockerfile as base64 and builds/pushes. + yaml = captured["cloudbuild_yaml"] + expected_b64 = _b64.b64encode(DOCKERFILE.encode()).decode() + assert expected_b64 in yaml + assert 'docker build -t "$_IMAGE"' in yaml + assert 'docker push "$_IMAGE"' in yaml + assert "images:" in yaml + + # The script's `gcloud builds submit --tag X .` was rewritten to --config=. + script = captured["script"] + assert "--tag" not in script + assert '--config="${REFLEX_CLOUDBUILD_YAML}"' in script + assert '--substitutions=_IMAGE="${IMAGE}"' in script + # Surrounding lines (run deploy etc.) are preserved. + assert 'gcloud run deploy "${SERVICE_NAME}"' in script + + assert captured["env_overrides"]["GCP_PROJECT"] == "my-gcp-project" + assert captured["env_overrides"]["GCP_REGION"] == "europe-west1" + assert captured["env_overrides"]["SERVICE_NAME"] == "myapp" + assert captured["env_overrides"]["AR_REPO"] == "myrepo" + assert captured["env_overrides"]["VERSION"] == "v1" + 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): + """Declining the run prompt aborts before any staging.""" run_mock = _patch_environment(mocker) _mock_manifest_response(mocker) @@ -114,8 +169,8 @@ def test_gcp_deploy_aborts_on_no(mocker: MockFixture, tmp_path: Path): ) assert result.exit_code == 1 - # Dockerfile is still written so the user can run it later. - assert (tmp_path / "Dockerfile").exists() + # Nothing was written into the source tree. + assert not (tmp_path / "Dockerfile").exists() assert run_mock.call_count == 0 @@ -156,48 +211,23 @@ def test_gcp_deploy_dry_run(mocker: MockFixture, tmp_path: Path): assert "Dry run" in result.output -def test_gcp_deploy_prompts_before_overwriting_dockerfile( +def test_gcp_deploy_existing_dockerfile_in_source_is_preserved( mocker: MockFixture, tmp_path: Path ): + """An existing Dockerfile in --source is never read or modified.""" 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, ["deploy", "--gcp", "--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, - [ - "deploy", - "--gcp", - "--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 existing.read_text() == "FROM existing\n" assert run_mock.call_count == 1 @@ -317,39 +347,11 @@ def test_gcp_deploy_no_interactive_skips_run_prompt( ) assert result.exit_code == 0, result.output - assert (tmp_path / "Dockerfile").read_text() == DOCKERFILE + # Source tree was not modified. + assert not (tmp_path / "Dockerfile").exists() 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, - [ - "deploy", - "--gcp", - "--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 @@ -417,6 +419,77 @@ def test_deploy_requires_gcp_target_flag(tmp_path: Path): assert "--gcp" in result.output +def test_build_cloudbuild_yaml_embeds_dockerfile_as_base64(): + """The generated cloudbuild.yaml round-trips the Dockerfile through base64.""" + import base64 as _b64 + + from reflex_cli.v2 import gcp as gcp_module + + dockerfile = "FROM python:3.13-slim\nRUN echo $weird '\"chars\"' \\\nthings\n" + yaml = gcp_module._build_cloudbuild_yaml(dockerfile) + + # The Dockerfile body shows up exactly once as a base64 blob. + expected_b64 = _b64.b64encode(dockerfile.encode()).decode() + assert yaml.count(expected_b64) == 1 + # And the recovery step decodes it back into a Dockerfile. + assert "base64 -d > Dockerfile" in yaml + # The build and push are wired up to the _IMAGE substitution. + assert 'docker build -t "$_IMAGE" .' in yaml + assert 'docker push "$_IMAGE"' in yaml + assert "images:\n - $_IMAGE\n" in yaml + + +def test_rewrite_builds_submit_replaces_tag_form_with_config(): + """The rewrite consumes the full multi-line `gcloud builds submit ... .`.""" + from reflex_cli.v2 import gcp as gcp_module + + original = ( + "set -e\n" + "gcloud builds submit \\\n" + ' --tag "${IMAGE}" \\\n' + ' --project "${GCP_PROJECT}" \\\n' + " .\n" + 'gcloud run deploy "${SERVICE_NAME}"\n' + ) + + rewritten = gcp_module._rewrite_builds_submit(original) + + assert "--tag" not in rewritten + assert '--config="${REFLEX_CLOUDBUILD_YAML}"' in rewritten + assert '--substitutions=_IMAGE="${IMAGE}"' in rewritten + # Lines outside the rewritten block are preserved. + assert rewritten.startswith("set -e\n") + assert rewritten.endswith('gcloud run deploy "${SERVICE_NAME}"\n') + + +def test_rewrite_builds_submit_errors_if_pattern_missing(): + """Without a `gcloud builds submit` line, we fail loudly so the CLI surfaces it.""" + from reflex_cli.v2 import gcp as gcp_module + + with pytest.raises(ValueError, match="gcloud builds submit"): + gcp_module._rewrite_builds_submit("echo nothing here\n") + + +def test_gcp_deploy_surfaces_rewrite_failure(mocker: MockFixture, tmp_path: Path): + """If the manifest's script can't be rewritten, the command errors out clearly.""" + _patch_environment(mocker) + _mock_manifest_response( + mocker, + body={ + "dockerfile": DOCKERFILE, + "deploy_command": "#!/usr/bin/env bash\necho no build here\n", + }, + ) + + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "gcloud builds submit" 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") From 1225595401840444230081bff2cda8cdc72fd697 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Mon, 11 May 2026 14:11:34 -0700 Subject: [PATCH 5/7] update to not overwrite users dockerfile --- .../src/reflex_cli/v2/gcp.py | 156 ++++++++++-------- tests/units/reflex_cli/v2/test_gcp.py | 70 ++++++-- 2 files changed, 142 insertions(+), 84 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 2725e7fa3fe..f33d34b7ad5 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -1,6 +1,6 @@ """GCP Cloud Run deploy commands for the Reflex Cloud CLI. -Fetches a Dockerfile + bash deploy script from flexgen and runs the script +Fetches a Dockerfile + bash deploy script from Reflex and runs the script against the user's source directory. The Dockerfile is materialized inside a Cloud Build job (via a ``cloudbuild.yaml`` written to a tempfile and referenced with ``gcloud builds submit --config=...``) — the user's project @@ -11,7 +11,6 @@ from __future__ import annotations -import base64 import contextlib import os import re @@ -43,7 +42,7 @@ ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML" # Pattern for the start of the `gcloud builds submit` invocation in the -# flexgen deploy script. We rewrite that whole multi-line command to use +# Reflex deploy script. We rewrite that whole multi-line command to use # `--config=` so the Dockerfile lives inside a cloudbuild.yaml instead of # being staged on disk next to the user's source. _BUILDS_SUBMIT_PATTERN = re.compile( @@ -51,52 +50,54 @@ re.MULTILINE, ) -# Manifest response field names from flexgen. +# Manifest response field names from Reflex. 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", -}) +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.command(name="deploy") @@ -180,7 +181,7 @@ def deploy_command( """Deploy a Reflex app to a cloud target. Currently the only supported target is GCP Cloud Run via --gcp. The - command fetches a Dockerfile and bash deploy script from flexgen, stages + command fetches a Dockerfile and bash deploy script from Reflex, stages them in an ephemeral build context alongside symlinked source entries (your project tree is never modified), and runs the script from there. """ @@ -253,7 +254,7 @@ def deploy_command( ENV_VERSION: version_value, } - console.info("Received deploy manifest from flexgen.") + console.info("Received deploy manifest from Reflex.") console.print("") console.print(f"Source: {source_path}") console.print("Deploy environment:") @@ -343,7 +344,7 @@ def _get_active_gcp_account(gcloud_path: str) -> str | None: def _request_manifest(token: str) -> tuple[str, str]: - """Fetch the Dockerfile + deploy script from flexgen. + """Fetch the Dockerfile + deploy script from Reflex. Args: token: The Reflex API token to authenticate with. @@ -373,36 +374,36 @@ def _request_manifest(token: str) -> tuple[str, str]: 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 " + "Reflex denied the request (403). GCP Cloud Run deploys require an " "Enterprise tier subscription." ) else: - console.error(f"Flexgen rejected the manifest request: {detail}") + console.error(f"Reflex 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}") + console.error(f"Failed to reach Reflex 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.") + console.error("Reflex 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.") + console.error("Reflex returned an unexpected response shape.") raise click.exceptions.Exit(1) dockerfile = body.get(FIELD_DOCKERFILE) deploy_command = body.get(FIELD_DEPLOY_COMMAND) if not isinstance(dockerfile, str) or not dockerfile.strip(): console.error( - f"Flexgen response is missing a non-empty {FIELD_DOCKERFILE!r} field." + f"Reflex 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( - f"Flexgen response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field." + f"Reflex response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field." ) raise click.exceptions.Exit(1) @@ -410,22 +411,39 @@ def _request_manifest(token: str) -> tuple[str, str]: def _build_cloudbuild_yaml(dockerfile_contents: str) -> str: - """Generate a Cloud Build config that materializes the Dockerfile inline. + r"""Generate a Cloud Build config that materializes the Dockerfile inline. - The Dockerfile body is embedded as a single base64 line so we don't have - to worry about YAML literal-block indentation, bash here-doc markers, or - shell-meta characters in the Dockerfile leaking into the config. The - resulting build does ``docker build`` + ``docker push`` against the - user's source as the build context. + The Dockerfile body is dropped into a bash heredoc (``cat <<'MARKER' > + Dockerfile``) inside the build step. The marker is single-quoted so bash + treats the body literally — no shell-meta expansion of ``$``, `` ` ``, or + ``\``. YAML literal-block indentation gets stripped uniformly so the + closing marker line ends up at column 0 where bash expects it. Args: - dockerfile_contents: The Dockerfile body from flexgen. + dockerfile_contents: The Dockerfile body from Reflex. Returns: A complete ``cloudbuild.yaml`` body, ready to write to disk. + Raises: + ValueError: If the Dockerfile contains a line that exactly matches the + heredoc marker (would terminate the heredoc early). + """ - b64 = base64.b64encode(dockerfile_contents.encode("utf-8")).decode("ascii") + marker = "REFLEX_FLEXGEN_DOCKERFILE_EOF" + if any(line.rstrip() == marker for line in dockerfile_contents.splitlines()): + raise ValueError( + f"Dockerfile content contains the reserved heredoc marker {marker!r}." + ) + # Cloud Build runs its own substitution pass over `args`, so any `$NAME` or + # `${NAME}` in the Dockerfile (e.g. `ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin"`) + # would be treated as a Cloud Build variable and fail with + # "not a valid built-in substitution". Escape literal `$` to `$$` so the + # parser restores `$` before bash runs. + escaped = dockerfile_contents.replace("$", "$$") + # 6 spaces to fit inside the YAML literal block under `args:\n - -c\n - |`. + indent = " " + body = "".join(f"{indent}{line}\n" for line in escaped.splitlines()) return ( "steps:\n" "- name: gcr.io/cloud-builders/docker\n" @@ -433,16 +451,18 @@ def _build_cloudbuild_yaml(dockerfile_contents: str) -> str: " args:\n" " - -c\n" " - |\n" - f" printf '%s' '{b64}' | base64 -d > Dockerfile\n" - ' docker build -t "$_IMAGE" .\n' - ' docker push "$_IMAGE"\n' + f"{indent}cat > Dockerfile <<'{marker}'\n" + f"{body}" + f"{indent}{marker}\n" + f'{indent}docker build -t "$_IMAGE" .\n' + f'{indent}docker push "$_IMAGE"\n' "images:\n" " - $_IMAGE\n" ) def _rewrite_builds_submit(script: str) -> str: - """Rewrite the flexgen script's `gcloud builds submit` invocation to use --config=. + """Rewrite the Reflex script's `gcloud builds submit` invocation to use --config=. Replaces the (possibly multi-line) ``gcloud builds submit --tag X .`` command with one that references our generated cloudbuild.yaml via the @@ -450,7 +470,7 @@ def _rewrite_builds_submit(script: str) -> str: through ``--substitutions=_IMAGE=...``. Args: - script: The flexgen deploy script body. + script: The Reflex deploy script body. Returns: The script with the build-submit step rewritten. @@ -463,7 +483,7 @@ def _rewrite_builds_submit(script: str) -> str: if not match: raise ValueError( "Couldn't find `gcloud builds submit` in the deploy script. The " - "flexgen manifest format may have changed; the CLI needs updating." + "manifest format may have changed; Contact support@reflex.dev" ) indent = match.group("indent") line_start = script.rfind("\n", 0, match.start()) + 1 @@ -525,7 +545,7 @@ def _run_deploy_script( Args: bash_path: Resolved path to the bash executable. - script: The bash script body received from flexgen. + script: The bash script body received from Reflex. cwd: Working directory to run the script in. env_overrides: Environment variables required by the deploy script. diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index bb21399c75b..200837a11cc 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -74,8 +74,6 @@ def test_gcp_deploy_runs_script_from_source_with_cloudbuild_yaml( tempfile that contains the generated cloudbuild.yaml, the script is rewritten to use --config=, and the source tree is never written to. """ - import base64 as _b64 - captured: dict = {} def capture(**kwargs): @@ -130,11 +128,13 @@ def capture(**kwargs): assert captured["cloudbuild_existed_during_run"] assert not captured["cloudbuild_path"].exists() - # cloudbuild.yaml embeds the flexgen Dockerfile as base64 and builds/pushes. + # cloudbuild.yaml embeds the Reflex Dockerfile via heredoc and builds/pushes. yaml = captured["cloudbuild_yaml"] - expected_b64 = _b64.b64encode(DOCKERFILE.encode()).decode() - assert expected_b64 in yaml - assert 'docker build -t "$_IMAGE"' in yaml + assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml + # Each Dockerfile line shows up in the YAML (indented under the literal block). + for line in DOCKERFILE.splitlines(): + assert f" {line}" in yaml + assert 'docker build -t "$_IMAGE" .' in yaml assert 'docker push "$_IMAGE"' in yaml assert "images:" in yaml @@ -419,26 +419,64 @@ def test_deploy_requires_gcp_target_flag(tmp_path: Path): assert "--gcp" in result.output -def test_build_cloudbuild_yaml_embeds_dockerfile_as_base64(): - """The generated cloudbuild.yaml round-trips the Dockerfile through base64.""" - import base64 as _b64 +def test_build_cloudbuild_yaml_embeds_dockerfile_via_heredoc(): + r"""The cloudbuild.yaml writes the Dockerfile via a single-quoted heredoc. + The single-quoted marker means bash treats `/\ in the Dockerfile body + literally; `$` is doubled to `$$` so Cloud Build's substitution pass + over `args` doesn't grab Dockerfile variables. YAML literal-block indent + (6 spaces) gets stripped uniformly so the closing marker ends up at + column 0. + """ from reflex_cli.v2 import gcp as gcp_module - dockerfile = "FROM python:3.13-slim\nRUN echo $weird '\"chars\"' \\\nthings\n" + # Dockerfile with the kinds of `$`/`${...}` Cloud Build would otherwise + # try to substitute, plus shell-meta chars that break naive quoting. + dockerfile = ( + "FROM python:3.13-slim\n" + 'ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin:$PATH"\n' + "RUN echo $weird '\"chars\"' \\\nthings\n" + ) yaml = gcp_module._build_cloudbuild_yaml(dockerfile) - # The Dockerfile body shows up exactly once as a base64 blob. - expected_b64 = _b64.b64encode(dockerfile.encode()).decode() - assert yaml.count(expected_b64) == 1 - # And the recovery step decodes it back into a Dockerfile. - assert "base64 -d > Dockerfile" in yaml - # The build and push are wired up to the _IMAGE substitution. + # Heredoc opens and closes with the same single-quoted marker. + assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml + assert yaml.count("REFLEX_FLEXGEN_DOCKERFILE_EOF") == 2 + + # Inside the heredoc body, every literal `$` from the Dockerfile is doubled + # to escape Cloud Build's substitution pass. Slice out the heredoc body and + # verify no bare `$` survives there. + open_marker = "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'\n" + close_marker = " REFLEX_FLEXGEN_DOCKERFILE_EOF\n" + body_start = yaml.index(open_marker) + len(open_marker) + body_end = yaml.index(close_marker) + heredoc_body = yaml[body_start:body_end] + # Every `$` in the heredoc body is part of a `$$` pair — i.e. no isolated `$`. + assert "$" in heredoc_body # sanity + assert heredoc_body.replace("$$", "") .count("$") == 0 + # Concrete escapes are present. + assert ' ENV PATH="$${UV_PROJECT_ENVIRONMENT}/bin:$$PATH"' in heredoc_body + assert " RUN echo $$weird '\"chars\"' \\" in heredoc_body + + # Non-`$` Dockerfile lines pass through verbatim (with 6-space indent). + assert " FROM python:3.13-slim" in yaml + assert " things" in yaml + + # Build + push lines use the `_IMAGE` substitution (single `$`). assert 'docker build -t "$_IMAGE" .' in yaml assert 'docker push "$_IMAGE"' in yaml assert "images:\n - $_IMAGE\n" in yaml +def test_build_cloudbuild_yaml_rejects_marker_collision(): + """If the Dockerfile happens to contain the heredoc marker as a whole line, error.""" + from reflex_cli.v2 import gcp as gcp_module + + dockerfile = "FROM scratch\nREFLEX_FLEXGEN_DOCKERFILE_EOF\nCMD true\n" + with pytest.raises(ValueError, match="heredoc marker"): + gcp_module._build_cloudbuild_yaml(dockerfile) + + def test_rewrite_builds_submit_replaces_tag_form_with_config(): """The rewrite consumes the full multi-line `gcloud builds submit ... .`.""" from reflex_cli.v2 import gcp as gcp_module From ad6068e34dfb2e0942701e63b81f45bd0f54af87 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Mon, 11 May 2026 15:18:45 -0700 Subject: [PATCH 6/7] update docs --- docs/hosting/deploy-to-gcp.md | 28 +++++++++++++++++-- .../src/reflex_cli/v2/gcp.py | 14 ++++++---- tests/units/reflex_cli/v2/test_gcp.py | 14 +++++----- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/hosting/deploy-to-gcp.md b/docs/hosting/deploy-to-gcp.md index 845bb2a1895..4b45104534e 100644 --- a/docs/hosting/deploy-to-gcp.md +++ b/docs/hosting/deploy-to-gcp.md @@ -45,7 +45,7 @@ The CLI will: 4. Ask for confirmation, then run the script with `cwd=` your source directory: enable the required APIs, create the Artifact Registry repository, build the image on Cloud Build (which materializes the Dockerfile inside the build step from the `cloudbuild.yaml`), and deploy a public Cloud Run service. 5. Delete the tempfile after the script finishes. -Your source tree is never written to — if you have an existing `Dockerfile` in `--source`, it's left in place and ignored. The flexgen Dockerfile only exists inside the `cloudbuild.yaml` tempfile (and inside the Cloud Build job). +Your source tree is never written to — if you have an existing `Dockerfile` in `--source`, it's left in place and ignored. The Reflex-provided Dockerfile only exists inside the `cloudbuild.yaml` tempfile (and inside the Cloud Build job). When it's done, you'll get a service URL like `https://my-reflex-app-.us-central1.run.app`. @@ -81,6 +81,24 @@ It then creates (idempotently) and uses: Re-running the command pushes a new image tag and rolls the Cloud Run service forward. +## How the build runs + +The generated `cloudbuild.yaml` is a single Cloud Build step that: + +1. Writes the Dockerfile into the build workspace via a single-quoted heredoc: + ```yaml + - | + cat > Dockerfile <<'REFLEX_DOCKERFILE_EOF' + FROM python:3.13-slim + ... + REFLEX_DOCKERFILE_EOF + docker build -t "$_IMAGE" . + docker push "$_IMAGE" + ``` +2. Builds and pushes the image, tagging it with `_IMAGE` (passed to `gcloud builds submit` as `--substitutions=_IMAGE=...`). + +Because Cloud Build runs its own substitution pass over `args`, every literal `$` in the Dockerfile is doubled to `$$` before embedding (e.g. `ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin:$PATH"` becomes `ENV PATH="$${UV_PROJECT_ENVIRONMENT}/bin:$$PATH"` in the YAML). Cloud Build's parser converts `$$` back to `$` before bash runs, so the Dockerfile written into the workspace contains the original characters. + ## 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. @@ -109,7 +127,7 @@ In non-interactive mode the CLI will not prompt, and it will exit non-zero if a ## Troubleshooting -**`Flexgen denied the request (403). GCP Cloud Run deploys require an Enterprise tier subscription.`** +**`Reflex 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`)** @@ -123,3 +141,9 @@ 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. + +**`Dockerfile content contains the reserved heredoc marker 'REFLEX_DOCKERFILE_EOF'.`** +Vanishingly unlikely — the Dockerfile from Reflex Cloud happens to contain a line that exactly matches the heredoc terminator the CLI uses to embed it. Re-run after the next CLI release, or open an issue. + +**`Couldn't find 'gcloud builds submit' in the deploy script.`** +The CLI rewrites the `gcloud builds submit` block in the Reflex-supplied deploy script to use `--config=`. If Reflex Cloud changes the shape of that script before the CLI is updated to match, you'll see this error — upgrade `reflex-hosting-cli` (`uv tool upgrade reflex-hosting-cli` or `pip install -U reflex-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 f33d34b7ad5..1c37c4d68cc 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -144,7 +144,7 @@ default=".", show_default=True, type=click.Path(file_okay=False, dir_okay=True), - help="The directory containing the Reflex app. Staged into an ephemeral build context; the source tree itself is not modified.", + help="The directory containing the Reflex app. Uploaded to Cloud Build as the build context; the source tree itself is not modified.", ) @click.option("--token", help="The Reflex authentication token.") @click.option( @@ -157,7 +157,7 @@ "--dry-run", is_flag=True, default=False, - help="Print the manifest without staging the build context or running the script.", + help="Print the manifest and generated cloudbuild.yaml without writing the tempfile or running the script.", ) @click.option( "--loglevel", @@ -181,9 +181,11 @@ def deploy_command( """Deploy a Reflex app to a cloud target. Currently the only supported target is GCP Cloud Run via --gcp. The - command fetches a Dockerfile and bash deploy script from Reflex, stages - them in an ephemeral build context alongside symlinked source entries - (your project tree is never modified), and runs the script from there. + command fetches a Dockerfile and bash deploy script from Reflex, embeds + the Dockerfile inside a generated ``cloudbuild.yaml`` (written to a + tempfile), rewrites the script's ``gcloud builds submit`` invocation to + reference that config, then runs the script with cwd= your source dir. + Your project tree is never modified. """ from reflex_cli.utils import hosting @@ -430,7 +432,7 @@ def _build_cloudbuild_yaml(dockerfile_contents: str) -> str: heredoc marker (would terminate the heredoc early). """ - marker = "REFLEX_FLEXGEN_DOCKERFILE_EOF" + marker = "REFLEX_DOCKERFILE_EOF" if any(line.rstrip() == marker for line in dockerfile_contents.splitlines()): raise ValueError( f"Dockerfile content contains the reserved heredoc marker {marker!r}." diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index 200837a11cc..a6b9595c97d 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -19,7 +19,7 @@ runner = CliRunner() DOCKERFILE = "FROM python:3.13-slim\nWORKDIR /app\n" -# A realistic-shaped flexgen script — the rewrite logic targets the +# A realistic-shaped Reflex deploy script — the rewrite logic targets the # `gcloud builds submit ... .` block in here. DEPLOY_SCRIPT = ( "#!/usr/bin/env bash\n" @@ -130,7 +130,7 @@ def capture(**kwargs): # cloudbuild.yaml embeds the Reflex Dockerfile via heredoc and builds/pushes. yaml = captured["cloudbuild_yaml"] - assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml + assert "cat > Dockerfile <<'REFLEX_DOCKERFILE_EOF'" in yaml # Each Dockerfile line shows up in the YAML (indented under the literal block). for line in DOCKERFILE.splitlines(): assert f" {line}" in yaml @@ -440,14 +440,14 @@ def test_build_cloudbuild_yaml_embeds_dockerfile_via_heredoc(): yaml = gcp_module._build_cloudbuild_yaml(dockerfile) # Heredoc opens and closes with the same single-quoted marker. - assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml - assert yaml.count("REFLEX_FLEXGEN_DOCKERFILE_EOF") == 2 + assert "cat > Dockerfile <<'REFLEX_DOCKERFILE_EOF'" in yaml + assert yaml.count("REFLEX_DOCKERFILE_EOF") == 2 # Inside the heredoc body, every literal `$` from the Dockerfile is doubled # to escape Cloud Build's substitution pass. Slice out the heredoc body and # verify no bare `$` survives there. - open_marker = "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'\n" - close_marker = " REFLEX_FLEXGEN_DOCKERFILE_EOF\n" + open_marker = "cat > Dockerfile <<'REFLEX_DOCKERFILE_EOF'\n" + close_marker = " REFLEX_DOCKERFILE_EOF\n" body_start = yaml.index(open_marker) + len(open_marker) body_end = yaml.index(close_marker) heredoc_body = yaml[body_start:body_end] @@ -472,7 +472,7 @@ def test_build_cloudbuild_yaml_rejects_marker_collision(): """If the Dockerfile happens to contain the heredoc marker as a whole line, error.""" from reflex_cli.v2 import gcp as gcp_module - dockerfile = "FROM scratch\nREFLEX_FLEXGEN_DOCKERFILE_EOF\nCMD true\n" + dockerfile = "FROM scratch\nREFLEX_DOCKERFILE_EOF\nCMD true\n" with pytest.raises(ValueError, match="heredoc marker"): gcp_module._build_cloudbuild_yaml(dockerfile) From 74b114d993f2a25f4465baece8c8bd252f911e5c Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Mon, 11 May 2026 15:49:14 -0700 Subject: [PATCH 7/7] pre commit --- .../src/reflex_cli/v2/gcp.py | 80 +++++++++---------- tests/units/reflex_cli/v2/test_gcp.py | 2 +- 2 files changed, 40 insertions(+), 42 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 1c37c4d68cc..e1a1819aa60 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -57,47 +57,45 @@ # 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", - } -) +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.command(name="deploy") diff --git a/tests/units/reflex_cli/v2/test_gcp.py b/tests/units/reflex_cli/v2/test_gcp.py index a6b9595c97d..c7ba4a3bb7a 100644 --- a/tests/units/reflex_cli/v2/test_gcp.py +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -453,7 +453,7 @@ def test_build_cloudbuild_yaml_embeds_dockerfile_via_heredoc(): heredoc_body = yaml[body_start:body_end] # Every `$` in the heredoc body is part of a `$$` pair — i.e. no isolated `$`. assert "$" in heredoc_body # sanity - assert heredoc_body.replace("$$", "") .count("$") == 0 + assert heredoc_body.replace("$$", "").count("$") == 0 # Concrete escapes are present. assert ' ENV PATH="$${UV_PROJECT_ENVIRONMENT}/bin:$$PATH"' in heredoc_body assert " RUN echo $$weird '\"chars\"' \\" in heredoc_body