Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
364 changes: 364 additions & 0 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +25 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded env-var and response-field key strings

Per the project rule, string literals used as identifiers or dictionary keys should be extracted into named constants. The five GCP environment variable keys and the two response field names ("dockerfile", "deploy_command") appear inline in multiple places, making typos hard to catch and refactoring error-prone. Extracting them to module-level constants (as is already done for GCP_MANIFEST_ENDPOINT and DOCKERFILE_NAME) keeps the file consistent.

Suggested change
GCP_MANIFEST_ENDPOINT = "/api/v1/cli/gcp-cloud-run-manifest"
DOCKERFILE_NAME = "Dockerfile"
GCP_MANIFEST_ENDPOINT = "/api/v1/cli/gcp-cloud-run-manifest"
DOCKERFILE_NAME = "Dockerfile"
# Environment variable keys 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"
# Flexgen manifest response field names
_FIELD_DOCKERFILE = "dockerfile"
_FIELD_DEPLOY_COMMAND = "deploy_command"

Rule Used: String literals that are used as identifiers or ke... (source)

Learned From
reflex-dev/flexgen#2170



@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)
Comment on lines +185 to +191
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No --yes / --auto-approve flag for unattended use

Both the "Overwrite Dockerfile?" and "Run the deploy script now?" prompts unconditionally call console.ask, which blocks on stdin. Running gcp deploy from a CI/CD pipeline (e.g. GitHub Actions, Cloud Build) with no TTY will stall indefinitely. Adding a --yes boolean flag that short-circuits both prompts — analogous to the existing --overwrite-dockerfile flag — would make the command automation-friendly without changing the interactive default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should respect the --no-interactive flag that is already part of the hosting cli


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
Comment on lines +348 to +364
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Full environment passed to server-provided script

os.environ.copy() forwards every parent process environment variable — including AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, cloud-provider credential files, and SSH agent sockets — to a bash script whose content is determined server-side. If the flexgen endpoint is compromised or the response is tampered with, the script can silently exfiltrate all of these. The user sees the deploy script before confirming, but reviewers may miss nested variable expansions or obfuscated commands.

At minimum, document the environmental exposure prominently in the help text; consider stripping well-known credential variables (e.g. AWS_*, GITHUB_TOKEN, *_SECRET_*) from the copy before passing it, or maintaining an explicit allowlist of variables the script actually needs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintaining an explicit allowlist of variables the script actually needs

this seems more ideal, both from a security and a documentation perspective

Loading
Loading