reflex-hosting-cli 0.1.66__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.66 → reflex_hosting_cli-0.1.67}/.gitignore +9 -1
- {reflex_hosting_cli-0.1.66 → 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.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/hosting.py +129 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/deployments.py +5 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/gcp.py +174 -6
- reflex_hosting_cli-0.1.67/src/reflex_cli/v2/scan.py +262 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/README.md +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/pyproject.toml +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/__init__.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/cli.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/__init__.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/base.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/compiler.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/hosting.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/__init__.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/config.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/deployments.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/__init__.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/console.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/dependency.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/exceptions.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/__init__.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/apps.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/cli.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/project.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/secrets.py +0 -0
- {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/utils.py +0 -0
- {reflex_hosting_cli-0.1.66 → 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)
|
|
@@ -7,12 +7,15 @@ 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
|
-
|
|
10
|
+
CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED,
|
|
11
|
+
CLOUD_RUN_SERVICE_ACCOUNT, REFLEX_CLOUDBUILD_YAML,
|
|
12
|
+
REFLEX_ENV_VARS_FILE).
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
from __future__ import annotations
|
|
14
16
|
|
|
15
17
|
import contextlib
|
|
18
|
+
import json
|
|
16
19
|
import os
|
|
17
20
|
import re
|
|
18
21
|
import shutil
|
|
@@ -41,9 +44,15 @@ ENV_VERSION = "VERSION"
|
|
|
41
44
|
ENV_CPU = "CLOUD_RUN_CPU"
|
|
42
45
|
ENV_MEMORY = "CLOUD_RUN_MEMORY"
|
|
43
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"
|
|
44
50
|
# Path to the Cloud Build config file written by the CLI. The rewritten
|
|
45
51
|
# deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
|
|
46
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"
|
|
47
56
|
|
|
48
57
|
# Pattern for the start of the `gcloud builds submit` invocation in the
|
|
49
58
|
# Reflex deploy script. We rewrite that whole multi-line command to use
|
|
@@ -162,6 +171,38 @@ DEPLOY_ENV_ALLOWLIST = frozenset({
|
|
|
162
171
|
type=click.IntRange(min=0),
|
|
163
172
|
help="Minimum number of Cloud Run instances to keep warm (sets CLOUD_RUN_MIN_INSTANCES). Set to 0 to scale to zero.",
|
|
164
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
|
+
)
|
|
165
206
|
@click.option(
|
|
166
207
|
"--source",
|
|
167
208
|
"source_dir",
|
|
@@ -199,6 +240,11 @@ def deploy_command(
|
|
|
199
240
|
cpu: str,
|
|
200
241
|
memory: str,
|
|
201
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, ...],
|
|
202
248
|
source_dir: str,
|
|
203
249
|
token: str | None,
|
|
204
250
|
interactive: bool,
|
|
@@ -226,6 +272,13 @@ def deploy_command(
|
|
|
226
272
|
if not gcp_project:
|
|
227
273
|
console.error("--gcp-project is required when using --gcp.")
|
|
228
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)
|
|
229
282
|
|
|
230
283
|
authenticated_client = hosting.get_authenticated_client(
|
|
231
284
|
token=token, interactive=interactive
|
|
@@ -262,6 +315,21 @@ def deploy_command(
|
|
|
262
315
|
|
|
263
316
|
dockerfile, deploy_script = _request_manifest(authenticated_client.token)
|
|
264
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
|
+
|
|
265
333
|
source_path = Path(source_dir).resolve()
|
|
266
334
|
if not source_path.is_dir():
|
|
267
335
|
console.error(f"Source directory does not exist: {source_path}")
|
|
@@ -284,7 +352,14 @@ def deploy_command(
|
|
|
284
352
|
ENV_CPU: cpu,
|
|
285
353
|
ENV_MEMORY: memory,
|
|
286
354
|
ENV_MIN_INSTANCES: str(min_instances),
|
|
355
|
+
ENV_MAX_INSTANCES: str(max_instances),
|
|
356
|
+
ENV_ALLOW_UNAUTHENTICATED: "true" if allow_unauthenticated else "false",
|
|
287
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
|
|
288
363
|
|
|
289
364
|
console.info("Received deploy manifest from Reflex.")
|
|
290
365
|
console.print("")
|
|
@@ -306,6 +381,8 @@ def deploy_command(
|
|
|
306
381
|
"tempfile; your source directory is not modified."
|
|
307
382
|
)
|
|
308
383
|
|
|
384
|
+
env_vars_yaml = _format_env_vars_yaml(parsed_envs) if parsed_envs else None
|
|
385
|
+
|
|
309
386
|
if dry_run:
|
|
310
387
|
console.print("")
|
|
311
388
|
console.print("cloudbuild.yaml contents:")
|
|
@@ -317,6 +394,12 @@ def deploy_command(
|
|
|
317
394
|
console.print("─" * 60)
|
|
318
395
|
console.print(dockerfile)
|
|
319
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)
|
|
320
403
|
console.info("Dry run — nothing staged or executed.")
|
|
321
404
|
return
|
|
322
405
|
|
|
@@ -328,15 +411,21 @@ def deploy_command(
|
|
|
328
411
|
console.warn("Aborted by user.")
|
|
329
412
|
raise click.exceptions.Exit(1)
|
|
330
413
|
|
|
331
|
-
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
|
+
|
|
332
424
|
exit_code = _run_deploy_script(
|
|
333
425
|
bash_path=bash_path,
|
|
334
426
|
script=deploy_script,
|
|
335
427
|
cwd=source_path,
|
|
336
|
-
env_overrides=
|
|
337
|
-
**deploy_env,
|
|
338
|
-
ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
|
|
339
|
-
},
|
|
428
|
+
env_overrides=env_overrides,
|
|
340
429
|
)
|
|
341
430
|
if exit_code != 0:
|
|
342
431
|
console.error(f"Deploy script exited with status {exit_code}.")
|
|
@@ -563,6 +652,85 @@ def _temp_cloudbuild_yaml(contents: str):
|
|
|
563
652
|
path.unlink()
|
|
564
653
|
|
|
565
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
|
+
|
|
566
734
|
def _run_deploy_script(
|
|
567
735
|
bash_path: str,
|
|
568
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.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/compiler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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.66 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/vmtypes_regions.py
RENAMED
|
File without changes
|