Skip to content

Commit 1225595

Browse files
committed
update to not overwrite users dockerfile
1 parent 5bc9851 commit 1225595

2 files changed

Lines changed: 142 additions & 84 deletions

File tree

  • packages/reflex-hosting-cli/src/reflex_cli/v2
  • tests/units/reflex_cli/v2

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

Lines changed: 88 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""GCP Cloud Run deploy commands for the Reflex Cloud CLI.
22
3-
Fetches a Dockerfile + bash deploy script from flexgen and runs the script
3+
Fetches a Dockerfile + bash deploy script from Reflex and runs the script
44
against the user's source directory. The Dockerfile is materialized inside
55
a Cloud Build job (via a ``cloudbuild.yaml`` written to a tempfile and
66
referenced with ``gcloud builds submit --config=...``) — the user's project
@@ -11,7 +11,6 @@
1111

1212
from __future__ import annotations
1313

14-
import base64
1514
import contextlib
1615
import os
1716
import re
@@ -43,60 +42,62 @@
4342
ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML"
4443

4544
# 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
45+
# Reflex deploy script. We rewrite that whole multi-line command to use
4746
# `--config=` so the Dockerfile lives inside a cloudbuild.yaml instead of
4847
# being staged on disk next to the user's source.
4948
_BUILDS_SUBMIT_PATTERN = re.compile(
5049
r"(?P<indent>^[ \t]*)gcloud[ \t]+builds[ \t]+submit\b",
5150
re.MULTILINE,
5251
)
5352

54-
# Manifest response field names from flexgen.
53+
# Manifest response field names from Reflex.
5554
FIELD_DOCKERFILE = "dockerfile"
5655
FIELD_DEPLOY_COMMAND = "deploy_command"
5756

5857
# Allowlist of host environment variables forwarded to the deploy script.
5958
# We deliberately exclude things like AWS_*/GITHUB_TOKEN/SSH agent sockets so a
6059
# compromised or tampered manifest cannot exfiltrate unrelated credentials.
61-
DEPLOY_ENV_ALLOWLIST = frozenset({
62-
"PATH",
63-
"HOME",
64-
"USER",
65-
"LOGNAME",
66-
"SHELL",
67-
"TERM",
68-
"LANG",
69-
"LC_ALL",
70-
"LC_CTYPE",
71-
"TMPDIR",
72-
"TEMP",
73-
"TMP",
74-
"XDG_CONFIG_HOME",
75-
# gcloud configuration
76-
"CLOUDSDK_CONFIG",
77-
"CLOUDSDK_ACTIVE_CONFIG_NAME",
78-
"CLOUDSDK_CORE_PROJECT",
79-
"CLOUDSDK_CORE_ACCOUNT",
80-
"CLOUDSDK_AUTH_ACCESS_TOKEN_FILE",
81-
"GOOGLE_APPLICATION_CREDENTIALS",
82-
# docker configuration
83-
"DOCKER_HOST",
84-
"DOCKER_TLS_VERIFY",
85-
"DOCKER_CERT_PATH",
86-
"DOCKER_CONFIG",
87-
"DOCKER_BUILDKIT",
88-
# corporate proxy / TLS trust
89-
"HTTP_PROXY",
90-
"HTTPS_PROXY",
91-
"NO_PROXY",
92-
"http_proxy",
93-
"https_proxy",
94-
"no_proxy",
95-
"SSL_CERT_FILE",
96-
"SSL_CERT_DIR",
97-
"REQUESTS_CA_BUNDLE",
98-
"CURL_CA_BUNDLE",
99-
})
60+
DEPLOY_ENV_ALLOWLIST = frozenset(
61+
{
62+
"PATH",
63+
"HOME",
64+
"USER",
65+
"LOGNAME",
66+
"SHELL",
67+
"TERM",
68+
"LANG",
69+
"LC_ALL",
70+
"LC_CTYPE",
71+
"TMPDIR",
72+
"TEMP",
73+
"TMP",
74+
"XDG_CONFIG_HOME",
75+
# gcloud configuration
76+
"CLOUDSDK_CONFIG",
77+
"CLOUDSDK_ACTIVE_CONFIG_NAME",
78+
"CLOUDSDK_CORE_PROJECT",
79+
"CLOUDSDK_CORE_ACCOUNT",
80+
"CLOUDSDK_AUTH_ACCESS_TOKEN_FILE",
81+
"GOOGLE_APPLICATION_CREDENTIALS",
82+
# docker configuration
83+
"DOCKER_HOST",
84+
"DOCKER_TLS_VERIFY",
85+
"DOCKER_CERT_PATH",
86+
"DOCKER_CONFIG",
87+
"DOCKER_BUILDKIT",
88+
# corporate proxy / TLS trust
89+
"HTTP_PROXY",
90+
"HTTPS_PROXY",
91+
"NO_PROXY",
92+
"http_proxy",
93+
"https_proxy",
94+
"no_proxy",
95+
"SSL_CERT_FILE",
96+
"SSL_CERT_DIR",
97+
"REQUESTS_CA_BUNDLE",
98+
"CURL_CA_BUNDLE",
99+
}
100+
)
100101

