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")