reflex-hosting-cli 0.1.63__tar.gz → 0.1.65a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/PKG-INFO +1 -1
  2. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/utils/hosting.py +26 -9
  3. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/cli.py +45 -58
  4. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/deployments.py +5 -0
  5. reflex_hosting_cli-0.1.65a1/src/reflex_cli/v2/gcp.py +576 -0
  6. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/.gitignore +0 -0
  7. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/README.md +0 -0
  8. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/pyproject.toml +0 -0
  9. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/__init__.py +0 -0
  10. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/cli.py +0 -0
  11. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/constants/__init__.py +0 -0
  12. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/constants/base.py +0 -0
  13. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/constants/compiler.py +0 -0
  14. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/constants/hosting.py +0 -0
  15. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/core/__init__.py +0 -0
  16. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/core/config.py +0 -0
  17. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/deployments.py +0 -0
  18. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/utils/__init__.py +0 -0
  19. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/utils/console.py +0 -0
  20. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/utils/dependency.py +0 -0
  21. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/utils/exceptions.py +0 -0
  22. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/__init__.py +0 -0
  23. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/apps.py +0 -0
  24. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/project.py +0 -0
  25. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/secrets.py +0 -0
  26. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/utils.py +0 -0
  27. {reflex_hosting_cli-0.1.63 → reflex_hosting_cli-0.1.65a1}/src/reflex_cli/v2/vmtypes_regions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-hosting-cli
3
- Version: 0.1.63
3
+ Version: 0.1.65a1
4
4
  Summary: Reflex Hosting CLI
5
5
  Author-email: Nikhil Rao <nikhil@reflex.dev>, Alek Petuskey <alek@reflex.dev>
6
6
  Maintainer-email: Simon Young <simon@reflex.dev>, Khaleel Al-Adhami <khaleel@reflex.dev>
@@ -216,16 +216,15 @@ class ScaleParams:
216
216
  dict: The object as a dictionary.
217
217
 
218
218
  """
219
- if self.type is None:
220
- self.type = ScaleType.REGION
219
+ effective_type = self.type or ScaleType.REGION
221
220
  return (
222
221
  {
223
- "type": str(self.type.value),
222
+ "type": str(effective_type.value),
224
223
  "size": self.vm_type,
225
224
  }
226
- if self.type == ScaleType.SIZE
225
+ if effective_type == ScaleType.SIZE
227
226
  else {
228
- "type": str(self.type.value),
227
+ "type": str(effective_type.value),
229
228
  "regions": {
230
229
  region["name"]: region["number_of_machines"]
231
230
  for region in self.regions
@@ -1079,6 +1078,20 @@ def select_project(project: str, token: str | None = None) -> str:
1079
1078
  return f"{project} is now selected."
1080
1079
 
1081
1080
 
1081
+ def normalize_project_id(value: Any) -> str | None:
1082
+ """Normalize a project ID value, treating empty/whitespace strings and non-strings as None.
1083
+
1084
+ Args:
1085
+ value: The raw project ID value from config, CLI args, or hosting.json.
1086
+
1087
+ Returns:
1088
+ The stripped project ID, or None if the value is missing or blank.
1089
+ """
1090
+ if isinstance(value, str) and value.strip():
1091
+ return value.strip()
1092
+ return None
1093
+
1094
+
1082
1095
  def get_selected_project() -> str | None:
1083
1096
  """Retrieve the currently selected project ID.
1084
1097
 
@@ -1089,10 +1102,10 @@ def get_selected_project() -> str | None:
1089
1102
  try:
1090
1103
  with constants.Hosting.HOSTING_JSON.open() as config_file:
1091
1104
  hosting_config = json.load(config_file)
1092
- return hosting_config.get("project")
1105
+ return normalize_project_id(hosting_config.get("project"))
1093
1106
  except Exception as ex:
1094
1107
  console.debug(
1095
- f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}"
1108
+ f"Unable to read selected project from {constants.Hosting.HOSTING_JSON} due to: {ex}"
1096
1109
  )
1097
1110
  return None
1098
1111
 
@@ -1971,14 +1984,18 @@ def authenticate_on_browser() -> tuple[str, dict[str, Any]]:
1971
1984
  constants.Hosting.HOSTING_SERVICE_UI, f"/cli/login?request_id={request_id}"
1972
1985
  )
1973
1986
 