101102

102103
@click.command(name="deploy")
@@ -180,7 +181,7 @@ def deploy_command(
180181
"""Deploy a Reflex app to a cloud target.
181182
182183
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+
command fetches a Dockerfile and bash deploy script from Reflex, stages
184185
them in an ephemeral build context alongside symlinked source entries
185186
(your project tree is never modified), and runs the script from there.
186187
"""
@@ -253,7 +254,7 @@ def deploy_command(
253254
ENV_VERSION: version_value,
254255
}
255256

256-
console.info("Received deploy manifest from flexgen.")
257+
console.info("Received deploy manifest from Reflex.")
257258
console.print("")
258259
console.print(f"Source: {source_path}")
259260
console.print("Deploy environment:")
@@ -343,7 +344,7 @@ def _get_active_gcp_account(gcloud_path: str) -> str | None:
343344

344345

345346
def _request_manifest(token: str) -> tuple[str, str]:
346-
"""Fetch the Dockerfile + deploy script from flexgen.
347+
"""Fetch the Dockerfile + deploy script from Reflex.
347348
348349
Args:
349350
token: The Reflex API token to authenticate with.
@@ -373,84 +374,103 @@ def _request_manifest(token: str) -> tuple[str, str]:
373374
detail = ex.response.json().get("detail", detail)
374375
if ex.response.status_code == 403:
375376
console.error(
376-
"Flexgen denied the request (403). GCP Cloud Run deploys require an "
377+
"Reflex denied the request (403). GCP Cloud Run deploys require an "
377378
"Enterprise tier subscription."
378379
)
379380
else:
380-
console.error(f"Flexgen rejected the manifest request: {detail}")
381+
console.error(f"Reflex rejected the manifest request: {detail}")
381382
raise click.exceptions.Exit(1) from ex
382383
except httpx.HTTPError as ex:
383-
console.error(f"Failed to reach flexgen at {url}: {ex}")
384+
console.error(f"Failed to reach Reflex at {url}: {ex}")
384385
raise click.exceptions.Exit(1) from ex
385386

386387
try:
387388
body = response.json()
388389
except ValueError as ex:
389-
console.error("Flexgen returned a non-JSON response.")
390+
console.error("Reflex returned a non-JSON response.")
390391
raise click.exceptions.Exit(1) from ex
391392

392393
if not isinstance(body, dict):
393-
console.error("Flexgen returned an unexpected response shape.")
394+
console.error("Reflex returned an unexpected response shape.")
394395
raise click.exceptions.Exit(1)
395396

396397
dockerfile = body.get(FIELD_DOCKERFILE)
397398
deploy_command = body.get(FIELD_DEPLOY_COMMAND)
398399
if not isinstance(dockerfile, str) or not dockerfile.strip():
399400
console.error(
400-
f"Flexgen response is missing a non-empty {FIELD_DOCKERFILE!r} field."
401+
f"Reflex response is missing a non-empty {FIELD_DOCKERFILE!r} field."
401402
)
402403
raise click.exceptions.Exit(1)
403404
if not isinstance(deploy_command, str) or not deploy_command.strip():
404405
console.error(
405-
f"Flexgen response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field."
406+
f"Reflex response is missing a non-empty {FIELD_DEPLOY_COMMAND!r} field."
406407
)
407408
raise click.exceptions.Exit(1)
408409

409410
return dockerfile, deploy_command
410411

411412

412413
def _build_cloudbuild_yaml(dockerfile_contents: str) -> str:
413-
"""Generate a Cloud Build config that materializes the Dockerfile inline.
414+
r"""Generate a Cloud Build config that materializes the Dockerfile inline.
414415
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.
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.
420421
421422
Args:
422-
dockerfile_contents: The Dockerfile body from flexgen.
423+
dockerfile_contents: The Dockerfile body from Reflex.
423424
424425
Returns:
425426
A complete ``cloudbuild.yaml`` body, ready to write to disk.
426427
428+
Raises:
429+
ValueError: If the Dockerfile contains a line that exactly matches the
430+
heredoc marker (would terminate the heredoc early).
431+
427432
"""
428-
b64 = base64.b64encode(dockerfile_contents.encode("utf-8")).decode("ascii")
433+
marker = "REFLEX_FLEXGEN_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())
429447
return (
430448
"steps:\n"
431449
"- name: gcr.io/cloud-builders/docker\n"
432450
" entrypoint: bash\n"
433451
" args:\n"
434452
" - -c\n"
435453
" - |\n"
436-
f" printf '%s' '{b64}' | base64 -d > Dockerfile\n"
437-
' docker build -t "$_IMAGE" .\n'
438-
' docker push "$_IMAGE"\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'
439459
"images:\n"
440460
" - $_IMAGE\n"
441461
)
442462

443463

444464
def _rewrite_builds_submit(script: str) -> str:
445-
"""Rewrite the flexgen script's `gcloud builds submit` invocation to use --config=.
465+
"""Rewrite the Reflex script's `gcloud builds submit` invocation to use --config=.
446466
447467
Replaces the (possibly multi-line) ``gcloud builds submit --tag X .``
448468
command with one that references our generated cloudbuild.yaml via the
449469
``REFLEX_CLOUDBUILD_YAML`` environment variable and passes the image tag
450470
through ``--substitutions=_IMAGE=...``.
451471
452472
Args:
453-
script: The flexgen deploy script body.
473+
script: The Reflex deploy script body.
454474
455475
Returns:
456476
The script with the build-submit step rewritten.
@@ -463,7 +483,7 @@ def _rewrite_builds_submit(script: str) -> str:
463483
if not match:
464484
raise ValueError(
465485
"Couldn't find `gcloud builds submit` in the deploy script. The "
466-
"flexgen manifest format may have changed; the CLI needs updating."
486+
"manifest format may have changed; Contact support@reflex.dev"
467487
)
468488
indent = match.group("indent")
469489
line_start = script.rfind("\n", 0, match.start()) + 1
@@ -525,7 +545,7 @@ def _run_deploy_script(
525545
526546
Args:
527547
bash_path: Resolved path to the bash executable.
528-
script: The bash script body received from flexgen.
548+
script: The bash script body received from Reflex.
529549
cwd: Working directory to run the script in.
530550
env_overrides: Environment variables required by the deploy script.
531551

tests/units/reflex_cli/v2/test_gcp.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ def test_gcp_deploy_runs_script_from_source_with_cloudbuild_yaml(
7474
tempfile that contains the generated cloudbuild.yaml, the script is rewritten
7575
to use --config=, and the source tree is never written to.
7676
"""
77-
import base64 as _b64
78-
7977
captured: dict = {}
8078

8179
def capture(**kwargs):
@@ -130,11 +128,13 @@ def capture(**kwargs):
130128
assert captured["cloudbuild_existed_during_run"]
131129
assert not captured["cloudbuild_path"].exists()
132130

133-
# cloudbuild.yaml embeds the flexgen Dockerfile as base64 and builds/pushes.
131+
# cloudbuild.yaml embeds the Reflex Dockerfile via heredoc and builds/pushes.
134132
yaml = captured["cloudbuild_yaml"]
135-
expected_b64 = _b64.b64encode(DOCKERFILE.encode()).decode()
136-
assert expected_b64 in yaml
137-
assert 'docker build -t "$_IMAGE"' in yaml
133+
assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml
134+
# Each Dockerfile line shows up in the YAML (indented under the literal block).
135+
for line in DOCKERFILE.splitlines():
136+
assert f" {line}" in yaml
137+
assert 'docker build -t "$_IMAGE" .' in yaml
138138
assert 'docker push "$_IMAGE"' in yaml
139139
assert "images:" in yaml
140140

@@ -419,26 +419,64 @@ def test_deploy_requires_gcp_target_flag(tmp_path: Path):
419419
assert "--gcp" in result.output
420420

421421

422-
def test_build_cloudbuild_yaml_embeds_dockerfile_as_base64():
423-
"""The generated cloudbuild.yaml round-trips the Dockerfile through base64."""
424-
import base64 as _b64
422+
def test_build_cloudbuild_yaml_embeds_dockerfile_via_heredoc():
423+
r"""The cloudbuild.yaml writes the Dockerfile via a single-quoted heredoc.
425424
425+
The single-quoted marker means bash treats `/\ in the Dockerfile body
426+
literally; `$` is doubled to `$$` so Cloud Build's substitution pass
427+
over `args` doesn't grab Dockerfile variables. YAML literal-block indent
428+
(6 spaces) gets stripped uniformly so the closing marker ends up at
429+
column 0.
430+
"""
426431
from reflex_cli.v2 import gcp as gcp_module
427432

428-
dockerfile = "FROM python:3.13-slim\nRUN echo $weird '\"chars\"' \\\nthings\n"
433+
# Dockerfile with the kinds of `$`/`${...}` Cloud Build would otherwise
434+
# try to substitute, plus shell-meta chars that break naive quoting.
435+
dockerfile = (
436+
"FROM python:3.13-slim\n"
437+
'ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin:$PATH"\n'
438+
"RUN echo $weird '\"chars\"' \\\nthings\n"
439+
)
429440
yaml = gcp_module._build_cloudbuild_yaml(dockerfile)
430441

431-
# The Dockerfile body shows up exactly once as a base64 blob.
432-
expected_b64 = _b64.b64encode(dockerfile.encode()).decode()
433-
assert yaml.count(expected_b64) == 1
434-
# And the recovery step decodes it back into a Dockerfile.
435-
assert "base64 -d > Dockerfile" in yaml
436-
# The build and push are wired up to the _IMAGE substitution.
442+
# Heredoc opens and closes with the same single-quoted marker.
443+
assert "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'" in yaml
444+
assert yaml.count("REFLEX_FLEXGEN_DOCKERFILE_EOF") == 2
445+
446+
# Inside the heredoc body, every literal `$` from the Dockerfile is doubled
447+
# to escape Cloud Build's substitution pass. Slice out the heredoc body and
448+
# verify no bare `$` survives there.
449+
open_marker = "cat > Dockerfile <<'REFLEX_FLEXGEN_DOCKERFILE_EOF'\n"
450+
close_marker = " REFLEX_FLEXGEN_DOCKERFILE_EOF\n"
451+
body_start = yaml.index(open_marker) + len(open_marker)
452+
body_end = yaml.index(close_marker)
453+
heredoc_body = yaml[body_start:body_end]
454+
# Every `$` in the heredoc body is part of a `$$` pair — i.e. no isolated `$`.
455+
assert "$" in heredoc_body # sanity
456+
assert heredoc_body.replace("$$", "") .count("$") == 0
457+
# Concrete escapes are present.
458+
assert ' ENV PATH="$${UV_PROJECT_ENVIRONMENT}/bin:$$PATH"' in heredoc_body
459+
assert " RUN echo $$weird '\"chars\"' \\" in heredoc_body
460+
461+
# Non-`$` Dockerfile lines pass through verbatim (with 6-space indent).
462+
assert " FROM python:3.13-slim" in yaml
463+
assert " things" in yaml
464+
465+
# Build + push lines use the `_IMAGE` substitution (single `$`).
437466
assert 'docker build -t "$_IMAGE" .' in yaml
438467
assert 'docker push "$_IMAGE"' in yaml
439468
assert "images:\n - $_IMAGE\n" in yaml
440469

441470

471+
def test_build_cloudbuild_yaml_rejects_marker_collision():
472+
"""If the Dockerfile happens to contain the heredoc marker as a whole line, error."""
473+
from reflex_cli.v2 import gcp as gcp_module
474+
475+
dockerfile = "FROM scratch\nREFLEX_FLEXGEN_DOCKERFILE_EOF\nCMD true\n"
476+
with pytest.raises(ValueError, match="heredoc marker"):
477+
gcp_module._build_cloudbuild_yaml(dockerfile)
478+
479+
442480
def test_rewrite_builds_submit_replaces_tag_form_with_config():
443481
"""The rewrite consumes the full multi-line `gcloud builds submit ... .`."""
444482
from reflex_cli.v2 import gcp as gcp_module

0 commit comments

Comments
 (0)