Pārlūkot izejas kodu

CLI switch to prod server (#2016)

Martin Xu 1 gadu atpakaļ
vecāks
revīzija
81053618c9

+ 5 - 0
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
 

+ 4 - 0
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: ...

+ 3 - 28
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

+ 41 - 30
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,

+ 4 - 3
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,
             )
 
 

+ 182 - 122
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 []

+ 12 - 52
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",
-        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_existing_token(mocker):
     mocker.patch(
-        "reflex.utils.hosting.get_existing_access_token",
+        "reflex.utils.hosting.authenticated_token",
         return_value=("fake-token", "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()
-
+    assert result.exit_code == 0
 
-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
+def test_login_success_on_browser(mocker):
     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"
+        "reflex.utils.hosting.authenticated_token",
+        return_value=("", "fake-code"),
     )
-    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
 

+ 37 - 33
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