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..4b45104534e --- /dev/null +++ b/docs/hosting/deploy-to-gcp.md @@ -0,0 +1,149 @@ +```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, 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. + +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. 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 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`. + +## 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. Uploaded to Cloud Build as the build context; the source tree itself is not modified. | +| `--token` | _from `~/.reflex` config_ | Reflex authentication token. | +| `--interactive / --no-interactive` | `--interactive` | Whether to prompt before running the deploy script. | +| `--dry-run` | _off_ | Print the manifest, the generated `cloudbuild.yaml`, and the rewritten script without writing the tempfile 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. + +## 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. + +You can preview the rewritten script, generated `cloudbuild.yaml`, 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` and an explicit `--token`: + +```bash +reflex cloud deploy --gcp \ + --gcp-project "$GCP_PROJECT_ID" \ + --service-name my-reflex-app \ + --token "$REFLEX_TOKEN" \ + --no-interactive +``` + +In non-interactive mode the CLI will not prompt, and it will exit non-zero if a token cannot be resolved. + +## Troubleshooting + +**`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`)** +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. + +**`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/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index 8042bf8f6c5..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,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 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 @@ -64,6 +65,10 @@ def hosting_cli(ctx: click.Context) -> None: secrets_cli, name="secrets", ) +hosting_cli.add_command( + gcp_deploy_command, + name="deploy", +) 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..e1a1819aa60 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -0,0 +1,576 @@ +"""GCP Cloud Run deploy commands for the Reflex Cloud CLI. + +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 +tree is never modified. The script reads its parameters from environment +variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION, +REFLEX_CLOUDBUILD_YAML). +""" + +from __future__ import annotations + +import contextlib +import os +import re +import shutil +import subprocess +import sys +import tempfile +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" + +# 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" +# Path to the Cloud Build config file written by the CLI. The rewritten +# deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``. +ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML" + +# Pattern for the start of the `gcloud builds submit` invocation in the +# 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( + r"(?P^[ \t]*)gcloud[ \t]+builds[ \t]+submit\b", + re.MULTILINE, +) + +# 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", +}) + + +@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", + default=None, + help="The GCP project ID to deploy into (sets GCP_PROJECT). Required with --gcp.", +) +@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. 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( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to prompt before running the deploy script.", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Print the manifest and generated cloudbuild.yaml without writing the tempfile 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 deploy_command( + use_gcp: bool, + gcp_project: str | None, + region: str, + service_name: str, + ar_repo: str, + version_tag: str | None, + source_dir: str, + token: str | None, + interactive: bool, + dry_run: bool, + loglevel: str, +): + """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, 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 + + 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 + ) + + 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) + + 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 = { + 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 Reflex.") + console.print("") + 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 (rewritten to use cloudbuild.yaml):") + 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)." + ) + 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("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 staged or executed.") + return + + if interactive: + answer = console.ask( + "Run the deploy script now?", choices=["y", "n"], default="y" + ) + if answer != "y": + console.warn("Aborted by user.") + raise click.exceptions.Exit(1) + + 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) + 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 Reflex. + + 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( + "Reflex denied the request (403). GCP Cloud Run deploys require an " + "Enterprise tier subscription." + ) + else: + 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 Reflex at {url}: {ex}") + raise click.exceptions.Exit(1) from ex + + try: + body = response.json() + except ValueError as ex: + console.error("Reflex returned a non-JSON response.") + raise click.exceptions.Exit(1) from ex + + if not isinstance(body, dict): + 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"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"Reflex response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field." + ) + raise click.exceptions.Exit(1) + + return dockerfile, deploy_command + + +def _build_cloudbuild_yaml(dockerfile_contents: str) -> str: + r"""Generate a Cloud Build config that materializes the Dockerfile inline. + + 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 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). + + """ + 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}." + ) + # 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" + " entrypoint: bash\n" + " args:\n" + " - -c\n" + " - |\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 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 + ``REFLEX_CLOUDBUILD_YAML`` environment variable and passes the image tag + through ``--substitutions=_IMAGE=...``. + + Args: + script: The Reflex 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 " + "manifest format may have changed; Contact support@reflex.dev" + ) + 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: + with os.fdopen(fd, "w") as fh: + fh.write(contents) + yield path + finally: + with contextlib.suppress(FileNotFoundError): + path.unlink() + + +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. + + 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 Reflex. + cwd: Working directory to run the script in. + env_overrides: Environment variables required by the deploy script. + + Returns: + The exit code of the bash process. + + """ + env = { + name: value + for name, value in os.environ.items() + if name in DEPLOY_ENV_ALLOWLIST + } + 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..c7ba4a3bb7a --- /dev/null +++ b/tests/units/reflex_cli/v2/test_gcp.py @@ -0,0 +1,549 @@ +from __future__ import annotations + +import os +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" +# A realistic-shaped Reflex deploy script — the rewrite logic targets the +# `gcloud builds submit ... .` block in here. +DEPLOY_SCRIPT = ( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'IMAGE="us-central1-docker.pkg.dev/${GCP_PROJECT}/reflex/${SERVICE_NAME}:${VERSION}"\n' + "gcloud builds submit \\\n" + ' --tag "${IMAGE}" \\\n' + ' --project "${GCP_PROJECT}" \\\n' + " .\n" + 'gcloud run deploy "${SERVICE_NAME}" --image "${IMAGE}"\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_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. + """ + 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, + [ + "deploy", + "--gcp", + "--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 + # 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 Reflex Dockerfile via heredoc and builds/pushes. + yaml = captured["cloudbuild_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 + 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 + # 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) + + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], + input="n\n", + ) + + assert result.exit_code == 1 + # Nothing was written into the source tree. + assert not (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, + ["deploy", "--gcp", "--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, + [ + "deploy", + "--gcp", + "--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_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") + + result = runner.invoke( + hosting_cli, + ["deploy", "--gcp", "--gcp-project", "p", "--source", str(tmp_path)], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert existing.read_text() == "FROM existing\n" + 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, + ["deploy", "--gcp", "--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, + ["deploy", "--gcp", "--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, + ["deploy", "--gcp", "--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, + ["deploy", "--gcp", "--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, + ["deploy", "--gcp", "--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, + ["deploy", "--gcp", "--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() + + +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, + [ + "deploy", + "--gcp", + "--gcp-project", + "p", + "--source", + str(tmp_path), + "--no-interactive", + "--token", + "fake-token", + ], + ) + + assert result.exit_code == 0, result.output + # Source tree was not modified. + assert not (tmp_path / "Dockerfile").exists() + assert run_mock.call_count == 1 + + +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, + ["deploy", "--gcp", "--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 + + +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_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 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) + + # Heredoc opens and closes with the same single-quoted marker. + 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_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] + # 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_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 + + 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") + 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")