hosting.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. """Hosting service related utilities."""
  2. from __future__ import annotations
  3. import contextlib
  4. import enum
  5. import json
  6. import os
  7. import re
  8. import time
  9. import uuid
  10. import webbrowser
  11. from datetime import datetime, timedelta
  12. from http import HTTPStatus
  13. from typing import List, Optional
  14. import httpx
  15. import websockets
  16. from pydantic import Field, ValidationError, root_validator
  17. from reflex import constants
  18. from reflex.base import Base
  19. from reflex.config import get_config
  20. from reflex.utils import console
  21. config = get_config()
  22. # Endpoint to create or update a deployment
  23. POST_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
  24. # Endpoint to get all deployments for the user
  25. GET_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
  26. # Endpoint to fetch information from backend in preparation of a deployment
  27. POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{config.cp_backend_url}/deployments/prepare"
  28. # Endpoint to authenticate current user
  29. POST_VALIDATE_ME_ENDPOINT = f"{config.cp_backend_url}/authenticate/me"
  30. # Endpoint to fetch a login token after user completes authentication on web
  31. FETCH_TOKEN_ENDPOINT = f"{config.cp_backend_url}/authenticate"
  32. # Endpoint to delete a deployment
  33. DELETE_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
  34. # Endpoint to get deployment status
  35. GET_DEPLOYMENT_STATUS_ENDPOINT = f"{config.cp_backend_url}/deployments"
  36. # Public endpoint to get the list of supported regions in hosting service
  37. GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions"
  38. # Websocket endpoint to stream logs of a deployment
  39. DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments'
  40. # The HTTP endpoint to fetch logs of a deployment
  41. POST_DEPLOYMENT_LOGS_ENDPOINT = f"{config.cp_backend_url}/deployments/logs"
  42. # Expected server response time to new deployment request. In seconds.
  43. DEPLOYMENT_PICKUP_DELAY = 30
  44. # End of deployment workflow message. Used to determine if it is the last message from server.
  45. END_OF_DEPLOYMENT_MESSAGES = ["deploy success"]
  46. # How many iterations to try and print the deployment event messages from server during deployment.
  47. DEPLOYMENT_EVENT_MESSAGES_RETRIES = 120
  48. # Timeout limit for http requests
  49. HTTP_REQUEST_TIMEOUT = 60 # seconds
  50. def get_existing_access_token() -> tuple[str, str]:
  51. """Fetch the access token from the existing config if applicable.
  52. Returns:
  53. The access token and the invitation code.
  54. If either is not found, return empty string for it instead.
  55. """
  56. console.debug("Fetching token from existing config...")
  57. access_token = invitation_code = ""
  58. try:
  59. with open(constants.Hosting.HOSTING_JSON, "r") as config_file:
  60. hosting_config = json.load(config_file)
  61. access_token = hosting_config.get("access_token", "")
  62. invitation_code = hosting_config.get("code", "")
  63. except Exception as ex:
  64. console.debug(
  65. f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}"
  66. )
  67. return access_token, invitation_code
  68. def validate_token(token: str):
  69. """Validate the token with the control plane.
  70. Args:
  71. token: The access token to validate.
  72. Raises:
  73. ValueError: if access denied.
  74. Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again.
  75. """
  76. try:
  77. response = httpx.post(
  78. POST_VALIDATE_ME_ENDPOINT,
  79. headers=authorization_header(token),
  80. timeout=HTTP_REQUEST_TIMEOUT,
  81. )
  82. if response.status_code == HTTPStatus.FORBIDDEN:
  83. raise ValueError
  84. response.raise_for_status()
  85. except httpx.RequestError as re:
  86. console.debug(f"Request to auth server failed due to {re}")
  87. raise Exception(str(re)) from re
  88. except httpx.HTTPError as ex:
  89. console.debug(f"Unable to validate the token due to: {ex}")
  90. raise Exception("server error") from ex
  91. except ValueError as ve:
  92. console.debug(f"Access denied for {token}")
  93. raise ValueError("access denied") from ve
  94. except Exception as ex:
  95. console.debug(f"Unexpected error: {ex}")
  96. raise Exception("internal errors") from ex
  97. def delete_token_from_config(include_invitation_code: bool = False):
  98. """Delete the invalid token from the config file if applicable.
  99. Args:
  100. include_invitation_code:
  101. Whether to delete the invitation code as well.
  102. When user logs out, we delete the invitation code together.
  103. """
  104. if os.path.exists(constants.Hosting.HOSTING_JSON):
  105. hosting_config = {}
  106. try:
  107. with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
  108. hosting_config = json.load(config_file)
  109. del hosting_config["access_token"]
  110. if include_invitation_code:
  111. del hosting_config["code"]
  112. json.dump(hosting_config, config_file)
  113. except Exception as ex:
  114. # Best efforts removing invalid token is OK
  115. console.debug(
  116. f"Unable to delete the invalid token from config file, err: {ex}"
  117. )
  118. def save_token_to_config(token: str, code: str | None = None):
  119. """Best efforts cache the token, and optionally invitation code to the config file.
  120. Args:
  121. token: The access token to save.
  122. code: The invitation code to save if exists.
  123. """
  124. hosting_config: dict[str, str] = {"access_token": token}
  125. if code:
  126. hosting_config["code"] = code
  127. try:
  128. if not os.path.exists(constants.Reflex.DIR):
  129. os.makedirs(constants.Reflex.DIR)
  130. with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
  131. json.dump(hosting_config, config_file)
  132. except Exception as ex:
  133. console.warn(
  134. f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}"
  135. )
  136. def requires_access_token() -> str:
  137. """Fetch the access token from the existing config if applicable.
  138. Returns:
  139. The access token. If not found, return empty string for it instead.
  140. """
  141. # Check if the user is authenticated
  142. access_token, _ = get_existing_access_token()
  143. if not access_token:
  144. console.debug("No access token found from the existing config.")
  145. return access_token
  146. def authenticated_token() -> tuple[str, str]:
  147. """Fetch the access token from the existing config if applicable and validate it.
  148. Returns:
  149. The access token and the invitation code.
  150. If either is not found, return empty string for it instead.
  151. """
  152. # Check if the user is authenticated
  153. access_token, invitation_code = get_existing_access_token()
  154. if not access_token:
  155. console.debug("No access token found from the existing config.")
  156. access_token = ""
  157. elif not validate_token_with_retries(access_token):
  158. access_token = ""
  159. return access_token, invitation_code
  160. def authorization_header(token: str) -> dict[str, str]:
  161. """Construct an authorization header with the specified token as bearer token.
  162. Args:
  163. token: The access token to use.
  164. Returns:
  165. The authorization header in dict format.
  166. """
  167. return {"Authorization": f"Bearer {token}"}
  168. def requires_authenticated() -> str:
  169. """Check if the user is authenticated.
  170. Returns:
  171. The validated access token or empty string if not authenticated.
  172. """
  173. access_token, invitation_code = authenticated_token()
  174. if access_token:
  175. return access_token
  176. return authenticate_on_browser(invitation_code)
  177. class DeploymentPrepInfo(Base):
  178. """The params/settings returned from the prepare endpoint
  179. including the deployment key and the frontend/backend URLs once deployed.
  180. The key becomes part of both frontend and backend URLs.
  181. """
  182. # The deployment key
  183. key: str
  184. # The backend URL
  185. api_url: str
  186. # The frontend URL
  187. deploy_url: str
  188. class DeploymentPrepareResponse(Base):
  189. """The params/settings returned from the prepare endpoint,
  190. used in the CLI for the subsequent launch request.
  191. """
  192. # The app prefix, used on the server side only
  193. app_prefix: str
  194. # The reply from the server for a prepare request to deploy over a particular key
  195. # If reply is not None, it means server confirms the key is available for use.
  196. reply: Optional[DeploymentPrepInfo] = None
  197. # The list of existing deployments by the user under the same app name.
  198. # This is used to allow easy upgrade case when user attempts to deploy
  199. # in the same named app directory, user intends to upgrade the existing deployment.
  200. existing: Optional[List[DeploymentPrepInfo]] = None
  201. # The suggested key name based on the app name.
  202. # This is for a new deployment, user has not deployed this app before.
  203. # The server returns key suggestion based on the app name.
  204. suggestion: Optional[DeploymentPrepInfo] = None
  205. enabled_regions: Optional[List[str]] = None
  206. @root_validator(pre=True)
  207. def ensure_at_least_one_deploy_params(cls, values):
  208. """Ensure at least one set of param is returned for any of the cases we try to prepare.
  209. Args:
  210. values: The values passed in.
  211. Raises:
  212. ValueError: If all of the optional fields are None.
  213. Returns:
  214. The values passed in.
  215. """
  216. if (
  217. values.get("reply") is None
  218. and not values.get("existing") # existing cannot be an empty list either
  219. and values.get("suggestion") is None
  220. ):
  221. raise ValueError(
  222. "At least one set of params for deploy is required from control plane."
  223. )
  224. return values
  225. class DeploymentsPreparePostParam(Base):
  226. """Params for app API URL creation backend API."""
  227. # The app name which is found in the config
  228. app_name: str
  229. # The deployment key
  230. key: Optional[str] = None # name of the deployment
  231. # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
  232. frontend_hostname: Optional[str] = None
  233. def prepare_deploy(
  234. app_name: str,
  235. key: str | None = None,
  236. frontend_hostname: str | None = None,
  237. ) -> DeploymentPrepareResponse:
  238. """Send a POST request to Control Plane to prepare a new deployment.
  239. Control Plane checks if there is conflict with the key if provided.
  240. If the key is absent, it will return existing deployments and a suggested name based on the app_name in the request.
  241. Args:
  242. key: The deployment name.
  243. app_name: The app name.
  244. frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
  245. Raises:
  246. Exception: If the operation fails. The exception message is the reason.
  247. Returns:
  248. The response containing the backend URLs if successful, None otherwise.
  249. """
  250. # Check if the user is authenticated
  251. if not (token := requires_authenticated()):
  252. raise Exception("not authenticated")
  253. try:
  254. response = httpx.post(
  255. POST_DEPLOYMENTS_PREPARE_ENDPOINT,
  256. headers=authorization_header(token),
  257. json=DeploymentsPreparePostParam(
  258. app_name=app_name, key=key, frontend_hostname=frontend_hostname
  259. ).dict(exclude_none=True),
  260. timeout=HTTP_REQUEST_TIMEOUT,
  261. )
  262. response_json = response.json()
  263. console.debug(f"Response from prepare endpoint: {response_json}")
  264. if response.status_code == HTTPStatus.FORBIDDEN:
  265. console.debug(f'Server responded with 403: {response_json.get("detail")}')
  266. raise ValueError(f'{response_json.get("detail", "forbidden")}')
  267. response.raise_for_status()
  268. return DeploymentPrepareResponse(
  269. app_prefix=response_json["app_prefix"],
  270. reply=response_json["reply"],
  271. suggestion=response_json["suggestion"],
  272. existing=response_json["existing"],
  273. enabled_regions=response_json.get("enabled_regions"),
  274. )
  275. except httpx.RequestError as re:
  276. console.debug(f"Unable to prepare launch due to {re}.")
  277. raise Exception(str(re)) from re
  278. except httpx.HTTPError as he:
  279. console.debug(f"Unable to prepare deploy due to {he}.")
  280. raise Exception(f"{he}") from he
  281. except json.JSONDecodeError as jde:
  282. console.debug(f"Server did not respond with valid json: {jde}")
  283. raise Exception("internal errors") from jde
  284. except (KeyError, ValidationError) as kve:
  285. console.debug(f"The server response format is unexpected {kve}")
  286. raise Exception("internal errors") from kve
  287. except ValueError as ve:
  288. # This is a recognized client error, currently indicates forbidden
  289. raise Exception(f"{ve}") from ve
  290. except Exception as ex:
  291. console.debug(f"Unexpected error: {ex}.")
  292. raise Exception("internal errors") from ex
  293. class DeploymentPostResponse(Base):
  294. """The URL for the deployed site."""
  295. # The frontend URL
  296. frontend_url: str = Field(..., regex=r"^https?://", min_length=8)
  297. # The backend URL
  298. backend_url: str = Field(..., regex=r"^https?://", min_length=8)
  299. class DeploymentsPostParam(Base):
  300. """Params for hosted instance deployment POST request."""
  301. # Key is the name of the deployment, it becomes part of the URL
  302. key: str = Field(..., regex=r"^[a-z0-9-]+$")
  303. # Name of the app
  304. app_name: str = Field(..., min_length=1)
  305. # json encoded list of regions to deploy to
  306. regions_json: str = Field(..., min_length=1)
  307. # The app prefix, used on the server side only
  308. app_prefix: str = Field(..., min_length=1)
  309. # The version of reflex CLI used to deploy
  310. reflex_version: str = Field(..., min_length=1)
  311. # The number of CPUs
  312. cpus: Optional[int] = None
  313. # The memory in MB
  314. memory_mb: Optional[int] = None
  315. # Whether to auto start the hosted deployment
  316. auto_start: Optional[bool] = None
  317. # Whether to auto stop the hosted deployment when idling
  318. auto_stop: Optional[bool] = None
  319. # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
  320. frontend_hostname: Optional[str] = None
  321. # The description of the deployment
  322. description: Optional[str] = None
  323. # The json encoded list of environment variables
  324. envs_json: Optional[str] = None
  325. # The command line prefix for tracing
  326. reflex_cli_entrypoint: Optional[str] = None
  327. # The metrics endpoint
  328. metrics_endpoint: Optional[str] = None
  329. def deploy(
  330. frontend_file_name: str,
  331. backend_file_name: str,
  332. export_dir: str,
  333. key: str,
  334. app_name: str,
  335. regions: list[str],
  336. app_prefix: str,
  337. vm_type: str | None = None,
  338. cpus: int | None = None,
  339. memory_mb: int | None = None,
  340. auto_start: bool | None = None,
  341. auto_stop: bool | None = None,
  342. frontend_hostname: str | None = None,
  343. envs: dict[str, str] | None = None,
  344. with_tracing: str | None = None,
  345. with_metrics: str | None = None,
  346. ) -> DeploymentPostResponse:
  347. """Send a POST request to Control Plane to launch a new deployment.
  348. Args:
  349. frontend_file_name: The frontend file name.
  350. backend_file_name: The backend file name.
  351. export_dir: The directory where the frontend/backend zip files are exported.
  352. key: The deployment name.
  353. app_name: The app name.
  354. regions: The list of regions to deploy to.
  355. app_prefix: The app prefix.
  356. vm_type: The VM type.
  357. cpus: The number of CPUs.
  358. memory_mb: The memory in MB.
  359. auto_start: Whether to auto start.
  360. auto_stop: Whether to auto stop.
  361. frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
  362. envs: The environment variables.
  363. with_tracing: A string indicating the command line prefix for tracing.
  364. with_metrics: A string indicating the metrics endpoint.
  365. Raises:
  366. AssertionError: If the request is rejected by the hosting server.
  367. Exception: If the operation fails. The exception message is the reason.
  368. Returns:
  369. The response containing the URL of the site to be deployed if successful, None otherwise.
  370. """
  371. # Check if the user is authenticated
  372. if not (token := requires_access_token()):
  373. raise Exception("not authenticated")
  374. try:
  375. params = DeploymentsPostParam(
  376. key=key,
  377. app_name=app_name,
  378. regions_json=json.dumps(regions),
  379. app_prefix=app_prefix,
  380. cpus=cpus,
  381. memory_mb=memory_mb,
  382. auto_start=auto_start,
  383. auto_stop=auto_stop,
  384. envs_json=json.dumps(envs) if envs else None,
  385. frontend_hostname=frontend_hostname,
  386. reflex_version=constants.Reflex.VERSION,
  387. reflex_cli_entrypoint=with_tracing,
  388. metrics_endpoint=with_metrics,
  389. )
  390. with open(
  391. os.path.join(export_dir, frontend_file_name), "rb"
  392. ) as frontend_file, open(
  393. os.path.join(export_dir, backend_file_name), "rb"
  394. ) as backend_file:
  395. # https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files
  396. files = [
  397. ("files", (frontend_file_name, frontend_file)),
  398. ("files", (backend_file_name, backend_file)),
  399. ]
  400. response = httpx.post(
  401. POST_DEPLOYMENTS_ENDPOINT,
  402. headers=authorization_header(token),
  403. data=params.dict(exclude_none=True),
  404. files=files,
  405. timeout=HTTP_REQUEST_TIMEOUT,
  406. )
  407. # If the server explicitly states bad request,
  408. # display a different error
  409. if response.status_code == HTTPStatus.BAD_REQUEST:
  410. raise AssertionError(f"Server rejected this request: {response.text}")
  411. response.raise_for_status()
  412. response_json = response.json()
  413. return DeploymentPostResponse(
  414. frontend_url=response_json["frontend_url"],
  415. backend_url=response_json["backend_url"],
  416. )
  417. except OSError as oe:
  418. console.error(f"Client side error related to file operation: {oe}")
  419. raise
  420. except httpx.RequestError as re:
  421. console.error(f"Unable to deploy due to request error: {re}")
  422. raise Exception("request error") from re
  423. except httpx.HTTPError as he:
  424. console.error(f"Unable to deploy due to {he}.")
  425. raise Exception(str) from he
  426. except json.JSONDecodeError as jde:
  427. console.error(f"Server did not respond with valid json: {jde}")
  428. raise Exception("internal errors") from jde
  429. except (KeyError, ValidationError) as kve:
  430. console.error(f"Post params or server response format unexpected: {kve}")
  431. raise Exception("internal errors") from kve
  432. except AssertionError as ve:
  433. console.error(f"Unable to deploy due to request error: {ve}")
  434. # re-raise the error back to the user as client side error
  435. raise
  436. except Exception as ex:
  437. console.error(f"Unable to deploy due to internal errors: {ex}.")
  438. raise Exception("internal errors") from ex
  439. class DeploymentsGetParam(Base):
  440. """Params for hosted instance GET request."""
  441. # The app name which is found in the config
  442. app_name: Optional[str]
  443. def list_deployments(
  444. app_name: str | None = None,
  445. ) -> list[dict]:
  446. """Send a GET request to Control Plane to list deployments.
  447. Args:
  448. app_name: the app name as an optional filter when listing deployments.
  449. Raises:
  450. Exception: If the operation fails. The exception message shows the reason.
  451. Returns:
  452. The list of deployments if successful, None otherwise.
  453. """
  454. if not (token := requires_authenticated()):
  455. raise Exception("not authenticated")
  456. params = DeploymentsGetParam(app_name=app_name)
  457. try:
  458. response = httpx.get(
  459. GET_DEPLOYMENTS_ENDPOINT,
  460. headers=authorization_header(token),
  461. params=params.dict(exclude_none=True),
  462. timeout=HTTP_REQUEST_TIMEOUT,
  463. )
  464. response.raise_for_status()
  465. return response.json()
  466. except httpx.RequestError as re:
  467. console.error(f"Unable to list deployments due to request error: {re}")
  468. raise Exception("request timeout") from re
  469. except httpx.HTTPError as he:
  470. console.error(f"Unable to list deployments due to {he}.")
  471. raise Exception("internal errors") from he
  472. except (ValidationError, KeyError, json.JSONDecodeError) as vkje:
  473. console.error(f"Server response format unexpected: {vkje}")
  474. raise Exception("internal errors") from vkje
  475. except Exception as ex:
  476. console.error(f"Unexpected error: {ex}.")
  477. raise Exception("internal errors") from ex
  478. def fetch_token(request_id: str) -> tuple[str, str]:
  479. """Fetch the access token for the request_id from Control Plane.
  480. Args:
  481. request_id: The request ID used when the user opens the browser for authentication.
  482. Returns:
  483. The access token if it exists, None otherwise.
  484. """
  485. access_token = invitation_code = ""
  486. try:
  487. resp = httpx.get(
  488. f"{FETCH_TOKEN_ENDPOINT}/{request_id}",
  489. timeout=HTTP_REQUEST_TIMEOUT,
  490. )
  491. resp.raise_for_status()
  492. access_token = (resp_json := resp.json()).get("access_token", "")
  493. invitation_code = resp_json.get("code", "")
  494. except httpx.RequestError as re:
  495. console.debug(f"Unable to fetch token due to request error: {re}")
  496. except httpx.HTTPError as he:
  497. console.debug(f"Unable to fetch token due to {he}")
  498. except json.JSONDecodeError as jde:
  499. console.debug(f"Server did not respond with valid json: {jde}")
  500. except KeyError as ke:
  501. console.debug(f"Server response format unexpected: {ke}")
  502. except Exception:
  503. console.debug("Unexpected errors: {ex}")
  504. return access_token, invitation_code
  505. def poll_backend(backend_url: str) -> bool:
  506. """Poll the backend to check if it is up.
  507. Args:
  508. backend_url: The URL of the backend to poll.
  509. Returns:
  510. True if the backend is up, False otherwise.
  511. """
  512. try:
  513. console.debug(f"Polling backend at {backend_url}")
  514. resp = httpx.get(f"{backend_url}/ping", timeout=1)
  515. resp.raise_for_status()
  516. return True
  517. except httpx.HTTPError:
  518. return False
  519. def poll_frontend(frontend_url: str) -> bool:
  520. """Poll the frontend to check if it is up.
  521. Args:
  522. frontend_url: The URL of the frontend to poll.
  523. Returns:
  524. True if the frontend is up, False otherwise.
  525. """
  526. try:
  527. console.debug(f"Polling frontend at {frontend_url}")
  528. resp = httpx.get(f"{frontend_url}", timeout=1)
  529. resp.raise_for_status()
  530. return True
  531. except httpx.HTTPError:
  532. return False
  533. class DeploymentDeleteParam(Base):
  534. """Params for hosted instance DELETE request."""
  535. # key is the name of the deployment, it becomes part of the site URL
  536. key: str
  537. def delete_deployment(key: str):
  538. """Send a DELETE request to Control Plane to delete a deployment.
  539. Args:
  540. key: The deployment name.
  541. Raises:
  542. ValueError: If the key is not provided.
  543. Exception: If the operation fails. The exception message is the reason.
  544. """
  545. if not (token := requires_authenticated()):
  546. raise Exception("not authenticated")
  547. if not key:
  548. raise ValueError("Valid key is required for the delete.")
  549. try:
  550. response = httpx.delete(
  551. f"{DELETE_DEPLOYMENTS_ENDPOINT}/{key}",
  552. headers=authorization_header(token),
  553. timeout=HTTP_REQUEST_TIMEOUT,
  554. )
  555. response.raise_for_status()
  556. except httpx.TimeoutException as te:
  557. console.error("Unable to delete deployment due to request timeout.")
  558. raise Exception("request timeout") from te
  559. except httpx.HTTPError as he:
  560. console.error(f"Unable to delete deployment due to {he}.")
  561. raise Exception("internal errors") from he
  562. except Exception as ex:
  563. console.error(f"Unexpected errors {ex}.")
  564. raise Exception("internal errors") from ex
  565. class SiteStatus(Base):
  566. """Deployment status info."""
  567. # The frontend URL
  568. frontend_url: Optional[str] = None
  569. # The backend URL
  570. backend_url: Optional[str] = None
  571. # Whether the frontend/backend URL is reachable
  572. reachable: bool
  573. # The last updated iso formatted timestamp if site is reachable
  574. updated_at: Optional[str] = None
  575. @root_validator(pre=True)
  576. def ensure_one_of_urls(cls, values):
  577. """Ensure at least one of the frontend/backend URLs is provided.
  578. Args:
  579. values: The values passed in.
  580. Raises:
  581. ValueError: If none of the URLs is provided.
  582. Returns:
  583. The values passed in.
  584. """
  585. if values.get("frontend_url") is None and values.get("backend_url") is None:
  586. raise ValueError("At least one of the URLs is required.")
  587. return values
  588. class DeploymentStatusResponse(Base):
  589. """Response for deployment status request."""
  590. # The frontend status
  591. frontend: SiteStatus
  592. # The backend status
  593. backend: SiteStatus
  594. def get_deployment_status(key: str) -> DeploymentStatusResponse:
  595. """Get the deployment status.
  596. Args:
  597. key: The deployment name.
  598. Raises:
  599. ValueError: If the key is not provided.
  600. Exception: If the operation fails. The exception message is the reason.
  601. Returns:
  602. The deployment status response including backend and frontend.
  603. """
  604. if not key:
  605. raise ValueError(
  606. "A non empty key is required for querying the deployment status."
  607. )
  608. if not (token := requires_authenticated()):
  609. raise Exception("not authenticated")
  610. try:
  611. response = httpx.get(
  612. f"{GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status",
  613. headers=authorization_header(token),
  614. timeout=HTTP_REQUEST_TIMEOUT,
  615. )
  616. response.raise_for_status()
  617. response_json = response.json()
  618. return DeploymentStatusResponse(
  619. frontend=SiteStatus(
  620. frontend_url=response_json["frontend"]["url"],
  621. reachable=response_json["frontend"]["reachable"],
  622. updated_at=response_json["frontend"]["updated_at"],
  623. ),
  624. backend=SiteStatus(
  625. backend_url=response_json["backend"]["url"],
  626. reachable=response_json["backend"]["reachable"],
  627. updated_at=response_json["backend"]["updated_at"],
  628. ),
  629. )
  630. except Exception as ex:
  631. console.error(f"Unable to get deployment status due to {ex}.")
  632. raise Exception("internal errors") from ex
  633. def convert_to_local_time_with_tz(iso_timestamp: str) -> datetime | None:
  634. """Helper function to convert the iso timestamp to local time.
  635. Args:
  636. iso_timestamp: The iso timestamp to convert.
  637. Returns:
  638. The converted timestamp with timezone.
  639. """
  640. try:
  641. return datetime.fromisoformat(iso_timestamp).astimezone()
  642. except (TypeError, ValueError) as ex:
  643. console.error(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.")
  644. return None
  645. def convert_to_local_time_str(iso_timestamp: str) -> str:
  646. """Convert the iso timestamp to local time.
  647. Args:
  648. iso_timestamp: The iso timestamp to convert.
  649. Returns:
  650. The converted timestamp string.
  651. """
  652. if (local_dt := convert_to_local_time_with_tz(iso_timestamp)) is None:
  653. return iso_timestamp
  654. return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z")
  655. class LogType(str, enum.Enum):
  656. """Enum for log types."""
  657. # Logs printed from the user code, the "app"
  658. APP_LOG = "app"
  659. # Build logs are the server messages while building/running user deployment
  660. BUILD_LOG = "build"
  661. # Deploy logs are specifically for the messages at deploy time
  662. # returned to the user the current stage of the deployment, such as building, uploading.
  663. DEPLOY_LOG = "deploy"
  664. # All the logs which can be printed by all above types.
  665. ALL_LOG = "all"
  666. async def get_logs(
  667. key: str,
  668. log_type: LogType = LogType.APP_LOG,
  669. from_iso_timestamp: datetime | None = None,
  670. ):
  671. """Get the deployment logs and stream on console.
  672. Args:
  673. key: The deployment name.
  674. log_type: The type of logs to query from server.
  675. See the LogType definitions for how they are used.
  676. from_iso_timestamp: An optional timestamp with timezone info to limit
  677. where the log queries should start from.
  678. Raises:
  679. ValueError: If the key is not provided.
  680. Exception: If the operation fails. The exception message is the reason.
  681. """
  682. if not (token := requires_authenticated()):
  683. raise Exception("not authenticated")
  684. if not key:
  685. raise ValueError("Valid key is required for querying logs.")
  686. try:
  687. logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}"
  688. console.debug(f"log server endpoint: {logs_endpoint}")
  689. if from_iso_timestamp is not None:
  690. logs_endpoint += (
  691. f"&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}"
  692. )
  693. _ws = websockets.connect(logs_endpoint) # type: ignore
  694. async with _ws as ws:
  695. while True:
  696. row_json = json.loads(await ws.recv())
  697. console.debug(f"Server responded with logs: {row_json}")
  698. if row_json and isinstance(row_json, dict):
  699. row_to_print = {}
  700. for k, v in row_json.items():
  701. if v is None:
  702. row_to_print[k] = str(v)
  703. elif k == "timestamp":
  704. row_to_print[k] = convert_to_local_time_str(v)
  705. else:
  706. row_to_print[k] = v
  707. print(" | ".join(row_to_print.values()))
  708. else:
  709. console.debug("Server responded, no new logs, this is normal")
  710. except Exception as ex:
  711. console.debug(f"Unable to get more deployment logs due to {ex}.")
  712. console.print("Log server disconnected ...")
  713. console.print(
  714. "Note that the server has limit to only stream logs for several minutes"
  715. )
  716. def check_requirements_txt_exist() -> bool:
  717. """Check if requirements.txt exists in the top level app directory.
  718. Returns:
  719. True if requirements.txt exists, False otherwise.
  720. """
  721. return os.path.exists(constants.RequirementsTxt.FILE)
  722. def check_requirements_for_non_reflex_packages() -> bool:
  723. """Check the requirements.txt file for packages other than reflex.
  724. Returns:
  725. True if packages other than reflex are found, False otherwise.
  726. """
  727. if not check_requirements_txt_exist():
  728. return False
  729. try:
  730. with open(constants.RequirementsTxt.FILE) as fp:
  731. for req_line in fp.readlines():
  732. package_name = re.search(r"^([^=<>!~]+)", req_line.lstrip())
  733. # If we find a package that is not reflex
  734. if (
  735. package_name
  736. and package_name.group(1) != constants.Reflex.MODULE_NAME
  737. ):
  738. return True
  739. except Exception as ex:
  740. console.warn(f"Unable to scan requirements.txt for dependencies due to {ex}")
  741. return False
  742. def authenticate_on_browser(invitation_code: str) -> str:
  743. """Open the browser to authenticate the user.
  744. Args:
  745. invitation_code: The invitation code if it exists.
  746. Returns:
  747. The access token if valid, empty otherwise.
  748. """
  749. console.print(f"Opening {config.cp_web_url} ...")
  750. request_id = uuid.uuid4().hex
  751. auth_url = f"{config.cp_web_url}?request-id={request_id}&code={invitation_code}"
  752. if not webbrowser.open(auth_url):
  753. console.warn(
  754. f"Unable to automatically open the browser. Please go to {auth_url} to authenticate."
  755. )
  756. access_token = invitation_code = ""
  757. with console.status("Waiting for access token ..."):
  758. for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
  759. access_token, invitation_code = fetch_token(request_id)
  760. if access_token:
  761. break
  762. else:
  763. time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
  764. if access_token and validate_token_with_retries(access_token):
  765. save_token_to_config(access_token, invitation_code)
  766. else:
  767. access_token = ""
  768. return access_token
  769. def validate_token_with_retries(access_token: str) -> bool:
  770. """Validate the access token with retries.
  771. Args:
  772. access_token: The access token to validate.
  773. Returns:
  774. True if the token is valid,
  775. False if invalid or unable to validate.
  776. """
  777. with console.status("Validating access token ..."):
  778. for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
  779. try:
  780. validate_token(access_token)
  781. return True
  782. except ValueError:
  783. console.error(f"Access denied")
  784. delete_token_from_config()
  785. break
  786. except Exception as ex:
  787. console.debug(f"Unable to validate token due to: {ex}, trying again")
  788. time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
  789. return False
  790. def is_valid_deployment_key(key: str):
  791. """Helper function to check if the deployment key is valid. Must be a domain name safe string.
  792. Args:
  793. key: The deployment key to check.
  794. Returns:
  795. True if the key contains only domain name safe characters, False otherwise.
  796. """
  797. return re.match(r"^[a-zA-Z0-9-]*$", key)
  798. def interactive_get_deployment_key_from_user_input(
  799. pre_deploy_response: DeploymentPrepareResponse,
  800. app_name: str,
  801. frontend_hostname: str | None = None,
  802. ) -> tuple[str, str, str]:
  803. """Interactive get the deployment key from user input.
  804. Args:
  805. pre_deploy_response: The response from the initial prepare call to server.
  806. app_name: The app name.
  807. frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
  808. Returns:
  809. The deployment key, backend URL, frontend URL.
  810. """
  811. key_candidate = api_url = deploy_url = ""
  812. if reply := pre_deploy_response.reply:
  813. api_url = reply.api_url
  814. deploy_url = reply.deploy_url
  815. key_candidate = reply.key
  816. elif pre_deploy_response.existing:
  817. # validator already checks existing field is not empty list
  818. # Note: keeping this simple as we only allow one deployment per app
  819. existing = pre_deploy_response.existing[0]
  820. console.print(f"Overwrite deployment [ {existing.key} ] ...")
  821. key_candidate = existing.key
  822. api_url = existing.api_url
  823. deploy_url = existing.deploy_url
  824. elif suggestion := pre_deploy_response.suggestion:
  825. key_candidate = suggestion.key
  826. api_url = suggestion.api_url
  827. deploy_url = suggestion.deploy_url
  828. # If user takes the suggestion, we will use the suggested key and proceed
  829. while key_input := console.ask(
  830. f"Choose a name for your deployed app. Enter to use default.",
  831. default=key_candidate,
  832. ):
  833. if not is_valid_deployment_key(key_input):
  834. console.error(
  835. "Invalid key input, should only contain domain name safe characters: letters, digits, or hyphens."
  836. )
  837. continue
  838. elif any(x.isupper() for x in key_input):
  839. key_input = key_input.lower()
  840. console.info(
  841. f"Domain name is case insensitive, automatically converting to all lower cases: {key_input}"
  842. )
  843. try:
  844. pre_deploy_response = prepare_deploy(
  845. app_name,
  846. key=key_input,
  847. frontend_hostname=frontend_hostname,
  848. )
  849. if (
  850. pre_deploy_response.reply is None
  851. or key_input != pre_deploy_response.reply.key
  852. ):
  853. # Rejected by server, try again
  854. continue
  855. key_candidate = pre_deploy_response.reply.key
  856. api_url = pre_deploy_response.reply.api_url
  857. deploy_url = pre_deploy_response.reply.deploy_url
  858. # we get the confirmation, so break from the loop
  859. break
  860. except Exception:
  861. console.error(
  862. "Cannot deploy at this name, try picking a different name"
  863. )
  864. return key_candidate, api_url, deploy_url
  865. def process_envs(envs: list[str]) -> dict[str, str]:
  866. """Process the environment variables.
  867. Args:
  868. envs: The environment variables expected in key=value format.
  869. Raises:
  870. SystemExit: If the envs are not in valid format.
  871. Returns:
  872. The processed environment variables in a dict.
  873. """
  874. processed_envs = {}
  875. for env in envs:
  876. kv = env.split("=", maxsplit=1)
  877. if len(kv) != 2:
  878. raise SystemExit("Invalid env format: should be <key>=<value>.")
  879. if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", kv[0]):
  880. raise SystemExit(
  881. "Invalid env name: should start with a letter or underscore, followed by letters, digits, or underscores."
  882. )
  883. processed_envs[kv[0]] = kv[1]
  884. return processed_envs
  885. def log_out_on_browser():
  886. """Open the browser to authenticate the user."""
  887. # Fetching existing invitation code so user sees the log out page without having to enter it
  888. invitation_code = None
  889. with contextlib.suppress(Exception):
  890. _, invitation_code = get_existing_access_token()
  891. console.debug("Found existing invitation code in config")
  892. delete_token_from_config()
  893. console.print(f"Opening {config.cp_web_url} ...")
  894. if not webbrowser.open(f"{config.cp_web_url}?code={invitation_code}"):
  895. console.warn(
  896. f"Unable to open the browser automatically. Please go to {config.cp_web_url} to log out."
  897. )
  898. def poll_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool | None:
  899. """Periodically poll the hosting server for deploy milestones.
  900. Args:
  901. key: The deployment key.
  902. from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
  903. Raises:
  904. ValueError: If a non-empty key is not provided.
  905. Exception: If the user is not authenticated.
  906. Returns:
  907. False if server reports back failure, True otherwise. None if do not receive the end of deployment message.
  908. """
  909. if not key:
  910. raise ValueError("Non-empty key is required for querying deploy status.")
  911. if not (token := requires_authenticated()):
  912. raise Exception("not authenticated")
  913. for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
  914. try:
  915. response = httpx.post(
  916. POST_DEPLOYMENT_LOGS_ENDPOINT,
  917. json={
  918. "key": key,
  919. "log_type": LogType.DEPLOY_LOG.value,
  920. "from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(),
  921. },
  922. headers=authorization_header(token),
  923. )
  924. response.raise_for_status()
  925. # The return is expected to be a list of dicts
  926. response_json = response.json()
  927. for row in response_json:
  928. console.print(
  929. " | ".join(
  930. [
  931. convert_to_local_time_str(row["timestamp"]),
  932. row["message"],
  933. ]
  934. )
  935. )
  936. # update the from timestamp to the last timestamp of received message
  937. if (
  938. maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"])
  939. ) is not None:
  940. console.debug(
  941. f"Updating from {from_iso_timestamp} to {maybe_timestamp}"
  942. )
  943. # Add a small delta so does not poll the same logs
  944. from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5)
  945. else:
  946. console.warn(f"Unable to parse timestamp {row['timestamp']}")
  947. server_message = row["message"].lower()
  948. if "fail" in server_message:
  949. console.debug(
  950. "Received failure message, stop event message streaming"
  951. )
  952. return False
  953. if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
  954. console.debug(
  955. "Received end of deployment message, stop event message streaming"
  956. )
  957. return True
  958. time.sleep(1)
  959. except httpx.HTTPError as he:
  960. # This includes HTTP server and client error
  961. console.debug(f"Unable to get more deployment events due to {he}.")
  962. except Exception as ex:
  963. console.warn(f"Unable to parse server response due to {ex}.")
  964. async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool:
  965. """Display the deploy milestone messages reported back from the hosting server.
  966. Args:
  967. key: The deployment key.
  968. from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
  969. Raises:
  970. ValueError: If a non-empty key is not provided.
  971. Exception: If the user is not authenticated.
  972. Returns:
  973. False if server reports back failure, True otherwise.
  974. """
  975. if not key:
  976. raise ValueError("Non-empty key is required for querying deploy status.")
  977. if not (token := requires_authenticated()):
  978. raise Exception("not authenticated")
  979. try:
  980. 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()}"
  981. console.debug(f"log server endpoint: {logs_endpoint}")
  982. _ws = websockets.connect(logs_endpoint) # type: ignore
  983. async with _ws as ws:
  984. # Stream back the deploy events reported back from the server
  985. for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
  986. row_json = json.loads(await ws.recv())
  987. console.debug(f"Server responded with: {row_json}")
  988. if row_json and isinstance(row_json, dict):
  989. # Only show the timestamp and actual message
  990. console.print(
  991. " | ".join(
  992. [
  993. convert_to_local_time_str(row_json["timestamp"]),
  994. row_json["message"],
  995. ]
  996. )
  997. )
  998. server_message = row_json["message"].lower()
  999. if "fail" in server_message:
  1000. console.debug(
  1001. "Received failure message, stop event message streaming"
  1002. )
  1003. return False
  1004. if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
  1005. console.debug(
  1006. "Received end of deployment message, stop event message streaming"
  1007. )
  1008. return True
  1009. else:
  1010. console.debug("Server responded, no new events yet, this is normal")
  1011. except Exception as ex:
  1012. console.debug(f"Unable to get more deployment events due to {ex}.")
  1013. return False
  1014. def wait_for_server_to_pick_up_request():
  1015. """Wait for server to pick up the request. Right now is just sleep."""
  1016. with console.status(
  1017. f"Waiting for server to pick up request ~ {DEPLOYMENT_PICKUP_DELAY} seconds ..."
  1018. ):
  1019. for _ in range(DEPLOYMENT_PICKUP_DELAY):
  1020. time.sleep(1)
  1021. def interactive_prompt_for_envs() -> list[str]:
  1022. """Interactive prompt for environment variables.
  1023. Returns:
  1024. The list of environment variables in key=value string format.
  1025. """
  1026. envs = []
  1027. envs_finished = False
  1028. env_count = 1
  1029. env_key_prompt = f" * env-{env_count} name (enter to skip)"
  1030. console.print("Environment variables for your production App ...")
  1031. while not envs_finished:
  1032. env_key = console.ask(env_key_prompt)
  1033. if not env_key:
  1034. envs_finished = True
  1035. if envs:
  1036. console.print("Finished adding envs.")
  1037. else:
  1038. console.print("No envs added. Continuing ...")
  1039. break
  1040. # If it possible to have empty values for env, so we do not check here
  1041. env_value = console.ask(f" env-{env_count} value")
  1042. envs.append(f"{env_key}={env_value}")
  1043. env_count += 1
  1044. env_key_prompt = f" * env-{env_count} name (enter to skip)"
  1045. return envs
  1046. def get_regions() -> list[dict]:
  1047. """Get the supported regions from the hosting server.
  1048. Returns:
  1049. A list of dict representation of the region information.
  1050. """
  1051. try:
  1052. response = httpx.get(
  1053. GET_REGIONS_ENDPOINT,
  1054. timeout=HTTP_REQUEST_TIMEOUT,
  1055. )
  1056. response.raise_for_status()
  1057. response_json = response.json()
  1058. if response_json is None or not isinstance(response_json, list):
  1059. console.error("Expect server to return a list ")
  1060. return []
  1061. if (
  1062. response_json
  1063. and response_json[0] is not None
  1064. and not isinstance(response_json[0], dict)
  1065. ):
  1066. console.error("Expect return values are dict's")
  1067. return []
  1068. return response_json
  1069. except Exception as ex:
  1070. console.error(f"Unable to get regions due to {ex}.")
  1071. return []