reflex-hosting-cli 0.1.65a1__tar.gz → 0.1.67__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.
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/.gitignore +9 -1
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/PKG-INFO +1 -1
- reflex_hosting_cli-0.1.67/news/.gitkeep +0 -0
- reflex_hosting_cli-0.1.67/news/6556.feature.md +1 -0
- reflex_hosting_cli-0.1.67/news/6557.feature.md +1 -0
- reflex_hosting_cli-0.1.67/news/6632.feature.md +1 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/hosting.py +129 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/deployments.py +5 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/gcp.py +206 -6
- reflex_hosting_cli-0.1.67/src/reflex_cli/v2/scan.py +262 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/README.md +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/pyproject.toml +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/__init__.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/cli.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/__init__.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/base.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/compiler.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/hosting.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/__init__.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/config.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/deployments.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/__init__.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/console.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/dependency.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/exceptions.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/__init__.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/apps.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/cli.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/project.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/secrets.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/utils.py +0 -0
- {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/vmtypes_regions.py +0 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
**/.DS_Store
|
|
2
2
|
**/*.pyc
|
|
3
|
+
**/__pycache__/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
**/.ruff_cache/
|
|
7
|
+
.mypy_cache/
|
|
3
8
|
assets/external/*
|
|
4
9
|
dist/*
|
|
5
10
|
examples/
|
|
@@ -21,4 +26,7 @@ reflex.db
|
|
|
21
26
|
node_modules
|
|
22
27
|
package-lock.json
|
|
23
28
|
*.pyi
|
|
24
|
-
.pre-commit-config.yaml
|
|
29
|
+
.pre-commit-config.yaml
|
|
30
|
+
.claude/.worktrees
|
|
31
|
+
.claude/settings.local.json
|
|
32
|
+
CLAUDE.local.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reflex-hosting-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.67
|
|
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>
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Added a `--service-account` flag to `reflex deploy --gcp`, letting Cloud Run services run as a least-privilege per-service service account instead of the project's default compute SA.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Added `--max-instances`, `--allow-unauthenticated/--no-allow-unauthenticated`, `--env KEY=VALUE`, and `--envfile` flags to `reflex deploy --gcp`, letting you cap Cloud Run autoscaling, deploy a private service, and set environment variables at deploy time.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Added `reflex cloud scan`, which uploads your app source for a Reflex-aware security review and reports security and logic flaws. Supports `--json` output and a `--fail-on` severity gate for CI.
|
|
@@ -1456,6 +1456,135 @@ def create_deployment(
|
|
|
1456
1456
|
return response.json()
|
|
1457
1457
|
|
|
1458
1458
|
|
|
1459
|
+
class SecurityReviewError(ResponseError):
|
|
1460
|
+
"""Raised when a security review request fails."""
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
_SECURITY_REVIEW_PREFIX = "/api/v1/agents/security-review"
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _security_review_detail(response: Any) -> str:
|
|
1467
|
+
"""Extract a human-readable ``detail`` from a failed review response.
|
|
1468
|
+
|
|
1469
|
+
Args:
|
|
1470
|
+
response: The error response from the security review API.
|
|
1471
|
+
|
|
1472
|
+
Returns:
|
|
1473
|
+
The server-provided detail, or a generic fallback if the body is not
|
|
1474
|
+
a JSON object with a ``detail`` field.
|
|
1475
|
+
|
|
1476
|
+
"""
|
|
1477
|
+
try:
|
|
1478
|
+
return str(response.json()["detail"])
|
|
1479
|
+
except (ValueError, TypeError, KeyError):
|
|
1480
|
+
return "internal server error"
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def submit_security_review(zip_bytes: bytes, client: AuthenticatedClient) -> str:
|
|
1484
|
+
"""Submit a zipped app for security review.
|
|
1485
|
+
|
|
1486
|
+
Uploads the archive straight to object storage via a presigned URL, then
|
|
1487
|
+
submits the stored object for review.
|
|
1488
|
+
|
|
1489
|
+
Args:
|
|
1490
|
+
zip_bytes: The zipped app source to review.
|
|
1491
|
+
client: The authenticated client.
|
|
1492
|
+
|
|
1493
|
+
Returns:
|
|
1494
|
+
The id of the submitted job, to be polled with ``get_security_review``.
|
|
1495
|
+
|
|
1496
|
+
Raises:
|
|
1497
|
+
NotAuthenticatedError: If the token is not valid.
|
|
1498
|
+
SecurityReviewError: If any step of the submission fails.
|
|
1499
|
+
|
|
1500
|
+
"""
|
|
1501
|
+
import httpx
|
|
1502
|
+
|
|
1503
|
+
if not isinstance(client, AuthenticatedClient):
|
|
1504
|
+
raise NotAuthenticatedError("not authenticated")
|
|
1505
|
+
|
|
1506
|
+
auth = authorization_header(client.token)
|
|
1507
|
+
|
|
1508
|
+
# 1. Ask the API for a presigned URL to upload the archive directly.
|
|
1509
|
+
upload_url_response = httpx.post(
|
|
1510
|
+
urljoin(
|
|
1511
|
+
constants.Hosting.HOSTING_SERVICE,
|
|
1512
|
+
f"{_SECURITY_REVIEW_PREFIX}/jobs/upload-url",
|
|
1513
|
+
),
|
|
1514
|
+
json={"content_length": len(zip_bytes), "content_type": "application/zip"},
|
|
1515
|
+
headers=auth,
|
|
1516
|
+
timeout=constants.Hosting.TIMEOUT,
|
|
1517
|
+
)
|
|
1518
|
+
try:
|
|
1519
|
+
upload_url_response.raise_for_status()
|
|
1520
|
+
except httpx.HTTPStatusError as ex:
|
|
1521
|
+
raise SecurityReviewError(_security_review_detail(ex.response)) from ex
|
|
1522
|
+
upload = upload_url_response.json()
|
|
1523
|
+
|
|
1524
|
+
# 2. Upload the bytes to storage. The presigned URL pins the content length
|
|
1525
|
+
# and type, so send the returned headers verbatim and let httpx derive
|
|
1526
|
+
# Content-Length from the body — setting it manually breaks the signature.
|
|
1527
|
+
put_response = httpx.put(
|
|
1528
|
+
upload["url"],
|
|
1529
|
+
content=zip_bytes,
|
|
1530
|
+
headers=upload.get("headers", {}),
|
|
1531
|
+
timeout=120,
|
|
1532
|
+
)
|
|
1533
|
+
try:
|
|
1534
|
+
put_response.raise_for_status()
|
|
1535
|
+
except httpx.HTTPStatusError as ex:
|
|
1536
|
+
raise SecurityReviewError("failed to upload app source for review") from ex
|
|
1537
|
+
|
|
1538
|
+
# 3. Submit the uploaded object for review.
|
|
1539
|
+
response = httpx.post(
|
|
1540
|
+
urljoin(constants.Hosting.HOSTING_SERVICE, f"{_SECURITY_REVIEW_PREFIX}/jobs"),
|
|
1541
|
+
json={"key": upload["key"]},
|
|
1542
|
+
headers=auth,
|
|
1543
|
+
timeout=constants.Hosting.TIMEOUT,
|
|
1544
|
+
)
|
|
1545
|
+
try:
|
|
1546
|
+
response.raise_for_status()
|
|
1547
|
+
except httpx.HTTPStatusError as ex:
|
|
1548
|
+
raise SecurityReviewError(_security_review_detail(ex.response)) from ex
|
|
1549
|
+
return response.json()["job_id"]
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def get_security_review(job_id: str, client: AuthenticatedClient) -> dict[str, Any]:
|
|
1553
|
+
"""Poll a previously submitted security review job.
|
|
1554
|
+
|
|
1555
|
+
Args:
|
|
1556
|
+
job_id: The id returned by ``submit_security_review``.
|
|
1557
|
+
client: The authenticated client.
|
|
1558
|
+
|
|
1559
|
+
Returns:
|
|
1560
|
+
The job status payload: ``status`` is one of ``pending``, ``complete``
|
|
1561
|
+
or ``error``; ``result`` holds the review once ``complete``.
|
|
1562
|
+
|
|
1563
|
+
Raises:
|
|
1564
|
+
NotAuthenticatedError: If the token is not valid.
|
|
1565
|
+
SecurityReviewError: If the server returns an error.
|
|
1566
|
+
|
|
1567
|
+
"""
|
|
1568
|
+
import httpx
|
|
1569
|
+
|
|
1570
|
+
if not isinstance(client, AuthenticatedClient):
|
|
1571
|
+
raise NotAuthenticatedError("not authenticated")
|
|
1572
|
+
|
|
1573
|
+
response = httpx.get(
|
|
1574
|
+
urljoin(
|
|
1575
|
+
constants.Hosting.HOSTING_SERVICE,
|
|
1576
|
+
f"{_SECURITY_REVIEW_PREFIX}/jobs/{job_id}",
|
|
1577
|
+
),
|
|
1578
|
+
headers=authorization_header(client.token),
|
|
1579
|
+
timeout=constants.Hosting.TIMEOUT,
|
|
1580
|
+
)
|
|
1581
|
+
try:
|
|
1582
|
+
response.raise_for_status()
|
|
1583
|
+
except httpx.HTTPStatusError as ex:
|
|
1584
|
+
raise SecurityReviewError(_security_review_detail(ex.response)) from ex
|
|
1585
|
+
return response.json()
|
|
1586
|
+
|
|
1587
|
+
|
|
1459
1588
|
def stop_app(app_id: str, client: AuthenticatedClient):
|
|
1460
1589
|
"""Stop a running application.
|
|
1461
1590
|
|
|
@@ -14,6 +14,7 @@ from reflex_cli.utils import console
|
|
|
14
14
|
from reflex_cli.v2.apps import apps_cli
|
|
15
15
|
from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command
|
|
16
16
|
from reflex_cli.v2.project import project_cli
|
|
17
|
+
from reflex_cli.v2.scan import scan_command
|
|
17
18
|
from reflex_cli.v2.secrets import secrets_cli
|
|
18
19
|
from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli
|
|
19
20
|
|
|
@@ -69,6 +70,10 @@ hosting_cli.add_command(
|
|
|
69
70
|
gcp_deploy_command,
|
|
70
71
|
name="deploy",
|
|
71
72
|
)
|
|
73
|
+
hosting_cli.add_command(
|
|
74
|
+
scan_command,
|
|
75
|
+
name="scan",
|
|
76
|
+
)
|
|
72
77
|
for name, command in vm_types_regions_cli.commands.items():
|
|
73
78
|
# Add the command to the hosting CLI
|
|
74
79
|
hosting_cli.add_command(command, name=name)
|
|
@@ -6,12 +6,16 @@ a Cloud Build job (via a ``cloudbuild.yaml`` written to a tempfile and
|
|
|
6
6
|
referenced with ``gcloud builds submit --config=...``) — the user's project
|
|
7
7
|
tree is never modified. The script reads its parameters from environment
|
|
8
8
|
variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION,
|
|
9
|
-
|
|
9
|
+
CLOUD_RUN_CPU, CLOUD_RUN_MEMORY, CLOUD_RUN_MIN_INSTANCES,
|
|
10
|
+
CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED,
|
|
11
|
+
CLOUD_RUN_SERVICE_ACCOUNT, REFLEX_CLOUDBUILD_YAML,
|
|
12
|
+
REFLEX_ENV_VARS_FILE).
|
|
10
13
|
"""
|
|
11
14
|
|
|
12
15
|
from __future__ import annotations
|
|
13
16
|
|
|
14
17
|
import contextlib
|
|
18
|
+
import json
|
|
15
19
|
import os
|
|
16
20
|
import re
|
|
17
21
|
import shutil
|
|
@@ -37,9 +41,18 @@ ENV_GCP_REGION = "GCP_REGION"
|
|
|
37
41
|
ENV_SERVICE_NAME = "SERVICE_NAME"
|
|
38
42
|
ENV_AR_REPO = "AR_REPO"
|
|
39
43
|
ENV_VERSION = "VERSION"
|
|
44
|
+
ENV_CPU = "CLOUD_RUN_CPU"
|
|
45
|
+
ENV_MEMORY = "CLOUD_RUN_MEMORY"
|
|
46
|
+
ENV_MIN_INSTANCES = "CLOUD_RUN_MIN_INSTANCES"
|
|
47
|
+
ENV_MAX_INSTANCES = "CLOUD_RUN_MAX_INSTANCES"
|
|
48
|
+
ENV_ALLOW_UNAUTHENTICATED = "CLOUD_RUN_ALLOW_UNAUTHENTICATED"
|
|
49
|
+
ENV_SERVICE_ACCOUNT = "CLOUD_RUN_SERVICE_ACCOUNT"
|
|
40
50
|
# Path to the Cloud Build config file written by the CLI. The rewritten
|
|
41
51
|
# deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
|
|
42
52
|
ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML"
|
|
53
|
+
# Path to a YAML file with user-supplied env vars. When set, the deploy
|
|
54
|
+
# script passes it to ``gcloud run deploy --env-vars-file=...``.
|
|
55
|
+
ENV_REFLEX_ENV_VARS_FILE = "REFLEX_ENV_VARS_FILE"
|
|
43
56
|
|
|
44
57
|
# Pattern for the start of the `gcloud builds submit` invocation in the
|
|
45
58
|
# Reflex deploy script. We rewrite that whole multi-line command to use
|
|
@@ -136,6 +149,60 @@ DEPLOY_ENV_ALLOWLIST = frozenset({
|
|
|
136
149
|
default=None,
|
|
137
150
|
help="The image version tag (sets VERSION). Defaults to a UTC timestamp.",
|
|
138
151
|
)
|
|
152
|
+
@click.option(
|
|
153
|
+
"--cpu",
|
|
154
|
+
"cpu",
|
|
155
|
+
default="1",
|
|
156
|
+
show_default=True,
|
|
157
|
+
help="Cloud Run CPU allocation, e.g. '1', '2', '4' (sets CLOUD_RUN_CPU).",
|
|
158
|
+
)
|
|
159
|
+
@click.option(
|
|
160
|
+
"--memory",
|
|
161
|
+
"memory",
|
|
162
|
+
default="1Gi",
|
|
163
|
+
show_default=True,
|
|
164
|
+
help="Cloud Run memory allocation, e.g. '512Mi', '1Gi', '2Gi' (sets CLOUD_RUN_MEMORY).",
|
|
165
|
+
)
|
|
166
|
+
@click.option(
|
|
167
|
+
"--min-instances",
|
|
168
|
+
"min_instances",
|
|
169
|
+
default=1,
|
|
170
|
+
show_default=True,
|
|
171
|
+
type=click.IntRange(min=0),
|
|
172
|
+
help="Minimum number of Cloud Run instances to keep warm (sets CLOUD_RUN_MIN_INSTANCES). Set to 0 to scale to zero.",
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--max-instances",
|
|
176
|
+
"max_instances",
|
|
177
|
+
default=100,
|
|
178
|
+
show_default=True,
|
|
179
|
+
type=click.IntRange(min=1),
|
|
180
|
+
help="Maximum number of Cloud Run instances during autoscaling (sets CLOUD_RUN_MAX_INSTANCES). Caps cost under traffic spikes.",
|
|
181
|
+
)
|
|
182
|
+
@click.option(
|
|
183
|
+
"--allow-unauthenticated/--no-allow-unauthenticated",
|
|
184
|
+
"allow_unauthenticated",
|
|
185
|
+
default=True,
|
|
186
|
+
show_default=True,
|
|
187
|
+
help="Whether to make the Cloud Run service publicly reachable (sets CLOUD_RUN_ALLOW_UNAUTHENTICATED). Use --no-allow-unauthenticated for internal / IAP-fronted services; callers will then need a roles/run.invoker IAM binding.",
|
|
188
|
+
)
|
|
189
|
+
@click.option(
|
|
190
|
+
"--service-account",
|
|
191
|
+
"service_account",
|
|
192
|
+
default=None,
|
|
193
|
+
help="IAM service account email the Cloud Run service runs as (sets CLOUD_RUN_SERVICE_ACCOUNT). If omitted, Cloud Run uses the project's default compute SA. The deploying principal needs roles/iam.serviceAccountUser on the target SA.",
|
|
194
|
+
)
|
|
195
|
+
@click.option(
|
|
196
|
+
"--envfile",
|
|
197
|
+
default=None,
|
|
198
|
+
help="Path to a .env file. Loaded into the Cloud Run service as env vars. Takes precedence over --env.",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--env",
|
|
202
|
+
"envs",
|
|
203
|
+
multiple=True,
|
|
204
|
+
help="Environment variable to set on the Cloud Run service: <key>=<value>. Repeat for multiple, e.g. --env K1=V1 --env K2=V2. Plain Cloud Run env vars — visible to anyone with roles/run.viewer; for sensitive values use Secret Manager separately.",
|
|
205
|
+
)
|
|
139
206
|
@click.option(
|
|
140
207
|
"--source",
|
|
141
208
|
"source_dir",
|
|
@@ -170,6 +237,14 @@ def deploy_command(
|
|
|
170
237
|
service_name: str,
|
|
171
238
|
ar_repo: str,
|
|
172
239
|
version_tag: str | None,
|
|
240
|
+
cpu: str,
|
|
241
|
+
memory: str,
|
|
242
|
+
min_instances: int,
|
|
243
|
+
max_instances: int,
|
|
244
|
+
allow_unauthenticated: bool,
|
|
245
|
+
service_account: str | None,
|
|
246
|
+
envfile: str | None,
|
|
247
|
+
envs: tuple[str, ...],
|
|
173
248
|
source_dir: str,
|
|
174
249
|
token: str | None,
|
|
175
250
|
interactive: bool,
|
|
@@ -197,6 +272,13 @@ def deploy_command(
|
|
|
197
272
|
if not gcp_project:
|
|
198
273
|
console.error("--gcp-project is required when using --gcp.")
|
|
199
274
|
raise click.exceptions.Exit(2)
|
|
275
|
+
if max_instances < min_instances:
|
|
276
|
+
console.error(
|
|
277
|
+
f"--max-instances ({max_instances}) must be >= --min-instances ({min_instances})."
|
|
278
|
+
)
|
|
279
|
+
raise click.exceptions.Exit(2)
|
|
280
|
+
|
|
281
|
+
parsed_envs = _parse_envs(envfile=envfile, envs=envs)
|
|
200
282
|
|
|
201
283
|
authenticated_client = hosting.get_authenticated_client(
|
|
202
284
|
token=token, interactive=interactive
|
|
@@ -233,6 +315,21 @@ def deploy_command(
|
|
|
233
315
|
|
|
234
316
|
dockerfile, deploy_script = _request_manifest(authenticated_client.token)
|
|
235
317
|
|
|
318
|
+
# If the user asks for a private service, abort when the fetched script
|
|
319
|
+
# doesn't reference CLOUD_RUN_ALLOW_UNAUTHENTICATED. Without that backend
|
|
320
|
+
# support the deploy would silently use the script's hard-coded
|
|
321
|
+
# --allow-unauthenticated, producing a public service when the user
|
|
322
|
+
# explicitly asked for a private one — a silent privacy flip we'd rather
|
|
323
|
+
# fail loud on.
|
|
324
|
+
if not allow_unauthenticated and ENV_ALLOW_UNAUTHENTICATED not in deploy_script:
|
|
325
|
+
console.error(
|
|
326
|
+
"The Reflex backend's deploy script doesn't yet recognize "
|
|
327
|
+
f"{ENV_ALLOW_UNAUTHENTICATED} — without it, --no-allow-unauthenticated "
|
|
328
|
+
"would be silently ignored and the service would deploy as PUBLIC. "
|
|
329
|
+
"Upgrade the Reflex backend, or remove --no-allow-unauthenticated."
|
|
330
|
+
)
|
|
331
|
+
raise click.exceptions.Exit(1)
|
|
332
|
+
|
|
236
333
|
source_path = Path(source_dir).resolve()
|
|
237
334
|
if not source_path.is_dir():
|
|
238
335
|
console.error(f"Source directory does not exist: {source_path}")
|
|
@@ -252,7 +349,17 @@ def deploy_command(
|
|
|
252
349
|
ENV_SERVICE_NAME: service_name,
|
|
253
350
|
ENV_AR_REPO: ar_repo,
|
|
254
351
|
ENV_VERSION: version_value,
|
|
352
|
+
ENV_CPU: cpu,
|
|
353
|
+
ENV_MEMORY: memory,
|
|
354
|
+
ENV_MIN_INSTANCES: str(min_instances),
|
|
355
|
+
ENV_MAX_INSTANCES: str(max_instances),
|
|
356
|
+
ENV_ALLOW_UNAUTHENTICATED: "true" if allow_unauthenticated else "false",
|
|
255
357
|
}
|
|
358
|
+
if service_account is not None:
|
|
359
|
+
if not service_account:
|
|
360
|
+
console.error("--service-account cannot be an empty string.")
|
|
361
|
+
raise click.exceptions.Exit(2)
|
|
362
|
+
deploy_env[ENV_SERVICE_ACCOUNT] = service_account
|
|
256
363
|
|
|
257
364
|
console.info("Received deploy manifest from Reflex.")
|
|
258
365
|
console.print("")
|
|
@@ -274,6 +381,8 @@ def deploy_command(
|
|
|
274
381
|
"tempfile; your source directory is not modified."
|
|
275
382
|
)
|
|
276
383
|
|
|
384
|
+
env_vars_yaml = _format_env_vars_yaml(parsed_envs) if parsed_envs else None
|
|
385
|
+
|
|
277
386
|
if dry_run:
|
|
278
387
|
console.print("")
|
|
279
388
|
console.print("cloudbuild.yaml contents:")
|
|
@@ -285,6 +394,12 @@ def deploy_command(
|
|
|
285
394
|
console.print("─" * 60)
|
|
286
395
|
console.print(dockerfile)
|
|
287
396
|
console.print("─" * 60)
|
|
397
|
+
if env_vars_yaml is not None:
|
|
398
|
+
console.print("")
|
|
399
|
+
console.print(f"env-vars file contents ({len(parsed_envs)} variable(s)):")
|
|
400
|
+
console.print("─" * 60)
|
|
401
|
+
console.print(env_vars_yaml)
|
|
402
|
+
console.print("─" * 60)
|
|
288
403
|
console.info("Dry run — nothing staged or executed.")
|
|
289
404
|
return
|
|
290
405
|
|
|
@@ -296,15 +411,21 @@ def deploy_command(
|
|
|
296
411
|
console.warn("Aborted by user.")
|
|
297
412
|
raise click.exceptions.Exit(1)
|
|
298
413
|
|
|
299
|
-
with
|
|
414
|
+
with contextlib.ExitStack() as stack:
|
|
415
|
+
cloudbuild_path = stack.enter_context(_temp_cloudbuild_yaml(cloudbuild_yaml))
|
|
416
|
+
env_overrides = {
|
|
417
|
+
**deploy_env,
|
|
418
|
+
ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
|
|
419
|
+
}
|
|
420
|
+
if env_vars_yaml is not None:
|
|
421
|
+
env_vars_path = stack.enter_context(_temp_env_vars_yaml(env_vars_yaml))
|
|
422
|
+
env_overrides[ENV_REFLEX_ENV_VARS_FILE] = str(env_vars_path)
|
|
423
|
+
|
|
300
424
|
exit_code = _run_deploy_script(
|
|
301
425
|
bash_path=bash_path,
|
|
302
426
|
script=deploy_script,
|
|
303
427
|
cwd=source_path,
|
|
304
|
-
env_overrides=
|
|
305
|
-
**deploy_env,
|
|
306
|
-
ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
|
|
307
|
-
},
|
|
428
|
+
env_overrides=env_overrides,
|
|
308
429
|
)
|
|
309
430
|
if exit_code != 0:
|
|
310
431
|
console.error(f"Deploy script exited with status {exit_code}.")
|
|
@@ -531,6 +652,85 @@ def _temp_cloudbuild_yaml(contents: str):
|
|
|
531
652
|
path.unlink()
|
|
532
653
|
|
|
533
654
|
|
|
655
|
+
@contextlib.contextmanager
|
|
656
|
+
def _temp_env_vars_yaml(contents: str):
|
|
657
|
+
"""Write the env-vars YAML to a tempfile and yield its path; always clean up.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
contents: The YAML body to write (one ``KEY: "value"`` line per env var).
|
|
661
|
+
|
|
662
|
+
Yields:
|
|
663
|
+
The path to the written tempfile.
|
|
664
|
+
|
|
665
|
+
"""
|
|
666
|
+
fd, path_str = tempfile.mkstemp(prefix="reflex-env-vars-", suffix=".yaml")
|
|
667
|
+
path = Path(path_str)
|
|
668
|
+
try:
|
|
669
|
+
with os.fdopen(fd, "w") as fh:
|
|
670
|
+
fh.write(contents)
|
|
671
|
+
yield path
|
|
672
|
+
finally:
|
|
673
|
+
with contextlib.suppress(FileNotFoundError):
|
|
674
|
+
path.unlink()
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _parse_envs(envfile: str | None, envs: tuple[str, ...]) -> dict[str, str]:
|
|
678
|
+
"""Resolve --envfile + --env into a single dict of env vars.
|
|
679
|
+
|
|
680
|
+
Mirrors the precedence of the existing `reflex deploy` / `reflex secrets
|
|
681
|
+
update` flow: when both are provided, --envfile wins and --env is
|
|
682
|
+
discarded with a warning. Empty values are preserved as empty strings;
|
|
683
|
+
keys defined without a value in the envfile (``FOO`` with no ``=``)
|
|
684
|
+
become empty strings rather than ``None``.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
envfile: Path to a .env file, or None.
|
|
688
|
+
envs: Tuple of ``KEY=VALUE`` strings from repeated --env flags.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
Dict of env var name → string value. Empty when neither input is set.
|
|
692
|
+
|
|
693
|
+
"""
|
|
694
|
+
from reflex_cli.utils import hosting
|
|
695
|
+
|
|
696
|
+
if envfile and envs:
|
|
697
|
+
console.warn("--envfile is set; ignoring --env")
|
|
698
|
+
|
|
699
|
+
if envfile:
|
|
700
|
+
try:
|
|
701
|
+
from dotenv import dotenv_values # pyright: ignore[reportMissingImports]
|
|
702
|
+
except ImportError:
|
|
703
|
+
console.error(
|
|
704
|
+
'The `python-dotenv` package is required for --envfile. Run `pip install "python-dotenv>=1.0.1"`.'
|
|
705
|
+
)
|
|
706
|
+
raise click.exceptions.Exit(1) from None
|
|
707
|
+
return {
|
|
708
|
+
k: (v if v is not None else "") for k, v in dotenv_values(envfile).items()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if envs:
|
|
712
|
+
return hosting.process_envs(list(envs))
|
|
713
|
+
|
|
714
|
+
return {}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _format_env_vars_yaml(envs: dict[str, str]) -> str:
|
|
718
|
+
"""Format env vars as YAML for gcloud ``--env-vars-file``.
|
|
719
|
+
|
|
720
|
+
Uses ``json.dumps`` per value so any string — quotes, backslashes,
|
|
721
|
+
newlines, unicode — is encoded safely. JSON strings are valid YAML, so
|
|
722
|
+
the output round-trips through gcloud's YAML loader.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
envs: Dict of env var name → string value.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
YAML body with one ``KEY: "value"`` line per env var.
|
|
729
|
+
|
|
730
|
+
"""
|
|
731
|
+
return "".join(f"{k}: {json.dumps(v)}\n" for k, v in envs.items())
|
|
732
|
+
|
|
733
|
+
|
|
534
734
|
def _run_deploy_script(
|
|
535
735
|
bash_path: str,
|
|
536
736
|
script: str,
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""The `reflex cloud scan` command: a Reflex-aware security review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from reflex_cli import constants
|
|
16
|
+
from reflex_cli.utils import console
|
|
17
|
+
from reflex_cli.utils.exceptions import NotAuthenticatedError
|
|
18
|
+
|
|
19
|
+
# Directory names whose contents are dependencies or build artifacts, never
|
|
20
|
+
# app source. Mirrors the server-side code-map loader so the upload stays
|
|
21
|
+
# small instead of shipping bytes the reviewer will discard.
|
|
22
|
+
_SKIP_DIRS = frozenset({
|
|
23
|
+
".git",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".next",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".ruff_cache",
|
|
28
|
+
".venv",
|
|
29
|
+
".web",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"build",
|
|
32
|
+
"dist",
|
|
33
|
+
"node_modules",
|
|
34
|
+
"venv",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# The reviewer skips files larger than this, so there is no point uploading
|
|
38
|
+
# them. Matches the server's per-file cap.
|
|
39
|
+
_MAX_FILE_BYTES = 1_000_000
|
|
40
|
+
# The submission endpoint rejects archives above this size.
|
|
41
|
+
_MAX_ZIP_BYTES = 50 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
_POLL_INTERVAL_SECONDS = 3.0
|
|
44
|
+
_POLL_TIMEOUT_SECONDS = 600.0
|
|
45
|
+
|
|
46
|
+
# Severities ordered from most to least serious, for sorting and gating.
|
|
47
|
+
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
|
|
48
|
+
_SEVERITY_RANK = {severity: rank for rank, severity in enumerate(_SEVERITY_ORDER)}
|
|
49
|
+
# Rich styles for the severity badge — a bold, padded, reverse-video label so
|
|
50
|
+
# findings stand out at a glance.
|
|
51
|
+
_SEVERITY_STYLE = {
|
|
52
|
+
"critical": "bold white on red",
|
|
53
|
+
"high": "bold red",
|
|
54
|
+
"medium": "bold yellow",
|
|
55
|
+
"low": "bold cyan",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _zip_app_source(directory: Path) -> bytes:
|
|
60
|
+
"""Zip the app source under ``directory`` for security review.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
directory: The app root to scan.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The zipped source as bytes.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
Exit: If no reviewable files are found or the archive is too large.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
buffer = io.BytesIO()
|
|
73
|
+
file_count = 0
|
|
74
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
75
|
+
for root, dirs, files in os.walk(directory):
|
|
76
|
+
# Prune skip dirs in place so os.walk never descends into them —
|
|
77
|
+
# node_modules/.web alone hold tens of thousands of files.
|
|
78
|
+
dirs[:] = [name for name in dirs if name not in _SKIP_DIRS]
|
|
79
|
+
root_path = Path(root)
|
|
80
|
+
for name in files:
|
|
81
|
+
path = root_path / name
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
continue
|
|
84
|
+
if path.stat().st_size > _MAX_FILE_BYTES:
|
|
85
|
+
continue
|
|
86
|
+
archive.write(path, path.relative_to(directory).as_posix())
|
|
87
|
+
file_count += 1
|
|
88
|
+
|
|
89
|
+
if not file_count:
|
|
90
|
+
console.error(f"No reviewable source files found in {directory}.")
|
|
91
|
+
raise click.exceptions.Exit(1)
|
|
92
|
+
|
|
93
|
+
data = buffer.getvalue()
|
|
94
|
+
if len(data) > _MAX_ZIP_BYTES:
|
|
95
|
+
console.error(
|
|
96
|
+
f"Project source is too large to scan (over {_MAX_ZIP_BYTES // (1024 * 1024)} MB). "
|
|
97
|
+
"Remove large files or move them into an ignored directory."
|
|
98
|
+
)
|
|
99
|
+
raise click.exceptions.Exit(1)
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _print_violations(result: dict[str, Any]) -> None:
|
|
104
|
+
"""Render a security review result to the console.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
result: The ``SecurityReviewResult`` payload from the server.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
from rich.markup import escape
|
|
111
|
+
|
|
112
|
+
# Disable Rich's auto-highlighter throughout so only the explicit styles
|
|
113
|
+
# below apply — otherwise it recolours line numbers, paths and punctuation.
|
|
114
|
+
violations = result.get("violations", [])
|
|
115
|
+
summary = result.get("summary")
|
|
116
|
+
if summary:
|
|
117
|
+
console.print(f"[bold]Summary:[/bold] {escape(summary)}", highlight=False)
|
|
118
|
+
|
|
119
|
+
if not violations:
|
|
120
|
+
console.success("No issues found.")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
console.rule("[bold]Security review findings[/bold]")
|
|
124
|
+
for violation in sorted(
|
|
125
|
+
violations,
|
|
126
|
+
key=lambda item: _SEVERITY_RANK.get(
|
|
127
|
+
item.get("severity", "low"), len(_SEVERITY_ORDER)
|
|
128
|
+
),
|
|
129
|
+
):
|
|
130
|
+
severity = violation.get("severity", "low")
|
|
131
|
+
style = _SEVERITY_STYLE.get(severity, "bold")
|
|
132
|
+
location = violation.get("file_path", "?")
|
|
133
|
+
if violation.get("line") is not None:
|
|
134
|
+
location = f"{location}:{violation['line']}"
|
|
135
|
+
console.print(
|
|
136
|
+
f"[{style}] {escape(severity.upper())} [/] "
|
|
137
|
+
f"[bold cyan]{escape(violation.get('rule_id', ''))}[/bold cyan] "
|
|
138
|
+
f"[dim]({escape(violation.get('category', ''))})[/dim] "
|
|
139
|
+
f"[dim underline]{escape(location)}[/dim underline]",
|
|
140
|
+
highlight=False,
|
|
141
|
+
)
|
|
142
|
+
console.print(f" {escape(violation.get('message', ''))}", highlight=False)
|
|
143
|
+
if recommendation := violation.get("recommendation"):
|
|
144
|
+
console.print(
|
|
145
|
+
f" [green]Fix:[/green] {escape(recommendation)}", highlight=False
|
|
146
|
+
)
|
|
147
|
+
console.print("")
|
|
148
|
+
|
|
149
|
+
# Colour the tally by the worst severity present (there is at least one).
|
|
150
|
+
worst = min(
|
|
151
|
+
_SEVERITY_RANK.get(v.get("severity", "low"), len(_SEVERITY_ORDER) - 1)
|
|
152
|
+
for v in violations
|
|
153
|
+
)
|
|
154
|
+
count_style = _SEVERITY_STYLE[_SEVERITY_ORDER[worst]]
|
|
155
|
+
console.print(
|
|
156
|
+
f"[{count_style}] Found {len(violations)} issue(s). [/]", highlight=False
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@click.command(name="scan")
|
|
161
|
+
@click.argument(
|
|
162
|
+
"directory",
|
|
163
|
+
required=False,
|
|
164
|
+
default=".",
|
|
165
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
166
|
+
)
|
|
167
|
+
@click.option("--token", help="The authentication token.")
|
|
168
|
+
@click.option(
|
|
169
|
+
"--fail-on",
|
|
170
|
+
type=click.Choice([*_SEVERITY_ORDER, "none"]),
|
|
171
|
+
default="low",
|
|
172
|
+
help="Exit non-zero if a violation at or above this severity is found. "
|
|
173
|
+
"Use 'none' to always exit 0.",
|
|
174
|
+
)
|
|
175
|
+
@click.option(
|
|
176
|
+
"--loglevel",
|
|
177
|
+
type=click.Choice([level.value for level in constants.LogLevel]),
|
|
178
|
+
default=constants.LogLevel.INFO.value,
|
|
179
|
+
help="The log level to use.",
|
|
180
|
+
)
|
|
181
|
+
@click.option(
|
|
182
|
+
"--json/--no-json",
|
|
183
|
+
"-j",
|
|
184
|
+
"as_json",
|
|
185
|
+
is_flag=True,
|
|
186
|
+
help="Whether to output the result in JSON format.",
|
|
187
|
+
)
|
|
188
|
+
@click.option(
|
|
189
|
+
"--interactive/--no-interactive",
|
|
190
|
+
"-i",
|
|
191
|
+
is_flag=True,
|
|
192
|
+
default=True,
|
|
193
|
+
help="Whether to use interactive mode.",
|
|
194
|
+
)
|
|
195
|
+
def scan_command(
|
|
196
|
+
directory: Path,
|
|
197
|
+
token: str | None,
|
|
198
|
+
fail_on: str,
|
|
199
|
+
loglevel: str,
|
|
200
|
+
as_json: bool,
|
|
201
|
+
interactive: bool,
|
|
202
|
+
):
|
|
203
|
+
"""Run a Reflex-aware security review over your app source.
|
|
204
|
+
|
|
205
|
+
Uploads the app source under DIRECTORY (the current directory by default)
|
|
206
|
+
and reports security and logic flaws specific to Reflex apps.
|
|
207
|
+
"""
|
|
208
|
+
from reflex_cli.utils import hosting
|
|
209
|
+
|
|
210
|
+
console.set_log_level(loglevel)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
authenticated_client = hosting.get_authenticated_client(
|
|
214
|
+
token=token, interactive=interactive
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
zip_bytes = _zip_app_source(directory)
|
|
218
|
+
|
|
219
|
+
with console.status("Uploading app source..."):
|
|
220
|
+
job_id = hosting.submit_security_review(
|
|
221
|
+
zip_bytes=zip_bytes, client=authenticated_client
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
|
|
225
|
+
payload: dict[str, Any] = {}
|
|
226
|
+
with console.status("Scanning..."):
|
|
227
|
+
while True:
|
|
228
|
+
payload = hosting.get_security_review(
|
|
229
|
+
job_id=job_id, client=authenticated_client
|
|
230
|
+
)
|
|
231
|
+
if payload.get("status") != "pending":
|
|
232
|
+
break
|
|
233
|
+
if time.monotonic() >= deadline:
|
|
234
|
+
console.error("Security review timed out. Try again later.")
|
|
235
|
+
raise click.exceptions.Exit(1)
|
|
236
|
+
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
237
|
+
except NotAuthenticatedError as err:
|
|
238
|
+
console.error("You are not authenticated. Run `reflex login` to authenticate.")
|
|
239
|
+
raise click.exceptions.Exit(1) from err
|
|
240
|
+
except hosting.SecurityReviewError as err:
|
|
241
|
+
console.error(f"Security review failed: {err}")
|
|
242
|
+
raise click.exceptions.Exit(1) from err
|
|
243
|
+
|
|
244
|
+
if payload.get("status") == "error":
|
|
245
|
+
console.error(f"Security review failed: {payload.get('error')}")
|
|
246
|
+
raise click.exceptions.Exit(1)
|
|
247
|
+
|
|
248
|
+
result = payload.get("result") or {}
|
|
249
|
+
|
|
250
|
+
if as_json:
|
|
251
|
+
console.print(json.dumps(result))
|
|
252
|
+
else:
|
|
253
|
+
_print_violations(result)
|
|
254
|
+
|
|
255
|
+
if fail_on != "none":
|
|
256
|
+
threshold = _SEVERITY_RANK[fail_on]
|
|
257
|
+
if any(
|
|
258
|
+
_SEVERITY_RANK.get(violation.get("severity", "low"), len(_SEVERITY_ORDER))
|
|
259
|
+
<= threshold
|
|
260
|
+
for violation in result.get("violations", [])
|
|
261
|
+
):
|
|
262
|
+
raise click.exceptions.Exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/compiler.py
RENAMED
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/hosting.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/dependency.py
RENAMED
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/vmtypes_regions.py
RENAMED
|
File without changes
|