reflex-hosting-cli 0.1.13__py3-none-any.whl
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_cli/__init__.py +1 -0
- reflex_cli/cli.py +362 -0
- reflex_cli/constants/__init__.py +8 -0
- reflex_cli/constants/base.py +47 -0
- reflex_cli/constants/compiler.py +31 -0
- reflex_cli/constants/hosting.py +45 -0
- reflex_cli/deployments.py +354 -0
- reflex_cli/utils/__init__.py +1 -0
- reflex_cli/utils/console.py +155 -0
- reflex_cli/utils/dependency.py +132 -0
- reflex_cli/utils/hosting.py +1908 -0
- reflex_hosting_cli-0.1.13.dist-info/LICENSE +201 -0
- reflex_hosting_cli-0.1.13.dist-info/METADATA +35 -0
- reflex_hosting_cli-0.1.13.dist-info/RECORD +15 -0
- reflex_hosting_cli-0.1.13.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1908 @@
|
|
|
1
|
+
"""Hosting service related utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import enum
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
import webbrowser
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
import dateutil.parser
|
|
20
|
+
import httpx
|
|
21
|
+
import typer
|
|
22
|
+
import websockets
|
|
23
|
+
from pydantic import BaseModel, Field, ValidationError, root_validator
|
|
24
|
+
|
|
25
|
+
from reflex_cli import constants
|
|
26
|
+
from reflex_cli.utils import console
|
|
27
|
+
from reflex_cli.utils.dependency import detect_encoding
|
|
28
|
+
|
|
29
|
+
# Endpoint to create or update a deployment
|
|
30
|
+
POST_DEPLOYMENTS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments"
|
|
31
|
+
# Endpoint to get all deployments for the user
|
|
32
|
+
GET_DEPLOYMENTS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments"
|
|
33
|
+
# Endpoint to fetch information from backend in preparation of a deployment
|
|
34
|
+
POST_DEPLOYMENTS_PREPARE_ENDPOINT = (
|
|
35
|
+
f"{constants.Hosting.CP_BACKEND_URL}/deployments/prepare"
|
|
36
|
+
)
|
|
37
|
+
# Endpoint to authenticate current user
|
|
38
|
+
POST_VALIDATE_ME_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/authenticate/me"
|
|
39
|
+
# Endpoint to fetch a login token after user completes authentication on web
|
|
40
|
+
FETCH_TOKEN_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/authenticate"
|
|
41
|
+
# Endpoint to delete a deployment
|
|
42
|
+
DELETE_DEPLOYMENTS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments"
|
|
43
|
+
# Endpoint to get deployment status
|
|
44
|
+
GET_DEPLOYMENT_STATUS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments"
|
|
45
|
+
# Public endpoint to get the list of supported regions in hosting service
|
|
46
|
+
GET_REGIONS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments/regions"
|
|
47
|
+
# Websocket endpoint to stream logs of a deployment
|
|
48
|
+
DEPLOYMENT_LOGS_ENDPOINT = (
|
|
49
|
+
f'{constants.Hosting.CP_BACKEND_URL.replace("http", "ws")}/deployments'
|
|
50
|
+
)
|
|
51
|
+
# The HTTP endpoint to fetch logs of a deployment
|
|
52
|
+
POST_DEPLOYMENT_LOGS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments/logs"
|
|
53
|
+
# The HTTP endpoint to update the gallery app data.
|
|
54
|
+
POST_GALLERY_APPS_ENDPOINT = f"{constants.Hosting.CP_BACKEND_URL}/deployments/gallery"
|
|
55
|
+
|
|
56
|
+
# Expected server response time to new deployment request. In seconds.
|
|
57
|
+
DEPLOYMENT_PICKUP_DELAY = 30
|
|
58
|
+
# End of deployment workflow message. Used to determine if it is the last message from server.
|
|
59
|
+
END_OF_DEPLOYMENT_MESSAGES = ["deploy success (backend)", "deploy success (frontend)"]
|
|
60
|
+
# Reflex App start message in all lower cases.
|
|
61
|
+
REFLEX_APP_START_MESSAGE = "app running"
|
|
62
|
+
# Stacktrace keyword
|
|
63
|
+
APP_LOG_STACKTRACE_WORD = "traceback"
|
|
64
|
+
# Stacktrace number of message limit
|
|
65
|
+
APP_LOG_STACKTRACE_LINE_LIMIT = 50
|
|
66
|
+
# How many iterations to try and print the deployment event messages from server during deployment.
|
|
67
|
+
DEPLOYMENT_EVENT_MESSAGES_RETRIES = 90
|
|
68
|
+
# Timeout limit for http requests
|
|
69
|
+
HTTP_REQUEST_TIMEOUT = 60 # seconds
|
|
70
|
+
# Timeout limit for log query
|
|
71
|
+
LOG_QUERY_TIMEOUT = 10 # seconds
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_existing_access_token() -> tuple[str, str]:
|
|
75
|
+
"""Fetch the access token from the existing config if applicable.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The access token and the invitation code.
|
|
79
|
+
If either is not found, return empty string for it instead.
|
|
80
|
+
"""
|
|
81
|
+
console.debug("Fetching token from existing config...")
|
|
82
|
+
access_token = invitation_code = ""
|
|
83
|
+
try:
|
|
84
|
+
with open(constants.Hosting.HOSTING_JSON, "r") as config_file:
|
|
85
|
+
hosting_config = json.load(config_file)
|
|
86
|
+
access_token = hosting_config.get("access_token", "")
|
|
87
|
+
invitation_code = hosting_config.get("code", "")
|
|
88
|
+
except Exception as ex:
|
|
89
|
+
console.debug(
|
|
90
|
+
f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}"
|
|
91
|
+
)
|
|
92
|
+
return access_token, invitation_code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_token(token: str):
|
|
96
|
+
"""Validate the token with the control plane.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
token: The access token to validate.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ValueError: if access denied.
|
|
103
|
+
Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
response = httpx.post(
|
|
107
|
+
POST_VALIDATE_ME_ENDPOINT,
|
|
108
|
+
headers=authorization_header(token),
|
|
109
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
110
|
+
)
|
|
111
|
+
if response.status_code == HTTPStatus.FORBIDDEN:
|
|
112
|
+
raise ValueError
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
except httpx.RequestError as re:
|
|
115
|
+
console.debug(f"Request to auth server failed due to {re}")
|
|
116
|
+
raise Exception(str(re)) from re
|
|
117
|
+
except httpx.HTTPError as ex:
|
|
118
|
+
console.debug(f"Unable to validate the token due to: {ex}")
|
|
119
|
+
raise Exception("server error") from ex
|
|
120
|
+
except ValueError as ve:
|
|
121
|
+
console.debug(f"Access denied for {token}")
|
|
122
|
+
raise ValueError("access denied") from ve
|
|
123
|
+
except Exception as ex:
|
|
124
|
+
console.debug(f"Unexpected error: {ex}")
|
|
125
|
+
raise Exception("internal errors") from ex
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete_token_from_config(include_invitation_code: bool = False):
|
|
129
|
+
"""Delete the invalid token from the config file if applicable.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
include_invitation_code:
|
|
133
|
+
Whether to delete the invitation code as well.
|
|
134
|
+
When user logs out, we delete the invitation code together.
|
|
135
|
+
"""
|
|
136
|
+
if os.path.exists(constants.Hosting.HOSTING_JSON):
|
|
137
|
+
hosting_config = {}
|
|
138
|
+
try:
|
|
139
|
+
with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
|
|
140
|
+
hosting_config = json.load(config_file)
|
|
141
|
+
del hosting_config["access_token"]
|
|
142
|
+
if include_invitation_code:
|
|
143
|
+
del hosting_config["code"]
|
|
144
|
+
json.dump(hosting_config, config_file)
|
|
145
|
+
except Exception as ex:
|
|
146
|
+
# Best efforts removing invalid token is OK
|
|
147
|
+
console.debug(
|
|
148
|
+
f"Unable to delete the invalid token from config file, err: {ex}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_token_to_config(token: str, code: str | None = None):
|
|
153
|
+
"""Best efforts cache the token, and optionally invitation code to the config file.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
token: The access token to save.
|
|
157
|
+
code: The invitation code to save if exists.
|
|
158
|
+
"""
|
|
159
|
+
hosting_config: dict[str, str] = {"access_token": token}
|
|
160
|
+
if code:
|
|
161
|
+
hosting_config["code"] = code
|
|
162
|
+
try:
|
|
163
|
+
if not os.path.exists(constants.Reflex.DIR):
|
|
164
|
+
os.makedirs(constants.Reflex.DIR)
|
|
165
|
+
with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
|
|
166
|
+
json.dump(hosting_config, config_file)
|
|
167
|
+
except Exception as ex:
|
|
168
|
+
console.warn(
|
|
169
|
+
f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def requires_access_token() -> str:
|
|
174
|
+
"""Fetch the access token from the existing config if applicable.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The access token. If not found, return empty string for it instead.
|
|
178
|
+
"""
|
|
179
|
+
# Check if the user is authenticated
|
|
180
|
+
|
|
181
|
+
access_token, _ = get_existing_access_token()
|
|
182
|
+
if not access_token:
|
|
183
|
+
console.debug("No access token found from the existing config.")
|
|
184
|
+
|
|
185
|
+
return access_token
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def authenticated_token() -> tuple[str, str]:
|
|
189
|
+
"""Fetch the access token from the existing config if applicable and validate it.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The access token and the invitation code.
|
|
193
|
+
If either is not found, return empty string for it instead.
|
|
194
|
+
"""
|
|
195
|
+
# Check if the user is authenticated
|
|
196
|
+
|
|
197
|
+
access_token, invitation_code = get_existing_access_token()
|
|
198
|
+
if not access_token:
|
|
199
|
+
console.debug("No access token found from the existing config.")
|
|
200
|
+
access_token = ""
|
|
201
|
+
elif not validate_token_with_retries(access_token):
|
|
202
|
+
access_token = ""
|
|
203
|
+
|
|
204
|
+
return access_token, invitation_code
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def authorization_header(token: str) -> dict[str, str]:
|
|
208
|
+
"""Construct an authorization header with the specified token as bearer token.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
token: The access token to use.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The authorization header in dict format.
|
|
215
|
+
"""
|
|
216
|
+
return {"Authorization": f"Bearer {token}"}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def requires_authenticated() -> str:
|
|
220
|
+
"""Check if the user is authenticated.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The validated access token or empty string if not authenticated.
|
|
224
|
+
"""
|
|
225
|
+
access_token, invitation_code = authenticated_token()
|
|
226
|
+
if access_token:
|
|
227
|
+
return access_token
|
|
228
|
+
return authenticate_on_browser(invitation_code)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class DeploymentPrepInfo(BaseModel):
|
|
232
|
+
"""The params/settings returned from the prepare endpoint
|
|
233
|
+
including the deployment key and the frontend/backend URLs once deployed.
|
|
234
|
+
The key becomes part of both frontend and backend URLs.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
# The deployment key
|
|
238
|
+
key: str
|
|
239
|
+
# The backend URL
|
|
240
|
+
api_url: str
|
|
241
|
+
# The frontend URL
|
|
242
|
+
deploy_url: str
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class DeploymentPrepareResponse(BaseModel):
|
|
246
|
+
"""The params/settings returned from the prepare endpoint,
|
|
247
|
+
used in the CLI for the subsequent launch request.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
# The app prefix, used on the server side only
|
|
251
|
+
app_prefix: str
|
|
252
|
+
# The reply from the server for a prepare request to deploy over a particular key
|
|
253
|
+
# If reply is not None, it means server confirms the key is available for use.
|
|
254
|
+
reply: Optional[DeploymentPrepInfo] = None
|
|
255
|
+
# The list of existing deployments by the user under the same app name.
|
|
256
|
+
# This is used to allow easy upgrade case when user attempts to deploy
|
|
257
|
+
# in the same named app directory, user intends to upgrade the existing deployment.
|
|
258
|
+
existing: Optional[List[DeploymentPrepInfo]] = None
|
|
259
|
+
# The suggested key name based on the app name.
|
|
260
|
+
# This is for a new deployment, user has not deployed this app before.
|
|
261
|
+
# The server returns key suggestion based on the app name.
|
|
262
|
+
suggestion: Optional[DeploymentPrepInfo] = None
|
|
263
|
+
enabled_regions: Optional[List[str]] = None
|
|
264
|
+
|
|
265
|
+
@root_validator(pre=True)
|
|
266
|
+
def ensure_at_least_one_deploy_params(cls, values):
|
|
267
|
+
"""Ensure at least one set of param is returned for any of the cases we try to prepare.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
values: The values passed in.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: If all of the optional fields are None.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The values passed in.
|
|
277
|
+
"""
|
|
278
|
+
if (
|
|
279
|
+
values.get("reply") is None
|
|
280
|
+
and not values.get("existing") # existing cannot be an empty list either
|
|
281
|
+
and values.get("suggestion") is None
|
|
282
|
+
):
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"At least one set of params for deploy is required from control plane."
|
|
285
|
+
)
|
|
286
|
+
return values
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class DeploymentsPreparePostParam(BaseModel):
|
|
290
|
+
"""Params for app API URL creation backend API."""
|
|
291
|
+
|
|
292
|
+
# The app name which is found in the config
|
|
293
|
+
app_name: str
|
|
294
|
+
# The deployment key
|
|
295
|
+
key: Optional[str] = None # name of the deployment
|
|
296
|
+
# The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
|
|
297
|
+
frontend_hostname: Optional[str] = None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def prepare_deploy(
|
|
301
|
+
app_name: str,
|
|
302
|
+
key: str | None = None,
|
|
303
|
+
frontend_hostname: str | None = None,
|
|
304
|
+
) -> DeploymentPrepareResponse:
|
|
305
|
+
"""Send a POST request to Control Plane to prepare a new deployment.
|
|
306
|
+
Control Plane checks if there is conflict with the key if provided.
|
|
307
|
+
If the key is absent, it will return existing deployments and a suggested name based on the app_name in the request.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
key: The deployment name.
|
|
311
|
+
app_name: The app name.
|
|
312
|
+
frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The response containing the backend URLs if successful, None otherwise.
|
|
319
|
+
"""
|
|
320
|
+
# Check if the user is authenticated
|
|
321
|
+
if not (token := requires_authenticated()):
|
|
322
|
+
raise Exception("not authenticated")
|
|
323
|
+
try:
|
|
324
|
+
response = httpx.post(
|
|
325
|
+
POST_DEPLOYMENTS_PREPARE_ENDPOINT,
|
|
326
|
+
headers=authorization_header(token),
|
|
327
|
+
json=DeploymentsPreparePostParam(
|
|
328
|
+
app_name=app_name, key=key, frontend_hostname=frontend_hostname
|
|
329
|
+
).dict(exclude_none=True),
|
|
330
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
response_json = response.json()
|
|
334
|
+
console.debug(f"Response from prepare endpoint: {response_json}")
|
|
335
|
+
if response.status_code == HTTPStatus.FORBIDDEN:
|
|
336
|
+
console.debug(f'Server responded with 403: {response_json.get("detail")}')
|
|
337
|
+
raise ValueError(f'{response_json.get("detail", "forbidden")}')
|
|
338
|
+
response.raise_for_status()
|
|
339
|
+
return DeploymentPrepareResponse(
|
|
340
|
+
app_prefix=response_json["app_prefix"],
|
|
341
|
+
reply=response_json["reply"],
|
|
342
|
+
suggestion=response_json["suggestion"],
|
|
343
|
+
existing=response_json["existing"],
|
|
344
|
+
enabled_regions=response_json.get("enabled_regions"),
|
|
345
|
+
)
|
|
346
|
+
except httpx.RequestError as re:
|
|
347
|
+
console.debug(f"Unable to prepare launch due to {re}.")
|
|
348
|
+
raise Exception(str(re)) from re
|
|
349
|
+
except httpx.HTTPError as he:
|
|
350
|
+
console.debug(f"Unable to prepare deploy due to {he}.")
|
|
351
|
+
raise Exception(f"{he}") from he
|
|
352
|
+
except json.JSONDecodeError as jde:
|
|
353
|
+
console.debug(f"Server did not respond with valid json: {jde}")
|
|
354
|
+
raise Exception("internal errors") from jde
|
|
355
|
+
except (KeyError, ValidationError) as kve:
|
|
356
|
+
console.debug(f"The server response format is unexpected {kve}")
|
|
357
|
+
raise Exception("internal errors") from kve
|
|
358
|
+
except ValueError as ve:
|
|
359
|
+
# This is a recognized client error, currently indicates forbidden
|
|
360
|
+
raise Exception(f"{ve}") from ve
|
|
361
|
+
except Exception as ex:
|
|
362
|
+
console.debug(f"Unexpected error: {ex}.")
|
|
363
|
+
raise Exception("internal errors") from ex
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class DeploymentPostResponse(BaseModel):
|
|
367
|
+
"""The deploy POST request response. This could be backend or frontend."""
|
|
368
|
+
|
|
369
|
+
# The URL
|
|
370
|
+
url: str = Field(pattern=r"^https?://", min_length=8)
|
|
371
|
+
# The sidecar URL, only relevant to backend
|
|
372
|
+
sidecar_url: Optional[str] = Field(
|
|
373
|
+
default=None, pattern=r"^https?://", min_length=8
|
|
374
|
+
)
|
|
375
|
+
# Deploy event ID
|
|
376
|
+
event_id: int
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class BackendDeploymentsPostParam(BaseModel):
|
|
380
|
+
"""Params for backend deployment POST request."""
|
|
381
|
+
|
|
382
|
+
# Key is the name of the deployment, it becomes part of the URL
|
|
383
|
+
key: Optional[str] = Field(default=None, pattern=r"^[a-z0-9-]+$")
|
|
384
|
+
initiator_event_id: Optional[int] = None
|
|
385
|
+
# Name of the app
|
|
386
|
+
app_name: Optional[str] = Field(default=None, min_length=1)
|
|
387
|
+
# json encoded list of regions to deploy to
|
|
388
|
+
regions_json: Optional[str] = Field(default=None, min_length=1)
|
|
389
|
+
# The app prefix, used on the server side only
|
|
390
|
+
app_prefix: str = Field(..., min_length=1)
|
|
391
|
+
# The version of reflex CLI used to deploy
|
|
392
|
+
reflex_version: Optional[str] = Field(default=None, min_length=1)
|
|
393
|
+
# The number of CPUs
|
|
394
|
+
cpus: Optional[int] = None
|
|
395
|
+
# The memory in MB
|
|
396
|
+
memory_mb: Optional[int] = None
|
|
397
|
+
# Whether to auto start the hosted deployment
|
|
398
|
+
auto_start: Optional[bool] = None
|
|
399
|
+
# Whether to auto stop the hosted deployment when idling
|
|
400
|
+
auto_stop: Optional[bool] = None
|
|
401
|
+
# The json encoded list of environment variables
|
|
402
|
+
envs_json: Optional[str] = None
|
|
403
|
+
# The command line prefix for tracing
|
|
404
|
+
reflex_cli_entrypoint: Optional[str] = None
|
|
405
|
+
# The metrics endpoint
|
|
406
|
+
metrics_endpoint: Optional[str] = None
|
|
407
|
+
# The reflex-hosting-cli version
|
|
408
|
+
reflex_hosting_cli_version: Optional[str] = None
|
|
409
|
+
# The user environment python version
|
|
410
|
+
python_version: Optional[str] = None
|
|
411
|
+
# The requirements.txt content as a single string
|
|
412
|
+
requirements_txt: Optional[str] = None
|
|
413
|
+
|
|
414
|
+
@root_validator(pre=True)
|
|
415
|
+
def ensure_key_or_initiator_event_id(cls, values):
|
|
416
|
+
"""Ensure either key or initiator_event_id is provided.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
values: The values passed in.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
ValueError: If neither key or initiator_event_id is provided.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The values passed in.
|
|
426
|
+
"""
|
|
427
|
+
if values.get("key") is None and values.get("initiator_event_id") is None:
|
|
428
|
+
raise ValueError("Either key or initiator_event_id is required.")
|
|
429
|
+
return values
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class FrontendDeploymentsPostParam(BaseModel):
|
|
433
|
+
"""Params for frontend deployment POST request."""
|
|
434
|
+
|
|
435
|
+
# Key is the name of the deployment, it becomes part of the URL
|
|
436
|
+
key: Optional[str] = Field(default=None, pattern=r"^[a-z0-9-]+$")
|
|
437
|
+
initiator_event_id: Optional[int] = None
|
|
438
|
+
# The app prefix, used on the server side only
|
|
439
|
+
app_prefix: str = Field(..., min_length=1)
|
|
440
|
+
# The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
|
|
441
|
+
frontend_hostname: Optional[str] = None
|
|
442
|
+
|
|
443
|
+
@root_validator(pre=True)
|
|
444
|
+
def ensure_key_or_initiator_event_id(cls, values):
|
|
445
|
+
"""Ensure either key or initiator_event_id is provided.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
values: The values passed in.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ValueError: If neither key or initiator_event_id is provided.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The values passed in.
|
|
455
|
+
"""
|
|
456
|
+
if values.get("key") is None and values.get("initiator_event_id") is None:
|
|
457
|
+
raise ValueError("Either key or initiator_event_id is required.")
|
|
458
|
+
return values
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def deploy_backend(
|
|
462
|
+
app_prefix: str,
|
|
463
|
+
export_dir: str,
|
|
464
|
+
backend_file_name: str,
|
|
465
|
+
key: str | None = None,
|
|
466
|
+
initiator_event_id: int | None = None,
|
|
467
|
+
app_name: str | None = None,
|
|
468
|
+
regions: list[str] | None = None,
|
|
469
|
+
vm_type: str | None = None,
|
|
470
|
+
cpus: int | None = None,
|
|
471
|
+
memory_mb: int | None = None,
|
|
472
|
+
auto_start: bool | None = None,
|
|
473
|
+
auto_stop: bool | None = None,
|
|
474
|
+
envs: dict[str, str] | None = None,
|
|
475
|
+
with_tracing: str | None = None,
|
|
476
|
+
with_metrics: str | None = None,
|
|
477
|
+
) -> DeploymentPostResponse:
|
|
478
|
+
"""Send a POST request to Control Plane to launch a backend deployment.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
app_prefix: The app prefix.
|
|
482
|
+
export_dir: The directory where the frontend/backend zip files are exported.
|
|
483
|
+
backend_file_name: The backend file name.
|
|
484
|
+
key: The deployment name.
|
|
485
|
+
initiator_event_id: The initiator event ID.
|
|
486
|
+
app_name: The app name.
|
|
487
|
+
regions: The list of regions to deploy to.
|
|
488
|
+
vm_type: The VM type.
|
|
489
|
+
cpus: The number of CPUs.
|
|
490
|
+
memory_mb: The memory in MB.
|
|
491
|
+
auto_start: Whether to auto start.
|
|
492
|
+
auto_stop: Whether to auto stop.
|
|
493
|
+
envs: The environment variables.
|
|
494
|
+
with_tracing: A string indicating the command line prefix for tracing.
|
|
495
|
+
with_metrics: A string indicating the metrics endpoint.
|
|
496
|
+
|
|
497
|
+
Raises:
|
|
498
|
+
AssertionError: If the request is rejected by the hosting server.
|
|
499
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
The response containing the backend URL of the site to be deployed and the deploy event ID if successful.
|
|
503
|
+
"""
|
|
504
|
+
# Check if the user is authenticated
|
|
505
|
+
if not (token := requires_access_token()):
|
|
506
|
+
raise Exception("not authenticated")
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
requirements_txt = None
|
|
510
|
+
if (encoding := detect_encoding(constants.RequirementsTxt.FILE)) is not None:
|
|
511
|
+
with open(constants.RequirementsTxt.FILE, "r", encoding=encoding) as f:
|
|
512
|
+
requirements_txt = f.read()
|
|
513
|
+
|
|
514
|
+
params = BackendDeploymentsPostParam(
|
|
515
|
+
key=key,
|
|
516
|
+
initiator_event_id=initiator_event_id,
|
|
517
|
+
app_name=app_name,
|
|
518
|
+
regions_json=json.dumps(regions) if regions else None,
|
|
519
|
+
app_prefix=app_prefix,
|
|
520
|
+
cpus=cpus,
|
|
521
|
+
memory_mb=memory_mb,
|
|
522
|
+
auto_start=auto_start,
|
|
523
|
+
auto_stop=auto_stop,
|
|
524
|
+
envs_json=json.dumps(envs) if envs else None,
|
|
525
|
+
reflex_version=metadata.version(constants.Reflex.MODULE_NAME),
|
|
526
|
+
reflex_cli_entrypoint=with_tracing,
|
|
527
|
+
metrics_endpoint=with_metrics,
|
|
528
|
+
python_version=platform.python_version(),
|
|
529
|
+
reflex_hosting_cli_version=metadata.version(
|
|
530
|
+
constants.ReflexHostingCli.MODULE_NAME
|
|
531
|
+
),
|
|
532
|
+
requirements_txt=requirements_txt,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
with open(os.path.join(export_dir, backend_file_name), "rb") as backend_file:
|
|
536
|
+
# https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files
|
|
537
|
+
files = [
|
|
538
|
+
("files", (backend_file_name, backend_file)),
|
|
539
|
+
]
|
|
540
|
+
response = httpx.post(
|
|
541
|
+
POST_DEPLOYMENTS_ENDPOINT,
|
|
542
|
+
headers=authorization_header(token),
|
|
543
|
+
data=params.dict(exclude_none=True),
|
|
544
|
+
files=files,
|
|
545
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
546
|
+
)
|
|
547
|
+
# If the server explicitly states bad request,
|
|
548
|
+
# display a different error
|
|
549
|
+
if response.status_code == HTTPStatus.BAD_REQUEST:
|
|
550
|
+
raise AssertionError(f"Server rejected this request: {response.text}")
|
|
551
|
+
response.raise_for_status()
|
|
552
|
+
response_json = response.json()
|
|
553
|
+
return DeploymentPostResponse(
|
|
554
|
+
url=response_json["backend_url"],
|
|
555
|
+
event_id=response_json["backend_event_id"],
|
|
556
|
+
sidecar_url=response_json["backend_sidecar_url"],
|
|
557
|
+
)
|
|
558
|
+
except (FileNotFoundError, OSError) as oe:
|
|
559
|
+
console.error(f"Client side error related to file operation: {oe}")
|
|
560
|
+
raise
|
|
561
|
+
except httpx.RequestError as re:
|
|
562
|
+
console.error(f"Unable to deploy due to request error: {re}")
|
|
563
|
+
raise Exception("request error") from re
|
|
564
|
+
except httpx.HTTPError as he:
|
|
565
|
+
console.error(f"Unable to deploy due to {he}.")
|
|
566
|
+
raise Exception(str) from he
|
|
567
|
+
except json.JSONDecodeError as jde:
|
|
568
|
+
console.error(f"Server did not respond with valid json: {jde}")
|
|
569
|
+
raise Exception("internal errors") from jde
|
|
570
|
+
except (KeyError, ValidationError) as kve:
|
|
571
|
+
console.error(f"Post params or server response format unexpected: {kve}")
|
|
572
|
+
raise Exception("internal errors") from kve
|
|
573
|
+
except AssertionError as ve:
|
|
574
|
+
console.error(f"Unable to deploy due to request error: {ve}")
|
|
575
|
+
# re-raise the error back to the user as client side error
|
|
576
|
+
raise
|
|
577
|
+
except Exception as ex:
|
|
578
|
+
console.error(f"Unable to deploy due to internal errors: {ex}.")
|
|
579
|
+
raise Exception("internal errors") from ex
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def deploy_frontend(
|
|
583
|
+
app_prefix: str,
|
|
584
|
+
export_dir: str,
|
|
585
|
+
frontend_file_name: str,
|
|
586
|
+
key: str | None = None,
|
|
587
|
+
initiator_event_id: int | None = None,
|
|
588
|
+
frontend_hostname: str | None = None,
|
|
589
|
+
) -> DeploymentPostResponse:
|
|
590
|
+
"""Send a POST request to Control Plane to launch a frontend deployment.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
app_prefix: The app prefix.
|
|
594
|
+
export_dir: The directory where the frontend/backend zip files are exported.
|
|
595
|
+
frontend_file_name: The frontend file name.
|
|
596
|
+
key: The deployment name.
|
|
597
|
+
initiator_event_id: The initiator event ID.
|
|
598
|
+
frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
|
|
599
|
+
|
|
600
|
+
Raises:
|
|
601
|
+
AssertionError: If the request is rejected by the hosting server.
|
|
602
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
The response containing the frontend URL of the site to be deployed and the deploy event ID if successful.
|
|
606
|
+
"""
|
|
607
|
+
# Check if the user is authenticated
|
|
608
|
+
if not (token := requires_access_token()):
|
|
609
|
+
raise Exception("not authenticated")
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
params = FrontendDeploymentsPostParam(
|
|
613
|
+
app_prefix=app_prefix,
|
|
614
|
+
key=key,
|
|
615
|
+
initiator_event_id=initiator_event_id,
|
|
616
|
+
frontend_hostname=frontend_hostname,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
with open(os.path.join(export_dir, frontend_file_name), "rb") as frontend_file:
|
|
620
|
+
# https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files
|
|
621
|
+
files = [
|
|
622
|
+
("files", (frontend_file_name, frontend_file)),
|
|
623
|
+
]
|
|
624
|
+
response = httpx.post(
|
|
625
|
+
POST_DEPLOYMENTS_ENDPOINT,
|
|
626
|
+
headers=authorization_header(token),
|
|
627
|
+
data=params.dict(exclude_none=True),
|
|
628
|
+
files=files,
|
|
629
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
630
|
+
)
|
|
631
|
+
# If the server explicitly states bad request,
|
|
632
|
+
# display a different error
|
|
633
|
+
if response.status_code == HTTPStatus.BAD_REQUEST:
|
|
634
|
+
raise AssertionError(f"Server rejected this request: {response.text}")
|
|
635
|
+
response.raise_for_status()
|
|
636
|
+
response_json = response.json()
|
|
637
|
+
return DeploymentPostResponse(
|
|
638
|
+
url=response_json["frontend_url"],
|
|
639
|
+
event_id=response_json["frontend_event_id"],
|
|
640
|
+
)
|
|
641
|
+
except OSError as oe:
|
|
642
|
+
console.error(f"Client side error related to file operation: {oe}")
|
|
643
|
+
raise
|
|
644
|
+
except httpx.RequestError as re:
|
|
645
|
+
console.error(f"Unable to deploy due to request error: {re}")
|
|
646
|
+
raise Exception("request error") from re
|
|
647
|
+
except httpx.HTTPError as he:
|
|
648
|
+
console.error(f"Unable to deploy due to {he}.")
|
|
649
|
+
raise Exception(str) from he
|
|
650
|
+
except json.JSONDecodeError as jde:
|
|
651
|
+
console.error(f"Server did not respond with valid json: {jde}")
|
|
652
|
+
raise Exception("internal errors") from jde
|
|
653
|
+
except (KeyError, ValidationError) as kve:
|
|
654
|
+
console.error(f"Post params or server response format unexpected: {kve}")
|
|
655
|
+
raise Exception("internal errors") from kve
|
|
656
|
+
except AssertionError as ve:
|
|
657
|
+
console.error(f"Unable to deploy due to request error: {ve}")
|
|
658
|
+
# re-raise the error back to the user as client side error
|
|
659
|
+
raise
|
|
660
|
+
except Exception as ex:
|
|
661
|
+
console.error(f"Unable to deploy due to internal errors: {ex}.")
|
|
662
|
+
raise Exception("internal errors") from ex
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class DeploymentsGetParam(BaseModel):
|
|
666
|
+
"""Params for hosted instance GET request."""
|
|
667
|
+
|
|
668
|
+
# The app name which is found in the config
|
|
669
|
+
app_name: Optional[str]
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def list_deployments(
|
|
673
|
+
app_name: str | None = None,
|
|
674
|
+
) -> list[dict]:
|
|
675
|
+
"""Send a GET request to Control Plane to list deployments.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
app_name: the app name as an optional filter when listing deployments.
|
|
679
|
+
|
|
680
|
+
Raises:
|
|
681
|
+
Exception: If the operation fails. The exception message shows the reason.
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
The list of deployments if successful, None otherwise.
|
|
685
|
+
"""
|
|
686
|
+
if not (token := requires_authenticated()):
|
|
687
|
+
raise Exception("not authenticated")
|
|
688
|
+
|
|
689
|
+
params = DeploymentsGetParam(app_name=app_name)
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
response = httpx.get(
|
|
693
|
+
GET_DEPLOYMENTS_ENDPOINT,
|
|
694
|
+
headers=authorization_header(token),
|
|
695
|
+
params=params.dict(exclude_none=True),
|
|
696
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
697
|
+
)
|
|
698
|
+
response.raise_for_status()
|
|
699
|
+
return response.json()
|
|
700
|
+
except httpx.RequestError as re:
|
|
701
|
+
console.error(f"Unable to list deployments due to request error: {re}")
|
|
702
|
+
raise Exception("request timeout") from re
|
|
703
|
+
except httpx.HTTPError as he:
|
|
704
|
+
console.error(f"Unable to list deployments due to {he}.")
|
|
705
|
+
raise Exception("internal errors") from he
|
|
706
|
+
except (ValidationError, KeyError, json.JSONDecodeError) as vkje:
|
|
707
|
+
console.error(f"Server response format unexpected: {vkje}")
|
|
708
|
+
raise Exception("internal errors") from vkje
|
|
709
|
+
except Exception as ex:
|
|
710
|
+
console.error(f"Unexpected error: {ex}.")
|
|
711
|
+
raise Exception("internal errors") from ex
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def fetch_token(request_id: str) -> tuple[str, str]:
|
|
715
|
+
"""Fetch the access token for the request_id from Control Plane.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
request_id: The request ID used when the user opens the browser for authentication.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
The access token if it exists, None otherwise.
|
|
722
|
+
"""
|
|
723
|
+
access_token = invitation_code = ""
|
|
724
|
+
try:
|
|
725
|
+
resp = httpx.get(
|
|
726
|
+
f"{FETCH_TOKEN_ENDPOINT}/{request_id}",
|
|
727
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
728
|
+
)
|
|
729
|
+
resp.raise_for_status()
|
|
730
|
+
access_token = (resp_json := resp.json()).get("access_token", "")
|
|
731
|
+
invitation_code = resp_json.get("code", "")
|
|
732
|
+
except httpx.RequestError as re:
|
|
733
|
+
console.debug(f"Unable to fetch token due to request error: {re}")
|
|
734
|
+
except httpx.HTTPError as he:
|
|
735
|
+
console.debug(f"Unable to fetch token due to {he}")
|
|
736
|
+
except json.JSONDecodeError as jde:
|
|
737
|
+
console.debug(f"Server did not respond with valid json: {jde}")
|
|
738
|
+
except KeyError as ke:
|
|
739
|
+
console.debug(f"Server response format unexpected: {ke}")
|
|
740
|
+
except Exception:
|
|
741
|
+
console.debug("Unexpected errors: {ex}")
|
|
742
|
+
|
|
743
|
+
return access_token, invitation_code
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def poll_backend_sidecar_with_retries(sidecar_url: str) -> bool:
|
|
747
|
+
"""Poll the backend sidecar with retries.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
sidecar_url: The URL of the backend sidecar.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Boolean indicating whether the backend sidecar is reachable.
|
|
754
|
+
"""
|
|
755
|
+
for _ in range(constants.Hosting.BACKEND_SIDECAR_WAIT_AND_PING_DURATION):
|
|
756
|
+
console.debug(f"Polling backend sidecar at {sidecar_url}")
|
|
757
|
+
try:
|
|
758
|
+
ping_response = httpx.get(f"{sidecar_url}", timeout=1)
|
|
759
|
+
ping_response.raise_for_status()
|
|
760
|
+
return True
|
|
761
|
+
|
|
762
|
+
except httpx.HTTPError as hpe:
|
|
763
|
+
console.debug(f"Backend sidecar responded with: {hpe}")
|
|
764
|
+
time.sleep(1)
|
|
765
|
+
console.debug(
|
|
766
|
+
f"After {constants.Hosting.BACKEND_SIDECAR_WAIT_AND_PING_DURATION} retries, backend sidecar is still not reachable. Likely the deployment was not successful."
|
|
767
|
+
)
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def check_stacktrace_in_log_lines(
|
|
772
|
+
log_json: list[dict], current_from_iso_timestamp: datetime
|
|
773
|
+
) -> tuple[int, datetime]:
|
|
774
|
+
"""Check if there is a stacktrace in the log lines.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
log_json: The log lines.
|
|
778
|
+
current_from_iso_timestamp: The `from` timestamp used to fetch log_json.
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
A tuple of integer and optional timestamp:
|
|
782
|
+
The index of the log line where the stacktrace starts if found, -1 otherwise;
|
|
783
|
+
And the `from` timestamp for the next log query.
|
|
784
|
+
"""
|
|
785
|
+
next_from_iso_timestamp = current_from_iso_timestamp
|
|
786
|
+
# The return is expected to be a list of dicts
|
|
787
|
+
for idx, row in enumerate(log_json):
|
|
788
|
+
if (message := row.get("message")) is None:
|
|
789
|
+
continue
|
|
790
|
+
message = message.lower()
|
|
791
|
+
if APP_LOG_STACKTRACE_WORD in message:
|
|
792
|
+
# we detect error and eagerly return to user
|
|
793
|
+
return idx, next_from_iso_timestamp
|
|
794
|
+
|
|
795
|
+
if (
|
|
796
|
+
maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"])
|
|
797
|
+
) is not None:
|
|
798
|
+
console.debug(
|
|
799
|
+
f"Updating from {next_from_iso_timestamp} to {maybe_timestamp}"
|
|
800
|
+
)
|
|
801
|
+
# Add a small delta so does not poll the same logs
|
|
802
|
+
next_from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5)
|
|
803
|
+
else:
|
|
804
|
+
console.warn(f"Unable to parse timestamp {row['timestamp']}")
|
|
805
|
+
|
|
806
|
+
return -1, next_from_iso_timestamp
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def poll_backend_ping_endpoint_check_app_stacktrace_with_retries(
|
|
810
|
+
key: str, backend_ping_url: str, from_iso_timestamp: datetime
|
|
811
|
+
) -> tuple[bool, str | None]:
|
|
812
|
+
"""Poll the backend ping endpoint and check app logs for stacktrace with retries.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
key: The name of the deployment.
|
|
816
|
+
backend_ping_url: The URL of the backend ping endpoint.
|
|
817
|
+
from_iso_timestamp: The `from` (oldest) timestamp to fetch the logs from.
|
|
818
|
+
|
|
819
|
+
Raises:
|
|
820
|
+
Exception: If not authenticated.
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
A tuple of boolean and optional string: indicating whether the backend is reachable and optionally the detailed error message if backend is not responsive.
|
|
824
|
+
"""
|
|
825
|
+
# For log query, we need to be authenticated
|
|
826
|
+
if not (token := requires_access_token()):
|
|
827
|
+
raise Exception("not authenticated")
|
|
828
|
+
|
|
829
|
+
stacktrace_start_index_in_logs = -1
|
|
830
|
+
ping_success = False
|
|
831
|
+
for _ in range(constants.Hosting.BACKEND_REFLEX_APP_PING_LOG_QUERY_RETRIES):
|
|
832
|
+
# If already pinged through, no need to ping again
|
|
833
|
+
if not ping_success:
|
|
834
|
+
console.debug(f"Polling backend ping endpoint at {backend_ping_url}")
|
|
835
|
+
try:
|
|
836
|
+
ping_response = httpx.get(f"{backend_ping_url}", timeout=1)
|
|
837
|
+
ping_response.raise_for_status()
|
|
838
|
+
ping_success = True
|
|
839
|
+
except httpx.HTTPError as hpe:
|
|
840
|
+
console.debug(f"Ping endpoint responded with {hpe}")
|
|
841
|
+
try:
|
|
842
|
+
log_response = httpx.post(
|
|
843
|
+
POST_DEPLOYMENT_LOGS_ENDPOINT,
|
|
844
|
+
json={
|
|
845
|
+
"key": key,
|
|
846
|
+
"log_type": LogType.APP_LOG.value,
|
|
847
|
+
"from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(),
|
|
848
|
+
},
|
|
849
|
+
headers=authorization_header(token),
|
|
850
|
+
timeout=LOG_QUERY_TIMEOUT,
|
|
851
|
+
)
|
|
852
|
+
log_response.raise_for_status()
|
|
853
|
+
log_json = log_response.json()
|
|
854
|
+
console.debug(f"log server responded with {log_json}")
|
|
855
|
+
|
|
856
|
+
(
|
|
857
|
+
stacktrace_start_index_in_logs,
|
|
858
|
+
from_iso_timestamp,
|
|
859
|
+
) = check_stacktrace_in_log_lines(
|
|
860
|
+
log_json=log_json, current_from_iso_timestamp=from_iso_timestamp
|
|
861
|
+
)
|
|
862
|
+
if stacktrace_start_index_in_logs >= 0:
|
|
863
|
+
# Format the tail bounded by a limit as multi-line string
|
|
864
|
+
# For each line, format is: "timestamp | message"
|
|
865
|
+
stacktrace_tail = [
|
|
866
|
+
" | ".join(
|
|
867
|
+
[
|
|
868
|
+
convert_to_local_time_str(row["timestamp"]),
|
|
869
|
+
row["message"],
|
|
870
|
+
]
|
|
871
|
+
)
|
|
872
|
+
for row in log_json[
|
|
873
|
+
stacktrace_start_index_in_logs : stacktrace_start_index_in_logs
|
|
874
|
+
+ APP_LOG_STACKTRACE_LINE_LIMIT
|
|
875
|
+
]
|
|
876
|
+
]
|
|
877
|
+
return False, "\n".join(stacktrace_tail)
|
|
878
|
+
except httpx.HTTPError as hpe:
|
|
879
|
+
console.debug(f"Log endpoint responded with: {hpe}")
|
|
880
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
881
|
+
console.debug("Unable to parse the returned logs")
|
|
882
|
+
time.sleep(1)
|
|
883
|
+
|
|
884
|
+
return ping_success, None
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def poll_backend_with_retries(
|
|
888
|
+
key: str,
|
|
889
|
+
from_iso_timestamp: datetime,
|
|
890
|
+
backend_url: str,
|
|
891
|
+
sidecar_url: str,
|
|
892
|
+
) -> tuple[bool, str | None]:
|
|
893
|
+
"""Poll the backend with retries. Backend of the reflex is deployed
|
|
894
|
+
behind a proxy with sidecar. The sidecar is designed to come up
|
|
895
|
+
and respond immediately to indicate the deployment is successful.
|
|
896
|
+
The backend of reflex app can take additional time. Based on this,
|
|
897
|
+
first poll the sidecar, then poll the backend while fetching the logs.
|
|
898
|
+
Early return these logs as part of the error message.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
key: the name of the deployment
|
|
902
|
+
from_iso_timestamp: the timestamp to fetch the logs from
|
|
903
|
+
backend_url: the URL of the backend of the deployed app
|
|
904
|
+
sidecar_url: the URL of the sidecar of the deployed app
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
A tuple of boolean and optional string: indicating whether the backend is reachable and optionally the detailed error message if backend is not responsive.
|
|
908
|
+
"""
|
|
909
|
+
# This backend poll happens after the milestone events indicate success
|
|
910
|
+
# If any errors, we expect those most probably are coming the user app
|
|
911
|
+
backend_ping_url = f"{backend_url}/ping"
|
|
912
|
+
|
|
913
|
+
with console.status("Checking backend ..."):
|
|
914
|
+
if not poll_backend_sidecar_with_retries(sidecar_url=sidecar_url):
|
|
915
|
+
return False, None
|
|
916
|
+
|
|
917
|
+
return poll_backend_ping_endpoint_check_app_stacktrace_with_retries(
|
|
918
|
+
key=key,
|
|
919
|
+
backend_ping_url=backend_ping_url,
|
|
920
|
+
from_iso_timestamp=from_iso_timestamp,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def poll_frontend_with_retries(frontend_url: str) -> bool:
|
|
925
|
+
"""Poll the frontend with retries to check if it is up.
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
frontend_url: The URL of the frontend to poll.
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
Boolean indicating whether the frontend is reachable.
|
|
932
|
+
"""
|
|
933
|
+
with console.status("Checking frontend ..."):
|
|
934
|
+
for _ in range(constants.Hosting.FRONTEND_POLL_DURATION):
|
|
935
|
+
try:
|
|
936
|
+
console.debug(f"Polling frontend at {frontend_url}")
|
|
937
|
+
resp = httpx.get(f"{frontend_url}", timeout=1)
|
|
938
|
+
resp.raise_for_status()
|
|
939
|
+
return True
|
|
940
|
+
except httpx.HTTPError:
|
|
941
|
+
time.sleep(1)
|
|
942
|
+
|
|
943
|
+
return False
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class DeploymentDeleteParam(BaseModel):
|
|
947
|
+
"""Params for hosted instance DELETE request."""
|
|
948
|
+
|
|
949
|
+
# key is the name of the deployment, it becomes part of the site URL
|
|
950
|
+
key: str
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def delete_deployment(key: str):
|
|
954
|
+
"""Send a DELETE request to Control Plane to delete a deployment.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
key: The deployment name.
|
|
958
|
+
|
|
959
|
+
Raises:
|
|
960
|
+
ValueError: If the key is not provided.
|
|
961
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
962
|
+
"""
|
|
963
|
+
if not (token := requires_authenticated()):
|
|
964
|
+
raise Exception("not authenticated")
|
|
965
|
+
if not key:
|
|
966
|
+
raise ValueError("Valid key is required for the delete.")
|
|
967
|
+
|
|
968
|
+
try:
|
|
969
|
+
response = httpx.delete(
|
|
970
|
+
f"{DELETE_DEPLOYMENTS_ENDPOINT}/{key}",
|
|
971
|
+
headers=authorization_header(token),
|
|
972
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
973
|
+
)
|
|
974
|
+
response.raise_for_status()
|
|
975
|
+
|
|
976
|
+
except httpx.TimeoutException as te:
|
|
977
|
+
console.error("Unable to delete deployment due to request timeout.")
|
|
978
|
+
raise Exception("request timeout") from te
|
|
979
|
+
except httpx.HTTPError as he:
|
|
980
|
+
console.error(f"Unable to delete deployment due to {he}.")
|
|
981
|
+
raise Exception("internal errors") from he
|
|
982
|
+
except Exception as ex:
|
|
983
|
+
console.error(f"Unexpected errors {ex}.")
|
|
984
|
+
raise Exception("internal errors") from ex
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
class SiteStatus(BaseModel):
|
|
988
|
+
"""Deployment status info."""
|
|
989
|
+
|
|
990
|
+
# The frontend URL
|
|
991
|
+
frontend_url: Optional[str] = None
|
|
992
|
+
# The backend URL
|
|
993
|
+
backend_url: Optional[str] = None
|
|
994
|
+
# Whether the frontend/backend URL is reachable
|
|
995
|
+
reachable: bool
|
|
996
|
+
# The last updated iso formatted timestamp if site is reachable
|
|
997
|
+
updated_at: Optional[str] = None
|
|
998
|
+
|
|
999
|
+
@root_validator(pre=True)
|
|
1000
|
+
def ensure_one_of_urls(cls, values):
|
|
1001
|
+
"""Ensure at least one of the frontend/backend URLs is provided.
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
values: The values passed in.
|
|
1005
|
+
|
|
1006
|
+
Raises:
|
|
1007
|
+
ValueError: If none of the URLs is provided.
|
|
1008
|
+
|
|
1009
|
+
Returns:
|
|
1010
|
+
The values passed in.
|
|
1011
|
+
"""
|
|
1012
|
+
if values.get("frontend_url") is None and values.get("backend_url") is None:
|
|
1013
|
+
raise ValueError("At least one of the URLs is required.")
|
|
1014
|
+
return values
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
class DeploymentStatusResponse(BaseModel):
|
|
1018
|
+
"""Response for deployment status request."""
|
|
1019
|
+
|
|
1020
|
+
# The frontend status
|
|
1021
|
+
frontend: SiteStatus
|
|
1022
|
+
# The backend status
|
|
1023
|
+
backend: SiteStatus
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def get_deployment_status(key: str) -> DeploymentStatusResponse:
|
|
1027
|
+
"""Get the deployment status.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
key: The deployment name.
|
|
1031
|
+
|
|
1032
|
+
Raises:
|
|
1033
|
+
ValueError: If the key is not provided.
|
|
1034
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
The deployment status response including backend and frontend.
|
|
1038
|
+
"""
|
|
1039
|
+
if not key:
|
|
1040
|
+
raise ValueError(
|
|
1041
|
+
"A non empty key is required for querying the deployment status."
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
if not (token := requires_authenticated()):
|
|
1045
|
+
raise Exception("not authenticated")
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
response = httpx.get(
|
|
1049
|
+
f"{GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status",
|
|
1050
|
+
headers=authorization_header(token),
|
|
1051
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
1052
|
+
)
|
|
1053
|
+
response.raise_for_status()
|
|
1054
|
+
response_json = response.json()
|
|
1055
|
+
return DeploymentStatusResponse(
|
|
1056
|
+
frontend=SiteStatus(
|
|
1057
|
+
frontend_url=response_json["frontend"]["url"],
|
|
1058
|
+
reachable=response_json["frontend"]["reachable"],
|
|
1059
|
+
updated_at=response_json["frontend"]["updated_at"],
|
|
1060
|
+
),
|
|
1061
|
+
backend=SiteStatus(
|
|
1062
|
+
backend_url=response_json["backend"]["url"],
|
|
1063
|
+
reachable=response_json["backend"]["reachable"],
|
|
1064
|
+
updated_at=response_json["backend"]["updated_at"],
|
|
1065
|
+
),
|
|
1066
|
+
)
|
|
1067
|
+
except Exception as ex:
|
|
1068
|
+
console.error(f"Unable to get deployment status due to {ex}.")
|
|
1069
|
+
raise Exception("internal errors") from ex
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def convert_to_local_time_with_tz(iso_timestamp: str) -> datetime | None:
|
|
1073
|
+
"""Helper function to convert the iso timestamp to local time.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
iso_timestamp: The iso timestamp to convert.
|
|
1077
|
+
|
|
1078
|
+
Returns:
|
|
1079
|
+
The converted timestamp with timezone.
|
|
1080
|
+
"""
|
|
1081
|
+
try:
|
|
1082
|
+
return dateutil.parser.isoparse(iso_timestamp).astimezone()
|
|
1083
|
+
except (TypeError, ValueError) as ex:
|
|
1084
|
+
# In certain scenarios such as the app status cannot be
|
|
1085
|
+
# fetched, it is expected the server returns None,
|
|
1086
|
+
# the conversion will fail but should not print error here.
|
|
1087
|
+
console.debug(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.")
|
|
1088
|
+
return None
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def convert_to_local_time_str(iso_timestamp: str) -> str:
|
|
1092
|
+
"""Convert the iso timestamp to local time.
|
|
1093
|
+
|
|
1094
|
+
Args:
|
|
1095
|
+
iso_timestamp: The iso timestamp to convert.
|
|
1096
|
+
|
|
1097
|
+
Returns:
|
|
1098
|
+
The converted timestamp string.
|
|
1099
|
+
"""
|
|
1100
|
+
if (local_dt := convert_to_local_time_with_tz(iso_timestamp)) is None:
|
|
1101
|
+
return iso_timestamp
|
|
1102
|
+
return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z")
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
class LogType(str, enum.Enum):
|
|
1106
|
+
"""Enum for log types."""
|
|
1107
|
+
|
|
1108
|
+
# Logs printed from the user code, the "app"
|
|
1109
|
+
APP_LOG = "app"
|
|
1110
|
+
# Build logs are the server messages while building/running user deployment
|
|
1111
|
+
BUILD_LOG = "build"
|
|
1112
|
+
# Deploy logs are specifically for the messages at deploy time
|
|
1113
|
+
# returned to the user the current stage of the deployment, such as building, uploading.
|
|
1114
|
+
DEPLOY_LOG = "deploy"
|
|
1115
|
+
# All the logs which can be printed by all above types.
|
|
1116
|
+
ALL_LOG = "all"
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
async def stream_logs(
|
|
1120
|
+
key: str,
|
|
1121
|
+
log_type: LogType = LogType.APP_LOG,
|
|
1122
|
+
from_iso_timestamp: str | None = None,
|
|
1123
|
+
):
|
|
1124
|
+
"""Get the deployment logs and stream on console.
|
|
1125
|
+
|
|
1126
|
+
Args:
|
|
1127
|
+
key: The deployment name.
|
|
1128
|
+
log_type: The type of logs to query from server.
|
|
1129
|
+
See the LogType definitions for how they are used.
|
|
1130
|
+
from_iso_timestamp: An optional timestamp with timezone info to limit
|
|
1131
|
+
where the log queries should start from.
|
|
1132
|
+
|
|
1133
|
+
Raises:
|
|
1134
|
+
ValueError: If the key is not provided.
|
|
1135
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
1136
|
+
|
|
1137
|
+
"""
|
|
1138
|
+
if not (token := requires_authenticated()):
|
|
1139
|
+
raise Exception("not authenticated")
|
|
1140
|
+
if not key:
|
|
1141
|
+
raise ValueError("Valid key is required for querying logs.")
|
|
1142
|
+
try:
|
|
1143
|
+
logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}"
|
|
1144
|
+
console.debug(f"log server endpoint: {logs_endpoint}")
|
|
1145
|
+
if from_iso_timestamp is not None:
|
|
1146
|
+
logs_endpoint += f"&from_iso_timestamp={from_iso_timestamp}"
|
|
1147
|
+
_ws = websockets.connect(logs_endpoint) # type: ignore
|
|
1148
|
+
async with _ws as ws:
|
|
1149
|
+
while True:
|
|
1150
|
+
row_json = json.loads(await ws.recv())
|
|
1151
|
+
console.debug(f"Server responded with logs: {row_json}")
|
|
1152
|
+
if row_json and isinstance(row_json, dict):
|
|
1153
|
+
row_to_print = {}
|
|
1154
|
+
for k, v in row_json.items():
|
|
1155
|
+
if v is None:
|
|
1156
|
+
row_to_print[k] = str(v)
|
|
1157
|
+
elif k == "timestamp":
|
|
1158
|
+
row_to_print[k] = convert_to_local_time_str(v)
|
|
1159
|
+
else:
|
|
1160
|
+
row_to_print[k] = v
|
|
1161
|
+
print(" | ".join(row_to_print.values()))
|
|
1162
|
+
else:
|
|
1163
|
+
console.debug("Server responded, no new logs, this is normal")
|
|
1164
|
+
except Exception as ex:
|
|
1165
|
+
console.debug(f"Unable to get more deployment logs due to {ex}.")
|
|
1166
|
+
console.print("Log server disconnected ...")
|
|
1167
|
+
console.print(
|
|
1168
|
+
"Note that the server has limit to only stream logs for several minutes"
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def get_logs(
|
|
1173
|
+
key: str,
|
|
1174
|
+
log_type: LogType = LogType.APP_LOG,
|
|
1175
|
+
from_iso_timestamp: str | None = None,
|
|
1176
|
+
to_iso_timestamp: str | None = None,
|
|
1177
|
+
limit: int | None = None,
|
|
1178
|
+
):
|
|
1179
|
+
"""Get the deployment logs and print on console. This is roughly equivalent to `stream_logs` but return logs in one shot.
|
|
1180
|
+
|
|
1181
|
+
Args:
|
|
1182
|
+
key: The deployment name.
|
|
1183
|
+
log_type: The type of logs to query from server.
|
|
1184
|
+
See the LogType definitions for how they are used.
|
|
1185
|
+
from_iso_timestamp: An optional timestamp with timezone info to limit
|
|
1186
|
+
where the log queries should start from.
|
|
1187
|
+
to_iso_timestamp: An optional timestamp with timezone info to limit
|
|
1188
|
+
where the log queries should end at.
|
|
1189
|
+
limit: An optional limit to the number of logs to query.
|
|
1190
|
+
|
|
1191
|
+
Raises:
|
|
1192
|
+
ValueError: If the key is not provided.
|
|
1193
|
+
Exception: If the operation fails. The exception message is the reason.
|
|
1194
|
+
|
|
1195
|
+
"""
|
|
1196
|
+
if not (token := requires_authenticated()):
|
|
1197
|
+
raise Exception("not authenticated")
|
|
1198
|
+
if not key:
|
|
1199
|
+
raise ValueError("Valid key is required for querying logs.")
|
|
1200
|
+
try:
|
|
1201
|
+
params_json = {
|
|
1202
|
+
"key": key,
|
|
1203
|
+
"log_type": log_type.value,
|
|
1204
|
+
"from_iso_timestamp": from_iso_timestamp,
|
|
1205
|
+
"to_iso_timestamp": to_iso_timestamp,
|
|
1206
|
+
"lookback_limit": limit,
|
|
1207
|
+
}
|
|
1208
|
+
console.debug(f"log server params: {params_json}")
|
|
1209
|
+
log_response = httpx.post(
|
|
1210
|
+
POST_DEPLOYMENT_LOGS_ENDPOINT,
|
|
1211
|
+
json=params_json,
|
|
1212
|
+
headers=authorization_header(token),
|
|
1213
|
+
timeout=LOG_QUERY_TIMEOUT,
|
|
1214
|
+
)
|
|
1215
|
+
log_response.raise_for_status()
|
|
1216
|
+
log_json = log_response.json()
|
|
1217
|
+
console.debug(f"log server responded with {log_json}")
|
|
1218
|
+
for row_json in log_json:
|
|
1219
|
+
row_to_print = {}
|
|
1220
|
+
for k, v in row_json.items():
|
|
1221
|
+
if v is None:
|
|
1222
|
+
row_to_print[k] = str(v)
|
|
1223
|
+
elif k == "timestamp":
|
|
1224
|
+
row_to_print[k] = convert_to_local_time_str(v)
|
|
1225
|
+
else:
|
|
1226
|
+
row_to_print[k] = v
|
|
1227
|
+
print(" | ".join(row_to_print.values()))
|
|
1228
|
+
|
|
1229
|
+
except httpx.HTTPError as he:
|
|
1230
|
+
console.debug(f"Unable to get more deployment logs due to {he}.")
|
|
1231
|
+
except (AttributeError, TypeError, ValueError) as ex:
|
|
1232
|
+
console.error(f"Unable to process the server response: {ex}.")
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def check_requirements_txt_exist() -> bool:
|
|
1236
|
+
"""Check if requirements.txt exists in the top level app directory.
|
|
1237
|
+
|
|
1238
|
+
Returns:
|
|
1239
|
+
True if requirements.txt exists, False otherwise.
|
|
1240
|
+
"""
|
|
1241
|
+
return os.path.exists(constants.RequirementsTxt.FILE)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def check_requirements_for_non_reflex_packages() -> bool:
|
|
1245
|
+
"""Check the requirements.txt file for packages other than reflex.
|
|
1246
|
+
|
|
1247
|
+
Returns:
|
|
1248
|
+
True if packages other than reflex are found, False otherwise.
|
|
1249
|
+
"""
|
|
1250
|
+
if not check_requirements_txt_exist():
|
|
1251
|
+
return False
|
|
1252
|
+
try:
|
|
1253
|
+
with open(constants.RequirementsTxt.FILE) as fp:
|
|
1254
|
+
for req_line in fp.readlines():
|
|
1255
|
+
package_name = re.search(r"^([^=<>!~]+)", req_line.lstrip())
|
|
1256
|
+
# If we find a package that is not reflex
|
|
1257
|
+
if (
|
|
1258
|
+
package_name
|
|
1259
|
+
and package_name.group(1) != constants.Reflex.MODULE_NAME
|
|
1260
|
+
):
|
|
1261
|
+
return True
|
|
1262
|
+
except Exception as ex:
|
|
1263
|
+
console.warn(f"Unable to scan requirements.txt for dependencies due to {ex}")
|
|
1264
|
+
|
|
1265
|
+
return False
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def authenticate_on_browser(invitation_code: str) -> str:
|
|
1269
|
+
"""Open the browser to authenticate the user.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
invitation_code: The invitation code if it exists.
|
|
1273
|
+
|
|
1274
|
+
Returns:
|
|
1275
|
+
The access token if valid, empty otherwise.
|
|
1276
|
+
"""
|
|
1277
|
+
console.print(f"Opening {constants.Hosting.CP_WEB_URL} ...")
|
|
1278
|
+
request_id = uuid.uuid4().hex
|
|
1279
|
+
auth_url = (
|
|
1280
|
+
f"{constants.Hosting.CP_WEB_URL}?request-id={request_id}&code={invitation_code}"
|
|
1281
|
+
)
|
|
1282
|
+
if not webbrowser.open(auth_url):
|
|
1283
|
+
console.warn(
|
|
1284
|
+
f"Unable to automatically open the browser. Please go to {auth_url} to authenticate."
|
|
1285
|
+
)
|
|
1286
|
+
access_token = invitation_code = ""
|
|
1287
|
+
with console.status("Waiting for access token ..."):
|
|
1288
|
+
for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
|
|
1289
|
+
access_token, invitation_code = fetch_token(request_id)
|
|
1290
|
+
if access_token:
|
|
1291
|
+
break
|
|
1292
|
+
else:
|
|
1293
|
+
time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
|
|
1294
|
+
|
|
1295
|
+
if access_token and validate_token_with_retries(access_token):
|
|
1296
|
+
save_token_to_config(access_token, invitation_code)
|
|
1297
|
+
else:
|
|
1298
|
+
access_token = ""
|
|
1299
|
+
return access_token
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def validate_token_with_retries(access_token: str) -> bool:
|
|
1303
|
+
"""Validate the access token with retries.
|
|
1304
|
+
|
|
1305
|
+
Args:
|
|
1306
|
+
access_token: The access token to validate.
|
|
1307
|
+
|
|
1308
|
+
Returns:
|
|
1309
|
+
True if the token is valid,
|
|
1310
|
+
False if invalid or unable to validate.
|
|
1311
|
+
"""
|
|
1312
|
+
with console.status("Validating access token ..."):
|
|
1313
|
+
for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
|
|
1314
|
+
try:
|
|
1315
|
+
validate_token(access_token)
|
|
1316
|
+
return True
|
|
1317
|
+
except ValueError:
|
|
1318
|
+
console.error(f"Access denied")
|
|
1319
|
+
delete_token_from_config()
|
|
1320
|
+
break
|
|
1321
|
+
except Exception as ex:
|
|
1322
|
+
console.debug(f"Unable to validate token due to: {ex}, trying again")
|
|
1323
|
+
time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
|
|
1324
|
+
return False
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def is_valid_deployment_key(key: str):
|
|
1328
|
+
"""Helper function to check if the deployment key is valid. Must be a domain name safe string.
|
|
1329
|
+
|
|
1330
|
+
Args:
|
|
1331
|
+
key: The deployment key to check.
|
|
1332
|
+
|
|
1333
|
+
Returns:
|
|
1334
|
+
True if the key contains only domain name safe characters, False otherwise.
|
|
1335
|
+
"""
|
|
1336
|
+
return re.match(r"^[a-zA-Z0-9-]*$", key)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def interactive_get_deployment_key_from_user_input(
|
|
1340
|
+
pre_deploy_response: DeploymentPrepareResponse,
|
|
1341
|
+
app_name: str,
|
|
1342
|
+
frontend_hostname: str | None = None,
|
|
1343
|
+
) -> tuple[str, str, str]:
|
|
1344
|
+
"""Interactive get the deployment key from user input.
|
|
1345
|
+
|
|
1346
|
+
Args:
|
|
1347
|
+
pre_deploy_response: The response from the initial prepare call to server.
|
|
1348
|
+
app_name: The app name.
|
|
1349
|
+
frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
|
|
1350
|
+
|
|
1351
|
+
Returns:
|
|
1352
|
+
The deployment key, backend URL, frontend URL.
|
|
1353
|
+
"""
|
|
1354
|
+
key_candidate = api_url = deploy_url = ""
|
|
1355
|
+
if reply := pre_deploy_response.reply:
|
|
1356
|
+
api_url = reply.api_url
|
|
1357
|
+
deploy_url = reply.deploy_url
|
|
1358
|
+
key_candidate = reply.key
|
|
1359
|
+
elif pre_deploy_response.existing:
|
|
1360
|
+
# validator already checks existing field is not empty list
|
|
1361
|
+
# Note: keeping this simple as we only allow one deployment per app
|
|
1362
|
+
existing = pre_deploy_response.existing[0]
|
|
1363
|
+
console.print(f"Overwrite deployment [ {existing.key} ] ...")
|
|
1364
|
+
key_candidate = existing.key
|
|
1365
|
+
api_url = existing.api_url
|
|
1366
|
+
deploy_url = existing.deploy_url
|
|
1367
|
+
elif suggestion := pre_deploy_response.suggestion:
|
|
1368
|
+
key_candidate = suggestion.key
|
|
1369
|
+
api_url = suggestion.api_url
|
|
1370
|
+
deploy_url = suggestion.deploy_url
|
|
1371
|
+
|
|
1372
|
+
# If user takes the suggestion, we will use the suggested key and proceed
|
|
1373
|
+
while key_input := console.ask(
|
|
1374
|
+
f"Choose a name for your deployed app (https://<picked-name>.reflex.run)\nEnter to use default.",
|
|
1375
|
+
default=key_candidate,
|
|
1376
|
+
):
|
|
1377
|
+
if not is_valid_deployment_key(key_input):
|
|
1378
|
+
console.error(
|
|
1379
|
+
"Invalid key input, should only contain domain name safe characters: letters, digits, or hyphens."
|
|
1380
|
+
)
|
|
1381
|
+
continue
|
|
1382
|
+
|
|
1383
|
+
elif any(x.isupper() for x in key_input):
|
|
1384
|
+
key_input = key_input.lower()
|
|
1385
|
+
console.info(
|
|
1386
|
+
f"Domain name is case insensitive, automatically converting to all lower cases: {key_input}"
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
pre_deploy_response = prepare_deploy(
|
|
1391
|
+
app_name,
|
|
1392
|
+
key=key_input,
|
|
1393
|
+
frontend_hostname=frontend_hostname,
|
|
1394
|
+
)
|
|
1395
|
+
if (
|
|
1396
|
+
pre_deploy_response.reply is None
|
|
1397
|
+
or key_input != pre_deploy_response.reply.key
|
|
1398
|
+
):
|
|
1399
|
+
# Rejected by server, try again
|
|
1400
|
+
continue
|
|
1401
|
+
key_candidate = pre_deploy_response.reply.key
|
|
1402
|
+
api_url = pre_deploy_response.reply.api_url
|
|
1403
|
+
deploy_url = pre_deploy_response.reply.deploy_url
|
|
1404
|
+
# we get the confirmation, so break from the loop
|
|
1405
|
+
break
|
|
1406
|
+
except Exception:
|
|
1407
|
+
console.error(
|
|
1408
|
+
"Cannot deploy at this name, try picking a different name"
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
return key_candidate, api_url, deploy_url
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def process_envs(envs: list[str]) -> dict[str, str]:
|
|
1415
|
+
"""Process the environment variables.
|
|
1416
|
+
|
|
1417
|
+
Args:
|
|
1418
|
+
envs: The environment variables expected in key=value format.
|
|
1419
|
+
|
|
1420
|
+
Raises:
|
|
1421
|
+
SystemExit: If the envs are not in valid format.
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
The processed environment variables in a dict.
|
|
1425
|
+
"""
|
|
1426
|
+
processed_envs = {}
|
|
1427
|
+
for env in envs:
|
|
1428
|
+
kv = env.split("=", maxsplit=1)
|
|
1429
|
+
if len(kv) != 2:
|
|
1430
|
+
raise SystemExit("Invalid env format: should be <key>=<value>.")
|
|
1431
|
+
|
|
1432
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", kv[0]):
|
|
1433
|
+
raise SystemExit(
|
|
1434
|
+
"Invalid env name: should start with a letter or underscore, followed by letters, digits, or underscores."
|
|
1435
|
+
)
|
|
1436
|
+
processed_envs[kv[0]] = kv[1]
|
|
1437
|
+
return processed_envs
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def log_out_on_browser():
|
|
1441
|
+
"""Open the browser to authenticate the user."""
|
|
1442
|
+
# Fetching existing invitation code so user sees the log out page without having to enter it
|
|
1443
|
+
invitation_code = None
|
|
1444
|
+
with contextlib.suppress(Exception):
|
|
1445
|
+
_, invitation_code = get_existing_access_token()
|
|
1446
|
+
console.debug("Found existing invitation code in config")
|
|
1447
|
+
delete_token_from_config()
|
|
1448
|
+
console.print(f"Opening {constants.Hosting.CP_WEB_URL} ...")
|
|
1449
|
+
if not webbrowser.open(f"{constants.Hosting.CP_WEB_URL}?code={invitation_code}"):
|
|
1450
|
+
console.warn(
|
|
1451
|
+
f"Unable to open the browser automatically. Please go to {constants.Hosting.CP_WEB_URL} to log out."
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def poll_deploy_milestones(
|
|
1456
|
+
key: str, from_iso_timestamp: datetime, deploy_event_ids: list[int]
|
|
1457
|
+
) -> bool | None:
|
|
1458
|
+
"""Periodically poll the hosting server for deploy milestones.
|
|
1459
|
+
|
|
1460
|
+
Args:
|
|
1461
|
+
key: The deployment key.
|
|
1462
|
+
from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
|
|
1463
|
+
deploy_event_ids: The list of deploy event IDs to query.
|
|
1464
|
+
|
|
1465
|
+
Raises:
|
|
1466
|
+
ValueError: If a non-empty key is not provided.
|
|
1467
|
+
Exception: If the user is not authenticated.
|
|
1468
|
+
|
|
1469
|
+
Returns:
|
|
1470
|
+
False if server reports back failure, True otherwise. None if do not receive the end of deployment message.
|
|
1471
|
+
"""
|
|
1472
|
+
if not key:
|
|
1473
|
+
raise ValueError("Non-empty key is required for querying deploy status.")
|
|
1474
|
+
if not (token := requires_authenticated()):
|
|
1475
|
+
raise Exception("not authenticated")
|
|
1476
|
+
|
|
1477
|
+
expected_success_messages_count = len(END_OF_DEPLOYMENT_MESSAGES)
|
|
1478
|
+
success_messages = set()
|
|
1479
|
+
for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
|
|
1480
|
+
try:
|
|
1481
|
+
response = httpx.post(
|
|
1482
|
+
POST_DEPLOYMENT_LOGS_ENDPOINT,
|
|
1483
|
+
json={
|
|
1484
|
+
"key": key,
|
|
1485
|
+
"log_type": LogType.DEPLOY_LOG.value,
|
|
1486
|
+
"from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(),
|
|
1487
|
+
"deploy_event_ids": deploy_event_ids,
|
|
1488
|
+
},
|
|
1489
|
+
headers=authorization_header(token),
|
|
1490
|
+
)
|
|
1491
|
+
response.raise_for_status()
|
|
1492
|
+
response_json = response.json()
|
|
1493
|
+
# The return is expected to be a list of dicts
|
|
1494
|
+
for row in response_json:
|
|
1495
|
+
console.print(
|
|
1496
|
+
" | ".join(
|
|
1497
|
+
[
|
|
1498
|
+
local_time_str := convert_to_local_time_str(
|
|
1499
|
+
row["timestamp"]
|
|
1500
|
+
),
|
|
1501
|
+
row["message"],
|
|
1502
|
+
]
|
|
1503
|
+
)
|
|
1504
|
+
)
|
|
1505
|
+
# If any details returned in the milestone,
|
|
1506
|
+
# print them here without the timestamp,
|
|
1507
|
+
# with the intention to make it look like a sub level message
|
|
1508
|
+
if message_details := row.get("details"):
|
|
1509
|
+
for det in message_details.splitlines():
|
|
1510
|
+
console.print(
|
|
1511
|
+
" | ".join(
|
|
1512
|
+
[
|
|
1513
|
+
# we print the same number of spaces as the timestamp instead
|
|
1514
|
+
# depending on the font, this might not create a consistently
|
|
1515
|
+
# aligned indentation on user terminal
|
|
1516
|
+
" " * len(local_time_str),
|
|
1517
|
+
det,
|
|
1518
|
+
]
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
# Update the `from`` timestamp to the last timestamp of received message
|
|
1522
|
+
if (
|
|
1523
|
+
maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"])
|
|
1524
|
+
) is not None:
|
|
1525
|
+
console.debug(
|
|
1526
|
+
f"Updating from {from_iso_timestamp} to {maybe_timestamp}"
|
|
1527
|
+
)
|
|
1528
|
+
# Add a small delta so does not poll the same logs
|
|
1529
|
+
from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5)
|
|
1530
|
+
else:
|
|
1531
|
+
console.warn(f"Unable to parse timestamp {row['timestamp']}")
|
|
1532
|
+
server_message = row["message"].lower()
|
|
1533
|
+
if "fail" in server_message:
|
|
1534
|
+
console.debug(
|
|
1535
|
+
"Received failure message, stop event message streaming"
|
|
1536
|
+
)
|
|
1537
|
+
return False
|
|
1538
|
+
if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
|
|
1539
|
+
success_messages.add(server_message)
|
|
1540
|
+
if len(success_messages) == expected_success_messages_count:
|
|
1541
|
+
console.debug(
|
|
1542
|
+
"Received end of deployment message, stop event message streaming"
|
|
1543
|
+
)
|
|
1544
|
+
return True
|
|
1545
|
+
time.sleep(1)
|
|
1546
|
+
except httpx.HTTPError as he:
|
|
1547
|
+
# This includes HTTP server and client error
|
|
1548
|
+
console.debug(f"Unable to get more deployment events due to {he}.")
|
|
1549
|
+
except Exception as ex:
|
|
1550
|
+
console.warn(f"Unable to parse server response due to {ex}.")
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool:
|
|
1554
|
+
"""Display the deploy milestone messages reported back from the hosting server.
|
|
1555
|
+
|
|
1556
|
+
Args:
|
|
1557
|
+
key: The deployment key.
|
|
1558
|
+
from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
|
|
1559
|
+
|
|
1560
|
+
Raises:
|
|
1561
|
+
ValueError: If a non-empty key is not provided.
|
|
1562
|
+
Exception: If the user is not authenticated.
|
|
1563
|
+
|
|
1564
|
+
Returns:
|
|
1565
|
+
False if server reports back failure, True otherwise.
|
|
1566
|
+
"""
|
|
1567
|
+
if not key:
|
|
1568
|
+
raise ValueError("Non-empty key is required for querying deploy status.")
|
|
1569
|
+
if not (token := requires_authenticated()):
|
|
1570
|
+
raise Exception("not authenticated")
|
|
1571
|
+
|
|
1572
|
+
try:
|
|
1573
|
+
logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={LogType.DEPLOY_LOG.value}&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}"
|
|
1574
|
+
console.debug(f"log server endpoint: {logs_endpoint}")
|
|
1575
|
+
_ws = websockets.connect(logs_endpoint) # type: ignore
|
|
1576
|
+
expected_success_messages_count = len(END_OF_DEPLOYMENT_MESSAGES)
|
|
1577
|
+
success_messages = set()
|
|
1578
|
+
async with _ws as ws:
|
|
1579
|
+
# Stream back the deploy events reported back from the server
|
|
1580
|
+
for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
|
|
1581
|
+
row_json = json.loads(await ws.recv())
|
|
1582
|
+
console.debug(f"Server responded with: {row_json}")
|
|
1583
|
+
if row_json and isinstance(row_json, dict):
|
|
1584
|
+
# Only show the timestamp and actual message
|
|
1585
|
+
console.print(
|
|
1586
|
+
" | ".join(
|
|
1587
|
+
[
|
|
1588
|
+
convert_to_local_time_str(row_json["timestamp"]),
|
|
1589
|
+
row_json["message"],
|
|
1590
|
+
]
|
|
1591
|
+
)
|
|
1592
|
+
)
|
|
1593
|
+
server_message = row_json["message"].lower()
|
|
1594
|
+
if "fail" in server_message:
|
|
1595
|
+
console.debug(
|
|
1596
|
+
"Received failure message, stop event message streaming"
|
|
1597
|
+
)
|
|
1598
|
+
return False
|
|
1599
|
+
if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
|
|
1600
|
+
success_messages.add(server_message)
|
|
1601
|
+
if len(success_messages) == expected_success_messages_count:
|
|
1602
|
+
console.debug(
|
|
1603
|
+
"Received end of deployment message, stop event message streaming"
|
|
1604
|
+
)
|
|
1605
|
+
return True
|
|
1606
|
+
else:
|
|
1607
|
+
console.debug("Server responded, no new events yet, this is normal")
|
|
1608
|
+
except Exception as ex:
|
|
1609
|
+
console.debug(f"Unable to get more deployment events due to {ex}.")
|
|
1610
|
+
return False
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def wait_for_server_to_pick_up_request():
|
|
1614
|
+
"""Wait for server to pick up the request. Right now is just sleep."""
|
|
1615
|
+
with console.status(
|
|
1616
|
+
f"Waiting for server to pick up request ~ {DEPLOYMENT_PICKUP_DELAY} seconds ..."
|
|
1617
|
+
):
|
|
1618
|
+
for _ in range(DEPLOYMENT_PICKUP_DELAY):
|
|
1619
|
+
time.sleep(1)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def interactive_prompt_for_envs() -> list[str]:
|
|
1623
|
+
"""Interactive prompt for environment variables.
|
|
1624
|
+
|
|
1625
|
+
Returns:
|
|
1626
|
+
The list of environment variables in key=value string format.
|
|
1627
|
+
"""
|
|
1628
|
+
envs = []
|
|
1629
|
+
envs_finished = False
|
|
1630
|
+
env_count = 1
|
|
1631
|
+
env_key_prompt = f" * env-{env_count} name (enter to skip)"
|
|
1632
|
+
console.print("Environment variables for your production App ...")
|
|
1633
|
+
while not envs_finished:
|
|
1634
|
+
env_key = console.ask(env_key_prompt)
|
|
1635
|
+
if not env_key:
|
|
1636
|
+
envs_finished = True
|
|
1637
|
+
if envs:
|
|
1638
|
+
console.print("Finished adding envs.")
|
|
1639
|
+
else:
|
|
1640
|
+
console.print("No envs added. Continuing ...")
|
|
1641
|
+
break
|
|
1642
|
+
# If it possible to have empty values for env, so we do not check here
|
|
1643
|
+
env_value = console.ask(f" env-{env_count} value")
|
|
1644
|
+
envs.append(f"{env_key}={env_value}")
|
|
1645
|
+
env_count += 1
|
|
1646
|
+
env_key_prompt = f" * env-{env_count} name (enter to skip)"
|
|
1647
|
+
return envs
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def get_regions() -> list[dict]:
|
|
1651
|
+
"""Get the supported regions from the hosting server.
|
|
1652
|
+
|
|
1653
|
+
Returns:
|
|
1654
|
+
A list of dict representation of the region information.
|
|
1655
|
+
"""
|
|
1656
|
+
try:
|
|
1657
|
+
response = httpx.get(
|
|
1658
|
+
GET_REGIONS_ENDPOINT,
|
|
1659
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
1660
|
+
)
|
|
1661
|
+
response.raise_for_status()
|
|
1662
|
+
response_json = response.json()
|
|
1663
|
+
if response_json is None or not isinstance(response_json, list):
|
|
1664
|
+
console.error("Expect server to return a list ")
|
|
1665
|
+
return []
|
|
1666
|
+
if (
|
|
1667
|
+
response_json
|
|
1668
|
+
and response_json[0] is not None
|
|
1669
|
+
and not isinstance(response_json[0], dict)
|
|
1670
|
+
):
|
|
1671
|
+
console.error("Expect return values are dict's")
|
|
1672
|
+
return []
|
|
1673
|
+
return response_json
|
|
1674
|
+
except Exception as ex:
|
|
1675
|
+
console.error(f"Unable to get regions due to {ex}.")
|
|
1676
|
+
return []
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def prompt_for_regions(
|
|
1680
|
+
enabled_regions: list[str] | None = None,
|
|
1681
|
+
regions_args: list[str] | None = None,
|
|
1682
|
+
) -> list[str]:
|
|
1683
|
+
"""Prompt for user to enter the regions to deploy to.
|
|
1684
|
+
|
|
1685
|
+
Args:
|
|
1686
|
+
enabled_regions: The list of enabled regions.
|
|
1687
|
+
regions_args: The regions passed in as command line arguments.
|
|
1688
|
+
|
|
1689
|
+
Returns:
|
|
1690
|
+
The list of regions to deploy to as entered by the user.
|
|
1691
|
+
"""
|
|
1692
|
+
regions = regions_args or []
|
|
1693
|
+
# Then CP needs to know the user's location, which requires user permission
|
|
1694
|
+
while True:
|
|
1695
|
+
region_input = console.ask(
|
|
1696
|
+
"Region to deploy to. See regions: https://bit.ly/46Qr3mF\nEnter to use default.",
|
|
1697
|
+
default=regions[0] if regions else "sjc",
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
if enabled_regions is None or region_input in enabled_regions:
|
|
1701
|
+
break
|
|
1702
|
+
else:
|
|
1703
|
+
console.warn(
|
|
1704
|
+
f"{region_input} is not a valid region. Must be one of {enabled_regions}"
|
|
1705
|
+
)
|
|
1706
|
+
console.warn("Run `reflex deployments regions` to see details.")
|
|
1707
|
+
return regions or [region_input]
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
UNIT_TO_KWARGS = {
|
|
1711
|
+
"d": "days",
|
|
1712
|
+
"h": "hours",
|
|
1713
|
+
"m": "minutes",
|
|
1714
|
+
"s": "seconds",
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def process_user_entered_timestamp(
|
|
1719
|
+
timestamp: str, reference: datetime = datetime.now().astimezone()
|
|
1720
|
+
) -> str | None:
|
|
1721
|
+
"""Process the user entered timestamp.
|
|
1722
|
+
|
|
1723
|
+
Args:
|
|
1724
|
+
timestamp: The timestamp string entered by the user.
|
|
1725
|
+
reference: The reference timestamp to calculate the returned timestamp from.
|
|
1726
|
+
|
|
1727
|
+
Returns:
|
|
1728
|
+
The processed timestamp string in ISO 8601 format. If unable to process, return None.
|
|
1729
|
+
"""
|
|
1730
|
+
if not timestamp:
|
|
1731
|
+
return None
|
|
1732
|
+
|
|
1733
|
+
# Check if the timestamp is in the format of <number><unit>, such as 1d, 2h, 3m, 4s
|
|
1734
|
+
match = re.match(r"^(\d+)([dhms])$", timestamp)
|
|
1735
|
+
if match:
|
|
1736
|
+
number, character = match.groups()
|
|
1737
|
+
kwargs = {UNIT_TO_KWARGS[character]: int(number)}
|
|
1738
|
+
return (reference - timedelta(**kwargs)).isoformat()
|
|
1739
|
+
|
|
1740
|
+
# Check if the timestamp is already a valid ISO 8601 timestamp
|
|
1741
|
+
try:
|
|
1742
|
+
return dateutil.parser.isoparse(timestamp).isoformat()
|
|
1743
|
+
except ValueError:
|
|
1744
|
+
console.debug(f"Unable to parse the timestamp {timestamp} as ISO format")
|
|
1745
|
+
|
|
1746
|
+
# Check if the timestamp is a unix epoch timestamp
|
|
1747
|
+
try:
|
|
1748
|
+
return datetime.fromtimestamp(float(timestamp)).astimezone().isoformat()
|
|
1749
|
+
except ValueError:
|
|
1750
|
+
console.debug(f"The timestamp is not a valid unix epoch timestamp: {timestamp}")
|
|
1751
|
+
except (OSError, OverflowError) as ex:
|
|
1752
|
+
console.warn(
|
|
1753
|
+
f"Unable to convert the timestamp ({timestamp}) as unix epoch timestamp: {ex}"
|
|
1754
|
+
)
|
|
1755
|
+
return None
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _validate_url_with_protocol_prefix(url: str | None) -> bool:
|
|
1759
|
+
"""Validate the URL with protocol prefix. Empty string is acceptable.
|
|
1760
|
+
|
|
1761
|
+
Args:
|
|
1762
|
+
url: the URL string to check.
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
Whether the entered URL is acceptable.
|
|
1766
|
+
"""
|
|
1767
|
+
return not url or (url.startswith("http://") or url.startswith("https://"))
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _process_entered_list(input: str | None) -> list | None:
|
|
1771
|
+
"""Process the user entered comma separated list into a list if applicable.
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
input: the user entered comma separated list
|
|
1775
|
+
|
|
1776
|
+
Returns:
|
|
1777
|
+
The list of items or None.
|
|
1778
|
+
"""
|
|
1779
|
+
return [t.strip() for t in (input or "").split(",") if t if input] or None
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def _get_file_from_prompt_in_loop() -> tuple[bytes, str] | None:
|
|
1783
|
+
image_file = file_extension = None
|
|
1784
|
+
while image_file is None:
|
|
1785
|
+
image_filepath = console.ask(
|
|
1786
|
+
f"Upload a preview image of your demo app (enter to skip)"
|
|
1787
|
+
)
|
|
1788
|
+
if not image_filepath:
|
|
1789
|
+
break
|
|
1790
|
+
file_extension = image_filepath.split(".")[-1]
|
|
1791
|
+
try:
|
|
1792
|
+
with open(image_filepath, "rb") as f:
|
|
1793
|
+
image_file = f.read()
|
|
1794
|
+
return image_file, file_extension
|
|
1795
|
+
except OSError as ose:
|
|
1796
|
+
console.error(f"Unable to read the {file_extension} file due to {ose}")
|
|
1797
|
+
raise typer.Exit(code=1) from ose
|
|
1798
|
+
|
|
1799
|
+
console.debug(f"File extension detected: {file_extension}")
|
|
1800
|
+
return None
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def collect_deployment_info_interactive(
|
|
1804
|
+
key: str | None = None, demo_url: str | None = None
|
|
1805
|
+
):
|
|
1806
|
+
"""Collect the deployment information interactively from the user.
|
|
1807
|
+
|
|
1808
|
+
Args:
|
|
1809
|
+
key: The deployment key.
|
|
1810
|
+
demo_url: The demo URL.
|
|
1811
|
+
|
|
1812
|
+
Raises:
|
|
1813
|
+
Exception: If the user is not authenticated.
|
|
1814
|
+
Exit: Requests to backend services failed.
|
|
1815
|
+
"""
|
|
1816
|
+
if not (token := requires_authenticated()):
|
|
1817
|
+
raise Exception("not authenticated")
|
|
1818
|
+
|
|
1819
|
+
params = {}
|
|
1820
|
+
|
|
1821
|
+
# Either key or demo_url is required.
|
|
1822
|
+
if not key and not demo_url:
|
|
1823
|
+
while True:
|
|
1824
|
+
demo_url = (
|
|
1825
|
+
console.ask(
|
|
1826
|
+
"[ Full URL of deployed app, e.g. `https://my-app.reflex.run` ]"
|
|
1827
|
+
)
|
|
1828
|
+
or None
|
|
1829
|
+
)
|
|
1830
|
+
if _validate_url_with_protocol_prefix(demo_url):
|
|
1831
|
+
break
|
|
1832
|
+
|
|
1833
|
+
if key:
|
|
1834
|
+
params["key"] = key
|
|
1835
|
+
if demo_url:
|
|
1836
|
+
params["demo_url"] = demo_url
|
|
1837
|
+
|
|
1838
|
+
try:
|
|
1839
|
+
# Verify the user has permission by sending POST request
|
|
1840
|
+
# in case the subsequent steps of collecting information are interrupted.
|
|
1841
|
+
console.debug(f"Sending POST to verify user permission: {params}")
|
|
1842
|
+
response = httpx.post(
|
|
1843
|
+
POST_GALLERY_APPS_ENDPOINT,
|
|
1844
|
+
data=params,
|
|
1845
|
+
headers=authorization_header(token),
|
|
1846
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
1847
|
+
)
|
|
1848
|
+
if response.status_code == httpx.codes.FORBIDDEN:
|
|
1849
|
+
console.error(
|
|
1850
|
+
f"You do not have permission to update the gallery data for {demo_url}."
|
|
1851
|
+
)
|
|
1852
|
+
raise typer.Exit(code=1)
|
|
1853
|
+
response.raise_for_status()
|
|
1854
|
+
except httpx.HTTPError as he:
|
|
1855
|
+
console.error(f"Unable to verify user permission due to {he}.")
|
|
1856
|
+
raise typer.Exit(code=1) from he
|
|
1857
|
+
|
|
1858
|
+
files = []
|
|
1859
|
+
if (image_file_and_extension := _get_file_from_prompt_in_loop()) is not None:
|
|
1860
|
+
files.append(
|
|
1861
|
+
("files", (image_file_and_extension[1], image_file_and_extension[0]))
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
if display_name := (
|
|
1865
|
+
console.ask("[ Friendly display name for your app ] (enter to skip)") or None
|
|
1866
|
+
):
|
|
1867
|
+
params["display_name"] = display_name
|
|
1868
|
+
|
|
1869
|
+
if keywords := _process_entered_list(
|
|
1870
|
+
console.ask("[ Keywords separated by commas ] (enter to skip)")
|
|
1871
|
+
):
|
|
1872
|
+
params["keywords"] = keywords
|
|
1873
|
+
|
|
1874
|
+
if (
|
|
1875
|
+
summary := console.ask("[ Short description of your app ] (enter to skip)")
|
|
1876
|
+
or None
|
|
1877
|
+
):
|
|
1878
|
+
params["summary"] = summary
|
|
1879
|
+
|
|
1880
|
+
source = None
|
|
1881
|
+
while True:
|
|
1882
|
+
source = (
|
|
1883
|
+
console.ask(
|
|
1884
|
+
"[ Full URL of source code, e.g. `https://github.com/owner/repo` ]"
|
|
1885
|
+
)
|
|
1886
|
+
or None
|
|
1887
|
+
)
|
|
1888
|
+
if _validate_url_with_protocol_prefix(source):
|
|
1889
|
+
params["source"] = source
|
|
1890
|
+
break
|
|
1891
|
+
|
|
1892
|
+
# Now send the post request to Reflex backend services.
|
|
1893
|
+
try:
|
|
1894
|
+
console.debug(f"Sending gallery app data: {params}")
|
|
1895
|
+
response = httpx.post(
|
|
1896
|
+
POST_GALLERY_APPS_ENDPOINT,
|
|
1897
|
+
data=params,
|
|
1898
|
+
files=files,
|
|
1899
|
+
headers=authorization_header(token),
|
|
1900
|
+
timeout=HTTP_REQUEST_TIMEOUT,
|
|
1901
|
+
)
|
|
1902
|
+
response.raise_for_status()
|
|
1903
|
+
|
|
1904
|
+
except httpx.HTTPError as he:
|
|
1905
|
+
console.error(f"Unable to complete request due to {he}.")
|
|
1906
|
+
raise typer.Exit(code=1) from he
|
|
1907
|
+
|
|
1908
|
+
console.info("Gallery app information successfully shared!")
|