1974
- console.print(f"Opening {auth_url} ...")
1975
-
1976
1987
  if not is_valid_url(constants.Hosting.HOSTING_SERVICE_UI):
1977
1988
  console.error(
1978
1989
  f"Invalid hosting URL: {constants.Hosting.HOSTING_SERVICE_UI}. Ensure the URL is in the correct format and includes a valid scheme"
1979
1990
  )
1980
1991
  raise click.exceptions.Exit(1)
1981
1992
 
1993
+ console.print(
1994
+ f"Opening {auth_url} ... By connecting your account, you agree to "
1995
+ "Reflex Cloud [Terms of Service] and [Privacy Policy].",
1996
+ markup=False,
1997
+ )
1998
+
1982
1999
  if not webbrowser.open(auth_url):
1983
2000
  console.warn(
1984
2001
  f"Unable to automatically open the browser. Please go to {auth_url} to authenticate."
@@ -28,7 +28,8 @@ def login(
28
28
  loglevel: The log level to use.
29
29
 
30
30
  Returns:
31
- Information about the logged in user.
31
+ Information about the newly logged in user or empty dict if already
32
+ logged in.
32
33
 
33
34
  Raises:
34
35
  SystemExit: If the command fails.
@@ -42,7 +43,7 @@ def login(
42
43
  access_token, validated_info = hosting.authenticated_token()
43
44
  if access_token:
44
45
  console.print("You already logged in.")
45
- return validated_info
46
+ return {}
46
47
 
47
48
  # If not already logged in, open a browser window/tab to the login page.
48
49
  access_token, validated_info = hosting.authenticate_on_browser()
@@ -179,19 +180,24 @@ def deploy(
179
180
  if not description:
180
181
  description = config.get("description", None)
181
182
 
182
- # resolve the project id from the project name.
183
+ project_id = hosting.normalize_project_id(project_id)
184
+
183
185
  if project_name and not project_id:
184
186
  result = hosting.search_project(
185
187
  project_name, client=authenticated_client, interactive=interactive
186
188
  )
187
- project_id = result.get("id") if result else None
189
+ project_id = hosting.normalize_project_id(result.get("id")) if result else None
190
+
191
+ selected_project_id = hosting.get_selected_project()
188
192
 
193
+ validated_project: dict[str, Any] | None = None
189
194
  try:
190
- # check if provided project exists.
195
+ if not project_id:
196
+ project_id = selected_project_id
191
197
  if project_id:
192
- hosting.get_project(project_id, client=authenticated_client)
193
- else:
194
- project_id = hosting.get_selected_project()
198
+ validated_project = hosting.get_project(
199
+ project_id, client=authenticated_client
200
+ )
195
201
  except httpx.HTTPStatusError as ex:
196
202
  try:
197
203
  console.error(ex.response.json().get("detail"))
@@ -209,9 +215,7 @@ def deploy(
209
215
  try:
210
216
  if app_name and not app_id:
211
217
  search_project_id = project_id
212
- if not interactive and not project and not search_project_id:
213
- search_project_id = hosting.get_selected_project()
214
- elif interactive and not project:
218
+ if interactive and not project:
215
219
  search_project_id = None
216
220
 
217
221
  app = hosting.search_app(
@@ -230,16 +234,13 @@ def deploy(
230
234
  raise click.exceptions.Exit(1) from ex
231
235
 
232
236
  if app and interactive and not project and not app_id:
233
- default_project_id = hosting.get_selected_project()
237
+ default_project_id = selected_project_id
234
238
  app_project_id = app.get("project_id")
235
239
 
236
240
  if app_project_id and (
237
241
  not default_project_id or app_project_id != default_project_id
238
242
  ):
239
- app_project = hosting.get_project(
240
- app_project_id, client=authenticated_client
241
- )
242
- app_project_name = app_project.get("name", "Unknown")
243
+ app_project_name = (app.get("project") or {}).get("name") or app_project_id
243
244
  if (
244
245
  console.ask(
245
246
  f"Deploy to app '{app['name']}' in project '{app_project_name}'?",
@@ -262,55 +263,40 @@ def deploy(
262
263
  )
263
264
  == "y"
264
265
  ):
265
- # Check if we need confirmation for deploying to non-default project
266
266
  if not project:
267
- default_project_id = hosting.get_selected_project()
268
- if not default_project_id:
269
- try:
270
- if project_id:
271
- target_project = hosting.get_project(
272
- project_id, client=authenticated_client
273
- )
274
- project_name = target_project.get("name", "Unknown")
275
- else:
276
- token = hosting.get_existing_access_token()
277
- default_project_id = hosting.get_default_project(
278
- authenticated_client
267
+ needs_confirmation = not selected_project_id or (
268
+ project_id and project_id != selected_project_id
269
+ )
270
+ if needs_confirmation:
271
+ if project_id:
272
+ project_display_name = (
273
+ (
274
+ validated_project.get("name")
275
+ if validated_project
276
+ else None
279
277
  )
280
- if default_project_id:
281
- default_project = hosting.get_project(
282
- default_project_id, client=authenticated_client
283
- )
284
- project_name = default_project.get(
285
- "name", "Default Project"
286
- )
287
- else:
288
- project_name = "Default Project"
289
- except Exception:
290
- project_name = "Unknown"
291
-
292
- if (
293
- console.ask(
294
- f"Create and deploy app '{app_name}' in project '{project_name}'?",
295
- choices=["y", "n"],
296
- default="y",
278
+ or project_name
279
+ or project_id
297
280
  )
298
- != "y"
299
- ):
300
- console.info("Deployment cancelled.")
301
- raise click.exceptions.Exit(0)
302
- elif project_id and project_id != default_project_id:
303
- try:
304
- target_project = hosting.get_project(
305
- project_id, client=authenticated_client
281
+ else:
282
+ project_display_name = "your default project"
283
+ fallback_project_id = hosting.get_default_project(
284
+ authenticated_client
306
285
  )
307
- project_name = target_project.get("name", "Unknown")
308
- except Exception:
309
- project_name = "Unknown"
286
+ if fallback_project_id:
287
+ try:
288
+ fallback_project = hosting.get_project(
289
+ fallback_project_id, client=authenticated_client
290
+ )
291
+ project_display_name = (
292
+ fallback_project.get("name") or project_display_name
293
+ )
294
+ except Exception:
295
+ pass
310
296
 
311
297
  if (
312
298
  console.ask(
313
- f"Create and deploy app '{app_name}' in project '{project_name}'?",
299
+ f"Create and deploy app '{app_name}' in project '{project_display_name}'?",
314
300
  choices=["y", "n"],
315
301
  default="y",
316
302
  )
@@ -386,6 +372,7 @@ def deploy(
386
372
  console.error(
387
373
  """The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`."""
388
374
  )
375
+ raise click.exceptions.Exit(1) from None
389
376
 
390
377
  # Compile the app in production mode: backend first then frontend.
391
378
  temporary_dir = tempfile.TemporaryDirectory()
@@ -12,6 +12,7 @@ from packaging import version
12
12
  from reflex_cli import constants
13
13
  from reflex_cli.utils import console
14
14
  from reflex_cli.v2.apps import apps_cli
15
+ from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command
15
16
  from reflex_cli.v2.project import project_cli
16
17
  from reflex_cli.v2.secrets import secrets_cli
17
18
  from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli
@@ -64,6 +65,10 @@ hosting_cli.add_command(
64
65
  secrets_cli,
65
66
  name="secrets",
66
67
  )
68
+ hosting_cli.add_command(
69
+ gcp_deploy_command,
70
+ name="deploy",
71
+ )
67
72
  for name, command in vm_types_regions_cli.commands.items():
68
73
  # Add the command to the hosting CLI
69
74
  hosting_cli.add_command(command, name=name)
@@ -0,0 +1,576 @@
1
+ """GCP Cloud Run deploy commands for the Reflex Cloud CLI.
2
+
3
+ Fetches a Dockerfile + bash deploy script from Reflex and runs the script
4
+ against the user's source directory. The Dockerfile is materialized inside
5
+ a Cloud Build job (via a ``cloudbuild.yaml`` written to a tempfile and
6
+ referenced with ``gcloud builds submit --config=...``) — the user's project
7
+ tree is never modified. The script reads its parameters from environment
8
+ variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION,
9
+ REFLEX_CLOUDBUILD_YAML).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from urllib.parse import urljoin
24
+
25
+ import click
26
+
27
+ from reflex_cli import constants
28
+ from reflex_cli.utils import console
29
+
30
+ GCP_MANIFEST_ENDPOINT = "/api/v1/cli/gcp-cloud-run-manifest"
31
+
32
+ DOCKERFILE_NAME = "Dockerfile"
33
+
34
+ # Environment variables passed to the deploy script.
35
+ ENV_GCP_PROJECT = "GCP_PROJECT"
36
+ ENV_GCP_REGION = "GCP_REGION"
37
+ ENV_SERVICE_NAME = "SERVICE_NAME"
38
+ ENV_AR_REPO = "AR_REPO"
39
+ ENV_VERSION = "VERSION"
40
+ # Path to the Cloud Build config file written by the CLI. The rewritten
41
+ # deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
42
+ ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML"
43
+
44
+ # Pattern for the start of the `gcloud builds submit` invocation in the
45
+ # Reflex deploy script. We rewrite that whole multi-line command to use
46
+ # `--config=` so the Dockerfile lives inside a cloudbuild.yaml instead of
47
+ # being staged on disk next to the user's source.
48
+ _BUILDS_SUBMIT_PATTERN = re.compile(
49
+ r"(?P<indent>^[ \t]*)gcloud[ \t]+builds[ \t]+submit\b",
50
+ re.MULTILINE,
51
+ )
52
+
53
+ # Manifest response field names from Reflex.
54
+ FIELD_DOCKERFILE = "dockerfile"
55
+ FIELD_DEPLOY_COMMAND = "deploy_command"
56
+
57
+ # Allowlist of host environment variables forwarded to the deploy script.
58
+ # We deliberately exclude things like AWS_*/GITHUB_TOKEN/SSH agent sockets so a
59
+ # compromised or tampered manifest cannot exfiltrate unrelated credentials.
60
+ DEPLOY_ENV_ALLOWLIST = frozenset({
61
+ "PATH",
62
+ "HOME",
63
+ "USER",
64
+ "LOGNAME",
65
+ "SHELL",
66
+ "TERM",
67
+ "LANG",
68
+ "LC_ALL",
69
+ "LC_CTYPE",
70
+ "TMPDIR",
71
+ "TEMP",
72
+ "TMP",
73
+ "XDG_CONFIG_HOME",
74
+ # gcloud configuration
75
+ "CLOUDSDK_CONFIG",
76
+ "CLOUDSDK_ACTIVE_CONFIG_NAME",
77
+ "CLOUDSDK_CORE_PROJECT",
78
+ "CLOUDSDK_CORE_ACCOUNT",
79
+ "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE",
80
+ "GOOGLE_APPLICATION_CREDENTIALS",
81
+ # docker configuration
82
+ "DOCKER_HOST",
83
+ "DOCKER_TLS_VERIFY",
84
+ "DOCKER_CERT_PATH",
85
+ "DOCKER_CONFIG",
86
+ "DOCKER_BUILDKIT",
87
+ # corporate proxy / TLS trust
88
+ "HTTP_PROXY",
89
+ "HTTPS_PROXY",
90
+ "NO_PROXY",
91
+ "http_proxy",
92
+ "https_proxy",
93
+ "no_proxy",
94
+ "SSL_CERT_FILE",
95
+ "SSL_CERT_DIR",
96
+ "REQUESTS_CA_BUNDLE",
97
+ "CURL_CA_BUNDLE",
98
+ })
99
+
100
+
101
+ @click.command(name="deploy")
102
+ @click.option(
103
+ "--gcp",
104
+ "use_gcp",
105
+ is_flag=True,
106
+ default=False,
107
+ help="Deploy to GCP Cloud Run. Required (the only supported target today).",
108
+ )
109
+ @click.option(
110
+ "--gcp-project",
111
+ "gcp_project",
112
+ default=None,
113
+ help="The GCP project ID to deploy into (sets GCP_PROJECT). Required with --gcp.",
114
+ )
115
+ @click.option(
116
+ "--region",
117
+ default="us-central1",
118
+ show_default=True,
119
+ help="The GCP region for Cloud Run (sets GCP_REGION).",
120
+ )
121
+ @click.option(
122
+ "--service-name",
123
+ default="reflex-app",
124
+ show_default=True,
125
+ help="The Cloud Run service name (sets SERVICE_NAME).",
126
+ )
127
+ @click.option(
128
+ "--ar-repo",
129
+ default="reflex",
130
+ show_default=True,
131
+ help="The Artifact Registry repository name (sets AR_REPO).",
132
+ )
133
+ @click.option(
134
+ "--version",
135
+ "version_tag",
136
+ default=None,
137
+ help="The image version tag (sets VERSION). Defaults to a UTC timestamp.",
138
+ )
139
+ @click.option(
140
+ "--source",
141
+ "source_dir",
142
+ default=".",
143
+ show_default=True,
144
+ type=click.Path(file_okay=False, dir_okay=True),
145
+ help="The directory containing the Reflex app. Uploaded to Cloud Build as the build context; the source tree itself is not modified.",
146
+ )
147
+ @click.option("--token", help="The Reflex authentication token.")
148
+ @click.option(
149
+ "--interactive/--no-interactive",
150
+ is_flag=True,
151
+ default=True,
152
+ help="Whether to prompt before running the deploy script.",
153
+ )
154
+ @click.option(
155
+ "--dry-run",
156
+ is_flag=True,
157
+ default=False,
158
+ help="Print the manifest and generated cloudbuild.yaml without writing the tempfile or running the script.",
159
+ )
160
+ @click.option(
161
+ "--loglevel",
162
+ type=click.Choice([level.value for level in constants.LogLevel]),
163
+ default=constants.LogLevel.INFO.value,
164
+ help="The log level to use.",
165
+ )
166
+ def deploy_command(
167
+ use_gcp: bool,
168
+ gcp_project: str | None,
169
+ region: str,
170
+ service_name: str,
171
+ ar_repo: str,
172
+ version_tag: str | None,
173
+ source_dir: str,
174
+ token: str | None,
175
+ interactive: bool,
176
+ dry_run: bool,
177
+ loglevel: str,
178
+ ):
179
+ """Deploy a Reflex app to a cloud target.
180
+
181
+ Currently the only supported target is GCP Cloud Run via --gcp. The
182
+ command fetches a Dockerfile and bash deploy script from Reflex, embeds
183
+ the Dockerfile inside a generated ``cloudbuild.yaml`` (written to a
184
+ tempfile), rewrites the script's ``gcloud builds submit`` invocation to
185
+ reference that config, then runs the script with cwd= your source dir.
186
+ Your project tree is never modified.
187
+ """
188
+ from reflex_cli.utils import hosting
189
+
190
+ console.set_log_level(loglevel)
191
+
192
+ if not use_gcp:
193
+ console.error(
194
+ "Specify a deploy target. Currently supported: --gcp (GCP Cloud Run)."
195
+ )
196
+ raise click.exceptions.Exit(2)
197
+ if not gcp_project:
198
+ console.error("--gcp-project is required when using --gcp.")
199
+ raise click.exceptions.Exit(2)
200
+
201
+ authenticated_client = hosting.get_authenticated_client(
202
+ token=token, interactive=interactive
203
+ )
204
+
205
+ bash_path = shutil.which("bash")
206
+ if not bash_path:
207
+ console.error(
208
+ "`bash` was not found on PATH; required to run the deploy script."
209
+ )
210
+ raise click.exceptions.Exit(1)
211
+
212
+ gcloud_path = shutil.which("gcloud")
213
+ if not gcloud_path:
214
+ console.error(
215
+ "The `gcloud` CLI was not found on PATH. Install it from "
216
+ "https://cloud.google.com/sdk/docs/install and run `gcloud auth login` "
217
+ "and `gcloud auth application-default login` before retrying."
218
+ )
219
+ raise click.exceptions.Exit(1)
220
+
221
+ if not shutil.which("docker"):
222
+ console.error(
223
+ "The `docker` CLI was not found on PATH; required to build the image."
224
+ )
225
+ raise click.exceptions.Exit(1)
226
+
227
+ if not _get_active_gcp_account(gcloud_path):
228
+ console.error(
229
+ "No active GCP account found. Run `gcloud auth login` and "
230
+ "`gcloud auth application-default login`, then retry."
231
+ )
232
+ raise click.exceptions.Exit(1)
233
+
234
+ dockerfile, deploy_script = _request_manifest(authenticated_client.token)
235
+
236
+ source_path = Path(source_dir).resolve()
237
+ if not source_path.is_dir():
238
+ console.error(f"Source directory does not exist: {source_path}")
239
+ raise click.exceptions.Exit(1)
240
+
241
+ cloudbuild_yaml = _build_cloudbuild_yaml(dockerfile)
242
+ try:
243
+ deploy_script = _rewrite_builds_submit(deploy_script)
244
+ except ValueError as ex:
245
+ console.error(str(ex))
246
+ raise click.exceptions.Exit(1) from ex
247
+
248
+ version_value = version_tag or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
249
+ deploy_env = {
250
+ ENV_GCP_PROJECT: gcp_project,
251
+ ENV_GCP_REGION: region,
252
+ ENV_SERVICE_NAME: service_name,
253
+ ENV_AR_REPO: ar_repo,
254
+ ENV_VERSION: version_value,
255
+ }
256
+
257
+ console.info("Received deploy manifest from Reflex.")
258
+ console.print("")
259
+ console.print(f"Source: {source_path}")
260
+ console.print("Deploy environment:")
261
+ for key, value in deploy_env.items():
262
+ console.print(f" {key}={value}")
263
+ console.print("")
264
+ console.print("Deploy script (rewritten to use cloudbuild.yaml):")
265
+ console.print("─" * 60)
266
+ console.print(deploy_script)
267
+ console.print("─" * 60)
268
+ console.info(
269
+ f"The script runs with a restricted env (only {len(DEPLOY_ENV_ALLOWLIST)} "
270
+ "allowlisted host variables forwarded plus the deploy variables above)."
271
+ )
272
+ console.info(
273
+ "The Dockerfile is embedded in a Cloud Build config written to a "
274
+ "tempfile; your source directory is not modified."
275
+ )
276
+
277
+ if dry_run:
278
+ console.print("")
279
+ console.print("cloudbuild.yaml contents:")
280
+ console.print("─" * 60)
281
+ console.print(cloudbuild_yaml)
282
+ console.print("─" * 60)
283
+ console.print("")
284
+ console.print("Dockerfile contents (embedded in the build step):")
285
+ console.print("─" * 60)
286
+ console.print(dockerfile)
287
+ console.print("─" * 60)
288
+ console.info("Dry run — nothing staged or executed.")
289
+ return
290
+
291
+ if interactive:
292
+ answer = console.ask(
293
+ "Run the deploy script now?", choices=["y", "n"], default="y"
294
+ )
295
+ if answer != "y":
296
+ console.warn("Aborted by user.")
297
+ raise click.exceptions.Exit(1)
298
+
299
+ with _temp_cloudbuild_yaml(cloudbuild_yaml) as cloudbuild_path:
300
+ exit_code = _run_deploy_script(
301
+ bash_path=bash_path,
302
+ script=deploy_script,
303
+ cwd=source_path,
304
+ env_overrides={
305
+ **deploy_env,
306
+ ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
307
+ },
308
+ )
309
+ if exit_code != 0:
310
+ console.error(f"Deploy script exited with status {exit_code}.")
311
+ raise click.exceptions.Exit(exit_code)
312
+ console.success("Deployment finished.")
313
+
314
+
315
+ def _get_active_gcp_account(gcloud_path: str) -> str | None:
316
+ """Return the email of the active gcloud account, or None.
317
+
318
+ Args:
319
+ gcloud_path: Resolved path to the gcloud executable.
320
+
321
+ Returns:
322
+ The active account email or None if not logged in.
323
+
324
+ """
325
+ try:
326
+ result = subprocess.run(
327
+ [
328
+ gcloud_path,
329
+ "auth",
330
+ "list",
331
+ "--filter=status:ACTIVE",
332
+ "--format=value(account)",
333
+ ],
334
+ check=False,
335
+ capture_output=True,
336
+ text=True,
337
+ timeout=10,
338
+ )
339
+ except (OSError, subprocess.SubprocessError) as ex:
340
+ console.debug(f"Failed to query gcloud auth list: {ex}")
341
+ return None
342
+ account = result.stdout.strip().splitlines()
343
+ return account[0] if account else None
344
+
345
+
346
+ def _request_manifest(token: str) -> tuple[str, str]:
347
+ """Fetch the Dockerfile + deploy script from Reflex.
348
+
349
+ Args:
350
+ token: The Reflex API token to authenticate with.
351
+
352
+ Returns:
353
+ A `(dockerfile, deploy_command)` tuple.
354
+
355
+ Raises:
356
+ Exit: If the request fails or the response shape is invalid.
357
+
358
+ """
359
+ import httpx
360
+
361
+ from reflex_cli.utils import hosting
362
+
363
+ url = urljoin(constants.Hosting.HOSTING_SERVICE, GCP_MANIFEST_ENDPOINT)
364
+ try:
365
+ response = httpx.get(
366
+ url,
367
+ headers=hosting.authorization_header(token),
368
+ timeout=constants.Hosting.TIMEOUT,
369
+ )
370
+ response.raise_for_status()
371
+ except httpx.HTTPStatusError as ex:
372
+ detail = ex.response.text
373
+ with contextlib.suppress(ValueError):
374
+ detail = ex.response.json().get("detail", detail)
375
+ if ex.response.status_code == 403:
376
+ console.error(
377
+ "Reflex denied the request (403). GCP Cloud Run deploys require an "
378
+ "Enterprise tier subscription."
379
+ )
380
+ else:
381
+ console.error(f"Reflex rejected the manifest request: {detail}")
382
+ raise click.exceptions.Exit(1) from ex
383
+ except httpx.HTTPError as ex:
384
+ console.error(f"Failed to reach Reflex at {url}: {ex}")
385
+ raise click.exceptions.Exit(1) from ex
386
+
387
+ try:
388
+ body = response.json()
389
+ except ValueError as ex:
390
+ console.error("Reflex returned a non-JSON response.")
391
+ raise click.exceptions.Exit(1) from ex
392
+
393
+ if not isinstance(body, dict):
394
+ console.error("Reflex returned an unexpected response shape.")
395
+ raise click.exceptions.Exit(1)
396
+
397
+ dockerfile = body.get(FIELD_DOCKERFILE)
398
+ deploy_command = body.get(FIELD_DEPLOY_COMMAND)
399
+ if not isinstance(dockerfile, str) or not dockerfile.strip():
400
+ console.error(
401
+ f"Reflex response is missing a non-empty {FIELD_DOCKERFILE!r} field."
402
+ )
403
+ raise click.exceptions.Exit(1)
404
+ if not isinstance(deploy_command, str) or not deploy_command.strip():
405
+ console.error(
406
+ f"Reflex response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field."
407
+ )
408
+ raise click.exceptions.Exit(1)
409
+
410
+ return dockerfile, deploy_command
411
+
412
+
413
+ def _build_cloudbuild_yaml(dockerfile_contents: str) -> str:
414
+ r"""Generate a Cloud Build config that materializes the Dockerfile inline.
415
+
416
+ The Dockerfile body is dropped into a bash heredoc (``cat <<'MARKER' >
417
+ Dockerfile``) inside the build step. The marker is single-quoted so bash
418
+ treats the body literally — no shell-meta expansion of ``$``, `` ` ``, or
419
+ ``\``. YAML literal-block indentation gets stripped uniformly so the
420
+ closing marker line ends up at column 0 where bash expects it.
421
+
422
+ Args:
423
+ dockerfile_contents: The Dockerfile body from Reflex.
424
+
425
+ Returns:
426
+ A complete ``cloudbuild.yaml`` body, ready to write to disk.
427
+
428
+ Raises:
429
+ ValueError: If the Dockerfile contains a line that exactly matches the
430
+ heredoc marker (would terminate the heredoc early).
431
+
432
+ """
433
+ marker = "REFLEX_DOCKERFILE_EOF"
434
+ if any(line.rstrip() == marker for line in dockerfile_contents.splitlines()):
435
+ raise ValueError(
436
+ f"Dockerfile content contains the reserved heredoc marker {marker!r}."
437
+ )
438
+ # Cloud Build runs its own substitution pass over `args`, so any `$NAME` or
439
+ # `${NAME}` in the Dockerfile (e.g. `ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin"`)
440
+ # would be treated as a Cloud Build variable and fail with
441
+ # "not a valid built-in substitution". Escape literal `$` to `$$` so the
442
+ # parser restores `$` before bash runs.
443
+ escaped = dockerfile_contents.replace("$", "$$")
444
+ # 6 spaces to fit inside the YAML literal block under `args:\n - -c\n - |`.
445
+ indent = " "
446
+ body = "".join(f"{indent}{line}\n" for line in escaped.splitlines())
447
+ return (
448
+ "steps:\n"
449
+ "- name: gcr.io/cloud-builders/docker\n"
450
+ " entrypoint: bash\n"
451
+ " args:\n"
452
+ " - -c\n"
453
+ " - |\n"
454
+ f"{indent}cat > Dockerfile <<'{marker}'\n"
455
+ f"{body}"
456
+ f"{indent}{marker}\n"
457
+ f'{indent}docker build -t "$_IMAGE" .\n'
458
+ f'{indent}docker push "$_IMAGE"\n'
459
+ "images:\n"
460
+ " - $_IMAGE\n"
461
+ )
462
+
463
+
464
+ def _rewrite_builds_submit(script: str) -> str:
465
+ """Rewrite the Reflex script's `gcloud builds submit` invocation to use --config=.
466
+
467
+ Replaces the (possibly multi-line) ``gcloud builds submit --tag X .``
468
+ command with one that references our generated cloudbuild.yaml via the
469
+ ``REFLEX_CLOUDBUILD_YAML`` environment variable and passes the image tag
470
+ through ``--substitutions=_IMAGE=...``.
471
+
472
+ Args:
473
+ script: The Reflex deploy script body.
474
+
475
+ Returns:
476
+ The script with the build-submit step rewritten.
477
+
478
+ Raises:
479
+ ValueError: If `gcloud builds submit` cannot be located in the script.
480
+
481
+ """
482
+ match = _BUILDS_SUBMIT_PATTERN.search(script)
483
+ if not match:
484
+ raise ValueError(
485
+ "Couldn't find `gcloud builds submit` in the deploy script. The "
486
+ "manifest format may have changed; Contact support@reflex.dev"
487
+ )
488
+ indent = match.group("indent")
489
+ line_start = script.rfind("\n", 0, match.start()) + 1
490
+ # Consume continuation lines (trailing backslash) until we hit a final line.
491
+ cursor = match.end()
492
+ while True:
493
+ nl = script.find("\n", cursor)
494
+ if nl == -1:
495
+ cmd_end = len(script)
496
+ break
497
+ if not script[cursor:nl].rstrip().endswith("\\"):
498
+ cmd_end = nl
499
+ break
500
+ cursor = nl + 1
501
+
502
+ replacement = (
503
+ f"{indent}gcloud builds submit \\\n"
504
+ f'{indent} --config="${{{ENV_REFLEX_CLOUDBUILD_YAML}}}" \\\n'
505
+ f'{indent} --substitutions=_IMAGE="${{IMAGE}}" \\\n'
506
+ f'{indent} --project "${{GCP_PROJECT}}" \\\n'
507
+ f"{indent} ."
508
+ )
509
+ return script[:line_start] + replacement + script[cmd_end:]
510
+
511
+
512
+ @contextlib.contextmanager
513
+ def _temp_cloudbuild_yaml(contents: str):
514
+ """Write a cloudbuild.yaml to a tempfile and yield its path; always clean up.
515
+
516
+ Args:
517
+ contents: The cloudbuild.yaml body to write.
518
+
519
+ Yields:
520
+ The path to the written tempfile.
521
+
522
+ """
523
+ fd, path_str = tempfile.mkstemp(prefix="reflex-cloudbuild-", suffix=".yaml")
524
+ path = Path(path_str)
525
+ try:
526
+ with os.fdopen(fd, "w") as fh:
527
+ fh.write(contents)
528
+ yield path
529
+ finally:
530
+ with contextlib.suppress(FileNotFoundError):
531
+ path.unlink()
532
+
533
+
534
+ def _run_deploy_script(
535
+ bash_path: str,
536
+ script: str,
537
+ cwd: Path,
538
+ env_overrides: dict[str, str],
539
+ ) -> int:
540
+ """Run the bash deploy script, streaming output to the user's terminal.
541
+
542
+ The script's environment is restricted to ``DEPLOY_ENV_ALLOWLIST`` (plus the
543
+ explicit ``env_overrides``) so unrelated host secrets like ``AWS_*`` or
544
+ ``GITHUB_TOKEN`` cannot be exfiltrated by a tampered or compromised manifest.
545
+
546
+ Args:
547
+ bash_path: Resolved path to the bash executable.
548
+ script: The bash script body received from Reflex.
549
+ cwd: Working directory to run the script in.
550
+ env_overrides: Environment variables required by the deploy script.
551
+
552
+ Returns:
553
+ The exit code of the bash process.
554
+
555
+ """
556
+ env = {
557
+ name: value
558
+ for name, value in os.environ.items()
559
+ if name in DEPLOY_ENV_ALLOWLIST
560
+ }
561
+ env.update(env_overrides)
562
+ try:
563
+ result = subprocess.run(
564
+ [bash_path, "-s"],
565
+ input=script,
566
+ text=True,
567
+ cwd=cwd,
568
+ env=env,
569
+ check=False,
570
+ stdout=sys.stdout,
571
+ stderr=sys.stderr,
572
+ )
573
+ except OSError as ex:
574
+ console.error(f"Failed to launch bash: {ex}")
575
+ return 1
576
+ return result.returncode