CLI switch to prod server (#2016)

This commit is contained in:
Martin Xu 2023-10-24 09:43:20 -07:00 committed by GitHub
parent 53566c2adf
commit 81053618c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 288 additions and 268 deletions

View File

@ -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

View File

@ -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: ...

View File

@ -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

View File

@ -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,

View File

@ -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,
)

View File

@ -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 []

View File

@ -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

View File

@ -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