Skip to content

Commit 5bc9851

Browse files
Kastier1claude
andcommitted
refactor(hosting-cli): use cloudbuild.yaml instead of symlinks for gcp deploy
The Dockerfile no longer touches disk anywhere near the user's source — it's embedded (base64) as an inline `docker build` step inside a Cloud Build config written to a tempfile, and the flexgen script's `gcloud builds submit --tag X .` invocation is rewritten in-memory to `--config="${REFLEX_CLOUDBUILD_YAML}" --substitutions=_IMAGE="${IMAGE}"`. The script runs with cwd = the user's source dir, so the user's tree is the Cloud Build upload context. No temp dir of symlinks, no source-tree mutation. The cloudbuild.yaml tempfile is removed after the deploy. If `gcloud builds submit` can't be located in the manifest's script (format drift on flexgen's side), the rewrite errors out clearly so the breakage surfaces immediately rather than half-running. Verified end-to-end against a real GCP project: 1m53s Cloud Build, new Cloud Run revision deployed and serving traffic, source Dockerfile timestamp unchanged, tempfile cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 16a39ee commit 5bc9851

3 files changed

Lines changed: 313 additions & 153 deletions

File tree

docs/hosting/deploy-to-gcp.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import reflex as rx
44

55
# Deploy to GCP Cloud Run
66

