From 81053618c900d37bcf394382a2c60ccdd2cc74d6 Mon Sep 17 00:00:00 2001 From: Martin Xu <15661672+martinxu9@users.noreply.github.com> Date: Tue, 24 Oct 2023 09:43:20 -0700 Subject: [PATCH] CLI switch to prod server (#2016) --- reflex/config.py | 5 + reflex/config.pyi | 4 + reflex/constants/hosting.py | 31 +--- reflex/reflex.py | 71 +++++---- reflex/utils/build.py | 7 +- reflex/utils/hosting.py | 304 +++++++++++++++++++++--------------- tests/test_reflex.py | 64 ++------ tests/utils/test_hosting.py | 70 +++++---- 8 files changed, 288 insertions(+), 268 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 625b4add3..9ab7dd57c 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -190,6 +190,11 @@ class Config(Base): # The rxdeploy url. rxdeploy_url: Optional[str] = None + # The hosting service backend URL. + cp_backend_url: str = constants.Hosting.CP_BACKEND_URL + # The hosting service frontend URL. + cp_web_url: str = constants.Hosting.CP_WEB_URL + # The username. username: Optional[str] = None diff --git a/reflex/config.pyi b/reflex/config.pyi index 73c9f6766..ebe015ffd 100644 --- a/reflex/config.pyi +++ b/reflex/config.pyi @@ -66,6 +66,8 @@ class Config(Base): event_namespace: Optional[str] frontend_packages: List[str] rxdeploy_url: Optional[str] + cp_backend_url: str + cp_web_url: str username: Optional[str] def __init__( @@ -90,6 +92,8 @@ class Config(Base): event_namespace: Optional[str] = None, frontend_packages: Optional[List[str]] = None, rxdeploy_url: Optional[str] = None, + cp_backend_url: Optional[str] = None, + cp_web_url: Optional[str] = None, username: Optional[str] = None, **kwargs ) -> None: ... diff --git a/reflex/constants/hosting.py b/reflex/constants/hosting.py index 47a819f91..d4080fb3f 100644 --- a/reflex/constants/hosting.py +++ b/reflex/constants/hosting.py @@ -10,40 +10,15 @@ class Hosting: # The hosting config json file HOSTING_JSON = os.path.join(Reflex.DIR, "hosting_v0.json") # The hosting service backend URL - CP_BACKEND_URL = "https://rxcp-dev-control-plane.fly.dev" + CP_BACKEND_URL = "https://rxcp-prod-control-plane.fly.dev" # The hosting service webpage URL - CP_WEB_URL = "https://control-plane.dev.reflexcorp.run" - # Endpoint to create or update a deployment - POST_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments" - # Endpoint to get all deployments for the user - GET_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments" - # Endpoint to fetch information from backend in preparation of a deployment - POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{CP_BACKEND_URL}/deployments/prepare" - # Endpoint to authenticate current user - POST_VALIDATE_ME_ENDPOINT = f"{CP_BACKEND_URL}/authenticate/me" - # Endpoint to fetch a login token after user completes authentication on web - FETCH_TOKEN_ENDPOINT = f"{CP_BACKEND_URL}/authenticate" - # Endpoint to delete a deployment - DELETE_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments" - # Endpoint to get deployment status - GET_DEPLOYMENT_STATUS_ENDPOINT = f"{CP_BACKEND_URL}/deployments" - # Websocket endpoint to stream logs of a deployment - DEPLOYMENT_LOGS_ENDPOINT = f'{CP_BACKEND_URL.replace("http", "ws")}/deployments' + CP_WEB_URL = "https://control-plane.reflex.run" + # The number of times to try and wait for the user to complete web authentication. WEB_AUTH_RETRIES = 60 # The time to sleep between requests to check if for authentication completion. In seconds. WEB_AUTH_SLEEP_DURATION = 5 - # The expected number of milestones - MILESTONES_COUNT = 6 - # Expected server response time to new deployment request. In seconds. - DEPLOYMENT_PICKUP_DELAY = 30 # The time to wait for the backend to come up after user initiates deployment. In seconds. BACKEND_POLL_RETRIES = 30 # The time to wait for the frontend to come up after user initiates deployment. In seconds. FRONTEND_POLL_RETRIES = 30 - # End of deployment workflow message. Used to determine if it is the last message from server. - END_OF_DEPLOYMENT_MESSAGES = ["deploy success", "deploy failed"] - # How many iterations to try and print the deployment event messages from server during deployment. - DEPLOYMENT_EVENT_MESSAGES_RETRIES = 30 - # Timeout limit for http requests - HTTP_REQUEST_TIMEOUT = 5 # seconds diff --git a/reflex/reflex.py b/reflex/reflex.py index 418084153..13bbede7e 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -2,7 +2,6 @@ import asyncio import atexit -import contextlib import json import os import shutil @@ -276,6 +275,11 @@ def export( help="The directory to export the zip files to.", show_default=False, ), + backend_exclude_sqlite_db_files: bool = typer.Option( + True, + help="Whether to exclude sqlite db files when exporting backend.", + show_default=False, + ), loglevel: constants.LogLevel = typer.Option( console._LOG_LEVEL, help="The log level to use." ), @@ -306,6 +310,7 @@ def export( zip=zipping, zip_dest_dir=zip_dest_dir, deploy_url=config.deploy_url, + backend_exclude_sqlite_db_files=backend_exclude_sqlite_db_files, ) # Post a telemetry event. @@ -322,39 +327,19 @@ def login( # Set the log level. console.set_log_level(loglevel) - # Check if the user is already logged in. - # Token is the access token, a JWT token obtained from auth provider - # after user completes authentication on web - access_token = None - # For initial hosting offering, it is by invitation only - # The login page is enabled only after a valid invitation code is entered - invitation_code = "" - using_existing_token = False - - with contextlib.suppress(Exception): - access_token, invitation_code = hosting.get_existing_access_token() - using_existing_token = True - console.debug("Existing token found, proceed to validate") + access_token, invitation_code = hosting.authenticated_token() + if access_token: + console.print("You already logged in.") + return # If not already logged in, open a browser window/tab to the login page. - if not using_existing_token: - access_token, invitation_code = hosting.authenticate_on_browser(invitation_code) + access_token = hosting.authenticate_on_browser(invitation_code) if not access_token: - console.error( - f"Unable to fetch access token. Please try again or contact support." - ) + console.error(f"Unable to authenticate. Please try again or contact support.") raise typer.Exit(1) - if not hosting.validate_token_with_retries(access_token): - console.error(f"Unable to validate token. Please try again or contact support.") - raise typer.Exit(1) - - if not using_existing_token: - hosting.save_token_to_config(access_token, invitation_code) - console.print("Successfully logged in.") - else: - console.print("You already logged in.") + console.print("Successfully logged in.") @cli.command() @@ -368,7 +353,7 @@ def logout( hosting.log_out_on_browser() console.debug("Deleting access token from config locally") - hosting.delete_token_from_config() + hosting.delete_token_from_config(include_invitation_code=True) db_cli = typer.Typer() @@ -483,6 +468,10 @@ def deploy( None, help="Setting to export tracing for the deployment. Setup required in user code.", ), + backend_exclude_sqlite_db_files: bool = typer.Option( + True, + help="Whether to exclude sqlite db files from the backend export.", + ), loglevel: constants.LogLevel = typer.Option( config.loglevel, help="The log level to use." ), @@ -569,6 +558,7 @@ def deploy( zipping=True, zip_dest_dir=tmp_dir, loglevel=loglevel, + backend_exclude_sqlite_db_files=backend_exclude_sqlite_db_files, ) except ImportError as ie: console.error( @@ -654,7 +644,7 @@ def deploy( ) return console.warn( - "Your deployment is taking unusually long. Check back later on its status: `reflex deployments status`" + f"Your deployment is taking unusually long. Check back later on its status: `reflex deployments status {key}`" ) @@ -797,6 +787,27 @@ def get_deployment_deploy_logs( raise typer.Exit(1) from ex +@deployments_cli.command(name="regions") +def get_deployment_regions( + loglevel: constants.LogLevel = typer.Option( + config.loglevel, help="The log level to use." + ), + as_json: bool = typer.Option( + False, "-j", "--json", help="Whether to output the result in json format." + ), +): + """List all the regions of the hosting service.""" + console.set_log_level(loglevel) + list_regions_info = hosting.get_regions() + if as_json: + console.print(json.dumps(list_regions_info)) + return + if list_regions_info: + headers = list(list_regions_info[0].keys()) + table = [list(deployment.values()) for deployment in list_regions_info] + console.print(tabulate(table, headers=headers)) + + cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.") cli.add_typer( deployments_cli, diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 3e38b0ea1..e00e510e1 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -60,7 +60,7 @@ def _zip( target: str, root_dir: str, exclude_venv_dirs: bool, - exclude_sqlite_db_files: bool, + exclude_sqlite_db_files: bool = True, dirs_to_exclude: set[str] | None = None, files_to_exclude: set[str] | None = None, ) -> None: @@ -127,6 +127,7 @@ def export( zip: bool = False, zip_dest_dir: str = os.getcwd(), deploy_url: str | None = None, + backend_exclude_sqlite_db_files: bool = True, ): """Export the app for deployment. @@ -136,6 +137,7 @@ def export( zip: Whether to zip the app. zip_dest_dir: The destination directory for created zip files (if any) deploy_url: The URL of the deployed app. + backend_exclude_sqlite_db_files: Whether to exclude sqlite db files from the backend zip. """ # Remove the static folder. path_ops.rm(constants.Dirs.WEB_STATIC) @@ -183,7 +185,6 @@ def export( root_dir=".web/_static", files_to_exclude=files_to_exclude, exclude_venv_dirs=False, - exclude_sqlite_db_files=False, ) if backend: _zip( @@ -195,7 +196,7 @@ def export( dirs_to_exclude={"assets", "__pycache__"}, files_to_exclude=files_to_exclude, exclude_venv_dirs=True, - exclude_sqlite_db_files=True, + exclude_sqlite_db_files=backend_exclude_sqlite_db_files, ) diff --git a/reflex/utils/hosting.py b/reflex/utils/hosting.py index f7466603d..5d4f194d3 100644 --- a/reflex/utils/hosting.py +++ b/reflex/utils/hosting.py @@ -19,32 +19,57 @@ from pydantic import Field, ValidationError, root_validator from reflex import constants from reflex.base import Base +from reflex.config import get_config from reflex.utils import console +config = get_config() +# Endpoint to create or update a deployment +POST_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" +# Endpoint to get all deployments for the user +GET_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" +# Endpoint to fetch information from backend in preparation of a deployment +POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{config.cp_backend_url}/deployments/prepare" +# Endpoint to authenticate current user +POST_VALIDATE_ME_ENDPOINT = f"{config.cp_backend_url}/authenticate/me" +# Endpoint to fetch a login token after user completes authentication on web +FETCH_TOKEN_ENDPOINT = f"{config.cp_backend_url}/authenticate" +# Endpoint to delete a deployment +DELETE_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" +# Endpoint to get deployment status +GET_DEPLOYMENT_STATUS_ENDPOINT = f"{config.cp_backend_url}/deployments" +# Public endpoint to get the list of supported regions in hosting service +GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions" +# Websocket endpoint to stream logs of a deployment +DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments' +# Expected server response time to new deployment request. In seconds. +DEPLOYMENT_PICKUP_DELAY = 30 +# End of deployment workflow message. Used to determine if it is the last message from server. +END_OF_DEPLOYMENT_MESSAGES = ["deploy success", "deploy failed"] +# How many iterations to try and print the deployment event messages from server during deployment. +DEPLOYMENT_EVENT_MESSAGES_RETRIES = 90 +# Timeout limit for http requests +HTTP_REQUEST_TIMEOUT = 60 # seconds + def get_existing_access_token() -> tuple[str, str]: """Fetch the access token from the existing config if applicable. - Raises: - Exception: if runs into any issues, file not exist, ill-formatted, etc. - Returns: - The access token and optionally the invitation code if valid, otherwise empty string. + The access token and the invitation code. + If either is not found, return empty string for it instead. """ console.debug("Fetching token from existing config...") + access_token = invitation_code = "" try: with open(constants.Hosting.HOSTING_JSON, "r") as config_file: hosting_config = json.load(config_file) - - assert ( - access_token := hosting_config.get("access_token", "") - ), "no access token found or empty token" - return access_token, hosting_config.get("code") + access_token = hosting_config.get("access_token", "") + invitation_code = hosting_config.get("code", "") except Exception as ex: console.debug( f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) - raise Exception("no existing login found") from ex + return access_token, invitation_code def validate_token(token: str): @@ -59,9 +84,9 @@ def validate_token(token: str): """ try: response = httpx.post( - constants.Hosting.POST_VALIDATE_ME_ENDPOINT, + POST_VALIDATE_ME_ENDPOINT, headers=authorization_header(token), - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + timeout=HTTP_REQUEST_TIMEOUT, ) if response.status_code == HTTPStatus.FORBIDDEN: raise ValueError @@ -80,14 +105,22 @@ def validate_token(token: str): raise Exception("internal errors") from ex -def delete_token_from_config(): - """Delete the invalid token from the config file if applicable.""" +def delete_token_from_config(include_invitation_code: bool = False): + """Delete the invalid token from the config file if applicable. + + Args: + include_invitation_code: + Whether to delete the invitation code as well. + When user logs out, we delete the invitation code together. + """ if os.path.exists(constants.Hosting.HOSTING_JSON): hosting_config = {} try: with open(constants.Hosting.HOSTING_JSON, "w") as config_file: hosting_config = json.load(config_file) del hosting_config["access_token"] + if include_invitation_code: + del hosting_config["code"] json.dump(hosting_config, config_file) except Exception as ex: # Best efforts removing invalid token is OK @@ -97,14 +130,11 @@ def delete_token_from_config(): def save_token_to_config(token: str, code: str | None = None): - """Cache the token, and optionally invitation code to the config file. + """Best efforts cache the token, and optionally invitation code to the config file. Args: token: The access token to save. code: The invitation code to save if exists. - - Raise: - Exception: if runs into any issues, file not exist, etc. """ hosting_config: dict[str, str] = {"access_token": token} if code: @@ -118,31 +148,23 @@ def save_token_to_config(token: str, code: str | None = None): ) -def authenticated_token() -> str | None: +def authenticated_token() -> tuple[str, str]: """Fetch the access token from the existing config if applicable and validate it. Returns: - The access token if it is valid, None otherwise. + The access token and the invitation code. + If either is not found, return empty string for it instead. """ # Check if the user is authenticated - try: - token, _ = get_existing_access_token() - if not token: - console.debug("No token found from the existing config.") - return None - validate_token(token) - return token - except Exception as ex: - console.debug(f"Unable to validate the token from the existing config: {ex}") - try: - console.debug("Try to delete the invalid token from config file") - with open(constants.Hosting.HOSTING_JSON, "rw") as config_file: - hosting_config = json.load(config_file) - del hosting_config["access_token"] - json.dump(hosting_config, config_file) - except Exception as ex: - console.debug(f"Unable to delete the invalid token from config file: {ex}") - return None + + access_token, invitation_code = get_existing_access_token() + if not access_token: + console.debug("No access token found from the existing config.") + access_token = "" + elif not validate_token_with_retries(access_token): + access_token = "" + + return access_token, invitation_code def authorization_header(token: str) -> dict[str, str]: @@ -157,6 +179,18 @@ def authorization_header(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +def requires_authenticated() -> str: + """Check if the user is authenticated. + + Returns: + The validated access token or empty string if not authenticated. + """ + access_token, invitation_code = authenticated_token() + if access_token: + return access_token + return authenticate_on_browser(invitation_code) + + class DeploymentPrepInfo(Base): """The params/settings returned from the prepare endpoint including the deployment key and the frontend/backend URLs once deployed. @@ -246,16 +280,16 @@ def prepare_deploy( The response containing the backend URLs if successful, None otherwise. """ # Check if the user is authenticated - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") try: response = httpx.post( - constants.Hosting.POST_DEPLOYMENTS_PREPARE_ENDPOINT, + POST_DEPLOYMENTS_PREPARE_ENDPOINT, headers=authorization_header(token), json=DeploymentsPreparePostParam( app_name=app_name, key=key, frontend_hostname=frontend_hostname ).dict(exclude_none=True), - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + timeout=HTTP_REQUEST_TIMEOUT, ) response_json = response.json() @@ -377,7 +411,7 @@ def deploy( The response containing the URL of the site to be deployed if successful, None otherwise. """ # Check if the user is authenticated - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") try: @@ -407,11 +441,15 @@ def deploy( ("files", (backend_file_name, backend_file)), ] response = httpx.post( - constants.Hosting.POST_DEPLOYMENTS_ENDPOINT, + POST_DEPLOYMENTS_ENDPOINT, headers=authorization_header(token), data=params.dict(exclude_none=True), files=files, ) + # If the server explicitly states bad request, + # display a different error + if response.status_code == HTTPStatus.BAD_REQUEST: + raise ValueError(response.json()["detail"]) response.raise_for_status() response_json = response.json() return DeploymentPostResponse( @@ -430,6 +468,9 @@ def deploy( except (KeyError, ValidationError) as kve: console.debug(f"Post params or server response format unexpected: {kve}") raise Exception("internal errors") from kve + except ValueError as ve: + console.debug(f"Unable to deploy due to request error: {ve}") + raise Exception("request error") from ve except Exception as ex: console.debug(f"Unable to deploy due to internal errors: {ex}.") raise Exception("internal errors") from ex @@ -477,17 +518,17 @@ def list_deployments( Returns: The list of deployments if successful, None otherwise. """ - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") params = DeploymentsGetParam(app_name=app_name) try: response = httpx.get( - constants.Hosting.GET_DEPLOYMENTS_ENDPOINT, + GET_DEPLOYMENTS_ENDPOINT, headers=authorization_header(token), params=params.dict(exclude_none=True), - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + timeout=HTTP_REQUEST_TIMEOUT, ) response.raise_for_status() return [ @@ -523,34 +564,30 @@ def fetch_token(request_id: str) -> tuple[str, str]: Args: request_id: The request ID used when the user opens the browser for authentication. - Raises: - Exception: For request timeout, failed requests, ill-formed responses, unexpected errors. - Returns: The access token if it exists, None otherwise. """ + access_token = invitation_code = "" try: resp = httpx.get( - f"{constants.Hosting.FETCH_TOKEN_ENDPOINT}/{request_id}", - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + f"{FETCH_TOKEN_ENDPOINT}/{request_id}", + timeout=HTTP_REQUEST_TIMEOUT, ) resp.raise_for_status() - return (resp_json := resp.json())["access_token"], resp_json.get("code", "") + access_token = (resp_json := resp.json()).get("access_token", "") + invitation_code = resp_json.get("code", "") except httpx.RequestError as re: console.debug(f"Unable to fetch token due to request error: {re}") - raise Exception("request timeout") from re except httpx.HTTPError as he: console.debug(f"Unable to fetch token due to {he}") - raise Exception("not found") from he except json.JSONDecodeError as jde: console.debug(f"Server did not respond with valid json: {jde}") - raise Exception("internal errors") from jde except KeyError as ke: console.debug(f"Server response format unexpected: {ke}") - raise Exception("internal errors") from ke - except Exception as ex: + except Exception: console.debug("Unexpected errors: {ex}") - raise Exception("internal errors") from ex + + return access_token, invitation_code def poll_backend(backend_url: str) -> bool: @@ -564,9 +601,7 @@ def poll_backend(backend_url: str) -> bool: """ try: console.debug(f"Polling backend at {backend_url}") - resp = httpx.get( - f"{backend_url}/ping", timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT - ) + resp = httpx.get(f"{backend_url}/ping", timeout=HTTP_REQUEST_TIMEOUT) resp.raise_for_status() return True except httpx.HTTPError: @@ -584,9 +619,7 @@ def poll_frontend(frontend_url: str) -> bool: """ try: console.debug(f"Polling frontend at {frontend_url}") - resp = httpx.get( - f"{frontend_url}", timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT - ) + resp = httpx.get(f"{frontend_url}", timeout=HTTP_REQUEST_TIMEOUT) resp.raise_for_status() return True except httpx.HTTPError: @@ -610,16 +643,16 @@ def delete_deployment(key: str): ValueError: If the key is not provided. Exception: If the operation fails. The exception message is the reason. """ - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") if not key: raise ValueError("Valid key is required for the delete.") try: response = httpx.delete( - f"{constants.Hosting.DELETE_DEPLOYMENTS_ENDPOINT}/{key}", + f"{DELETE_DEPLOYMENTS_ENDPOINT}/{key}", headers=authorization_header(token), - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + timeout=HTTP_REQUEST_TIMEOUT, ) response.raise_for_status() @@ -691,14 +724,14 @@ def get_deployment_status(key: str) -> DeploymentStatusResponse: "A non empty key is required for querying the deployment status." ) - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") try: response = httpx.get( - f"{constants.Hosting.GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status", + f"{GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status", headers=authorization_header(token), - timeout=constants.Hosting.HTTP_REQUEST_TIMEOUT, + timeout=HTTP_REQUEST_TIMEOUT, ) response.raise_for_status() response_json = response.json() @@ -769,12 +802,12 @@ async def get_logs( Exception: If the operation fails. The exception message is the reason. """ - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") if not key: raise ValueError("Valid key is required for querying logs.") try: - logs_endpoint = f"{constants.Hosting.DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}" + logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}" console.debug(f"log server endpoint: {logs_endpoint}") if from_iso_timestamp is not None: logs_endpoint += ( @@ -786,18 +819,22 @@ async def get_logs( row_json = json.loads(await ws.recv()) console.debug(f"Server responded with logs: {row_json}") if row_json and isinstance(row_json, dict): - if "timestamp" in row_json: - row_json["timestamp"] = convert_to_local_time( - row_json["timestamp"] - ) - print(" | ".join(row_json.values())) + row_to_print = {} + for k, v in row_json.items(): + if v is None: + row_to_print[k] = str(v) + elif k == "timestamp": + row_to_print[k] = convert_to_local_time(v) + else: + row_to_print[k] = v + print(" | ".join(row_to_print.values())) else: console.debug("Server responded, no new logs, this is normal") except Exception as ex: console.debug(f"Unable to get more deployment logs due to {ex}.") console.print("Log server disconnected ...") console.print( - "Note that the server has limit to only stream logs for several minutes to conserve resources" + "Note that the server has limit to only stream logs for several minutes" ) @@ -814,37 +851,37 @@ def check_requirements_txt_exist(): def authenticate_on_browser( - invitation_code: str | None, -) -> tuple[str | None, str | None]: + invitation_code: str, +) -> str: """Open the browser to authenticate the user. Args: invitation_code: The invitation code if it exists. - Raises: - SystemExit: If the browser cannot be opened. - Returns: - The access token and invitation if valid, Nones otherwise. + The access token if valid, empty otherwise. """ - console.print(f"Opening {constants.Hosting.CP_WEB_URL} ...") + console.print(f"Opening {config.cp_web_url} ...") request_id = uuid.uuid4().hex - if not webbrowser.open( - f"{constants.Hosting.CP_WEB_URL}?request-id={request_id}&code={invitation_code}" - ): - console.error( - f"Unable to open the browser to authenticate. Please contact support." + auth_url = f"{config.cp_web_url}?request-id={request_id}&code={invitation_code}" + if not webbrowser.open(auth_url): + console.warn( + f"Unable to automatically open the browser. Please go to {auth_url} to authenticate." ) - raise SystemExit("Unable to open browser for authentication.") + access_token = invitation_code = "" with console.status("Waiting for access token ..."): for _ in range(constants.Hosting.WEB_AUTH_RETRIES): - try: - return fetch_token(request_id) - except Exception: - pass - time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION) + access_token, invitation_code = fetch_token(request_id) + if access_token: + break + else: + time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION) - return None, None + if access_token and validate_token_with_retries(access_token): + save_token_to_config(access_token, invitation_code) + else: + access_token = "" + return access_token def validate_token_with_retries(access_token: str) -> bool: @@ -853,23 +890,21 @@ def validate_token_with_retries(access_token: str) -> bool: Args: access_token: The access token to validate. - Raises: - SystemExit: If the token is confirmed invalid by server. - Returns: - True if the token is valid, False otherwise. + True if the token is valid, + False if invalid or unable to validate. """ with console.status("Validating access token ..."): for _ in range(constants.Hosting.WEB_AUTH_RETRIES): try: validate_token(access_token) return True - except ValueError as ve: + except ValueError: console.error(f"Access denied") delete_token_from_config() - raise SystemExit("Access denied") from ve + break except Exception as ex: - console.debug(f"Unable to validate token due to: {ex}") + console.debug(f"Unable to validate token due to: {ex}, trying again") time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION) return False @@ -957,20 +992,17 @@ def process_envs(envs: list[str]) -> dict[str, str]: def log_out_on_browser(): - """Open the browser to authenticate the user. - - Raises: - SystemExit: If the browser cannot be opened. - """ + """Open the browser to authenticate the user.""" # Fetching existing invitation code so user sees the log out page without having to enter it invitation_code = None with contextlib.suppress(Exception): _, invitation_code = get_existing_access_token() console.debug("Found existing invitation code in config") - console.print(f"Opening {constants.Hosting.CP_WEB_URL} ...") - if not webbrowser.open(f"{constants.Hosting.CP_WEB_URL}?code={invitation_code}"): - raise SystemExit( - f"Unable to open the browser to log out. Please contact support." + delete_token_from_config() + console.print(f"Opening {config.cp_web_url} ...") + if not webbrowser.open(f"{config.cp_web_url}?code={invitation_code}"): + console.warn( + f"Unable to open the browser automatically. Please go to {config.cp_web_url} to log out." ) @@ -987,16 +1019,16 @@ async def display_deploy_milestones(key: str, from_iso_timestamp: datetime): """ if not key: raise ValueError("Non-empty key is required for querying deploy status.") - if not (token := authenticated_token()): + if not (token := requires_authenticated()): raise Exception("not authenticated") try: - logs_endpoint = f"{constants.Hosting.DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={LogType.DEPLOY_LOG.value}&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}" + 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()}" console.debug(f"log server endpoint: {logs_endpoint}") _ws = websockets.connect(logs_endpoint) # type: ignore async with _ws as ws: # Stream back the deploy events reported back from the server - for _ in range(constants.Hosting.DEPLOYMENT_EVENT_MESSAGES_RETRIES): + for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES): row_json = json.loads(await ws.recv()) console.debug(f"Server responded with: {row_json}") if row_json and isinstance(row_json, dict): @@ -1011,7 +1043,7 @@ async def display_deploy_milestones(key: str, from_iso_timestamp: datetime): ) if any( msg in row_json["message"].lower() - for msg in constants.Hosting.END_OF_DEPLOYMENT_MESSAGES + for msg in END_OF_DEPLOYMENT_MESSAGES ): console.debug( "Received end of deployment message, stop event message streaming" @@ -1026,9 +1058,9 @@ async def display_deploy_milestones(key: str, from_iso_timestamp: datetime): def wait_for_server_to_pick_up_request(): """Wait for server to pick up the request. Right now is just sleep.""" with console.status( - f"Waiting for server to pick up request ~ {constants.Hosting.DEPLOYMENT_PICKUP_DELAY} seconds ..." + f"Waiting for server to pick up request ~ {DEPLOYMENT_PICKUP_DELAY} seconds ..." ): - for _ in range(constants.Hosting.DEPLOYMENT_PICKUP_DELAY): + for _ in range(DEPLOYMENT_PICKUP_DELAY): time.sleep(1) @@ -1040,11 +1072,11 @@ def interactive_prompt_for_envs() -> list[str]: """ envs = [] envs_finished = False - env_key_prompt = " Env name (enter to skip)" + env_count = 1 + env_key_prompt = f" * env-{env_count} name (enter to skip)" console.print("Environment variables ...") while not envs_finished: env_key = console.ask(env_key_prompt) - env_key_prompt = " env name (enter to finish)" if not env_key: envs_finished = True if envs: @@ -1053,6 +1085,34 @@ def interactive_prompt_for_envs() -> list[str]: console.print("No envs added. Continuing ...") break # If it possible to have empty values for env, so we do not check here - env_value = console.ask(" env value") + env_value = console.ask(f" env-{env_count} value") envs.append(f"{env_key}={env_value}") + env_count += 1 + env_key_prompt = f" * env-{env_count} name (enter to skip)" return envs + + +def get_regions() -> list[dict]: + """Get the supported regions from the hosting server. + + Returns: + A list of dict representation of the region information. + """ + try: + response = httpx.get( + GET_REGIONS_ENDPOINT, + timeout=HTTP_REQUEST_TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + assert response_json and isinstance( + response_json, list + ), "Expect server to return a list " + assert not response_json or ( + response_json[0] is not None and isinstance(response_json[0], dict) + ), "Expect return values are dict's" + + return response_json + except Exception as ex: + console.debug(f"Unable to get regions due to {ex}.") + return [] diff --git a/tests/test_reflex.py b/tests/test_reflex.py index 9656d8282..ddba5d2cc 100644 --- a/tests/test_reflex.py +++ b/tests/test_reflex.py @@ -10,74 +10,34 @@ from reflex.utils.hosting import DeploymentPrepInfo runner = CliRunner() -def test_login_success(mocker): - mock_get_existing_access_token = mocker.patch( - "reflex.utils.hosting.get_existing_access_token", +def test_login_success_existing_token(mocker): + mocker.patch( + "reflex.utils.hosting.authenticated_token", return_value=("fake-token", "fake-code"), ) - mock_validate_token = mocker.patch( - "reflex.utils.hosting.validate_token_with_retries" - ) result = runner.invoke(cli, ["login"]) assert result.exit_code == 0 - mock_get_existing_access_token.assert_called_once() - mock_validate_token.assert_called_once_with("fake-token") -def test_login_existing_token_but_invalid(mocker): +def test_login_success_on_browser(mocker): mocker.patch( - "reflex.utils.hosting.get_existing_access_token", - return_value=("fake-token", "fake-code"), + "reflex.utils.hosting.authenticated_token", + return_value=("", "fake-code"), ) - mocker.patch( - "reflex.utils.hosting.validate_token", - side_effect=ValueError("token not valid"), - ) - mock_delete_token_from_config = mocker.patch( - "reflex.utils.hosting.delete_token_from_config" - ) - result = runner.invoke(cli, ["login"]) - assert result.exit_code == 1 - # Make sure the invalid token delete is performed - mock_delete_token_from_config.assert_called_once() - - -def test_login_no_existing_token_fetched_valid(mocker): - # Access token does not exist, but user authenticates successfully on browser. - mocker.patch( - "reflex.utils.hosting.get_existing_access_token", - side_effect=Exception("no token found"), - ) - - # Token is fetched successfully - mocker.patch( - "reflex.utils.hosting.authenticate_on_browser", - return_value=("fake-token2", "fake-code2"), - ) - mock_validate_token = mocker.patch( - "reflex.utils.hosting.validate_token_with_retries" - ) - mock_save_token_to_config = mocker.patch( - "reflex.utils.hosting.save_token_to_config" + mock_authenticate_on_browser = mocker.patch( + "reflex.utils.hosting.authenticate_on_browser", return_value="fake-token" ) result = runner.invoke(cli, ["login"]) assert result.exit_code == 0 - mock_validate_token.assert_called_once_with( - "fake-token2", - ) - mock_save_token_to_config.assert_called_once_with("fake-token2", "fake-code2") + mock_authenticate_on_browser.assert_called_once_with("fake-code") -def test_login_no_existing_token_fetch_none(mocker): +def test_login_fail(mocker): # Access token does not exist, but user authenticates successfully on browser. mocker.patch( - "reflex.utils.hosting.get_existing_access_token", - side_effect=Exception("no token found"), - ) - # Token is not fetched - mocker.patch( - "reflex.utils.hosting.authenticate_on_browser", return_value=(None, None) + "reflex.utils.hosting.get_existing_access_token", return_value=("", "") ) + mocker.patch("reflex.utils.hosting.authenticate_on_browser", return_value="") result = runner.invoke(cli, ["login"]) assert result.exit_code == 1 diff --git a/tests/utils/test_hosting.py b/tests/utils/test_hosting.py index 57e38afa3..bb44df705 100644 --- a/tests/utils/test_hosting.py +++ b/tests/utils/test_hosting.py @@ -14,7 +14,7 @@ def test_get_existing_access_token_and_no_invitation_code(mocker): mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config))) token, code = hosting.get_existing_access_token() assert token == mock_hosting_config["access_token"] - assert code is None + assert code == "" def test_get_existing_access_token_and_invitation_code(mocker): @@ -28,35 +28,37 @@ def test_get_existing_access_token_and_invitation_code(mocker): def test_no_existing_access_token(mocker): # Config file does not have access token - mock_hosting_config = {"code": "fake_code"} - mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config))) - with pytest.raises(Exception): - token, _ = hosting.get_existing_access_token() - assert token is None + mocker.patch( + "builtins.open", + mock_open(read_data=json.dumps({"no-token": "here", "no-code": "here"})), + ) + access_token, invitation_code = hosting.get_existing_access_token() + assert access_token == "" + assert invitation_code == "" def test_no_config_file(mocker): # Config file not exist mocker.patch("builtins.open", side_effect=FileNotFoundError) - with pytest.raises(Exception) as ex: - hosting.get_existing_access_token() - assert ex.value == "No existing login found" + access_token, invitation_code = hosting.get_existing_access_token() + assert access_token == "" + assert invitation_code == "" def test_empty_config_file(mocker): # Config file is empty mocker.patch("builtins.open", mock_open(read_data="")) - with pytest.raises(Exception) as ex: - hosting.get_existing_access_token() - assert ex.value == "No existing login found" + access_token, invitation_code = hosting.get_existing_access_token() + assert access_token == "" + assert invitation_code == "" def test_invalid_json_config_file(mocker): # Config file content is not valid json mocker.patch("builtins.open", mock_open(read_data="im not json content")) - with pytest.raises(Exception) as ex: - hosting.get_existing_access_token() - assert ex.value == "No existing login found" + access_token, invitation_code = hosting.get_existing_access_token() + assert access_token == "" + assert invitation_code == "" def test_validate_token_success(mocker): @@ -124,20 +126,21 @@ def test_save_access_code_but_none_invitation_code_to_config(mocker): def test_authenticated_token_success(mocker): access_token = "fake_token" + invitation_code = "fake_code" mocker.patch( "reflex.utils.hosting.get_existing_access_token", - return_value=(access_token, "fake_code"), + return_value=(access_token, invitation_code), ) - mocker.patch("reflex.utils.hosting.validate_token") - assert hosting.authenticated_token() == access_token + mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=True) + assert hosting.authenticated_token() == (access_token, invitation_code) def test_no_authenticated_token(mocker): mocker.patch( "reflex.utils.hosting.get_existing_access_token", - return_value=(None, None), + return_value=("", "code-does-not-matter"), ) - assert hosting.authenticated_token() is None + assert hosting.authenticated_token()[0] == "" def test_maybe_authenticated_token_is_invalid(mocker): @@ -145,30 +148,30 @@ def test_maybe_authenticated_token_is_invalid(mocker): "reflex.utils.hosting.get_existing_access_token", return_value=("invalid_token", "fake_code"), ) - mocker.patch("reflex.utils.hosting.validate_token", side_effect=ValueError) - mocker.patch("builtins.open") - mocker.patch("json.load") - mock_json_dump = mocker.patch("json.dump") - assert hosting.authenticated_token() is None - mock_json_dump.assert_called_once() + mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=False) + assert hosting.authenticated_token()[0] == "" def test_prepare_deploy_not_authenticated(mocker): - mocker.patch("reflex.utils.hosting.authenticated_token", return_value=None) + mocker.patch("reflex.utils.hosting.requires_authenticated", return_value=None) with pytest.raises(Exception) as ex: hosting.prepare_deploy("fake-app") assert ex.value == "Not authenticated" def test_server_unable_to_prepare_deploy(mocker): - mocker.patch("reflex.utils.hosting.authenticated_token", return_value="fake_token") + mocker.patch( + "reflex.utils.hosting.requires_authenticated", return_value="fake_token" + ) mocker.patch("httpx.post", return_value=httpx.Response(500)) with pytest.raises(Exception): hosting.prepare_deploy("fake-app") def test_prepare_deploy_success(mocker): - mocker.patch("reflex.utils.hosting.authenticated_token", return_value="fake_token") + mocker.patch( + "reflex.utils.hosting.requires_authenticated", return_value="fake_token" + ) mocker.patch( "httpx.post", return_value=Mock( @@ -190,7 +193,9 @@ def test_prepare_deploy_success(mocker): def test_deploy(mocker): - mocker.patch("reflex.utils.hosting.authenticated_token", return_value="fake_token") + mocker.patch( + "reflex.utils.hosting.requires_authenticated", return_value="fake_token" + ) mocker.patch("builtins.open") mocker.patch( "httpx.post", @@ -224,14 +229,13 @@ def test_validate_token_with_retries_failed(mocker): assert mock_delete_token.call_count == 0 -def test_validate_token_access_denied(mocker): +def test_validate_token_with_retries_access_denied(mocker): mock_validate_token = mocker.patch( "reflex.utils.hosting.validate_token", side_effect=ValueError ) mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config") mocker.patch("time.sleep") - with pytest.raises(SystemExit): - hosting.validate_token_with_retries("fake-token") + assert hosting.validate_token_with_retries("fake-token") is False assert mock_validate_token.call_count == 1 assert mock_delete_token.call_count == 1