Hosting CLI: use http endpoint to return deploy milestones (#2085)

This commit is contained in:
Martin Xu 2023-11-03 12:13:46 -07:00 committed by GitHub
parent b313aaf3ef
commit 4c97b4c4c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 108 additions and 17 deletions

View File

@ -686,10 +686,14 @@ def deploy(
console.print("Waiting for server to report progress ...") console.print("Waiting for server to report progress ...")
# Display the key events such as build, deploy, etc # Display the key events such as build, deploy, etc
server_report_deploy_success = asyncio.get_event_loop().run_until_complete( server_report_deploy_success = hosting.poll_deploy_milestones(
hosting.display_deploy_milestones(key, from_iso_timestamp=deploy_requested_at) key, from_iso_timestamp=deploy_requested_at
) )
if not server_report_deploy_success:
if server_report_deploy_success is None:
console.warn("Hosting server timed out.")
console.warn("The deployment may still be in progress. Proceeding ...")
elif not server_report_deploy_success:
console.error("Hosting server reports failure.") console.error("Hosting server reports failure.")
console.error( console.error(
f"Check the server logs using `reflex deployments build-logs {key}`" f"Check the server logs using `reflex deployments build-logs {key}`"
@ -814,7 +818,7 @@ def get_deployment_status(
status = hosting.get_deployment_status(key) status = hosting.get_deployment_status(key)
# TODO: refactor all these tabulate calls # TODO: refactor all these tabulate calls
status.backend.updated_at = hosting.convert_to_local_time( status.backend.updated_at = hosting.convert_to_local_time_str(
status.backend.updated_at or "N/A" status.backend.updated_at or "N/A"
) )
backend_status = status.backend.dict(exclude_none=True) backend_status = status.backend.dict(exclude_none=True)
@ -823,7 +827,7 @@ def get_deployment_status(
console.print(tabulate([table], headers=headers)) console.print(tabulate([table], headers=headers))
# Add a new line in console # Add a new line in console
console.print("\n") console.print("\n")
status.frontend.updated_at = hosting.convert_to_local_time( status.frontend.updated_at = hosting.convert_to_local_time_str(
status.frontend.updated_at or "N/A" status.frontend.updated_at or "N/A"
) )
frontend_status = status.frontend.dict(exclude_none=True) frontend_status = status.frontend.dict(exclude_none=True)

View File

@ -9,7 +9,7 @@ import re
import time import time
import uuid import uuid
import webbrowser import webbrowser
from datetime import datetime from datetime import datetime, timedelta
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional
@ -41,12 +41,14 @@ GET_DEPLOYMENT_STATUS_ENDPOINT = f"{config.cp_backend_url}/deployments"
GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions" GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions"
# Websocket endpoint to stream logs of a deployment # Websocket endpoint to stream logs of a deployment
DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments' DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments'
# The HTTP endpoint to fetch logs of a deployment
POST_DEPLOYMENT_LOGS_ENDPOINT = f"{config.cp_backend_url}/deployments/logs"
# Expected server response time to new deployment request. In seconds. # Expected server response time to new deployment request. In seconds.
DEPLOYMENT_PICKUP_DELAY = 30 DEPLOYMENT_PICKUP_DELAY = 30
# End of deployment workflow message. Used to determine if it is the last message from server. # End of deployment workflow message. Used to determine if it is the last message from server.
END_OF_DEPLOYMENT_MESSAGES = ["deploy success"] END_OF_DEPLOYMENT_MESSAGES = ["deploy success"]
# How many iterations to try and print the deployment event messages from server during deployment. # How many iterations to try and print the deployment event messages from server during deployment.
DEPLOYMENT_EVENT_MESSAGES_RETRIES = 90 DEPLOYMENT_EVENT_MESSAGES_RETRIES = 120
# Timeout limit for http requests # Timeout limit for http requests
HTTP_REQUEST_TIMEOUT = 60 # seconds HTTP_REQUEST_TIMEOUT = 60 # seconds
@ -741,7 +743,23 @@ def get_deployment_status(key: str) -> DeploymentStatusResponse:
raise Exception("internal errors") from ex raise Exception("internal errors") from ex
def convert_to_local_time(iso_timestamp: str) -> str: def convert_to_local_time_with_tz(iso_timestamp: str) -> datetime | None:
"""Helper function to convert the iso timestamp to local time.
Args:
iso_timestamp: The iso timestamp to convert.
Returns:
The converted timestamp with timezone.
"""
try:
return datetime.fromisoformat(iso_timestamp).astimezone()
except (TypeError, ValueError) as ex:
console.error(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.")
return None
def convert_to_local_time_str(iso_timestamp: str) -> str:
"""Convert the iso timestamp to local time. """Convert the iso timestamp to local time.
Args: Args:
@ -750,12 +768,9 @@ def convert_to_local_time(iso_timestamp: str) -> str:
Returns: Returns:
The converted timestamp string. The converted timestamp string.
""" """
try: if (local_dt := convert_to_local_time_with_tz(iso_timestamp)) is None:
local_dt = datetime.fromisoformat(iso_timestamp).astimezone()
return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z")
except Exception as ex:
console.error(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.")
return iso_timestamp return iso_timestamp
return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z")
class LogType(str, enum.Enum): class LogType(str, enum.Enum):
@ -813,7 +828,7 @@ async def get_logs(
if v is None: if v is None:
row_to_print[k] = str(v) row_to_print[k] = str(v)
elif k == "timestamp": elif k == "timestamp":
row_to_print[k] = convert_to_local_time(v) row_to_print[k] = convert_to_local_time_str(v)
else: else:
row_to_print[k] = v row_to_print[k] = v
print(" | ".join(row_to_print.values())) print(" | ".join(row_to_print.values()))
@ -1045,6 +1060,78 @@ def log_out_on_browser():
) )
def poll_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool | None:
"""Periodically poll the hosting server for deploy milestones.
Args:
key: The deployment key.
from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
Raises:
ValueError: If a non-empty key is not provided.
Exception: If the user is not authenticated.
Returns:
False if server reports back failure, True otherwise. None if do not receive the end of deployment message.
"""
if not key:
raise ValueError("Non-empty key is required for querying deploy status.")
if not (token := requires_authenticated()):
raise Exception("not authenticated")
for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
try:
response = httpx.post(
POST_DEPLOYMENT_LOGS_ENDPOINT,
json={
"key": key,
"log_type": LogType.DEPLOY_LOG.value,
"from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(),
},
headers=authorization_header(token),
)
response.raise_for_status()
# The return is expected to be a list of dicts
response_json = response.json()
for row in response_json:
console.print(
" | ".join(
[
convert_to_local_time_str(row["timestamp"]),
row["message"],
]
)
)
# update the from timestamp to the last timestamp of received message
if (
maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"])
) is not None:
console.debug(
f"Updating from {from_iso_timestamp} to {maybe_timestamp}"
)
# Add a small delta so does not poll the same logs
from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5)
else:
console.warn(f"Unable to parse timestamp {row['timestamp']}")
server_message = row["message"].lower()
if "fail" in server_message:
console.debug(
"Received failure message, stop event message streaming"
)
return False
if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
console.debug(
"Received end of deployment message, stop event message streaming"
)
return True
time.sleep(1)
except httpx.HTTPError as he:
# This includes HTTP server and client error
console.debug(f"Unable to get more deployment events due to {he}.")
except Exception as ex:
console.warn(f"Unable to parse server response due to {ex}.")
async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool: async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool:
"""Display the deploy milestone messages reported back from the hosting server. """Display the deploy milestone messages reported back from the hosting server.
@ -1078,7 +1165,7 @@ async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> b
console.print( console.print(
" | ".join( " | ".join(
[ [
convert_to_local_time(row_json["timestamp"]), convert_to_local_time_str(row_json["timestamp"]),
row_json["message"], row_json["message"],
] ]
) )

View File

@ -118,7 +118,7 @@ def test_deploy_non_interactive_success(
), ),
) )
mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request") mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
mocker.patch("reflex.utils.hosting.display_deploy_milestones") mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
mocker.patch("reflex.utils.hosting.poll_backend", return_value=True) mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True) mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)
# TODO: typer option default not working in test for app name # TODO: typer option default not working in test for app name
@ -351,7 +351,7 @@ def test_deploy_interactive(
), ),
) )
mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request") mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
mocker.patch("reflex.utils.hosting.display_deploy_milestones") mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
mocker.patch("reflex.utils.hosting.poll_backend", return_value=True) mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True) mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)