7-
The `reflex cloud deploy --gcp` command deploys a Reflex app to your own [Google Cloud Run](https://cloud.google.com/run) service. Reflex Cloud fetches a Cloud Run-ready Dockerfile and a `gcloud` deploy script, writes the Dockerfile into your project, and runs the script against the Google Cloud project you specify. The image is built on Cloud Build (so it works from any host OS, including Apple Silicon) and pushed to Artifact Registry.
7+
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.
88

99
```md alert info
1010
# Enterprise tier only.
@@ -40,9 +40,12 @@ reflex cloud deploy --gcp \
4040
The CLI will:
4141

4242
1. Authenticate against Reflex Cloud and fetch the deploy manifest (Dockerfile + `gcloud` script).
43-
2. Print the manifest so you can review it.
44-
3. Write a `Dockerfile` into your project (after asking, if one already exists).
45-
4. Ask for confirmation, then run the `gcloud` script: enable the required APIs, create the Artifact Registry repository, build the image on Cloud Build, and deploy a public Cloud Run service.
43+
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"`.
44+
3. Print the (rewritten) script so you can review it.
45+
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.
46+
5. Delete the tempfile after the script finishes.
47+
48+
Your source tree is never written to — if you have an existing `Dockerfile` in `--source`, it's left in place and ignored. The flexgen Dockerfile only exists inside the `cloudbuild.yaml` tempfile (and inside the Cloud Build job).
4649

4750
When it's done, you'll get a service URL like `https://my-reflex-app-<project-number>.us-central1.run.app`.
4851

@@ -56,11 +59,10 @@ When it's done, you'll get a service URL like `https://my-reflex-app-<project-nu
5659
| `--service-name` | `reflex-app` | Cloud Run service name. |
5760
| `--ar-repo` | `reflex` | Artifact Registry repository name (created on first deploy). |
5861
| `--version` | UTC timestamp (`YYYYMMDD-HHMMSS`) | Image version tag. |
59-
| `--source` | `.` | Directory containing the Reflex app and into which the Dockerfile is written. |
60-
| `--overwrite-dockerfile` | _off_ | Overwrite an existing `Dockerfile` without prompting. |
62+
| `--source` | `.` | Directory containing the Reflex app. Uploaded to Cloud Build as the build context; the source tree itself is not modified. |
6163
| `--token` | _from `~/.reflex` config_ | Reflex authentication token. |
62-
| `--interactive / --no-interactive` | `--interactive` | Whether to prompt before overwriting the Dockerfile and running the script. |
63-
| `--dry-run` | _off_ | Print the manifest without writing the Dockerfile or running the script. |
64+
| `--interactive / --no-interactive` | `--interactive` | Whether to prompt before running the deploy script. |
65+
| `--dry-run` | _off_ | Print the manifest, the generated `cloudbuild.yaml`, and the rewritten script without writing the tempfile or running the script. |
6466
| `--loglevel` | `info` | Log verbosity. |
6567

6668
## What gets created in your GCP project
@@ -83,7 +85,7 @@ Re-running the command pushes a new image tag and rolls the Cloud Run service fo
8385

8486
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.
8587

86-
You can preview the exact script and Dockerfile before anything runs by using `--dry-run`:
88+
You can preview the rewritten script, generated `cloudbuild.yaml`, and Dockerfile before anything runs by using `--dry-run`:
8789

8890
```bash
8991
reflex cloud deploy --gcp \
@@ -93,18 +95,17 @@ reflex cloud deploy --gcp \
9395

9496
## Non-interactive use (CI)
9597

96-
For automated pipelines, pass `--no-interactive`, an explicit `--token`, and `--overwrite-dockerfile`:
98+
For automated pipelines, pass `--no-interactive` and an explicit `--token`:
9799

98100
```bash
99101
reflex cloud deploy --gcp \
100102
--gcp-project "$GCP_PROJECT_ID" \
101103
--service-name my-reflex-app \
102104
--token "$REFLEX_TOKEN" \
103-
--no-interactive \
104-
--overwrite-dockerfile
105+
--no-interactive
105106
```
106107

107-
In non-interactive mode the CLI will not prompt — it will refuse to overwrite an existing `Dockerfile` unless `--overwrite-dockerfile` is set, and it will exit non-zero if a token cannot be resolved.
108+
In non-interactive mode the CLI will not prompt, and it will exit non-zero if a token cannot be resolved.
108109

109110
## Troubleshooting
110111

packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py

Lines changed: 152 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
"""GCP Cloud Run deploy commands for the Reflex Cloud CLI.
22
3-
Fetches a Dockerfile + bash deploy script from flexgen, writes the Dockerfile
4-
into the user's project, prints the script, and runs it via bash after the
5-
user confirms. The script reads its parameters from environment variables
6-
(GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION).
3+
Fetches a Dockerfile + bash deploy script from flexgen 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).
710
"""
811

912
from __future__ import annotations
1013

14+
import base64
1115
import contextlib
1216
import os
17+
import re
1318
import shutil
1419
import subprocess
1520
import sys
21+
import tempfile
1622
from datetime import datetime, timezone
1723
from pathlib import Path
1824
from urllib.parse import urljoin
@@ -32,6 +38,18 @@
3238
ENV_SERVICE_NAME = "SERVICE_NAME"
3339
ENV_AR_REPO = "AR_REPO"
3440
ENV_VERSION = "VERSION"
41+
# Path to the Cloud Build config file written by the CLI. The rewritten
42+
# deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
43+
ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML"
44+
45+
# Pattern for the start of the `gcloud builds submit` invocation in the
46+
# flexgen deploy script. We rewrite that whole multi-line command to use
47+
# `--config=` so the Dockerfile lives inside a cloudbuild.yaml instead of
48+
# being staged on disk next to the user's source.
49+
_BUILDS_SUBMIT_PATTERN = re.compile(
50+
r"(?P<indent>^[ \t]*)gcloud[ \t]+builds[ \t]+submit\b",
51+
re.MULTILINE,
52+
)
3553

3654
# Manifest response field names from flexgen.
3755
FIELD_DOCKERFILE = "dockerfile"
@@ -125,26 +143,20 @@
125143
default=".",
126144
show_default=True,
127145
type=click.Path(file_okay=False, dir_okay=True),
128-
help="The directory containing the Reflex app and into which the Dockerfile is written.",
129-
)
130-
@click.option(
131-
"--overwrite-dockerfile/--no-overwrite-dockerfile",
132-
default=False,
133-
show_default=True,
134-
help="Overwrite an existing Dockerfile without prompting.",
146+
help="The directory containing the Reflex app. Staged into an ephemeral build context; the source tree itself is not modified.",
135147
)
136148
@click.option("--token", help="The Reflex authentication token.")
137149
@click.option(
138150
"--interactive/--no-interactive",
139151
is_flag=True,
140152
default=True,
141-
help="Whether to prompt before overwriting the Dockerfile and running the script.",
153+
help="Whether to prompt before running the deploy script.",
142154
)
143155
@click.option(
144156
"--dry-run",
145157
is_flag=True,
146158
default=False,
147-
help="Print the manifest without writing the Dockerfile or running the script.",
159+
help="Print the manifest without staging the build context or running the script.",
148160
)
149161
@click.option(
150162
"--loglevel",
@@ -160,17 +172,17 @@ def deploy_command(
160172
ar_repo: str,
161173
version_tag: str | None,
162174
source_dir: str,
163-
overwrite_dockerfile: bool,
164175
token: str | None,
165176
interactive: bool,
166177
dry_run: bool,
167178
loglevel: str,
168179
):
169180
"""Deploy a Reflex app to a cloud target.
170181
171-
Currently the only supported target is GCP Cloud Run via --gcp. The command
172-
fetches a Dockerfile and bash deploy script from flexgen, writes the
173-
Dockerfile into the source directory, then asks before running the script.
182+
Currently the only supported target is GCP Cloud Run via --gcp. The
183+
command fetches a Dockerfile and bash deploy script from flexgen, stages
184+
them in an ephemeral build context alongside symlinked source entries
185+
(your project tree is never modified), and runs the script from there.
174186
"""
175187
from reflex_cli.utils import hosting
176188

@@ -224,7 +236,13 @@ def deploy_command(
224236
if not source_path.is_dir():
225237
console.error(f"Source directory does not exist: {source_path}")
226238
raise click.exceptions.Exit(1)
227-
dockerfile_path = source_path / DOCKERFILE_NAME
239+
240+
cloudbuild_yaml = _build_cloudbuild_yaml(dockerfile)
241+
try:
242+
deploy_script = _rewrite_builds_submit(deploy_script)
243+
except ValueError as ex:
244+
console.error(str(ex))
245+
raise click.exceptions.Exit(1) from ex
228246

229247
version_value = version_tag or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
230248
deploy_env = {
@@ -237,50 +255,56 @@ def deploy_command(
237255

238256
console.info("Received deploy manifest from flexgen.")
239257
console.print("")
240-
console.print(f"Dockerfile target: {dockerfile_path}")
258+
console.print(f"Source: {source_path}")
241259
console.print("Deploy environment:")
242260
for key, value in deploy_env.items():
243261
console.print(f" {key}={value}")
244262
console.print("")
245-
console.print("Deploy script:")
263+
console.print("Deploy script (rewritten to use cloudbuild.yaml):")
246264
console.print("─" * 60)
247265
console.print(deploy_script)
248266
console.print("─" * 60)
249267
console.info(
250268
f"The script runs with a restricted env (only {len(DEPLOY_ENV_ALLOWLIST)} "
251269
"allowlisted host variables forwarded plus the deploy variables above)."
252270
)
271+
console.info(
272+
"The Dockerfile is embedded in a Cloud Build config written to a "
273+
"tempfile; your source directory is not modified."
274+
)
253275

254276
if dry_run:
255277
console.print("")
256-
console.print("Dockerfile contents:")
278+
console.print("cloudbuild.yaml contents:")
279+
console.print("─" * 60)
280+
console.print(cloudbuild_yaml)
281+
console.print("─" * 60)
282+
console.print("")
283+
console.print("Dockerfile contents (embedded in the build step):")
257284
console.print("─" * 60)
258285
console.print(dockerfile)
259286
console.print("─" * 60)
260-
console.info("Dry run — nothing written or executed.")
287+
console.info("Dry run — nothing staged or executed.")
261288
return
262289

263-
if not _write_dockerfile(
264-
dockerfile_path, dockerfile, overwrite_dockerfile, interactive
265-
):
266-
raise click.exceptions.Exit(1)
267-
268290
if interactive:
269291
answer = console.ask(
270292
"Run the deploy script now?", choices=["y", "n"], default="y"
271293
)
272294
if answer != "y":
273-
console.warn(
274-
"Aborted by user. The Dockerfile has been written for later use."
275-
)
295+
console.warn("Aborted by user.")
276296
raise click.exceptions.Exit(1)
277297

278-
exit_code = _run_deploy_script(
279-
bash_path=bash_path,
280-
script=deploy_script,
281-
cwd=source_path,
282-
env_overrides=deploy_env,
283-
)
298+
with _temp_cloudbuild_yaml(cloudbuild_yaml) as cloudbuild_path:
299+
exit_code = _run_deploy_script(
300+
bash_path=bash_path,
301+
script=deploy_script,
302+
cwd=source_path,
303+
env_overrides={
304+
**deploy_env,
305+
ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
306+
},
307+
)
284308
if exit_code != 0:
285309
console.error(f"Deploy script exited with status {exit_code}.")
286310
raise click.exceptions.Exit(exit_code)
@@ -385,44 +409,106 @@ def _request_manifest(token: str) -> tuple[str, str]:
385409
return dockerfile, deploy_command
386410

387411

388-
def _write_dockerfile(
389-
path: Path, contents: str, overwrite: bool, interactive: bool
390-
) -> bool:
391-
"""Write the Dockerfile to disk, prompting before overwriting in interactive mode.
412+
def _build_cloudbuild_yaml(dockerfile_contents: str) -> str:
413+
"""Generate a Cloud Build config that materializes the Dockerfile inline.
414+
415+
The Dockerfile body is embedded as a single base64 line so we don't have
416+
to worry about YAML literal-block indentation, bash here-doc markers, or
417+
shell-meta characters in the Dockerfile leaking into the config. The
418+
resulting build does ``docker build`` + ``docker push`` against the
419+
user's source as the build context.
392420
393421
Args:
394-
path: Where to write the Dockerfile.
395-
contents: The Dockerfile body.
396-
overwrite: If True, overwrite without prompting.
397-
interactive: If False, never prompt; require `overwrite` when the file exists.
422+
dockerfile_contents: The Dockerfile body from flexgen.
398423
399424
Returns:
400-
True on success, False if the user declined to overwrite or write failed.
425+
A complete ``cloudbuild.yaml`` body, ready to write to disk.
401426
402427
"""
403-
if path.exists() and not overwrite:
404-
if not interactive:
405-
console.error(
406-
f"{path} already exists. Pass --overwrite-dockerfile to replace it "
407-
"in non-interactive mode."
408-
)
409-
return False
410-
answer = console.ask(
411-
f"{path} already exists. Overwrite?", choices=["y", "n"], default="n"
428+
b64 = base64.b64encode(dockerfile_contents.encode("utf-8")).decode("ascii")
429+
return (
430+
"steps:\n"
431+
"- name: gcr.io/cloud-builders/docker\n"
432+
" entrypoint: bash\n"
433+
" args:\n"
434+
" - -c\n"
435+
" - |\n"
436+
f" printf '%s' '{b64}' | base64 -d > Dockerfile\n"
437+
' docker build -t "$_IMAGE" .\n'
438+
' docker push "$_IMAGE"\n'
439+
"images:\n"
440+
" - $_IMAGE\n"
441+
)
442+
443+
444+
def _rewrite_builds_submit(script: str) -> str:
445+
"""Rewrite the flexgen script's `gcloud builds submit` invocation to use --config=.
446+
447+
Replaces the (possibly multi-line) ``gcloud builds submit --tag X .``
448+
command with one that references our generated cloudbuild.yaml via the
449+
``REFLEX_CLOUDBUILD_YAML`` environment variable and passes the image tag
450+
through ``--substitutions=_IMAGE=...``.
451+
452+
Args:
453+
script: The flexgen deploy script body.
454+
455+
Returns:
456+
The script with the build-submit step rewritten.
457+
458+
Raises:
459+
ValueError: If `gcloud builds submit` cannot be located in the script.
460+
461+
"""
462+
match = _BUILDS_SUBMIT_PATTERN.search(script)
463+
if not match:
464+
raise ValueError(
465+
"Couldn't find `gcloud builds submit` in the deploy script. The "
466+
"flexgen manifest format may have changed; the CLI needs updating."
412467
)
413-
if answer != "y":
414-
console.warn(
415-
f"Keeping the existing {path.name}. Re-run with --overwrite-dockerfile "
416-
"or move the file aside to use the flexgen Dockerfile."
417-
)
418-
return False
468+
indent = match.group("indent")
469+
line_start = script.rfind("\n", 0, match.start()) + 1
470+
# Consume continuation lines (trailing backslash) until we hit a final line.
471+
cursor = match.end()
472+
while True:
473+
nl = script.find("\n", cursor)
474+
if nl == -1:
475+
cmd_end = len(script)
476+
break
477+
if not script[cursor:nl].rstrip().endswith("\\"):
478+
cmd_end = nl
479+
break
480+
cursor = nl + 1
481+
482+
replacement = (
483+
f"{indent}gcloud builds submit \\\n"
484+
f'{indent} --config="${{{ENV_REFLEX_CLOUDBUILD_YAML}}}" \\\n'
485+
f'{indent} --substitutions=_IMAGE="${{IMAGE}}" \\\n'
486+
f'{indent} --project "${{GCP_PROJECT}}" \\\n'
487+
f"{indent} ."
488+
)
489+
return script[:line_start] + replacement + script[cmd_end:]
490+
491+
492+
@contextlib.contextmanager
493+
def _temp_cloudbuild_yaml(contents: str):
494+
"""Write a cloudbuild.yaml to a tempfile and yield its path; always clean up.
495+
496+
Args:
497+
contents: The cloudbuild.yaml body to write.
498+
499+
Yields:
500+
The path to the written tempfile.
501+
502+
"""
503+
fd, path_str = tempfile.mkstemp(prefix="reflex-cloudbuild-", suffix=".yaml")
504+
path = Path(path_str)
419505
try:
420-
path.write_text(contents)
421-
except OSError as ex:
422-
console.error(f"Failed to write {path}: {ex}")
423-
return False
424-
console.info(f"Wrote {path}.")
425-
return True
506+
with os.fdopen(fd, "w") as fh:
507+
fh.write(contents)
508+
yield path
509+
finally:
510+
with contextlib.suppress(FileNotFoundError):
511+
path.unlink()
426512

427513

428514
def _run_deploy_script(

0 commit comments

Comments
 (0)