479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""CLI for the hosting service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import click
|
|
from packaging import version
|
|
|
|
from reflex_cli import constants
|
|
from reflex_cli.utils import console
|
|
from reflex_cli.utils.dependency import extract_domain
|
|
|
|
|
|
def login(
|
|
loglevel: constants.LogLevel = constants.LogLevel.INFO,
|
|
) -> dict[str, Any]:
|
|
"""Authenticate with Reflex hosting service.
|
|
|
|
Args:
|
|
loglevel: The log level to use.
|
|
|
|
Returns:
|
|
Information about the logged in user.
|
|
|
|
Raises:
|
|
SystemExit: If the command fails.
|
|
|
|
"""
|
|
from reflex_cli.utils import hosting
|
|
|
|
# Set the log level.
|
|
console.set_log_level(loglevel)
|
|
|
|
access_token, validated_info = hosting.authenticated_token()
|
|
if access_token:
|
|
console.print("You already logged in.")
|
|
return validated_info
|
|
|
|
# If not already logged in, open a browser window/tab to the login page.
|
|
access_token, validated_info = hosting.authenticate_on_browser()
|
|
|
|
if not access_token:
|
|
console.error("Unable to authenticate. Please try again or contact support.")
|
|
raise SystemExit(1)
|
|
|
|
console.print("Successfully logged in.")
|
|
return validated_info
|
|
|
|
|
|
def logout(
|
|
loglevel: constants.LogLevel = constants.LogLevel.INFO,
|
|
):
|
|
"""Log out of access to Reflex hosting service.
|
|
|
|
Args:
|
|
loglevel: The log level to use.
|
|
|
|
"""
|
|
from reflex_cli.utils import hosting
|
|
|
|
console.set_log_level(loglevel)
|
|
|
|
console.debug("Deleting access token from config locally")
|
|
hosting.delete_token_from_config()
|
|
console.success("Successfully logged out.")
|
|
|
|
|
|
def deploy(
|
|
export_fn: Callable[[str, str, str, bool, bool, bool, bool], None]
|
|
| Callable[[str, str, str, bool, bool, bool], None],
|
|
app_name: str | None = None,
|
|
description: str | None = None,
|
|
regions: list[str] | None = None,
|
|
project: str | None = None,
|
|
envs: list[str] | None = None,
|
|
vmtype: str | None = None,
|
|
hostname: str | None = None,
|
|
interactive: bool = True,
|
|
envfile: str | None = None,
|
|
loglevel: constants.LogLevel = constants.LogLevel.INFO,
|
|
token: str | None = None,
|
|
config_path: str | None = None,
|
|
env: str | None = None,
|
|
project_name: str | None = None,
|
|
app_id: str | None = None,
|
|
**kwargs,
|
|
):
|
|
"""Deploy the app to the Reflex hosting service.
|
|
|
|
Args:
|
|
app_name: The name of the app.
|
|
export_fn: The function from the Reflex main framework to export the app.
|
|
description: The app's description.
|
|
regions: The regions to deploy to.
|
|
project: The project to deploy to.
|
|
envs: The environment variables to set.
|
|
vmtype: The VM type to allocate.
|
|
hostname: The hostname to use for the frontend.
|
|
interactive: Whether to use interactive mode.
|
|
envfile: The path to an env file to use. Will override any envs set manually.
|
|
loglevel: The log level to use.
|
|
token: The authentication token.
|
|
config_path: The path to the config file.
|
|
env: The environment to use for deployment.
|
|
project_name: The name of the project.
|
|
app_id: The ID of the app.
|
|
**kwargs: Additional keyword arguments.
|
|
|
|
Raises:
|
|
Exit: If the command fails.
|
|
|
|
"""
|
|
import httpx
|
|
|
|
from reflex_cli.utils import hosting
|
|
|
|
authenticated_client = hosting.get_authenticated_client(
|
|
token=token, interactive=interactive
|
|
)
|
|
|
|
# Set the log level.
|
|
console.set_log_level(loglevel)
|
|
project_id = project
|
|
config = {}
|
|
config_from_file = hosting.read_config(config_path, env=env)
|
|
if config_from_file:
|
|
config = dataclasses.asdict(config_from_file)
|
|
|
|
packages = None
|
|
strategy = None
|
|
include_db = False
|
|
# If a config file is provided, use values from the file that are not provided as arguments.
|
|
if config:
|
|
if not regions:
|
|
regions = config.get("regions", None)
|
|
if not vmtype:
|
|
vmtype = config.get("vmtype", None)
|
|
if not hostname:
|
|
hostname = config.get("hostname", None)
|
|
if not envfile:
|
|
envfile = config.get("envfile", None)
|
|
if not project_id:
|
|
project_id = config.get("project", None)
|
|
if not project_name:
|
|
project_name = config.get("projectname", None)
|
|
if not app_id:
|
|
app_id = config.get("appid", None)
|
|
if not isinstance(app_id, (str, type(None))):
|
|
console.error(
|
|
"app_id must be a string or None. Please check your config file."
|
|
)
|
|
raise SystemExit(1)
|
|
if not packages:
|
|
packages = config.get("packages", None)
|
|
if not include_db:
|
|
include_db = config.get("include_db", False)
|
|
if not strategy:
|
|
strategy = config.get("strategy", None)
|
|
app_name = config.get("name", app_name)
|
|
if not isinstance(app_name, (str, type(None))):
|
|
console.error(
|
|
"app_name must be a string or None. Please check your config file."
|
|
)
|
|
raise SystemExit(1)
|
|
if app_name == "default":
|
|
# not sure if this is the best check?
|
|
console.error(
|
|
"Please set real config values in cloud.yml or pyproject.toml"
|
|
)
|
|
raise SystemExit(1)
|
|
if not description:
|
|
description = config.get("description", None)
|
|
|
|
# resolve the project id from the project name.
|
|
if project_name and not project_id:
|
|
result = hosting.search_project(
|
|
project_name, client=authenticated_client, interactive=interactive
|
|
)
|
|
project_id = result.get("id") if result else None
|
|
|
|
try:
|
|
# check if provided project exists.
|
|
if project_id:
|
|
hosting.get_project(project_id, client=authenticated_client)
|
|
else:
|
|
project_id = hosting.get_selected_project()
|
|
except httpx.HTTPStatusError as ex:
|
|
try:
|
|
console.error(ex.response.json().get("detail"))
|
|
except json.JSONDecodeError:
|
|
console.error(ex.response.text)
|
|
raise click.exceptions.Exit(1) from ex
|
|
|
|
envs = envs or []
|
|
|
|
if not app_name and not app_id:
|
|
console.error(
|
|
"Please provide a valid app name or ID for the deployed instance."
|
|
)
|
|
raise click.exceptions.Exit(1)
|
|
try:
|
|
if app_name and not app_id:
|
|
search_project_id = project_id
|
|
if not interactive and not project and not search_project_id:
|
|
search_project_id = hosting.get_selected_project()
|
|
elif interactive and not project:
|
|
search_project_id = None
|
|
|
|
app = hosting.search_app(
|
|
app_name=app_name,
|
|
project_id=search_project_id,
|
|
client=authenticated_client,
|
|
interactive=interactive,
|
|
)
|
|
else:
|
|
app = hosting.get_app(app_id or "", client=authenticated_client)
|
|
app_name = app.get("name")
|
|
except click.exceptions.Exit:
|
|
raise
|
|
except Exception as ex:
|
|
console.error(f"Deployment failed: {ex}")
|
|
raise click.exceptions.Exit(1) from ex
|
|
|
|
if app and interactive and not project and not app_id:
|
|
default_project_id = hosting.get_selected_project()
|
|
app_project_id = app.get("project_id")
|
|
|
|
if app_project_id and (
|
|
not default_project_id or app_project_id != default_project_id
|
|
):
|
|
app_project = hosting.get_project(
|
|
app_project_id, client=authenticated_client
|
|
)
|
|
app_project_name = app_project.get("name", "Unknown")
|
|
if (
|
|
console.ask(
|
|
f"Deploy to app '{app['name']}' in project '{app_project_name}'?",
|
|
choices=["y", "n"],
|
|
default="y",
|
|
)
|
|
!= "y"
|
|
):
|
|
console.info("Deployment cancelled.")
|
|
raise click.exceptions.Exit(0)
|
|
|
|
project_id = app_project_id
|
|
|
|
if not app and interactive:
|
|
if (
|
|
console.ask(
|
|
f"No app with {app_name or app_id} found. Do you want to create a new app to deploy?",
|
|
choices=["y", "n"],
|
|
default="y",
|
|
)
|
|
== "y"
|
|
):
|
|
# Check if we need confirmation for deploying to non-default project
|
|
if not project:
|
|
default_project_id = hosting.get_selected_project()
|
|
if not default_project_id:
|
|
try:
|
|
if project_id:
|
|
target_project = hosting.get_project(
|
|
project_id, client=authenticated_client
|
|
)
|
|
project_name = target_project.get("name", "Unknown")
|
|
else:
|
|
token = hosting.get_existing_access_token()
|
|
default_project_id = hosting.get_default_project(
|
|
authenticated_client
|
|
)
|
|
if default_project_id:
|
|
default_project = hosting.get_project(
|
|
default_project_id, client=authenticated_client
|
|
)
|
|
project_name = default_project.get(
|
|
"name", "Default Project"
|
|
)
|
|
else:
|
|
project_name = "Default Project"
|
|
except Exception:
|
|
project_name = "Unknown"
|
|
|
|
if (
|
|
console.ask(
|
|
f"Create and deploy app '{app_name}' in project '{project_name}'?",
|
|
choices=["y", "n"],
|
|
default="y",
|
|
)
|
|
!= "y"
|
|
):
|
|
console.info("Deployment cancelled.")
|
|
raise click.exceptions.Exit(0)
|
|
elif project_id and project_id != default_project_id:
|
|
try:
|
|
target_project = hosting.get_project(
|
|
project_id, client=authenticated_client
|
|
)
|
|
project_name = target_project.get("name", "Unknown")
|
|
except Exception:
|
|
project_name = "Unknown"
|
|
|
|
if (
|
|
console.ask(
|
|
f"Create and deploy app '{app_name}' in project '{project_name}'?",
|
|
choices=["y", "n"],
|
|
default="y",
|
|
)
|
|
!= "y"
|
|
):
|
|
console.info("Deployment cancelled.")
|
|
raise click.exceptions.Exit(0)
|
|
|
|
if description is None:
|
|
description = console.ask(
|
|
"App Description (Enter to skip)",
|
|
)
|
|
app = hosting.create_app(
|
|
app_name=app_name or "",
|
|
description=description,
|
|
project_id=project_id,
|
|
client=authenticated_client,
|
|
)
|
|
console.info(f"created app. \nName: {app['name']} \nId: {app['id']}")
|
|
else:
|
|
console.error("Please create an app to deploy.")
|
|
raise click.exceptions.Exit(1)
|
|
elif not app:
|
|
app = hosting.create_app(
|
|
app_name=app_name or "",
|
|
description=description or "",
|
|
project_id=project_id,
|
|
client=authenticated_client,
|
|
)
|
|
console.info(f"created app. \nName: {app['name']} \nId: {app['id']}")
|
|
|
|
urls = hosting.get_hostname(
|
|
app_id=app["id"],
|
|
app_name=app["name"],
|
|
hostname=hostname,
|
|
client=authenticated_client,
|
|
)
|
|
if "error" in urls:
|
|
console.error(urls["error"])
|
|
raise click.exceptions.Exit(1)
|
|
server_url = os.getenv("REFLEX_OVERRIDE_BACKEND_URL") or urls["server"] # backend
|
|
host_url = os.getenv("REFLEX_OVERRIDE_FRONTEND_URL") or urls["hostname"] # frontend
|
|
processed_envs = hosting.process_envs(envs) if envs else None
|
|
|
|
if not app_name:
|
|
console.error("Please set an app name.")
|
|
raise click.exceptions.Exit(1)
|
|
|
|
# at this point, if project_id is None, the App should have the correct project_id and
|
|
# we should use that going forward to pass validation checks.
|
|
project_id = project_id or app.get("project_id")
|
|
|
|
validation_message = hosting.validate_deployment_args(
|
|
app_name=app_name,
|
|
app_id=app.get("id"),
|
|
project_id=project_id,
|
|
regions=regions,
|
|
vmtype=vmtype,
|
|
hostname=hostname,
|
|
client=authenticated_client,
|
|
)
|
|
|
|
if validation_message != "success":
|
|
console.error(validation_message)
|
|
raise click.exceptions.Exit(1)
|
|
|
|
if envfile:
|
|
try:
|
|
from dotenv import dotenv_values # pyright: ignore[reportMissingImports]
|
|
|
|
processed_envs = dotenv_values(envfile)
|
|
except ImportError:
|
|
console.error(
|
|
"""The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`."""
|
|
)
|
|
|
|
# Compile the app in production mode: backend first then frontend.
|
|
temporary_dir = tempfile.TemporaryDirectory()
|
|
temporary_dir_path = Path(temporary_dir.name)
|
|
|
|
import importlib.metadata
|
|
|
|
rx_version = version.parse(importlib.metadata.version("reflex"))
|
|
breaking_version = version.parse("0.7.6")
|
|
# Try zipping backend first
|
|
try:
|
|
# Check if the reflex version is >= 0.7.6
|
|
if rx_version <= breaking_version:
|
|
export_fn(
|
|
str(temporary_dir_path),
|
|
server_url,
|
|
host_url,
|
|
False,
|
|
True,
|
|
True,
|
|
) # pyright: ignore[reportCallIssue]
|
|
else:
|
|
export_fn(
|
|
str(temporary_dir_path),
|
|
server_url,
|
|
host_url,
|
|
False,
|
|
True,
|
|
include_db,
|
|
True, # pyright: ignore[reportCallIssue]
|
|
)
|
|
except Exception as ex:
|
|
console.error(f"Unable to export due to: {ex}")
|
|
if temporary_dir_path.exists():
|
|
shutil.rmtree(temporary_dir_path)
|
|
raise click.exceptions.Exit(1) from ex
|
|
|
|
# Zip frontend
|
|
try:
|
|
# Check if the reflex version is >= 0.7.6
|
|
if rx_version <= breaking_version:
|
|
export_fn(str(temporary_dir_path), server_url, host_url, True, False, True) # pyright: ignore[reportCallIssue]
|
|
else:
|
|
export_fn(
|
|
str(temporary_dir_path),
|
|
server_url,
|
|
host_url,
|
|
True,
|
|
False,
|
|
include_db,
|
|
True, # pyright: ignore[reportCallIssue]
|
|
)
|
|
except ImportError as ie:
|
|
console.error(
|
|
f"Encountered ImportError, did you install all the dependencies? {ie}"
|
|
)
|
|
if temporary_dir_path.exists():
|
|
shutil.rmtree(temporary_dir_path)
|
|
raise click.exceptions.Exit(1) from ie
|
|
except Exception as ex:
|
|
console.error(f"Unable to export due to: {ex}")
|
|
if temporary_dir_path.exists():
|
|
shutil.rmtree(temporary_dir_path)
|
|
raise click.exceptions.Exit(1) from ex
|
|
|
|
result = hosting.create_deployment(
|
|
app_id=app.get("id"),
|
|
app_name=app_name,
|
|
project_id=project_id,
|
|
regions=regions,
|
|
zip_dir=Path(temporary_dir_path),
|
|
hostname=extract_domain(host_url) if hostname else None,
|
|
vmtype=vmtype,
|
|
secrets=processed_envs,
|
|
client=authenticated_client,
|
|
packages=packages,
|
|
strategy=strategy,
|
|
)
|
|
if "failed" in result:
|
|
console.error(result)
|
|
raise click.exceptions.Exit(1)
|
|
hosting_ui_url = f"{constants.Hosting.HOSTING_SERVICE_UI}/project/{app['project_id']}/app/{app['id']}/"
|
|
console.print(
|
|
f"deployment progress can now be viewed on the website: {hosting_ui_url}"
|
|
)
|
|
console.print(
|
|
f"you are now safe to exit this command.\nfollow along with the deployment with the following command: \n reflex cloud apps status {result} --watch"
|
|
)
|
|
status = hosting.watch_deployment_status(result, client=authenticated_client)
|
|
if status is False:
|
|
raise click.exceptions.Exit(1)
|