fix: flatten React Router structure — no nested Routes, each page is a top-level route
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
from rich_toolkit.progress import Progress
|
||||
|
||||
from fastapi_cloud_cli.config import Settings
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import AuthConfig, AuthMode, write_auth_config
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
class AuthorizationData(BaseModel):
|
||||
user_code: str
|
||||
device_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
interval: int = 5
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
|
||||
|
||||
class LoginOutput(BaseModel):
|
||||
authenticated: bool
|
||||
auth_mode: AuthMode
|
||||
|
||||
|
||||
class DeviceAuthorizationOutput(BaseModel):
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
user_code: str
|
||||
device_code: str
|
||||
interval: int
|
||||
|
||||
|
||||
class LoginTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def render_login_output(data: LoginOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print("Now you are logged in! 🚀")
|
||||
|
||||
|
||||
def device_authorization_output(
|
||||
authorization_data: AuthorizationData,
|
||||
) -> DeviceAuthorizationOutput:
|
||||
return DeviceAuthorizationOutput(
|
||||
verification_uri=authorization_data.verification_uri,
|
||||
verification_uri_complete=authorization_data.verification_uri_complete,
|
||||
user_code=authorization_data.user_code,
|
||||
device_code=authorization_data.device_code,
|
||||
interval=authorization_data.interval,
|
||||
)
|
||||
|
||||
|
||||
def start_device_authorization(
|
||||
client: httpx.Client,
|
||||
) -> AuthorizationData:
|
||||
settings = Settings.get()
|
||||
|
||||
response = client.post(
|
||||
"/login/device/authorization", data={"client_id": settings.client_id}
|
||||
)
|
||||
logger.debug(f"Device authorization response status code: {response.status_code}")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return AuthorizationData.model_validate_json(response.text)
|
||||
|
||||
|
||||
def fetch_access_token(
|
||||
client: httpx.Client,
|
||||
device_code: str,
|
||||
interval: int,
|
||||
timeout: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
) -> str:
|
||||
settings = Settings.get()
|
||||
start = time.monotonic()
|
||||
|
||||
logger.debug("Starting to poll for access token")
|
||||
while True:
|
||||
response = client.post(
|
||||
"/login/device/token",
|
||||
data={
|
||||
"device_code": device_code,
|
||||
"client_id": settings.client_id,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
)
|
||||
logger.debug(f"Token response status code: {response.status_code}")
|
||||
|
||||
if response.status_code not in (200, 400):
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 400:
|
||||
data = response.json()
|
||||
error = data.get("error")
|
||||
logger.debug(f"Token response error: {error}")
|
||||
|
||||
if error != "authorization_pending":
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 200:
|
||||
break
|
||||
|
||||
remaining = timeout - (time.monotonic() - start)
|
||||
if remaining <= 0:
|
||||
raise LoginTimeoutError
|
||||
|
||||
sleep_for = min(interval, remaining)
|
||||
|
||||
logger.debug(f"Sleeping for {sleep_for} seconds before retrying...")
|
||||
time.sleep(sleep_for)
|
||||
|
||||
response_data = TokenResponse.model_validate_json(response.text)
|
||||
logger.debug("Access token received successfully.")
|
||||
|
||||
return response_data.access_token
|
||||
|
||||
|
||||
def complete_device_login(
|
||||
*,
|
||||
client: APIClient,
|
||||
progress: Progress,
|
||||
toolkit: FastAPIRichToolkit,
|
||||
device_code: str,
|
||||
interval: int,
|
||||
timeout: int,
|
||||
cancel_hint: str,
|
||||
) -> LoginOutput:
|
||||
try:
|
||||
with client.handle_http_errors(progress, toolkit=toolkit):
|
||||
access_token = fetch_access_token(client, device_code, interval, timeout)
|
||||
except LoginTimeoutError:
|
||||
message = "Login timed out before authorization completed."
|
||||
toolkit.fail(
|
||||
"timeout",
|
||||
message,
|
||||
hint="Try again with a longer --timeout value.",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
message = "Login cancelled before authorization completed."
|
||||
toolkit.fail(
|
||||
"cancelled",
|
||||
message,
|
||||
hint=cancel_hint,
|
||||
)
|
||||
|
||||
write_auth_config(AuthConfig(access_token=access_token))
|
||||
|
||||
return LoginOutput(authenticated=True, auth_mode="user")
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands.apps.create import create_app
|
||||
from fastapi_cloud_cli.commands.apps.get import get_app
|
||||
from fastapi_cloud_cli.commands.apps.link import link_app
|
||||
from fastapi_cloud_cli.commands.apps.list import list_apps
|
||||
from fastapi_cloud_cli.commands.apps.unlink import unlink_app
|
||||
from fastapi_cloud_cli.commands.apps.update import update_app
|
||||
from fastapi_cloud_cli.commands.logs import logs
|
||||
|
||||
apps_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage your FastAPI Cloud apps.",
|
||||
)
|
||||
apps_app.command("create")(create_app)
|
||||
apps_app.command("get")(get_app)
|
||||
apps_app.command("link")(link_app)
|
||||
apps_app.command("list")(list_apps)
|
||||
apps_app.command("logs")(logs)
|
||||
apps_app.command("unlink")(unlink_app)
|
||||
apps_app.command("update")(update_app)
|
||||
|
||||
__all__ = ["apps_app"]
|
||||
@@ -0,0 +1,192 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.apps.list import _prompt_for_team
|
||||
from fastapi_cloud_cli.commands.deploy.archive import (
|
||||
_get_app_name,
|
||||
validate_app_directory,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreatedApp(BaseModel):
|
||||
id: str
|
||||
team_id: str
|
||||
slug: str
|
||||
name: str
|
||||
directory: str | None
|
||||
|
||||
|
||||
class AppsCreateOutput(BaseModel):
|
||||
app: CreatedApp
|
||||
linked: bool
|
||||
path_to_link: Annotated[Path | None, Field(exclude=True)] = None
|
||||
|
||||
|
||||
def _create_app(
|
||||
client: APIClient, *, team_id: str, name: str, directory: str | None
|
||||
) -> CreatedApp:
|
||||
response = client.post(
|
||||
"/apps/",
|
||||
json={"team_id": team_id, "name": name, "directory": directory},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return CreatedApp.model_validate(response.json())
|
||||
|
||||
|
||||
def _render_apps_create_output(data: AppsCreateOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print(f"Created app [bold]{data.app.name}[/bold]", bullet=False)
|
||||
|
||||
if data.linked and data.path_to_link is not None:
|
||||
toolkit.print(
|
||||
f"Linked [bold]{data.path_to_link}[/bold] to [bold]{data.app.name}[/bold]",
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
team_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--team-id",
|
||||
help="ID of the team where the app should be created.",
|
||||
),
|
||||
] = None,
|
||||
name: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--name",
|
||||
help="Name of the app to create.",
|
||||
),
|
||||
] = None,
|
||||
directory: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--directory",
|
||||
help=(
|
||||
"Relative app directory containing the pyproject.toml "
|
||||
"(for example: backend or webserver)."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
link: Annotated[
|
||||
bool | None,
|
||||
typer.Option(
|
||||
"--link/--no-link",
|
||||
help="Link the local directory to the created app.",
|
||||
),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help="Directory to link when --link is enabled.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a FastAPI Cloud app.
|
||||
"""
|
||||
identity = Identity()
|
||||
path_to_link = path or Path.cwd()
|
||||
|
||||
# JSON output is non-interactive, so it defaults to create-only unless --link is explicit.
|
||||
link_app = link if link is not None else not json_output
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
if not link_app and path is not None:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
"Path can only be used when linking.",
|
||||
hint="Pass --link or omit --path.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
if team_id is None:
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Team ID is required.",
|
||||
hint="Pass --team-id to choose a team.",
|
||||
)
|
||||
|
||||
team = _prompt_for_team(toolkit, client)
|
||||
team_id = team.id
|
||||
toolkit.print_line()
|
||||
|
||||
if name is None:
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"App name is required.",
|
||||
hint="Pass --name to choose an app name.",
|
||||
)
|
||||
|
||||
name = toolkit.input(
|
||||
title="What's your app name?",
|
||||
default=_get_app_name(path_to_link),
|
||||
bullet=False,
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
try:
|
||||
directory = validate_app_directory(directory)
|
||||
except ValueError as e:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
f"Invalid app directory: {e}",
|
||||
hint=(
|
||||
"Pass a relative app directory such as `backend` or `webserver`; "
|
||||
"use --path with --link to choose a local filesystem path."
|
||||
),
|
||||
)
|
||||
|
||||
with toolkit.progress(
|
||||
title="Creating app",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error creating app. Please try again later.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
app = _create_app(
|
||||
client,
|
||||
team_id=team_id,
|
||||
name=name,
|
||||
directory=directory,
|
||||
)
|
||||
|
||||
if link_app:
|
||||
write_app_config(
|
||||
path_to_link,
|
||||
AppConfig(app_id=app.id, team_id=app.team_id),
|
||||
)
|
||||
|
||||
result = AppsCreateOutput(
|
||||
app=app,
|
||||
linked=link_app,
|
||||
path_to_link=path_to_link if link_app else None,
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_apps_create_output)
|
||||
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.apps.list import (
|
||||
App,
|
||||
_get_app,
|
||||
_get_app_dashboard_url,
|
||||
_get_team,
|
||||
)
|
||||
from fastapi_cloud_cli.config import Settings
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_details_table, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppGetOutput(BaseModel):
|
||||
app: App
|
||||
dashboard_url: Annotated[str | None, Field(exclude=True)] = None
|
||||
|
||||
|
||||
def _render_app_get_output(data: AppGetOutput, toolkit: RichToolkit) -> None:
|
||||
app = data.app
|
||||
|
||||
toolkit.print(f"[bold]{app.name}[/bold]", emoji="📦")
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
get_details_table(
|
||||
[
|
||||
("id", app.id),
|
||||
("slug", app.slug),
|
||||
(
|
||||
"directory",
|
||||
app.directory
|
||||
if app.directory is not None
|
||||
else Text("-", style="dim"),
|
||||
),
|
||||
("url", app.url if app.url is not None else Text("-", style="dim")),
|
||||
(
|
||||
"dashboard",
|
||||
Text(data.dashboard_url, style=f"link {data.dashboard_url}")
|
||||
if data.dashboard_url is not None
|
||||
else Text("-", style="dim"),
|
||||
),
|
||||
("team id", app.team_id),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_app(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="ID of the app to return (defaults to the app linked to the current directory).",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Get a FastAPI Cloud app by ID.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit,
|
||||
app_id=app_id,
|
||||
hint="Pass an app ID or run `fastapi cloud apps create --link` first.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching app",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching app. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
app = _get_app(client, target_app_id)
|
||||
|
||||
dashboard_url = None
|
||||
if not json_output:
|
||||
with toolkit.progress(
|
||||
title="Fetching team",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching team. Please try again later.",
|
||||
not_found_message="Team not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
team = _get_team(client, app.team_id)
|
||||
|
||||
dashboard_url = _get_app_dashboard_url(
|
||||
app,
|
||||
team_slug=team.slug,
|
||||
settings=Settings.get(),
|
||||
)
|
||||
|
||||
result = AppGetOutput(app=app, dashboard_url=dashboard_url)
|
||||
|
||||
toolkit.success(result, render_output=_render_app_get_output)
|
||||
@@ -0,0 +1,249 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, NoReturn
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich_toolkit import RichToolkit
|
||||
from rich_toolkit.menu import Option
|
||||
|
||||
from fastapi_cloud_cli.commands.apps.list import _get_app
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinkOutput(BaseModel):
|
||||
app_id: str
|
||||
team_id: str
|
||||
path: Path
|
||||
app_name: Annotated[str, Field(exclude=True)]
|
||||
config_path: Annotated[Path, Field(exclude=True)]
|
||||
|
||||
|
||||
def _render_link_output(data: LinkOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print(
|
||||
f"Linked [bold]{data.path}[/bold] to [bold]{data.app_name}[/bold]",
|
||||
bullet=False,
|
||||
)
|
||||
toolkit.print(f"Config: [bold]{data.config_path}[/bold]", bullet=False)
|
||||
|
||||
|
||||
def _fail_not_logged_in_interactive(toolkit: FastAPIRichToolkit) -> NoReturn:
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"You need to be logged in to link an app.",
|
||||
hint="Run [bold]fastapi cloud login[/] to authenticate.",
|
||||
)
|
||||
|
||||
|
||||
def _fail_already_linked_interactive(toolkit: FastAPIRichToolkit) -> NoReturn:
|
||||
toolkit.fail(
|
||||
"already_linked",
|
||||
"This directory is already linked to an app.",
|
||||
hint="Run [bold]fastapi cloud unlink[/] first to remove the existing configuration.",
|
||||
)
|
||||
|
||||
|
||||
def _link_app_by_id(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
app_id: str,
|
||||
path_to_link: Path,
|
||||
force: bool,
|
||||
) -> None:
|
||||
if get_app_config(path_to_link) and not force:
|
||||
toolkit.fail(
|
||||
"already_linked",
|
||||
"This directory is already linked to an app.",
|
||||
hint="Pass --force to replace the existing configuration.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching app",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching app. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
app = _get_app(client, app_id)
|
||||
|
||||
write_app_config(
|
||||
path_to_link,
|
||||
AppConfig(app_id=app.id, team_id=app.team_id),
|
||||
)
|
||||
|
||||
result = LinkOutput(
|
||||
app_id=app.id,
|
||||
team_id=app.team_id,
|
||||
path=path_to_link,
|
||||
app_name=app.name,
|
||||
config_path=path_to_link / ".fastapicloud" / "cloud.json",
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_link_output)
|
||||
|
||||
|
||||
def _link_app_interactively(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
path_to_link: Path,
|
||||
force: bool,
|
||||
) -> None:
|
||||
if get_app_config(path_to_link) and not force:
|
||||
_fail_already_linked_interactive(toolkit)
|
||||
|
||||
toolkit.print_title("Link to FastAPI Cloud")
|
||||
toolkit.print_line()
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress("Fetching teams...", transient=True) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching teams. Please try again later.",
|
||||
):
|
||||
response = client.get("/teams/")
|
||||
response.raise_for_status()
|
||||
teams_data = response.json()["data"]
|
||||
|
||||
if not teams_data:
|
||||
toolkit.print(
|
||||
"[error]No teams found. Please create a team first.[/]",
|
||||
bullet=False,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
team = toolkit.ask(
|
||||
"Select the team:",
|
||||
options=[
|
||||
Option({"name": t["name"], "value": {"id": t["id"], "name": t["name"]}})
|
||||
for t in sorted(teams_data, key=lambda t: t["name"].lower())
|
||||
],
|
||||
allow_filtering=True,
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress("Fetching apps...", transient=True) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching apps. Please try again later.",
|
||||
):
|
||||
response = client.get("/apps/", params={"team_id": team["id"]})
|
||||
response.raise_for_status()
|
||||
apps_data = response.json()["data"]
|
||||
|
||||
if not apps_data:
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
"No apps found in this team.",
|
||||
hint="Run [bold]fastapi cloud apps create[/] to create and deploy a new app.",
|
||||
)
|
||||
|
||||
app = toolkit.ask(
|
||||
"Select the app to link:",
|
||||
options=[
|
||||
Option({"name": a["slug"], "value": {"id": a["id"], "slug": a["slug"]}})
|
||||
for a in sorted(apps_data, key=lambda a: a["slug"].lower())
|
||||
],
|
||||
allow_filtering=True,
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
app_config = AppConfig(app_id=app["id"], team_id=team["id"])
|
||||
write_app_config(path_to_link, app_config)
|
||||
|
||||
toolkit.print(
|
||||
f"Successfully linked to app [bold]{app['slug']}[/bold]!",
|
||||
emoji="🔗",
|
||||
)
|
||||
logger.debug(f"Linked to app: {app['id']} in team: {team['id']}")
|
||||
|
||||
|
||||
def link_app(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="ID of the app to link.",
|
||||
),
|
||||
] = None,
|
||||
app_id_option: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app to link.",
|
||||
),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help="Directory to link.",
|
||||
),
|
||||
] = None,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--force",
|
||||
help="Replace an existing local app configuration.",
|
||||
),
|
||||
] = False,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Link a local directory to an existing FastAPI Cloud app.
|
||||
"""
|
||||
identity = Identity()
|
||||
path_to_link = path or Path.cwd()
|
||||
target_app_id = app_id_option or app_id
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
if target_app_id is None and not json_output:
|
||||
_fail_not_logged_in_interactive(toolkit)
|
||||
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
if app_id is not None and app_id_option is not None and app_id != app_id_option:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
"App ID was provided more than once.",
|
||||
hint="Pass either APP_ID or --app-id, not both.",
|
||||
)
|
||||
|
||||
if target_app_id is None:
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"App ID is required.",
|
||||
hint="Pass an app ID to link an app.",
|
||||
)
|
||||
|
||||
_link_app_interactively(
|
||||
toolkit,
|
||||
path_to_link=path_to_link,
|
||||
force=force,
|
||||
)
|
||||
return
|
||||
|
||||
_link_app_by_id(
|
||||
toolkit,
|
||||
app_id=target_app_id,
|
||||
path_to_link=path_to_link,
|
||||
force=force,
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
from rich_toolkit.menu import Option
|
||||
|
||||
from fastapi_cloud_cli.config import Settings
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LIMIT = 100
|
||||
DEFAULT_OFFSET = 0
|
||||
|
||||
|
||||
class App(BaseModel):
|
||||
id: str
|
||||
team_id: str
|
||||
slug: str
|
||||
name: str
|
||||
directory: str | None
|
||||
url: str | None = None
|
||||
region: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class AppsListAPIResponse(BaseModel):
|
||||
data: list[App]
|
||||
count: int
|
||||
|
||||
|
||||
class AppsListOutput(BaseModel):
|
||||
apps: list[App]
|
||||
total_count: int
|
||||
limit: int
|
||||
offset: int
|
||||
team_slug: Annotated[str, Field(exclude=True)]
|
||||
|
||||
|
||||
def _get_app_dashboard_url(app: App, *, team_slug: str, settings: Settings) -> str:
|
||||
return f"{settings.dashboard_base_url}/{team_slug}/apps/{app.slug}"
|
||||
|
||||
|
||||
def _format_app_name(app: App, *, team_slug: str, settings: Settings) -> str:
|
||||
dashboard_url = _get_app_dashboard_url(
|
||||
app,
|
||||
team_slug=team_slug,
|
||||
settings=settings,
|
||||
)
|
||||
return f"[link={dashboard_url}]{app.name}[/link]"
|
||||
|
||||
|
||||
def _get_apps_list_table(
|
||||
apps: list[App], *, team_slug: str, settings: Settings
|
||||
) -> Table:
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("ID", no_wrap=True, overflow="ignore")
|
||||
table.add_row(
|
||||
"[bold]Name[/bold]",
|
||||
"[bold]ID[/bold]",
|
||||
)
|
||||
table.add_row("", "")
|
||||
|
||||
for app in apps:
|
||||
table.add_row(
|
||||
_format_app_name(app, team_slug=team_slug, settings=settings),
|
||||
Text(app.id),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _get_teams(client: APIClient) -> list[Team]:
|
||||
response = client.get("/teams/")
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()["data"]
|
||||
|
||||
return [Team.model_validate(team) for team in data]
|
||||
|
||||
|
||||
def _get_team(client: APIClient, team_id: str) -> Team:
|
||||
response = client.get(f"/teams/{team_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
return Team.model_validate(response.json())
|
||||
|
||||
|
||||
def _get_app(client: APIClient, app_id: str) -> App:
|
||||
response = client.get(f"/apps/{app_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
return App.model_validate(response.json())
|
||||
|
||||
|
||||
def _get_apps(
|
||||
client: APIClient, *, team_id: str, limit: int, offset: int, team_slug: str
|
||||
) -> AppsListOutput:
|
||||
response = client.get(
|
||||
"/apps/",
|
||||
params={
|
||||
"team_id": team_id,
|
||||
"limit": limit,
|
||||
"skip": offset,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = AppsListAPIResponse.model_validate(response.json())
|
||||
|
||||
return AppsListOutput(
|
||||
apps=data.data,
|
||||
total_count=data.count,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
team_slug=team_slug,
|
||||
)
|
||||
|
||||
|
||||
def _render_apps_list_output(data: AppsListOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print_title("apps")
|
||||
toolkit.print_line()
|
||||
|
||||
if not data.apps:
|
||||
toolkit.print("No apps found.", bullet=False)
|
||||
return
|
||||
|
||||
toolkit.print(
|
||||
_get_apps_list_table(
|
||||
data.apps,
|
||||
team_slug=data.team_slug,
|
||||
settings=Settings.get(),
|
||||
),
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def _prompt_for_team(toolkit: FastAPIRichToolkit, client: APIClient) -> Team:
|
||||
with toolkit.progress(
|
||||
title="Fetching teams",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching teams. Please try again later.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
teams = _get_teams(client)
|
||||
|
||||
if not teams:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"No teams found.",
|
||||
hint="Create a team before listing apps.",
|
||||
)
|
||||
|
||||
return toolkit.ask(
|
||||
"Select the team:",
|
||||
options=[
|
||||
Option({"name": team.name, "value": team})
|
||||
for team in sorted(teams, key=lambda team: team.name.lower())
|
||||
],
|
||||
allow_filtering=True,
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def list_apps(
|
||||
team_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--team-id",
|
||||
help="ID of the team whose apps should be listed.",
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--limit",
|
||||
help="Maximum number of apps to return.",
|
||||
min=1,
|
||||
),
|
||||
] = DEFAULT_LIMIT,
|
||||
offset: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--offset",
|
||||
help="Offset into the app result set.",
|
||||
min=0,
|
||||
),
|
||||
] = DEFAULT_OFFSET,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
List FastAPI Cloud apps.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
team_slug: str | None = None
|
||||
|
||||
if team_id is None:
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Team ID is required.",
|
||||
hint="Pass --team-id to choose a team.",
|
||||
)
|
||||
|
||||
team = _prompt_for_team(toolkit, client)
|
||||
team_id = team.id
|
||||
team_slug = team.slug
|
||||
|
||||
toolkit.print_line()
|
||||
else:
|
||||
with toolkit.progress(
|
||||
title="Fetching team",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching team. Please try again later.",
|
||||
not_found_message="Team not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
team = _get_team(client, team_id)
|
||||
team_slug = team.slug
|
||||
|
||||
with toolkit.progress(
|
||||
title="Fetching apps",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching apps. Please try again later.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
result = _get_apps(
|
||||
client,
|
||||
team_id=team_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
team_slug=team_slug,
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_apps_list_output)
|
||||
@@ -0,0 +1,75 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnlinkOutput(BaseModel):
|
||||
unlinked: bool
|
||||
path: Annotated[Path, Field(exclude=True)]
|
||||
removed_path: Path
|
||||
path_provided: Annotated[bool, Field(exclude=True)] = False
|
||||
|
||||
|
||||
def _render_unlink_output(data: UnlinkOutput, toolkit: RichToolkit) -> None:
|
||||
removed_path = (
|
||||
data.removed_path
|
||||
if data.path_provided
|
||||
else data.removed_path.relative_to(Path.cwd())
|
||||
)
|
||||
|
||||
toolkit.print("Removed app link", emoji="🔗")
|
||||
toolkit.print_line()
|
||||
toolkit.print(Text(f"Deleted {removed_path}", style="dim"))
|
||||
|
||||
|
||||
def _fail_not_linked(toolkit: FastAPIRichToolkit) -> None:
|
||||
toolkit.fail(
|
||||
"not_linked",
|
||||
"No app is linked to this directory.",
|
||||
hint="Run `fastapi cloud link` to link an app.",
|
||||
)
|
||||
|
||||
|
||||
def unlink_app(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help="Directory to unlink.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Unlink by deleting the `.fastapicloud/cloud.json` file.
|
||||
"""
|
||||
path_to_unlink = path or Path.cwd()
|
||||
config_path = path_to_unlink / ".fastapicloud/cloud.json"
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not config_path.exists():
|
||||
logger.debug(f"Configuration file not found: {config_path}")
|
||||
_fail_not_linked(toolkit)
|
||||
|
||||
config_path.unlink()
|
||||
logger.debug(f"Deleted configuration file: {config_path}")
|
||||
|
||||
toolkit.success(
|
||||
UnlinkOutput(
|
||||
unlinked=True,
|
||||
path=path_to_unlink,
|
||||
removed_path=config_path,
|
||||
path_provided=path is not None,
|
||||
),
|
||||
render_output=_render_unlink_output,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.deploy.archive import validate_app_directory
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdatedApp(BaseModel):
|
||||
id: str
|
||||
team_id: str
|
||||
slug: str
|
||||
name: str
|
||||
directory: str | None
|
||||
|
||||
|
||||
class AppsUpdateOutput(BaseModel):
|
||||
app: UpdatedApp
|
||||
|
||||
|
||||
def _update_app(client: APIClient, *, app_id: str, directory: str | None) -> UpdatedApp:
|
||||
response = client.patch(
|
||||
f"/apps/{app_id}",
|
||||
json={"directory": directory},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return UpdatedApp.model_validate(response.json())
|
||||
|
||||
|
||||
def _render_apps_update_output(data: AppsUpdateOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print(f"Updated app [bold]{data.app.name}[/bold]", bullet=False)
|
||||
toolkit.print(
|
||||
f"Directory: [bold]{data.app.directory if data.app.directory is not None else '.'}[/bold]",
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def update_app(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="ID of the app to update (defaults to the app linked to the current directory).",
|
||||
),
|
||||
] = None,
|
||||
directory: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--directory",
|
||||
help=(
|
||||
"Relative app directory containing the pyproject.toml "
|
||||
"(for example: src or backend)."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Update FastAPI Cloud app metadata.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
if directory is None:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"No updates provided.",
|
||||
hint="Pass --directory to update the app directory.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit,
|
||||
app_id=app_id,
|
||||
hint="Pass an app ID or run `fastapi cloud apps create --link` first.",
|
||||
)
|
||||
|
||||
try:
|
||||
directory = validate_app_directory(directory)
|
||||
except ValueError as e:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
f"Invalid app directory: {e}",
|
||||
hint="Pass a relative app directory such as `src` or `backend`.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Updating app",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error updating app. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
app = _update_app(
|
||||
client,
|
||||
app_id=target_app_id,
|
||||
directory=directory,
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
AppsUpdateOutput(app=app),
|
||||
render_output=_render_apps_update_output,
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands.auth import wait as wait_command
|
||||
from fastapi_cloud_cli.commands.login import login
|
||||
|
||||
auth_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Authenticate with FastAPI Cloud.",
|
||||
)
|
||||
|
||||
auth_app.command()(login)
|
||||
auth_app.command("wait")(wait_command.wait)
|
||||
|
||||
__all__ = ["auth_app"]
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands._flow import (
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
complete_device_login,
|
||||
render_login_output,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
def wait(
|
||||
device_code: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--device-code",
|
||||
help="Device code returned by `fastapi cloud auth login --json`.",
|
||||
),
|
||||
],
|
||||
interval: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--interval",
|
||||
help="Seconds between authorization polling attempts.",
|
||||
min=5,
|
||||
),
|
||||
] = 5,
|
||||
timeout: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--timeout",
|
||||
help="Maximum seconds to wait for authorization.",
|
||||
min=10,
|
||||
),
|
||||
] = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Wait for a device authorization flow to complete.
|
||||
"""
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
with APIClient() as client:
|
||||
toolkit.print_title(
|
||||
"Login to FastAPI Cloud", tag="FastAPI Cloud", emoji="🔐"
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress(
|
||||
"Waiting for user to authorize...", transient=True
|
||||
) as progress:
|
||||
result = complete_device_login(
|
||||
client=client,
|
||||
progress=progress,
|
||||
toolkit=toolkit,
|
||||
device_code=device_code,
|
||||
interval=interval,
|
||||
timeout=timeout,
|
||||
cancel_hint="Run `fastapi cloud auth wait --json` again to retry.",
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
result,
|
||||
render_output=render_login_output,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands.ci.print_workflow import (
|
||||
print_workflow as print_workflow_command,
|
||||
)
|
||||
from fastapi_cloud_cli.commands.setup_ci import setup_ci
|
||||
|
||||
ci_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage CI integration helpers.",
|
||||
)
|
||||
ci_app.command("print-workflow")(print_workflow_command)
|
||||
ci_app.command("setup")(setup_ci)
|
||||
|
||||
__all__ = ["ci_app"]
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.setup_ci import (
|
||||
DEFAULT_WORKFLOW_PATH,
|
||||
_get_default_branch,
|
||||
_get_workflow_content,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class CIWorkflowOutput(BaseModel):
|
||||
filename: str
|
||||
content: str
|
||||
|
||||
|
||||
def _render_workflow_output(data: CIWorkflowOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.console.print(data.content, markup=False, end="")
|
||||
|
||||
|
||||
def print_workflow(
|
||||
branch: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--branch",
|
||||
"-b",
|
||||
help="Branch that triggers deploys (defaults to the repo's default branch).",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""Prints the GitHub Actions workflow YAML without writing files or secrets."""
|
||||
|
||||
branch = branch or _get_default_branch()
|
||||
workflow = CIWorkflowOutput(
|
||||
filename=DEFAULT_WORKFLOW_PATH.name,
|
||||
content=_get_workflow_content(branch),
|
||||
)
|
||||
|
||||
with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit:
|
||||
toolkit.success(workflow, render_output=_render_workflow_output)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .command import deploy
|
||||
|
||||
__all__ = ["deploy"]
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Annotated
|
||||
|
||||
import fastar
|
||||
import rignore
|
||||
from pydantic import AfterValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_app_directory(v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
|
||||
v = v.strip()
|
||||
|
||||
if not v:
|
||||
return None
|
||||
|
||||
if v.startswith("~"):
|
||||
raise ValueError("cannot start with '~'")
|
||||
|
||||
path = PurePosixPath(v)
|
||||
|
||||
if path.is_absolute():
|
||||
raise ValueError("must be a relative path, not absolute")
|
||||
|
||||
if ".." in path.parts:
|
||||
raise ValueError("cannot contain '..' path segments")
|
||||
|
||||
normalized = path.as_posix()
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9._/ -]+", normalized):
|
||||
raise ValueError(
|
||||
"contains invalid characters (allowed: letters, numbers, space, / . _ -)"
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
AppDirectory = Annotated[str | None, AfterValidator(validate_app_directory)]
|
||||
|
||||
|
||||
def _get_app_name(path: Path) -> str:
|
||||
# TODO: use pyproject.toml to get the app name
|
||||
return path.name
|
||||
|
||||
|
||||
def _should_exclude_entry(path: Path) -> bool:
|
||||
parts_to_exclude = [
|
||||
".venv",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".git",
|
||||
".gitignore",
|
||||
".fastapicloudignore",
|
||||
]
|
||||
|
||||
if any(part in path.parts for part in parts_to_exclude):
|
||||
return True
|
||||
|
||||
if path.suffix == ".pyc":
|
||||
return True
|
||||
|
||||
if path.name == ".env" or path.name.startswith(".env."):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _rignore_walk(path: Path) -> rignore.Walker:
|
||||
return rignore.walk(
|
||||
path,
|
||||
should_exclude_entry=_should_exclude_entry,
|
||||
additional_ignore_paths=[".fastapicloudignore"],
|
||||
ignore_hidden=False,
|
||||
)
|
||||
|
||||
|
||||
def archive(path: Path, tar_path: Path) -> Path:
|
||||
logger.debug("Starting archive creation for path: %s", path)
|
||||
files = _rignore_walk(path)
|
||||
|
||||
logger.debug("Archive will be created at: %s", tar_path)
|
||||
|
||||
file_count = 0
|
||||
with fastar.open(tar_path, "w:zst", sparse=False) as tar:
|
||||
for filename in files:
|
||||
if filename.is_dir():
|
||||
continue
|
||||
|
||||
arcname = filename.relative_to(path)
|
||||
logger.debug("Adding %s to archive", arcname)
|
||||
tar.append(filename, arcname=arcname)
|
||||
file_count += 1
|
||||
|
||||
logger.debug("Archive created successfully with %s files", file_count)
|
||||
return tar_path
|
||||
|
||||
|
||||
def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
|
||||
threshold_bytes = threshold_mb * 1024 * 1024
|
||||
large_files = []
|
||||
files = _rignore_walk(path)
|
||||
for filename in files:
|
||||
if filename.is_dir():
|
||||
continue
|
||||
file_size = filename.stat().st_size
|
||||
if file_size > threshold_bytes:
|
||||
large_files.append((filename.relative_to(path), file_size))
|
||||
|
||||
return sorted(large_files, key=lambda x: x[1], reverse=True)
|
||||
@@ -0,0 +1,86 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class AppResponse(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
directory: str | None
|
||||
|
||||
|
||||
class CreateDeploymentResponse(BaseModel):
|
||||
id: str
|
||||
app_id: str
|
||||
slug: str
|
||||
status: DeploymentStatus
|
||||
dashboard_url: str
|
||||
url: str
|
||||
|
||||
|
||||
def _get_teams(client: APIClient) -> list[Team]:
|
||||
response = client.get("/teams/")
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()["data"]
|
||||
|
||||
return [Team.model_validate(team) for team in data]
|
||||
|
||||
|
||||
def _update_app(client: APIClient, app_id: str, directory: str | None) -> AppResponse:
|
||||
response = client.patch(
|
||||
f"/apps/{app_id}",
|
||||
json={"directory": directory},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return AppResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _create_app(
|
||||
client: APIClient, team_id: str, app_name: str, directory: str | None
|
||||
) -> AppResponse:
|
||||
response = client.post(
|
||||
"/apps/",
|
||||
json={"name": app_name, "team_id": team_id, "directory": directory},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return AppResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _create_deployment(client: APIClient, app_id: str) -> CreateDeploymentResponse:
|
||||
response = client.post(f"/apps/{app_id}/deployments/")
|
||||
response.raise_for_status()
|
||||
|
||||
return CreateDeploymentResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _get_app(client: APIClient, app_id: str) -> AppResponse | None:
|
||||
response = client.get(f"/apps/{app_id}")
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
return AppResponse.model_validate(data)
|
||||
|
||||
|
||||
def _get_apps(client: APIClient, team_id: str) -> list[AppResponse]:
|
||||
response = client.get("/apps/", params={"team_id": team_id})
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()["data"]
|
||||
|
||||
return [AppResponse.model_validate(app) for app in data]
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi_cloud_cli.commands.deploy.archive import _get_large_files, archive
|
||||
from fastapi_cloud_cli.commands.deploy.cloud import (
|
||||
AppResponse,
|
||||
CreateDeploymentResponse,
|
||||
_create_deployment,
|
||||
_get_app,
|
||||
)
|
||||
from fastapi_cloud_cli.commands.deploy.configure import _configure_app
|
||||
from fastapi_cloud_cli.commands.deploy.upload import _cancel_upload, _upload_deployment
|
||||
from fastapi_cloud_cli.commands.deploy.wait import _wait_for_deployment
|
||||
from fastapi_cloud_cli.commands.login import _interactive_login
|
||||
from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus
|
||||
from fastapi_cloud_cli.utils.apps import get_app_config
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.errors import ErrorCode
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption, is_ci_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeployOutput(BaseModel):
|
||||
deployment_id: str
|
||||
app_id: str
|
||||
slug: str
|
||||
status: DeploymentStatus
|
||||
dashboard_url: str
|
||||
url: str
|
||||
|
||||
|
||||
def _get_deploy_output(deployment: CreateDeploymentResponse) -> DeployOutput:
|
||||
return DeployOutput(
|
||||
deployment_id=deployment.id,
|
||||
app_id=deployment.app_id,
|
||||
slug=deployment.slug,
|
||||
status=deployment.status,
|
||||
dashboard_url=deployment.dashboard_url,
|
||||
url=deployment.url,
|
||||
)
|
||||
|
||||
|
||||
def _get_large_file_warnings(
|
||||
large_files: list[tuple[Path, int]],
|
||||
*,
|
||||
threshold_mb: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not large_files:
|
||||
return []
|
||||
|
||||
count = len(large_files)
|
||||
message = (
|
||||
f"1 uploaded file is larger than {threshold_mb} MB."
|
||||
if count == 1
|
||||
else f"{count} uploaded files are larger than {threshold_mb} MB."
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"code": "large_files",
|
||||
"message": message,
|
||||
"files": [
|
||||
{"path": path.as_posix(), "size_bytes": size}
|
||||
for path, size in large_files
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _render_app_id_mismatch(
|
||||
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
|
||||
) -> None:
|
||||
toolkit.print_error(message)
|
||||
toolkit.print_line()
|
||||
toolkit.print_hint(hint)
|
||||
|
||||
|
||||
def _render_app_not_found(
|
||||
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
|
||||
) -> None:
|
||||
toolkit.print_line()
|
||||
|
||||
|
||||
def _render_linked_app_not_found(
|
||||
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
|
||||
) -> None:
|
||||
_render_app_not_found(toolkit, code=code, message=message, hint=hint)
|
||||
toolkit.print_hint(
|
||||
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration."
|
||||
)
|
||||
|
||||
|
||||
def deploy(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Argument(
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
skip_wait: Annotated[
|
||||
bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
|
||||
] = False,
|
||||
provided_app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="Application ID to deploy to",
|
||||
envvar="FASTAPI_CLOUD_APP_ID",
|
||||
),
|
||||
] = None,
|
||||
large_file_threshold: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
help="File size threshold in MB for warning about large files",
|
||||
min=1,
|
||||
envvar="FASTAPI_CLOUD_LARGE_FILE_THRESHOLD",
|
||||
),
|
||||
] = 10,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud.
|
||||
"""
|
||||
logger.debug("Deploy command started")
|
||||
logger.debug(
|
||||
"Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id
|
||||
)
|
||||
|
||||
identity = Identity()
|
||||
use_deploy_token = identity.has_deploy_token()
|
||||
has_auth = use_deploy_token or identity.is_logged_in()
|
||||
|
||||
logger.debug(
|
||||
"Authentication mode: %s", "deploy token" if use_deploy_token else "user token"
|
||||
)
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not has_auth:
|
||||
logger.debug("User not logged in, starting login")
|
||||
|
||||
if is_ci_enabled():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"FASTAPI_CLOUD_TOKEN is required to deploy from CI.",
|
||||
hint=(
|
||||
"Run `fastapi cloud setup-ci` to configure a deploy token, "
|
||||
"or set FASTAPI_CLOUD_TOKEN in your CI secrets."
|
||||
),
|
||||
)
|
||||
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
toolkit.print_title(
|
||||
"Welcome to FastAPI Cloud!",
|
||||
tag="FastAPI Cloud",
|
||||
emoji="👋",
|
||||
animate=True,
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
if identity.user_token and identity.is_user_token_expired():
|
||||
toolkit.print("Your session has expired. Please log in again.")
|
||||
else:
|
||||
toolkit.print("You need to be logged in to deploy to FastAPI Cloud.")
|
||||
|
||||
toolkit.print_line()
|
||||
should_login = toolkit.confirm(
|
||||
"Do you want to log in now?",
|
||||
default=True,
|
||||
)
|
||||
|
||||
if not should_login:
|
||||
toolkit.print_line()
|
||||
toolkit.print("Deployment cancelled.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
toolkit.print_line()
|
||||
_interactive_login(toolkit)
|
||||
toolkit.print_line()
|
||||
|
||||
with APIClient(use_deploy_token=use_deploy_token) as client:
|
||||
# the welcome title already shows the header when logging in
|
||||
if has_auth:
|
||||
toolkit.print_title("FastAPI Cloud", animate=True)
|
||||
toolkit.print_line()
|
||||
|
||||
if use_deploy_token:
|
||||
toolkit.print(
|
||||
"Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
path_to_deploy = path or Path.cwd()
|
||||
logger.debug("Deploying from path: %s", path_to_deploy)
|
||||
|
||||
app_config = get_app_config(path_to_deploy)
|
||||
|
||||
if app_config and provided_app_id and app_config.app_id != provided_app_id:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
f"Provided app ID ({provided_app_id}) does not match the local config ({app_config.app_id}).",
|
||||
hint=(
|
||||
"Run `fastapi cloud unlink` to remove the local config, "
|
||||
"or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app."
|
||||
),
|
||||
render_output=_render_app_id_mismatch,
|
||||
)
|
||||
|
||||
if provided_app_id:
|
||||
target_app_id = provided_app_id
|
||||
elif app_config:
|
||||
target_app_id = app_config.app_id
|
||||
else:
|
||||
if json_output:
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"App ID is required.",
|
||||
hint="Pass --app-id or run `fastapi cloud apps create --link` first.",
|
||||
)
|
||||
|
||||
logger.debug("No app config found, configuring new app")
|
||||
|
||||
app_config = _configure_app(
|
||||
toolkit=toolkit,
|
||||
client=client,
|
||||
path_to_deploy=path_to_deploy,
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
target_app_id = app_config.app_id
|
||||
|
||||
if provided_app_id:
|
||||
toolkit.print(
|
||||
f"Deploying to app [blue]{target_app_id}[/blue]...", emoji="🚀"
|
||||
)
|
||||
else:
|
||||
toolkit.print("Deploying app...", emoji="🚀")
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress("Checking app...", transient=True) as progress:
|
||||
with client.handle_http_errors(progress, toolkit=toolkit):
|
||||
logger.debug("Checking app with ID: %s", target_app_id)
|
||||
app = _get_app(client=client, app_id=target_app_id)
|
||||
|
||||
if app is None:
|
||||
logger.debug("App not found in API")
|
||||
progress.set_error(
|
||||
"App not found. Make sure you're logged in the correct account."
|
||||
)
|
||||
|
||||
if app is None:
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
"App not found. Make sure you're logged in the correct account.",
|
||||
render_output=(
|
||||
_render_app_not_found
|
||||
if provided_app_id
|
||||
else _render_linked_app_not_found
|
||||
),
|
||||
)
|
||||
|
||||
app = cast(AppResponse, app)
|
||||
|
||||
large_files = _get_large_files(
|
||||
path_to_deploy, threshold_mb=large_file_threshold
|
||||
)
|
||||
warnings = _get_large_file_warnings(
|
||||
large_files,
|
||||
threshold_mb=large_file_threshold,
|
||||
)
|
||||
if large_files:
|
||||
toolkit.print(
|
||||
f"Some uploaded files are larger than {large_file_threshold} MB:",
|
||||
emoji="⚠️",
|
||||
)
|
||||
toolkit.print_line()
|
||||
for fname, fsize in large_files[:3]:
|
||||
fsize_mb = fsize // (1024 * 1024)
|
||||
toolkit.print(
|
||||
f"• [bold]{fname}[/bold] [yellow]({fsize_mb} MB)[/yellow]"
|
||||
)
|
||||
is_more = len(large_files) > 3
|
||||
if is_more:
|
||||
toolkit.print(f"[dim]...and {len(large_files) - 3} more[/dim]")
|
||||
|
||||
large_files_docs_url = "https://fastapicloud.com/docs/fastapi-cloud-cli/deploy/#large-files-warning"
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"Read more: [link={large_files_docs_url}]{large_files_docs_url}[/link]",
|
||||
emoji="💡",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
will_wait = not skip_wait and not json_output
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
logger.debug("Creating archive for deployment")
|
||||
archive_path = Path(temp_dir) / "archive.tar"
|
||||
archive(path_to_deploy, archive_path)
|
||||
|
||||
with (
|
||||
toolkit.progress(
|
||||
title="Creating deployment",
|
||||
# the build status replaces this when waiting
|
||||
transient=will_wait,
|
||||
done_emoji="📦",
|
||||
) as progress,
|
||||
client.handle_http_errors(progress, toolkit=toolkit),
|
||||
):
|
||||
logger.debug("Creating deployment for app: %s", app.id)
|
||||
deployment = _create_deployment(client=client, app_id=app.id)
|
||||
|
||||
try:
|
||||
progress.log(
|
||||
f"Deployment created successfully! Deployment slug: {deployment.slug}"
|
||||
)
|
||||
|
||||
deployment = _upload_deployment(
|
||||
fastapi_client=client,
|
||||
deployment_id=deployment.id,
|
||||
archive_path=archive_path,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
progress.log("Deployment uploaded successfully!")
|
||||
except KeyboardInterrupt:
|
||||
_cancel_upload(client=client, deployment_id=deployment.id)
|
||||
raise
|
||||
|
||||
if will_wait:
|
||||
logger.debug("Waiting for deployment to complete")
|
||||
_wait_for_deployment(
|
||||
toolkit=toolkit,
|
||||
client=client,
|
||||
app_id=app.id,
|
||||
deployment=deployment,
|
||||
)
|
||||
else:
|
||||
toolkit.print_line()
|
||||
logger.debug("Skipping deployment wait as requested")
|
||||
if json_output:
|
||||
toolkit.success(
|
||||
_get_deploy_output(deployment),
|
||||
warnings=warnings,
|
||||
hint=(
|
||||
"Check deployment status in the FastAPI Cloud dashboard: "
|
||||
f"{deployment.dashboard_url}"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
toolkit.print(
|
||||
f"Check the status of your deployment at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
|
||||
)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from pydantic import TypeAdapter
|
||||
from rich_toolkit.menu import Option
|
||||
|
||||
from fastapi_cloud_cli.commands.deploy.archive import AppDirectory, _get_app_name
|
||||
from fastapi_cloud_cli.commands.deploy.cloud import (
|
||||
AppResponse,
|
||||
_create_app,
|
||||
_get_apps,
|
||||
_get_teams,
|
||||
_update_app,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
|
||||
|
||||
|
||||
def _configure_app(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
client: APIClient,
|
||||
path_to_deploy: Path,
|
||||
) -> AppConfig:
|
||||
toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", emoji="📁")
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress("Fetching teams...", transient=True) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching teams. Please try again later.",
|
||||
):
|
||||
teams = _get_teams(client)
|
||||
|
||||
team = toolkit.ask(
|
||||
"Select the team you want to deploy to:",
|
||||
options=[
|
||||
Option({"name": team.name, "value": team})
|
||||
for team in sorted(teams, key=lambda team: team.name.lower())
|
||||
],
|
||||
allow_filtering=True,
|
||||
emoji="🏢",
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
create_new_app = toolkit.confirm(
|
||||
"Do you want to create a new app?", default=True, emoji="📦"
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
selected_app: AppResponse | None = None
|
||||
|
||||
if not create_new_app:
|
||||
with toolkit.progress("Fetching apps...", transient=True) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching apps. Please try again later.",
|
||||
):
|
||||
apps = _get_apps(client=client, team_id=team.id)
|
||||
|
||||
if not apps:
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
"No apps found in this team. You can create a new app instead.",
|
||||
)
|
||||
|
||||
selected_app = toolkit.ask(
|
||||
"Select the app you want to deploy to:",
|
||||
options=[
|
||||
Option({"name": app.slug, "value": app})
|
||||
for app in sorted(apps, key=lambda app: app.slug.lower())
|
||||
],
|
||||
allow_filtering=True,
|
||||
emoji="📦",
|
||||
)
|
||||
|
||||
app_name = (
|
||||
selected_app.slug
|
||||
if selected_app
|
||||
else toolkit.input(
|
||||
title="What's your app name?",
|
||||
default=_get_app_name(path_to_deploy),
|
||||
emoji="✏️",
|
||||
)
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
initial_directory = selected_app.directory if selected_app else ""
|
||||
|
||||
directory_input = toolkit.input(
|
||||
title=(
|
||||
"Directory where your app's pyproject.toml file lives (e.g. src, backend):"
|
||||
),
|
||||
value=initial_directory or "",
|
||||
placeholder=(
|
||||
"[italic]Leave empty if pyproject.toml is in the current directory[/italic]"
|
||||
),
|
||||
validator=TypeAdapter(AppDirectory),
|
||||
emoji="📂",
|
||||
)
|
||||
|
||||
directory: str | None = directory_input if directory_input else None
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
toolkit.print("Deployment configuration:", emoji="📋")
|
||||
toolkit.print_line()
|
||||
toolkit.print(f"Team: [bold]{team.name}[/bold]")
|
||||
toolkit.print(f"App name: [bold]{app_name}[/bold]")
|
||||
toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]")
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
choice = toolkit.ask(
|
||||
"Does everything look right?",
|
||||
options=[
|
||||
Option({"name": "Yes, start the deployment!", "value": "deploy"}),
|
||||
Option({"name": "No, let me start over", "value": "cancel"}),
|
||||
],
|
||||
emoji="👀",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
if choice == "cancel":
|
||||
toolkit.print("Deployment cancelled.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if selected_app:
|
||||
if directory != selected_app.directory:
|
||||
with (
|
||||
toolkit.progress(title="Updating app directory...") as progress,
|
||||
client.handle_http_errors(progress),
|
||||
):
|
||||
app = _update_app(
|
||||
client=client, app_id=selected_app.id, directory=directory
|
||||
)
|
||||
|
||||
progress.log(f"App directory updated to '{directory or '.'}'")
|
||||
else:
|
||||
app = selected_app
|
||||
else:
|
||||
with toolkit.progress(title="Creating app...") as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
app = _create_app(
|
||||
client=client,
|
||||
team_id=team.id,
|
||||
app_name=app_name,
|
||||
directory=directory,
|
||||
)
|
||||
|
||||
progress.log(f"App created successfully! App slug: {app.slug}")
|
||||
|
||||
app_config = AppConfig(app_id=app.id, team_id=team.id)
|
||||
|
||||
write_app_config(path_to_deploy, app_config)
|
||||
|
||||
return app_config
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, cast
|
||||
|
||||
from httpx import Client
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit.progress import Progress
|
||||
|
||||
from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.progress_file import ProgressFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestUploadResponse(BaseModel):
|
||||
url: str
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
def _cancel_upload(client: APIClient, deployment_id: str) -> None:
|
||||
logger.debug("Cancelling upload for deployment: %s", deployment_id)
|
||||
|
||||
try:
|
||||
response = client.post(f"/deployments/{deployment_id}/upload-cancelled")
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug("Upload cancellation notification sent successfully")
|
||||
except Exception as e:
|
||||
logger.debug("Failed to notify server about upload cancellation: %s", e)
|
||||
|
||||
|
||||
def _format_size(size_in_bytes: int) -> str:
|
||||
if size_in_bytes >= 1024 * 1024:
|
||||
return f"{size_in_bytes / (1024 * 1024):.2f} MB"
|
||||
elif size_in_bytes >= 1024:
|
||||
return f"{size_in_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_in_bytes} bytes"
|
||||
|
||||
|
||||
def _upload_deployment(
|
||||
fastapi_client: APIClient,
|
||||
deployment_id: str,
|
||||
archive_path: Path,
|
||||
progress: Progress,
|
||||
) -> CreateDeploymentResponse:
|
||||
archive_size = archive_path.stat().st_size
|
||||
archive_size_str = _format_size(archive_size)
|
||||
|
||||
progress.log(f"Uploading deployment ({archive_size_str})...")
|
||||
logger.debug(
|
||||
"Starting deployment upload for deployment: %s",
|
||||
deployment_id,
|
||||
)
|
||||
logger.debug("Archive path: %s, size: %s bytes", archive_path, archive_size)
|
||||
|
||||
def progress_callback(bytes_read: int) -> None:
|
||||
progress.log(
|
||||
f"Uploading deployment ({_format_size(bytes_read)} of {archive_size_str})..."
|
||||
)
|
||||
|
||||
logger.debug("Requesting upload URL from API")
|
||||
response = fastapi_client.post(f"/deployments/{deployment_id}/upload")
|
||||
response.raise_for_status()
|
||||
|
||||
upload_data = RequestUploadResponse.model_validate(response.json())
|
||||
logger.debug("Received upload URL: %s", upload_data.url)
|
||||
|
||||
logger.debug("Starting file upload to S3")
|
||||
with Client() as s3_client:
|
||||
with open(archive_path, "rb") as archive_file:
|
||||
archive_file_with_progress = ProgressFile(
|
||||
archive_file, progress_callback=progress_callback
|
||||
)
|
||||
upload_response = s3_client.post(
|
||||
upload_data.url,
|
||||
data=upload_data.fields,
|
||||
files={"file": cast(BinaryIO, archive_file_with_progress)},
|
||||
)
|
||||
|
||||
upload_response.raise_for_status()
|
||||
logger.debug("File upload completed successfully")
|
||||
|
||||
logger.debug("Notifying API that upload is complete")
|
||||
notify_response = fastapi_client.post(
|
||||
f"/deployments/{deployment_id}/upload-complete"
|
||||
)
|
||||
|
||||
notify_response.raise_for_status()
|
||||
logger.debug("Upload notification sent successfully")
|
||||
|
||||
return CreateDeploymentResponse.model_validate(notify_response.json())
|
||||
@@ -0,0 +1,170 @@
|
||||
import time
|
||||
from itertools import cycle
|
||||
from textwrap import dedent
|
||||
|
||||
import typer
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse
|
||||
from fastapi_cloud_cli.utils.api import (
|
||||
SUCCESSFUL_STATUSES,
|
||||
APIClient,
|
||||
DeploymentStatus,
|
||||
StreamLogError,
|
||||
TooManyRetriesError,
|
||||
)
|
||||
|
||||
# (bullet emoji, message) — the emoji replaces the progress animation
|
||||
WAITING_MESSAGES = [
|
||||
("🚀", "Preparing for liftoff! Almost there..."),
|
||||
("👹", "Sneaking past the dependency gremlins... Don't wake them up!"),
|
||||
("🤏", "Squishing code into a tiny digital sandwich. Nom nom nom."),
|
||||
("🐱", "Removing cat videos from our servers to free up space."),
|
||||
("🐢", "Uploading at blazing speeds of 1 byte per hour. Patience, young padawan."),
|
||||
("🔌", "Connecting to server... Please stand by while we argue with the firewall."),
|
||||
(
|
||||
"💥",
|
||||
"Oops! We've angered the Python God. Sacrificing a rubber duck to appease it.",
|
||||
),
|
||||
("🧙", "Sprinkling magic deployment dust. Abracadabra!"),
|
||||
("👀", "Hoping that @tiangolo doesn't find out about this deployment."),
|
||||
("🍪", "Cookie monster detected on server. Deploying anti-cookie shields."),
|
||||
]
|
||||
|
||||
LONG_WAIT_MESSAGES = [
|
||||
(
|
||||
"😅",
|
||||
"Well, that's embarrassing. We're still waiting for the deployment to finish...",
|
||||
),
|
||||
("🤔", "Maybe we should have brought snacks for this wait..."),
|
||||
("🥱", "Yawn... Still waiting..."),
|
||||
("🤯", "Time is relative... Especially when you're waiting for a deployment..."),
|
||||
]
|
||||
|
||||
|
||||
def _verify_deployment(
|
||||
toolkit: RichToolkit,
|
||||
client: APIClient,
|
||||
deployment: CreateDeploymentResponse,
|
||||
) -> None:
|
||||
failed_status: str | None = None
|
||||
|
||||
with toolkit.progress(
|
||||
title="Verifying deployment...",
|
||||
inline_logs=True,
|
||||
done_emoji="✅",
|
||||
) as progress:
|
||||
try:
|
||||
final_status = client.poll_deployment_status(deployment.id)
|
||||
except (TimeoutError, TooManyRetriesError, StreamLogError):
|
||||
progress.metadata["done_emoji"] = "⚠️"
|
||||
progress.current_message = (
|
||||
f"Could not confirm deployment status. "
|
||||
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
|
||||
)
|
||||
return
|
||||
|
||||
if final_status in SUCCESSFUL_STATUSES:
|
||||
progress.current_message = "Ready the chicken! 🐔"
|
||||
else:
|
||||
progress.metadata["done_emoji"] = "❌"
|
||||
progress.current_message = "Deployment failed"
|
||||
|
||||
failed_status = DeploymentStatus.to_human_readable(final_status)
|
||||
|
||||
if failed_status is not None:
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"Oh no! Deployment failed: {failed_status}. "
|
||||
f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
|
||||
emoji="😔",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_deployment(
|
||||
toolkit: RichToolkit,
|
||||
client: APIClient,
|
||||
app_id: str,
|
||||
deployment: CreateDeploymentResponse,
|
||||
) -> None:
|
||||
messages = cycle(WAITING_MESSAGES)
|
||||
|
||||
time_elapsed = 0.0
|
||||
|
||||
started_at = time.monotonic()
|
||||
|
||||
last_message_changed_at = time.monotonic()
|
||||
|
||||
with (
|
||||
toolkit.progress(
|
||||
"Checking the status of your deployment",
|
||||
inline_logs=True,
|
||||
lines_to_show=20,
|
||||
emoji="👀",
|
||||
done_emoji="🚀",
|
||||
) as progress,
|
||||
):
|
||||
build_complete = False
|
||||
build_failed = False
|
||||
|
||||
try:
|
||||
for log in client.stream_build_logs(deployment.id):
|
||||
time_elapsed = time.monotonic() - started_at
|
||||
|
||||
if log.type == "message":
|
||||
progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute]
|
||||
|
||||
if log.type == "complete":
|
||||
build_complete = True
|
||||
progress.title = "Build complete!"
|
||||
break
|
||||
|
||||
if log.type == "failed":
|
||||
build_failed = True
|
||||
# the headline comes from the title once there are log
|
||||
# lines, and from current_message when there are none
|
||||
progress.title = "Build failed"
|
||||
progress.current_message = "Build failed"
|
||||
progress.metadata["done_emoji"] = "❌"
|
||||
break
|
||||
|
||||
if time_elapsed > 30:
|
||||
messages = cycle(LONG_WAIT_MESSAGES)
|
||||
|
||||
if (time.monotonic() - last_message_changed_at) > 2:
|
||||
emoji, title = next(messages)
|
||||
progress.metadata["emoji"] = emoji
|
||||
progress.title = title
|
||||
|
||||
last_message_changed_at = time.monotonic()
|
||||
|
||||
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
|
||||
progress.set_error(
|
||||
dedent(f"""
|
||||
[error]Build log streaming failed: {e}[/]
|
||||
|
||||
Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
|
||||
""").strip()
|
||||
)
|
||||
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if build_failed:
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
|
||||
emoji="😔",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if build_complete:
|
||||
toolkit.print_line()
|
||||
|
||||
_verify_deployment(toolkit=toolkit, client=client, deployment=deployment)
|
||||
@@ -0,0 +1,497 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from httpx import HTTPError
|
||||
from pydantic import BaseModel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import (
|
||||
APIClient,
|
||||
BuildLogLineMessage,
|
||||
DeploymentStatus,
|
||||
StreamLogError,
|
||||
TooManyRetriesError,
|
||||
get_http_error_code,
|
||||
get_http_error_hint,
|
||||
handle_http_error,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import (
|
||||
FastAPIRichToolkit,
|
||||
get_details_table,
|
||||
get_rich_toolkit,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.dates import format_last_updated
|
||||
from fastapi_cloud_cli.utils.errors import ErrorCode
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LIMIT = 100
|
||||
DEFAULT_OFFSET = 0
|
||||
|
||||
|
||||
class Deployment(BaseModel):
|
||||
id: str
|
||||
app_id: str
|
||||
slug: str
|
||||
status: DeploymentStatus
|
||||
created_at: str
|
||||
url: str | None = None
|
||||
dashboard_url: str | None = None
|
||||
|
||||
|
||||
class DeploymentsListAPIResponse(BaseModel):
|
||||
data: list[Deployment]
|
||||
count: int
|
||||
|
||||
|
||||
class DeploymentsListOutput(BaseModel):
|
||||
deployments: list[Deployment]
|
||||
total_count: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class DeploymentGetOutput(BaseModel):
|
||||
deployment: Deployment
|
||||
|
||||
|
||||
class BuildLogOutput(BaseModel):
|
||||
id: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class BuildLogsOutput(BaseModel):
|
||||
deployment_id: str
|
||||
failed: bool
|
||||
logs: list[BuildLogOutput]
|
||||
|
||||
|
||||
def _get_deployments(
|
||||
client: APIClient, *, app_id: str, limit: int, offset: int
|
||||
) -> DeploymentsListOutput:
|
||||
response = client.get(
|
||||
f"/apps/{app_id}/deployments/",
|
||||
params={
|
||||
"limit": limit,
|
||||
"skip": offset,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = DeploymentsListAPIResponse.model_validate(response.json())
|
||||
|
||||
return DeploymentsListOutput(
|
||||
deployments=data.data,
|
||||
total_count=data.count,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _get_deployment(client: APIClient, *, deployment_id: str) -> DeploymentGetOutput:
|
||||
response = client.get(f"/deployments/{deployment_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
return DeploymentGetOutput(deployment=Deployment.model_validate(response.json()))
|
||||
|
||||
|
||||
def _render_deployments_list_output(
|
||||
data: DeploymentsListOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
toolkit.print_title("deployments")
|
||||
toolkit.print_line()
|
||||
|
||||
if not data.deployments:
|
||||
toolkit.print("No deployments found.", bullet=False)
|
||||
return
|
||||
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column("ID", no_wrap=True)
|
||||
table.add_column("Status", no_wrap=True)
|
||||
table.add_column("Created", style="dim", no_wrap=True)
|
||||
table.add_row("[bold]ID[/bold]", "[bold]Status[/bold]", "[bold]Created[/bold]")
|
||||
table.add_row("", "", "")
|
||||
|
||||
for deployment in data.deployments:
|
||||
table.add_row(
|
||||
deployment.id,
|
||||
deployment.status.value,
|
||||
Text(format_last_updated(deployment.created_at)),
|
||||
)
|
||||
|
||||
toolkit.print(table, bullet=False)
|
||||
|
||||
|
||||
def _render_deployment_get_output(
|
||||
data: DeploymentGetOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
deployment = data.deployment
|
||||
|
||||
toolkit.print_title("deployment")
|
||||
toolkit.print_line()
|
||||
|
||||
toolkit.print(f"[bold]{deployment.id}[/bold]", emoji="🚀")
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
get_details_table(
|
||||
[
|
||||
("app id", deployment.app_id),
|
||||
("slug", deployment.slug),
|
||||
("status", deployment.status.value),
|
||||
("created", format_last_updated(deployment.created_at)),
|
||||
(
|
||||
"url",
|
||||
deployment.url
|
||||
if deployment.url is not None
|
||||
else Text("-", style="dim"),
|
||||
),
|
||||
(
|
||||
"dashboard",
|
||||
Text(
|
||||
deployment.dashboard_url,
|
||||
style=f"link {deployment.dashboard_url}",
|
||||
)
|
||||
if deployment.dashboard_url is not None
|
||||
else Text("-", style="dim"),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _print_build_log_json(
|
||||
deployment_id: str,
|
||||
record_type: str,
|
||||
*,
|
||||
log_id: str | None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
record = {
|
||||
"type": record_type,
|
||||
"deployment_id": deployment_id,
|
||||
"id": log_id,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{key: value for key, value in record.items() if value is not None},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
BUILD_LOG_BULLET = "[dim]▕[/dim]"
|
||||
|
||||
|
||||
def _print_build_log_line(toolkit: RichToolkit, message: str) -> None:
|
||||
toolkit.print(Text.from_ansi(message.rstrip()), emoji=BUILD_LOG_BULLET)
|
||||
|
||||
|
||||
def _render_build_logs_output(
|
||||
data: BuildLogsOutput, toolkit: FastAPIRichToolkit
|
||||
) -> None:
|
||||
if not data.logs:
|
||||
toolkit.print("No build logs found.")
|
||||
return
|
||||
|
||||
for log in data.logs:
|
||||
_print_build_log_line(toolkit, log.message)
|
||||
|
||||
if data.failed:
|
||||
toolkit.print_line()
|
||||
toolkit.print_error("Build failed.")
|
||||
|
||||
|
||||
def _stream_build_logs(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
client: APIClient,
|
||||
deployment_id: str,
|
||||
) -> bool:
|
||||
failed = False
|
||||
|
||||
for log in client.stream_build_logs(deployment_id, follow=True):
|
||||
if isinstance(log, BuildLogLineMessage):
|
||||
if toolkit.mode == "json":
|
||||
_print_build_log_json(
|
||||
deployment_id,
|
||||
"log",
|
||||
log_id=log.id,
|
||||
message=log.message,
|
||||
)
|
||||
else:
|
||||
_print_build_log_line(toolkit, log.message)
|
||||
|
||||
elif log.type == "complete":
|
||||
if toolkit.mode == "json":
|
||||
_print_build_log_json(
|
||||
deployment_id,
|
||||
"complete",
|
||||
log_id=log.id,
|
||||
)
|
||||
|
||||
elif log.type == "failed":
|
||||
failed = True
|
||||
if toolkit.mode == "json":
|
||||
_print_build_log_json(
|
||||
deployment_id,
|
||||
"failed",
|
||||
log_id=log.id,
|
||||
)
|
||||
else:
|
||||
toolkit.print_line()
|
||||
toolkit.print_error("Build failed.")
|
||||
|
||||
return failed
|
||||
|
||||
|
||||
def _fetch_build_logs(client: APIClient, deployment_id: str) -> BuildLogsOutput:
|
||||
logs: list[BuildLogOutput] = []
|
||||
failed = False
|
||||
|
||||
for log in client.stream_build_logs(deployment_id, follow=False):
|
||||
if isinstance(log, BuildLogLineMessage):
|
||||
logs.append(BuildLogOutput(id=log.id, message=log.message))
|
||||
|
||||
elif log.type == "failed":
|
||||
failed = True
|
||||
|
||||
return BuildLogsOutput(deployment_id=deployment_id, failed=failed, logs=logs)
|
||||
|
||||
|
||||
def _handle_build_log_error(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
error: StreamLogError,
|
||||
) -> None:
|
||||
hint: str | None = None
|
||||
|
||||
if error.status_code == 404:
|
||||
code: ErrorCode = "not_found"
|
||||
message = "Deployment not found."
|
||||
|
||||
elif isinstance(error.__cause__, HTTPError):
|
||||
code = get_http_error_code(error.__cause__)
|
||||
message = handle_http_error(error.__cause__)
|
||||
hint = get_http_error_hint(code)
|
||||
|
||||
else:
|
||||
code = "api_error"
|
||||
message = f"Error streaming build logs: {error}"
|
||||
|
||||
toolkit.fail(
|
||||
code,
|
||||
message,
|
||||
hint=hint,
|
||||
render_output=_render_build_log_error,
|
||||
)
|
||||
|
||||
|
||||
def _render_build_log_error(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
code: ErrorCode,
|
||||
message: str,
|
||||
hint: str,
|
||||
) -> None:
|
||||
toolkit.print_error(message)
|
||||
if hint:
|
||||
toolkit.print_line()
|
||||
toolkit.print_hint(hint)
|
||||
|
||||
|
||||
deployments_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage the deployments of your app.",
|
||||
)
|
||||
|
||||
|
||||
@deployments_app.command("get")
|
||||
def get_deployment(
|
||||
deployment_id: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="ID of the deployment to return.",
|
||||
),
|
||||
],
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app that owns the deployment.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Get a FastAPI Cloud deployment by ID.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
resolve_app_id_or_fail(toolkit, app_id=app_id)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching deployment",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching deployment. Please try again later.",
|
||||
not_found_message="Deployment not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
result = _get_deployment(
|
||||
client,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_deployment_get_output)
|
||||
|
||||
|
||||
@deployments_app.command("build-logs")
|
||||
def build_logs(
|
||||
deployment_id: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="ID of the deployment whose build logs should be returned.",
|
||||
),
|
||||
],
|
||||
follow: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--follow/--no-follow",
|
||||
"-f",
|
||||
help="Stream build logs until the build reaches a terminal state.",
|
||||
),
|
||||
] = True,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
Stream or fetch build logs for a FastAPI Cloud deployment.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
if follow:
|
||||
toolkit.print(
|
||||
f"Streaming build logs for [bold]{deployment_id}[/bold]...",
|
||||
emoji="📡",
|
||||
)
|
||||
else:
|
||||
toolkit.print(
|
||||
f"Fetching build logs for [bold]{deployment_id}[/bold]...",
|
||||
emoji="📜",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
try:
|
||||
with APIClient() as client:
|
||||
if follow:
|
||||
failed = _stream_build_logs(toolkit, client, deployment_id)
|
||||
else:
|
||||
result = _fetch_build_logs(client, deployment_id)
|
||||
toolkit.success(result, render_output=_render_build_logs_output)
|
||||
failed = result.failed
|
||||
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
toolkit.print_line()
|
||||
return
|
||||
except StreamLogError as e:
|
||||
_handle_build_log_error(toolkit, e)
|
||||
|
||||
except (TooManyRetriesError, TimeoutError):
|
||||
message = "Lost connection to build log stream. Please try again later."
|
||||
toolkit.fail(
|
||||
"network_error",
|
||||
message,
|
||||
hint="Please try again later.",
|
||||
render_output=_render_build_log_error,
|
||||
)
|
||||
|
||||
if failed:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@deployments_app.command("list")
|
||||
def list_deployments(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose deployments should be listed.",
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--limit",
|
||||
help="Maximum number of deployments to return.",
|
||||
min=1,
|
||||
),
|
||||
] = DEFAULT_LIMIT,
|
||||
offset: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--offset",
|
||||
help="Offset into the deployment result set.",
|
||||
min=0,
|
||||
),
|
||||
] = DEFAULT_OFFSET,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
List FastAPI Cloud deployments for an app.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching deployments",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching deployments. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
result = _get_deployments(
|
||||
client,
|
||||
app_id=target_app_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_deployments_list_output)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands.env.delete import delete
|
||||
from fastapi_cloud_cli.commands.env.get import get_variable
|
||||
from fastapi_cloud_cli.commands.env.list import list_variables
|
||||
from fastapi_cloud_cli.commands.env.set import set
|
||||
|
||||
env_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage the environment variables of your app.",
|
||||
)
|
||||
env_app.command("list")(list_variables)
|
||||
env_app.command("get")(get_variable)
|
||||
env_app.command()(delete)
|
||||
env_app.command()(set)
|
||||
|
||||
__all__ = ["env_app"]
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
from pydantic import BaseModel
|
||||
from rich.text import Text
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
|
||||
ENV_VAR_VALUE_MAX_LENGTH = 40
|
||||
|
||||
|
||||
class EnvironmentVariable(BaseModel):
|
||||
name: str
|
||||
value: str | None = None
|
||||
is_secret: bool = False
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class EnvironmentVariableResponse(BaseModel):
|
||||
data: list[EnvironmentVariable]
|
||||
|
||||
|
||||
def _get_environment_variables(
|
||||
client: APIClient, app_id: str
|
||||
) -> EnvironmentVariableResponse:
|
||||
response = client.get(f"/apps/{app_id}/environment-variables/")
|
||||
response.raise_for_status()
|
||||
|
||||
return EnvironmentVariableResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _find_environment_variable(
|
||||
environment_variables: list[EnvironmentVariable], name: str
|
||||
) -> EnvironmentVariable | None:
|
||||
return next(
|
||||
(
|
||||
environment_variable
|
||||
for environment_variable in environment_variables
|
||||
if environment_variable.name == name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _format_env_var_value(env_var: EnvironmentVariable) -> Text:
|
||||
if env_var.value is None:
|
||||
placeholder = "[secret]" if env_var.is_secret else "-"
|
||||
|
||||
return Text(placeholder, style="dim")
|
||||
|
||||
value = env_var.value.replace("\r", "\\r").replace("\n", "\\n")
|
||||
|
||||
if len(value) > ENV_VAR_VALUE_MAX_LENGTH:
|
||||
value = f"{value[: ENV_VAR_VALUE_MAX_LENGTH - 3]}..."
|
||||
|
||||
return Text(value)
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich_toolkit import RichToolkit
|
||||
from rich_toolkit.menu import Option
|
||||
|
||||
from fastapi_cloud_cli.commands.env._shared import _get_environment_variables
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class EnvironmentVariableDeleteOutput(BaseModel):
|
||||
app_id: str
|
||||
name: str
|
||||
deleted: bool = True
|
||||
show_tag: Annotated[bool, Field(exclude=True)] = True
|
||||
|
||||
|
||||
def _render_environment_variable_delete_output(
|
||||
data: EnvironmentVariableDeleteOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
if data.show_tag:
|
||||
toolkit.print_title("environment variables")
|
||||
|
||||
toolkit.print_line()
|
||||
toolkit.print(f"Environment variable [bold]{data.name}[/] deleted.", bullet=False)
|
||||
|
||||
|
||||
def _delete_environment_variable(client: APIClient, app_id: str, name: str) -> bool:
|
||||
response = client.delete(f"/apps/{app_id}/environment-variables/{name}")
|
||||
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete(
|
||||
name: str | None = typer.Argument(
|
||||
None,
|
||||
help="The name of the environment variable to delete",
|
||||
),
|
||||
path_arg: Annotated[
|
||||
Path | None,
|
||||
typer.Argument(
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose environment variable should be deleted.",
|
||||
),
|
||||
] = None,
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--yes",
|
||||
"-y",
|
||||
help="Confirm deletion without prompting.",
|
||||
),
|
||||
] = False,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Delete an environment variable from the app.
|
||||
"""
|
||||
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit, app_id=app_id, path=path or path_arg
|
||||
)
|
||||
name_provided = name is not None
|
||||
|
||||
with APIClient() as client:
|
||||
if not name:
|
||||
if toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Environment variable name is required.",
|
||||
hint="Pass NAME to choose an environment variable.",
|
||||
)
|
||||
|
||||
with toolkit.progress(
|
||||
"Fetching environment variables...", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
environment_variables = _get_environment_variables(
|
||||
client=client, app_id=target_app_id
|
||||
)
|
||||
|
||||
toolkit.print_title("environment variables")
|
||||
toolkit.print_line()
|
||||
|
||||
if not environment_variables.data:
|
||||
toolkit.print("No environment variables found.", bullet=False)
|
||||
return
|
||||
|
||||
name = toolkit.ask(
|
||||
"Select the environment variable to delete:",
|
||||
options=[
|
||||
Option({"name": env_var.name, "value": env_var.name})
|
||||
for env_var in environment_variables.data
|
||||
],
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
assert name
|
||||
else:
|
||||
if not validate_environment_variable_name(name):
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
f"The environment variable name [bold]{name}[/] is invalid.",
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
if name_provided and not yes:
|
||||
if toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Deletion confirmation is required.",
|
||||
hint="Pass --yes to confirm deletion.",
|
||||
)
|
||||
|
||||
should_delete = toolkit.confirm(
|
||||
f"Delete [bold]{name}[/]?",
|
||||
default=False,
|
||||
bullet=False,
|
||||
)
|
||||
if not should_delete:
|
||||
toolkit.print_title("environment variables")
|
||||
toolkit.print_line()
|
||||
toolkit.print("Deletion cancelled.", bullet=False)
|
||||
raise typer.Exit(0)
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress(
|
||||
"Deleting environment variable", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
deleted = _delete_environment_variable(
|
||||
client=client, app_id=target_app_id, name=name
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
message = (
|
||||
f"Environment variable {name} not found."
|
||||
if toolkit.mode == "json"
|
||||
else "Environment variable not found."
|
||||
)
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
message,
|
||||
hint="Run `fastapi cloud env list` to see available variables.",
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
EnvironmentVariableDeleteOutput(
|
||||
app_id=target_app_id, name=name, show_tag=name_provided
|
||||
),
|
||||
render_output=_render_environment_variable_delete_output,
|
||||
)
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.table import Table
|
||||
from rich_toolkit import RichToolkit
|
||||
from rich_toolkit.menu import Option
|
||||
|
||||
from fastapi_cloud_cli.commands.env._shared import (
|
||||
EnvironmentVariable,
|
||||
_find_environment_variable,
|
||||
_format_env_var_value,
|
||||
_get_environment_variables,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class EnvironmentVariableGetOutput(BaseModel):
|
||||
app_id: str
|
||||
variable: EnvironmentVariable
|
||||
show_tag: Annotated[bool, Field(exclude=True)] = True
|
||||
|
||||
|
||||
def _render_environment_variable_get_output(
|
||||
data: EnvironmentVariableGetOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
variable = data.variable
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column(no_wrap=True)
|
||||
table.add_column()
|
||||
table.add_row("name:", variable.name)
|
||||
table.add_row("value:", _format_env_var_value(variable))
|
||||
|
||||
if data.show_tag:
|
||||
toolkit.print_title("environment variables")
|
||||
|
||||
toolkit.print_line()
|
||||
toolkit.print(table, bullet=False)
|
||||
|
||||
|
||||
def get_variable(
|
||||
name: Annotated[
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="The name of the environment variable to return.",
|
||||
),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose environment variable should be returned.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Get an environment variable for the app.
|
||||
"""
|
||||
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id, path=path)
|
||||
name_provided = name is not None
|
||||
|
||||
if name is None and toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Environment variable name is required.",
|
||||
hint="Pass NAME to choose an environment variable.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
"Fetching environment variables...", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
environment_variables = _get_environment_variables(
|
||||
client=client, app_id=target_app_id
|
||||
)
|
||||
|
||||
if name is None:
|
||||
toolkit.print_title("environment variables")
|
||||
toolkit.print_line()
|
||||
|
||||
if not environment_variables.data:
|
||||
toolkit.print("No environment variables found.", bullet=False)
|
||||
return
|
||||
|
||||
name = toolkit.ask(
|
||||
"Select the environment variable to get:",
|
||||
options=[
|
||||
Option({"name": env_var.name, "value": env_var.name})
|
||||
for env_var in environment_variables.data
|
||||
],
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
variable = _find_environment_variable(environment_variables.data, name)
|
||||
|
||||
if variable is None:
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
f"Environment variable {name} not found.",
|
||||
hint="Run `fastapi cloud env list` to see available variables.",
|
||||
)
|
||||
assert variable is not None
|
||||
|
||||
toolkit.success(
|
||||
EnvironmentVariableGetOutput(
|
||||
app_id=target_app_id,
|
||||
variable=variable,
|
||||
show_tag=name_provided,
|
||||
),
|
||||
render_output=_render_environment_variable_get_output,
|
||||
)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.env._shared import (
|
||||
ENV_VAR_VALUE_MAX_LENGTH,
|
||||
EnvironmentVariable,
|
||||
_format_env_var_value,
|
||||
_get_environment_variables,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.dates import format_last_updated
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class EnvironmentVariablesListOutput(BaseModel):
|
||||
app_id: str
|
||||
variables: list[EnvironmentVariable]
|
||||
|
||||
|
||||
def _get_environment_variables_table(
|
||||
environment_variables: list[EnvironmentVariable],
|
||||
) -> Table:
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column("Key", no_wrap=True)
|
||||
table.add_column("Value", overflow="ellipsis", max_width=ENV_VAR_VALUE_MAX_LENGTH)
|
||||
table.add_column("Last updated", style="dim", no_wrap=True)
|
||||
table.add_row("[bold]Key[/bold]", "[bold]Value[/bold]", "[bold]Last updated[/bold]")
|
||||
table.add_row("", "", "")
|
||||
|
||||
for env_var in environment_variables:
|
||||
table.add_row(
|
||||
Text(env_var.name),
|
||||
_format_env_var_value(env_var),
|
||||
Text(format_last_updated(env_var.updated_at)),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _render_environment_variables_list_output(
|
||||
data: EnvironmentVariablesListOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
toolkit.print_title("environment variables")
|
||||
toolkit.print_line()
|
||||
|
||||
if not data.variables:
|
||||
toolkit.print("No environment variables found.", bullet=False)
|
||||
return
|
||||
|
||||
toolkit.print(_get_environment_variables_table(data.variables), bullet=False)
|
||||
|
||||
|
||||
def list_variables(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose environment variables should be listed.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
List the environment variables for the app.
|
||||
"""
|
||||
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id, path=path)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
"Fetching environment variables...", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
environment_variables = _get_environment_variables(
|
||||
client=client, app_id=target_app_id
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
EnvironmentVariablesListOutput(
|
||||
app_id=target_app_id,
|
||||
variables=environment_variables.data,
|
||||
),
|
||||
render_output=_render_environment_variables_list_output,
|
||||
)
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class EnvironmentVariableSetOutput(BaseModel):
|
||||
app_id: str
|
||||
name: str
|
||||
is_secret: bool
|
||||
show_tag: Annotated[bool, Field(exclude=True)] = True
|
||||
|
||||
|
||||
def _render_environment_variable_set_output(
|
||||
data: EnvironmentVariableSetOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
kind = "Secret environment variable" if data.is_secret else "Environment variable"
|
||||
message = f"{kind} [bold]{data.name}[/] set."
|
||||
|
||||
if data.show_tag:
|
||||
toolkit.print_title("environment variables")
|
||||
|
||||
toolkit.print_line()
|
||||
toolkit.print(message, bullet=False)
|
||||
|
||||
|
||||
def _input(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
prompt: str,
|
||||
*,
|
||||
password: bool = False,
|
||||
) -> str:
|
||||
return toolkit.input(prompt, password=password, bullet=False)
|
||||
|
||||
|
||||
def _resolve_environment_variable_name(
|
||||
toolkit: FastAPIRichToolkit, *, name: str | None, secret: bool
|
||||
) -> str:
|
||||
if name is not None:
|
||||
return name
|
||||
|
||||
if toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Environment variable name is required.",
|
||||
hint="Pass NAME to choose an environment variable.",
|
||||
)
|
||||
|
||||
if secret:
|
||||
return _input(toolkit, "Enter the name of the secret to set:")
|
||||
|
||||
return _input(toolkit, "Enter the name of the environment variable to set:")
|
||||
|
||||
|
||||
def _resolve_environment_variable_value(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
value: str | None,
|
||||
value_stdin: bool,
|
||||
secret: bool,
|
||||
) -> str:
|
||||
if value is not None and value_stdin:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
"Only one environment variable value source can be used.",
|
||||
hint="Pass either VALUE or --value-stdin.",
|
||||
)
|
||||
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
if value_stdin:
|
||||
return sys.stdin.read().rstrip("\r\n")
|
||||
|
||||
if toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Environment variable value is required.",
|
||||
hint="Pass VALUE or --value-stdin to set the environment variable.",
|
||||
)
|
||||
|
||||
if secret:
|
||||
return _input(toolkit, "Enter the secret value:", password=True)
|
||||
|
||||
return _input(toolkit, "Enter the value of the environment variable:")
|
||||
|
||||
|
||||
def _set_environment_variable(
|
||||
client: APIClient, app_id: str, name: str, value: str, is_secret: bool = False
|
||||
) -> None:
|
||||
response = client.post(
|
||||
f"/apps/{app_id}/environment-variables/",
|
||||
json={"name": name, "value": value, "is_secret": is_secret},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def set(
|
||||
name: str | None = typer.Argument(
|
||||
None,
|
||||
help="The name of the environment variable to set",
|
||||
),
|
||||
value: str | None = typer.Argument(
|
||||
None,
|
||||
help="The value of the environment variable to set",
|
||||
),
|
||||
path_arg: Annotated[
|
||||
Path | None,
|
||||
typer.Argument(
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
value_stdin: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--value-stdin",
|
||||
help="Read the environment variable value from stdin.",
|
||||
),
|
||||
] = False,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose environment variable should be set.",
|
||||
),
|
||||
] = None,
|
||||
secret: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--secret",
|
||||
help="Mark the environment variable as secret",
|
||||
),
|
||||
] = False,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Set an environment variable for the app.
|
||||
"""
|
||||
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit, app_id=app_id, path=path or path_arg
|
||||
)
|
||||
name_needs_prompt = name is None
|
||||
value_needs_prompt = value is None and not value_stdin
|
||||
prompts_user = name_needs_prompt or value_needs_prompt
|
||||
if prompts_user and toolkit.mode != "json":
|
||||
toolkit.print_title("environment variables")
|
||||
toolkit.print_line()
|
||||
|
||||
name = _resolve_environment_variable_name(
|
||||
toolkit,
|
||||
name=name,
|
||||
secret=secret,
|
||||
)
|
||||
value = _resolve_environment_variable_value(
|
||||
toolkit,
|
||||
value=value,
|
||||
value_stdin=value_stdin,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
"Setting environment variable", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress):
|
||||
_set_environment_variable(
|
||||
client=client,
|
||||
app_id=target_app_id,
|
||||
name=name,
|
||||
value=value,
|
||||
is_secret=secret,
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
EnvironmentVariableSetOutput(
|
||||
app_id=target_app_id,
|
||||
name=name,
|
||||
is_secret=secret,
|
||||
show_tag=not prompts_user,
|
||||
),
|
||||
render_output=_render_environment_variable_set_output,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands._flow import (
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
complete_device_login,
|
||||
device_authorization_output,
|
||||
render_login_output,
|
||||
start_device_authorization,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _interactive_login(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
no_open: bool = False,
|
||||
timeout: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
with APIClient() as client:
|
||||
with toolkit.progress("Starting authorization", transient=True) as progress:
|
||||
with client.handle_http_errors(progress, toolkit=toolkit):
|
||||
authorization_data = start_device_authorization(client)
|
||||
|
||||
url = authorization_data.verification_uri_complete
|
||||
|
||||
if no_open:
|
||||
toolkit.print(f"Open {url}")
|
||||
else:
|
||||
launch_cmd_res = typer.launch(url)
|
||||
logger.debug(f"Launch command result: {launch_cmd_res}")
|
||||
toolkit.print(f"Opening [link={url}]{url}[/link]")
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
with toolkit.progress(
|
||||
"Waiting for user to authorize...", transient=True
|
||||
) as progress:
|
||||
result = complete_device_login(
|
||||
client=client,
|
||||
progress=progress,
|
||||
toolkit=toolkit,
|
||||
device_code=authorization_data.device_code,
|
||||
interval=authorization_data.interval,
|
||||
timeout=timeout,
|
||||
cancel_hint="Run `fastapi cloud login` again to retry.",
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
result,
|
||||
render_output=render_login_output,
|
||||
)
|
||||
|
||||
|
||||
def login(
|
||||
no_open: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--no-open",
|
||||
help="Do not open the browser automatically.",
|
||||
),
|
||||
] = False,
|
||||
timeout: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--timeout",
|
||||
help="Maximum seconds to wait for authorization.",
|
||||
min=10,
|
||||
),
|
||||
] = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Login to FastAPI Cloud.
|
||||
"""
|
||||
if json_output:
|
||||
with get_rich_toolkit(json_output=json_output, minimal=True) as toolkit:
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
"Starting authorization", transient=True
|
||||
) as progress:
|
||||
with client.handle_http_errors(progress, toolkit=toolkit):
|
||||
authorization_data = start_device_authorization(client)
|
||||
|
||||
toolkit.success(device_authorization_output(authorization_data))
|
||||
|
||||
return
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit() as toolkit:
|
||||
toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI Cloud", emoji="🔐")
|
||||
toolkit.print_line()
|
||||
|
||||
if identity.is_logged_in():
|
||||
toolkit.print("You are already logged in.")
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
"Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts.",
|
||||
emoji="💡",
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
if identity.has_deploy_token():
|
||||
toolkit.print(
|
||||
"You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n"
|
||||
"This token will take precedence over the user token for "
|
||||
"[blue]`fastapi deploy`[/] command.",
|
||||
emoji="⚠️",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
_interactive_login(toolkit, no_open=no_open, timeout=timeout)
|
||||
@@ -0,0 +1,30 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.auth import delete_auth_config
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class LogoutOutput(BaseModel):
|
||||
logged_out: bool
|
||||
|
||||
|
||||
def _render_logout_output(data: LogoutOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print_title("FastAPI Cloud")
|
||||
toolkit.print_line()
|
||||
toolkit.print("You are now logged out!", emoji="👋")
|
||||
|
||||
|
||||
def logout(json_output: JsonOutputOption = False) -> Any:
|
||||
"""
|
||||
Logout from FastAPI Cloud.
|
||||
"""
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
delete_auth_config()
|
||||
toolkit.success(
|
||||
LogoutOutput(logged_out=True),
|
||||
render_output=_render_logout_output,
|
||||
)
|
||||
@@ -0,0 +1,294 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from httpx import HTTPError
|
||||
from pydantic import BaseModel
|
||||
from rich.markup import escape
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import (
|
||||
APIClient,
|
||||
AppLogEntry,
|
||||
StreamLogError,
|
||||
TooManyRetriesError,
|
||||
get_http_error_code,
|
||||
get_http_error_hint,
|
||||
handle_http_error,
|
||||
)
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.errors import ErrorCode
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LOG_LEVEL_COLORS = {
|
||||
"debug": "blue",
|
||||
"info": "cyan",
|
||||
"warning": "yellow",
|
||||
"warn": "yellow",
|
||||
"error": "red",
|
||||
"critical": "magenta",
|
||||
"fatal": "magenta",
|
||||
}
|
||||
|
||||
SINCE_PATTERN = re.compile(r"^\d+[smhd]$")
|
||||
MIN_LOG_TAIL = 1
|
||||
MAX_LOG_TAIL = 1000
|
||||
|
||||
|
||||
class AppLogsOutput(BaseModel):
|
||||
app_id: str
|
||||
logs: list[AppLogEntry]
|
||||
|
||||
|
||||
def _validate_since(value: str) -> str:
|
||||
"""Validate the --since parameter format."""
|
||||
if not SINCE_PATTERN.match(value):
|
||||
raise typer.BadParameter(
|
||||
"Invalid format. Use a number followed by s, m, h, or d (e.g., '5m', '1h', '2d')."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _validate_tail(value: int) -> int:
|
||||
if not MIN_LOG_TAIL <= value <= MAX_LOG_TAIL:
|
||||
raise typer.BadParameter(
|
||||
f"Invalid value. Use a number between {MIN_LOG_TAIL} and {MAX_LOG_TAIL}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _get_log_bullet(log: AppLogEntry) -> str:
|
||||
"""Colored indicator rendered in the emoji bullet column.
|
||||
|
||||
▕ draws at the right edge of its cell, centering the bar under the
|
||||
double-width emojis.
|
||||
"""
|
||||
color = LOG_LEVEL_COLORS.get(log.level.lower(), "dim")
|
||||
|
||||
return f"[{color}]▕[/{color}]"
|
||||
|
||||
|
||||
def _format_log_line(log: AppLogEntry) -> str:
|
||||
"""Format a log entry for display"""
|
||||
# Parse the timestamp string to format it consistently
|
||||
timestamp = datetime.fromisoformat(log.timestamp.replace("Z", "+00:00"))
|
||||
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
return f"[dim]{timestamp_str}[/dim] {escape(log.message)}"
|
||||
|
||||
|
||||
def _print_log_line(toolkit: RichToolkit, log: AppLogEntry) -> None:
|
||||
toolkit.print(_format_log_line(log), emoji=_get_log_bullet(log))
|
||||
|
||||
|
||||
def _render_app_logs_output(data: AppLogsOutput, toolkit: RichToolkit) -> None:
|
||||
if not data.logs:
|
||||
toolkit.print("No logs found for the specified time range.")
|
||||
return
|
||||
|
||||
for log in data.logs:
|
||||
_print_log_line(toolkit, log)
|
||||
|
||||
|
||||
def _print_app_log_json(app_id: str, log: AppLogEntry) -> None:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "log",
|
||||
"app_id": app_id,
|
||||
**log.model_dump(mode="json"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render_plain_error(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
*,
|
||||
code: ErrorCode,
|
||||
message: str,
|
||||
hint: str,
|
||||
) -> None:
|
||||
toolkit.print_error(message)
|
||||
if hint:
|
||||
toolkit.print_line()
|
||||
toolkit.print_hint(hint)
|
||||
|
||||
|
||||
def _handle_stream_log_error(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
error: StreamLogError,
|
||||
) -> None:
|
||||
hint: str | None = None
|
||||
|
||||
if error.status_code == 404:
|
||||
code: ErrorCode = "not_found"
|
||||
message = "App not found. Make sure to use the correct account."
|
||||
|
||||
elif isinstance(error.__cause__, HTTPError):
|
||||
code = get_http_error_code(error.__cause__)
|
||||
message = handle_http_error(error.__cause__)
|
||||
hint = get_http_error_hint(code)
|
||||
|
||||
if error.status_code == 400 and hint is None:
|
||||
hint = (
|
||||
"Try a shorter time range (e.g. `--since 1d`). "
|
||||
"Log retention depends on your plan."
|
||||
)
|
||||
|
||||
else:
|
||||
code = "api_error"
|
||||
message = f"[red]Error:[/] {escape(str(error))}"
|
||||
|
||||
toolkit.fail(code, message, hint=hint, render_output=_render_plain_error)
|
||||
|
||||
|
||||
def _process_log_stream(
|
||||
toolkit: FastAPIRichToolkit,
|
||||
app_id: str,
|
||||
tail: int,
|
||||
since: str,
|
||||
follow: bool,
|
||||
) -> None:
|
||||
"""Stream app logs and print them to the console."""
|
||||
logs: list[AppLogEntry] = []
|
||||
|
||||
try:
|
||||
with APIClient() as client:
|
||||
for log in client.stream_app_logs(
|
||||
app_id=app_id,
|
||||
tail=tail,
|
||||
since=since,
|
||||
follow=follow,
|
||||
):
|
||||
if follow:
|
||||
if toolkit.mode == "json":
|
||||
_print_app_log_json(app_id, log)
|
||||
else:
|
||||
_print_log_line(toolkit, log)
|
||||
continue
|
||||
|
||||
logs.append(log)
|
||||
|
||||
if not follow:
|
||||
toolkit.success(
|
||||
AppLogsOutput(app_id=app_id, logs=logs),
|
||||
render_output=_render_app_logs_output,
|
||||
)
|
||||
return
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
toolkit.print_line()
|
||||
|
||||
return
|
||||
except StreamLogError as e:
|
||||
_handle_stream_log_error(toolkit, e)
|
||||
except (TooManyRetriesError, TimeoutError):
|
||||
message = "Lost connection to log stream. Please try again later."
|
||||
toolkit.fail(
|
||||
"network_error",
|
||||
message,
|
||||
hint="Please try again later.",
|
||||
render_output=_render_plain_error,
|
||||
)
|
||||
|
||||
|
||||
def logs(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Argument(
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose logs should be fetched.",
|
||||
),
|
||||
] = None,
|
||||
tail: int = typer.Option(
|
||||
100,
|
||||
"--tail",
|
||||
"-t",
|
||||
help=f"Number of log lines to show before streaming (max {MAX_LOG_TAIL}).",
|
||||
show_default=True,
|
||||
callback=_validate_tail,
|
||||
),
|
||||
since: str = typer.Option(
|
||||
"5m",
|
||||
"--since",
|
||||
"-s",
|
||||
help=(
|
||||
"Show logs since a specific time (e.g., '5m', '1h', '2d'). "
|
||||
"Limited by your plan's log retention."
|
||||
),
|
||||
show_default=True,
|
||||
callback=_validate_since,
|
||||
),
|
||||
follow: bool = typer.Option(
|
||||
True,
|
||||
"--follow/--no-follow",
|
||||
"-f",
|
||||
help="Stream logs in real-time (use --no-follow to fetch and exit).",
|
||||
),
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> None:
|
||||
"""Stream or fetch logs from your deployed app.
|
||||
|
||||
Examples:
|
||||
fastapi cloud logs # Stream logs in real-time
|
||||
fastapi cloud logs --no-follow # Fetch recent logs and exit
|
||||
fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour
|
||||
"""
|
||||
identity = Identity()
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit,
|
||||
app_id=app_id,
|
||||
path=path,
|
||||
hint="Pass --app-id or run `fastapi cloud link` to link an app.",
|
||||
)
|
||||
|
||||
logger.debug("Fetching logs for app ID: %s", target_app_id)
|
||||
|
||||
if follow:
|
||||
toolkit.print(
|
||||
f"Streaming logs for [bold]{target_app_id}[/bold] (Ctrl+C to exit)...",
|
||||
emoji="📡",
|
||||
)
|
||||
else:
|
||||
toolkit.print(
|
||||
f"Fetching logs for [bold]{target_app_id}[/bold]...",
|
||||
emoji="📜",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
_process_log_stream(
|
||||
toolkit=toolkit,
|
||||
app_id=target_app_id,
|
||||
tail=tail,
|
||||
since=since,
|
||||
follow=follow,
|
||||
)
|
||||
@@ -0,0 +1,536 @@
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePath
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_EXPIRES_DAYS = 365
|
||||
DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
|
||||
|
||||
|
||||
class CISetupOutput(BaseModel):
|
||||
app_id: str
|
||||
repo: str
|
||||
branch: str
|
||||
workflow_path: str
|
||||
created_token: bool
|
||||
set_github_secrets: bool
|
||||
wrote_workflow: bool
|
||||
token_expired_at: str | None = None
|
||||
|
||||
|
||||
def _render_ci_setup_output(data: CISetupOutput, toolkit: FastAPIRichToolkit) -> None:
|
||||
if data.wrote_workflow and data.set_github_secrets:
|
||||
toolkit.print("Done! Commit and push to start deploying.", emoji="✅")
|
||||
elif data.wrote_workflow:
|
||||
toolkit.print(
|
||||
"Done — workflow file is ready, but GitHub secrets were not set.",
|
||||
emoji="✅",
|
||||
)
|
||||
elif data.set_github_secrets:
|
||||
toolkit.print("Done! GitHub Actions secrets are configured.", emoji="✅")
|
||||
else:
|
||||
toolkit.print("Done!", emoji="✅")
|
||||
|
||||
if data.token_expired_at:
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"Your deploy token expires on [bold]{data.token_expired_at[:10]}[/bold]. "
|
||||
"Regenerate it from the dashboard or re-run this command before then.",
|
||||
)
|
||||
|
||||
|
||||
class GitHubSecretError(Exception):
|
||||
"""Raised when setting a GitHub Actions secret fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _get_github_host(origin: str) -> str:
|
||||
match = re.search(r"(?:git@|https://)([^:/]+)", origin)
|
||||
return match.group(1) if match else "github.com"
|
||||
|
||||
|
||||
def _repo_slug_from_origin(origin: str) -> str | None:
|
||||
"""Extract 'owner/repo' from a GitHub remote URL."""
|
||||
# Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||
# Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git
|
||||
# Match the part after the last : or / (which is owner/repo)
|
||||
match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _check_git_installed() -> bool:
|
||||
"""Check if git is installed and available."""
|
||||
return shutil.which("git") is not None
|
||||
|
||||
|
||||
def _check_gh_cli_installed() -> bool:
|
||||
"""Check if the GitHub CLI (gh) is installed and available."""
|
||||
return shutil.which("gh") is not None
|
||||
|
||||
|
||||
def _get_remote_origin() -> str:
|
||||
"""Get the remote origin URL of the Git repository."""
|
||||
try:
|
||||
# Try gh first (to respect gh repo set-default)
|
||||
result = subprocess.run(
|
||||
["gh", "repo", "view", "--json", "url", "-q", ".url"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
# CalledProcessError if gh command fails, FileNotFoundError if gh is not installed
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# Fallback to git command
|
||||
result = subprocess.run(
|
||||
["git", "config", "--get", "remote.origin.url"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _set_github_secret(name: str, value: str) -> None:
|
||||
"""Set a GitHub Actions secret via the gh CLI.
|
||||
|
||||
Raises:
|
||||
GitHubSecretError: If setting the secret fails.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["gh", "secret", "set", name, "--body", value],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e
|
||||
|
||||
|
||||
def _create_token(client: APIClient, app_id: str, token_name: str) -> dict[str, str]:
|
||||
"""Create a new deploy token.
|
||||
|
||||
Returns token_data dict with 'value' and 'expired_at' keys.
|
||||
"""
|
||||
response = client.post(
|
||||
f"/apps/{app_id}/tokens",
|
||||
json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {"value": data["value"], "expired_at": data["expired_at"]}
|
||||
|
||||
|
||||
def _get_default_branch() -> str:
|
||||
"""Get the default branch of the Git repository."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"repo",
|
||||
"view",
|
||||
"--json",
|
||||
"defaultBranchRef",
|
||||
"-q",
|
||||
".defaultBranchRef.name",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return "main"
|
||||
|
||||
|
||||
def _get_workflow_content(branch: str) -> str:
|
||||
return f"""\
|
||||
name: Deploy to FastAPI Cloud
|
||||
on:
|
||||
push:
|
||||
branches: [{branch}]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- run: uv run fastapi deploy
|
||||
env:
|
||||
FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}}
|
||||
FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}}
|
||||
"""
|
||||
|
||||
|
||||
def _write_workflow_file(branch: str, workflow_path: Path) -> None:
|
||||
workflow_content = _get_workflow_content(branch)
|
||||
workflow_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
workflow_path.write_text(workflow_content)
|
||||
|
||||
|
||||
def _get_workflow_path(file: str | None) -> Path:
|
||||
if file:
|
||||
return Path(f".github/workflows/{file}")
|
||||
|
||||
return DEFAULT_WORKFLOW_PATH
|
||||
|
||||
|
||||
def _format_workflow_path(workflow_path: PurePath) -> str:
|
||||
return workflow_path.as_posix()
|
||||
|
||||
|
||||
def _resolve_existing_workflow_path(
|
||||
toolkit: FastAPIRichToolkit, workflow_path: Path
|
||||
) -> Path | None:
|
||||
if toolkit.confirm(
|
||||
f"Workflow file [bold]{_format_workflow_path(workflow_path)}[/bold] already exists. Overwrite?",
|
||||
default=False,
|
||||
emoji="🗂️",
|
||||
):
|
||||
toolkit.print_line()
|
||||
|
||||
return workflow_path
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
if new_name := toolkit.input(
|
||||
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
|
||||
emoji="✏️",
|
||||
).strip():
|
||||
toolkit.print_line()
|
||||
|
||||
return Path(f".github/workflows/{new_name}")
|
||||
|
||||
toolkit.print_line()
|
||||
toolkit.print("Skipped writing workflow file.", emoji="⏭️")
|
||||
toolkit.print_line()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def setup_ci(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Argument(
|
||||
help=(
|
||||
"Path to the directory with your app's pyproject.toml "
|
||||
"(defaults to current directory)"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
app_id: str | None = typer.Option(
|
||||
None,
|
||||
"--app-id",
|
||||
help="ID of the app to set up CI for (defaults to the app linked to the directory)",
|
||||
),
|
||||
branch: str | None = typer.Option(
|
||||
None,
|
||||
"--branch",
|
||||
"-b",
|
||||
help="Branch that triggers deploys (defaults to the repo's default branch)",
|
||||
),
|
||||
secrets_only: bool = typer.Option(
|
||||
False,
|
||||
"--secrets-only",
|
||||
"-s",
|
||||
help="Provisions token and sets secrets, skips writing the workflow file",
|
||||
show_default=True,
|
||||
),
|
||||
workflow_only: bool = typer.Option(
|
||||
False,
|
||||
"--workflow-only",
|
||||
help="Writes the workflow file without creating a token or setting secrets",
|
||||
show_default=True,
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False,
|
||||
"--dry-run",
|
||||
"-d",
|
||||
help="Prints steps that would be taken without actually performing them",
|
||||
show_default=True,
|
||||
),
|
||||
file: str | None = typer.Option(
|
||||
None,
|
||||
"--file",
|
||||
"-f",
|
||||
help="Custom workflow filename (written to .github/workflows/)",
|
||||
),
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
|
||||
|
||||
Examples:
|
||||
fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
|
||||
fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
|
||||
fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
|
||||
fastapi cloud setup-ci --workflow-only # Only writes the workflow file
|
||||
fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
|
||||
fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
|
||||
"""
|
||||
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
if secrets_only and workflow_only:
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
"--secrets-only and --workflow-only cannot be used together.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(
|
||||
toolkit,
|
||||
app_id=app_id,
|
||||
path=path,
|
||||
hint="Pass --app-id or run `fastapi deploy` first.",
|
||||
)
|
||||
|
||||
if not _check_git_installed():
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
"git is not installed. Please install git to use this command.",
|
||||
)
|
||||
|
||||
try:
|
||||
origin = _get_remote_origin()
|
||||
except subprocess.CalledProcessError:
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
"Could not retrieve the git remote origin URL. Make sure you're in a git repository with a remote origin set.",
|
||||
)
|
||||
|
||||
# Check if it's a GitHub host (github.com or GitHub Enterprise)
|
||||
if "github" not in origin.lower():
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
"Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.",
|
||||
)
|
||||
|
||||
repo_slug = _repo_slug_from_origin(origin) or origin
|
||||
|
||||
if not branch:
|
||||
branch = _get_default_branch()
|
||||
|
||||
workflow_path = _get_workflow_path(file)
|
||||
needs_secrets = not workflow_only
|
||||
needs_workflow = not secrets_only
|
||||
has_gh = _check_gh_cli_installed() if needs_secrets and not dry_run else True
|
||||
|
||||
if (
|
||||
toolkit.mode == "json"
|
||||
and needs_workflow
|
||||
and not dry_run
|
||||
and not file
|
||||
and workflow_path.exists()
|
||||
):
|
||||
toolkit.fail(
|
||||
"invalid_input",
|
||||
f"Workflow file {_format_workflow_path(workflow_path)} already exists.",
|
||||
hint="Pass --file to choose another workflow file or remove the existing file.",
|
||||
)
|
||||
|
||||
if needs_secrets and not dry_run and toolkit.mode == "json" and not has_gh:
|
||||
toolkit.fail(
|
||||
"dependency_missing",
|
||||
"GitHub CLI (`gh`) is required to set GitHub Actions secrets.",
|
||||
hint="Install gh or use --workflow-only to write only the workflow file.",
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
toolkit.print(
|
||||
"[yellow]This is a dry run — no changes will be made[/yellow]"
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
toolkit.print_title("Configuring CI")
|
||||
toolkit.print_line()
|
||||
|
||||
toolkit.print(
|
||||
f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})",
|
||||
emoji="⚙️",
|
||||
)
|
||||
toolkit.print_line()
|
||||
|
||||
msg_token = "Created deploy token"
|
||||
msg_secrets = (
|
||||
"Set GitHub Actions secrets [bold blue]FASTAPI_CLOUD_TOKEN[/] "
|
||||
"and [bold blue]FASTAPI_CLOUD_APP_ID[/]"
|
||||
)
|
||||
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
|
||||
|
||||
if dry_run:
|
||||
if needs_secrets:
|
||||
toolkit.print(msg_token)
|
||||
toolkit.print(msg_secrets)
|
||||
|
||||
if needs_workflow:
|
||||
toolkit.print(msg_workflow)
|
||||
|
||||
toolkit.success(
|
||||
CISetupOutput(
|
||||
app_id=target_app_id,
|
||||
repo=repo_slug,
|
||||
branch=branch,
|
||||
workflow_path=_format_workflow_path(workflow_path),
|
||||
created_token=False,
|
||||
set_github_secrets=False,
|
||||
wrote_workflow=False,
|
||||
),
|
||||
render_output=lambda _data, _toolkit: None,
|
||||
)
|
||||
return
|
||||
|
||||
token_expired_at: str | None = None
|
||||
created_token = False
|
||||
set_github_secrets = False
|
||||
wrote_workflow = False
|
||||
|
||||
if needs_secrets:
|
||||
should_create_token = (
|
||||
True
|
||||
if toolkit.mode == "json"
|
||||
else toolkit.confirm(
|
||||
"Create a FastAPI Cloud deploy token for GitHub Actions?",
|
||||
default=True,
|
||||
)
|
||||
)
|
||||
if toolkit.mode != "json":
|
||||
toolkit.print_line()
|
||||
|
||||
if should_create_token:
|
||||
# Create unique token name with timestamp to avoid duplicates
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
|
||||
|
||||
with (
|
||||
APIClient() as client,
|
||||
toolkit.progress(
|
||||
title="Generating deploy token...", done_emoji="🔑"
|
||||
) as progress,
|
||||
client.handle_http_errors(
|
||||
progress, default_message="Error creating deploy token."
|
||||
),
|
||||
):
|
||||
token_data = _create_token(
|
||||
client=client, app_id=target_app_id, token_name=token_name
|
||||
)
|
||||
token_expired_at = token_data["expired_at"]
|
||||
created_token = True
|
||||
progress.log(msg_token)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
if has_gh:
|
||||
should_set_secrets = (
|
||||
True
|
||||
if toolkit.mode == "json"
|
||||
else toolkit.confirm(
|
||||
"Set GitHub Actions secrets "
|
||||
"[bold blue]FASTAPI_CLOUD_TOKEN[/] and "
|
||||
"[bold blue]FASTAPI_CLOUD_APP_ID[/] via gh?",
|
||||
default=True,
|
||||
)
|
||||
)
|
||||
if toolkit.mode != "json":
|
||||
toolkit.print_line()
|
||||
else:
|
||||
should_set_secrets = False
|
||||
secrets_url = (
|
||||
f"https://{_get_github_host(origin)}/{repo_slug}"
|
||||
"/settings/secrets/actions"
|
||||
)
|
||||
toolkit.print(
|
||||
"[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
|
||||
)
|
||||
toolkit.print_line()
|
||||
toolkit.print(f"Repository: [blue]{secrets_url}[/]")
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
f"[bold blue]FASTAPI_CLOUD_TOKEN[/] = {token_data['value']}"
|
||||
)
|
||||
toolkit.print(
|
||||
f"[bold blue]FASTAPI_CLOUD_APP_ID[/] = {target_app_id}"
|
||||
)
|
||||
|
||||
if should_set_secrets:
|
||||
with toolkit.progress(
|
||||
title="Setting repo secrets...", done_emoji="🔒"
|
||||
) as progress:
|
||||
try:
|
||||
_set_github_secret(
|
||||
"FASTAPI_CLOUD_TOKEN", token_data["value"]
|
||||
)
|
||||
_set_github_secret("FASTAPI_CLOUD_APP_ID", target_app_id)
|
||||
|
||||
progress.log(msg_secrets)
|
||||
except GitHubSecretError:
|
||||
progress.set_error(
|
||||
"Failed to set GitHub secrets via gh CLI."
|
||||
)
|
||||
toolkit.fail(
|
||||
"api_error",
|
||||
"Failed to set GitHub secrets via gh CLI.",
|
||||
)
|
||||
set_github_secrets = True
|
||||
else:
|
||||
toolkit.print("Skipped setting GitHub Actions secrets.", emoji="⏭️")
|
||||
else:
|
||||
toolkit.print(
|
||||
"Skipped creating deploy token and GitHub secrets.", emoji="⏭️"
|
||||
)
|
||||
|
||||
toolkit.print_line()
|
||||
|
||||
if needs_workflow:
|
||||
if not file and workflow_path.exists():
|
||||
resolved_workflow_path = _resolve_existing_workflow_path(
|
||||
toolkit, workflow_path
|
||||
)
|
||||
|
||||
if resolved_workflow_path is None:
|
||||
needs_workflow = False
|
||||
else:
|
||||
workflow_path = resolved_workflow_path
|
||||
|
||||
if needs_workflow:
|
||||
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
|
||||
|
||||
_write_workflow_file(branch, workflow_path)
|
||||
wrote_workflow = True
|
||||
|
||||
toolkit.print(msg_workflow)
|
||||
toolkit.print_line()
|
||||
|
||||
output = CISetupOutput(
|
||||
app_id=target_app_id,
|
||||
repo=repo_slug,
|
||||
branch=branch,
|
||||
workflow_path=_format_workflow_path(workflow_path),
|
||||
created_token=created_token,
|
||||
set_github_secrets=set_github_secrets,
|
||||
wrote_workflow=wrote_workflow,
|
||||
token_expired_at=token_expired_at,
|
||||
)
|
||||
|
||||
toolkit.success(output, render_output=_render_ci_setup_output)
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.commands.teams.get import (
|
||||
Team,
|
||||
_get_team_dashboard_url,
|
||||
get_team,
|
||||
)
|
||||
from fastapi_cloud_cli.config import Settings
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LIMIT = 100
|
||||
DEFAULT_OFFSET = 0
|
||||
|
||||
|
||||
class TeamsListAPIResponse(BaseModel):
|
||||
data: list[Team]
|
||||
count: int
|
||||
|
||||
|
||||
class TeamsListOutput(BaseModel):
|
||||
teams: list[Team]
|
||||
total_count: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
def _get_teams(client: APIClient, *, limit: int, offset: int) -> TeamsListOutput:
|
||||
response = client.get(
|
||||
"/teams/",
|
||||
params={
|
||||
"limit": limit,
|
||||
"skip": offset,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = TeamsListAPIResponse.model_validate(response.json())
|
||||
|
||||
return TeamsListOutput(
|
||||
teams=data.data,
|
||||
total_count=data.count,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _render_teams_list_output(data: TeamsListOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print_title("teams")
|
||||
toolkit.print_line()
|
||||
|
||||
if not data.teams:
|
||||
toolkit.print("No teams found.", bullet=False)
|
||||
return
|
||||
|
||||
settings = Settings.get()
|
||||
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column("Name")
|
||||
table.add_column("ID")
|
||||
table.add_row("[bold]Name[/bold]", "[bold]ID[/bold]")
|
||||
table.add_row("", "")
|
||||
|
||||
for team in data.teams:
|
||||
table.add_row(
|
||||
f"[link={_get_team_dashboard_url(team, settings=settings)}]{escape(team.name)}[/link]",
|
||||
team.id,
|
||||
)
|
||||
|
||||
toolkit.print(table, bullet=False)
|
||||
|
||||
|
||||
teams_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage your FastAPI Cloud teams.",
|
||||
)
|
||||
|
||||
|
||||
@teams_app.command("list")
|
||||
def list_teams(
|
||||
limit: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--limit",
|
||||
help="Maximum number of teams to return.",
|
||||
min=1,
|
||||
),
|
||||
] = DEFAULT_LIMIT,
|
||||
offset: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--offset",
|
||||
help="Offset into the team result set.",
|
||||
min=0,
|
||||
),
|
||||
] = DEFAULT_OFFSET,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
List FastAPI Cloud teams.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching teams",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching teams. Please try again later.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
result = _get_teams(client, limit=limit, offset=offset)
|
||||
|
||||
toolkit.success(result, render_output=_render_teams_list_output)
|
||||
|
||||
|
||||
teams_app.command("get")(get_team)
|
||||
@@ -0,0 +1,91 @@
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.config import Settings
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_details_table, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class TeamGetOutput(BaseModel):
|
||||
team: Team
|
||||
|
||||
|
||||
def _get_team_dashboard_url(team: Team, *, settings: Settings) -> str:
|
||||
return f"{settings.dashboard_base_url}/{team.slug}/apps"
|
||||
|
||||
|
||||
def _get_team(client: APIClient, team_id: str) -> TeamGetOutput:
|
||||
response = client.get(f"/teams/{team_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
team = Team.model_validate(response.json())
|
||||
|
||||
return TeamGetOutput(team=team)
|
||||
|
||||
|
||||
def _render_team_get_output(data: TeamGetOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print(f"[bold]{data.team.name}[/bold]", emoji="🏢")
|
||||
toolkit.print_line()
|
||||
toolkit.print(
|
||||
get_details_table(
|
||||
[
|
||||
("id", data.team.id),
|
||||
("slug", data.team.slug),
|
||||
("url", _get_team_dashboard_url(data.team, settings=Settings.get())),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_team(
|
||||
team_id: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="ID of the team to return.",
|
||||
),
|
||||
],
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Get a FastAPI Cloud team by ID.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
with (
|
||||
APIClient() as client,
|
||||
toolkit.progress(
|
||||
title="Fetching team",
|
||||
transient=True,
|
||||
) as progress,
|
||||
):
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching team. Please try again later.",
|
||||
not_found_message="Team not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
result = _get_team(client, team_id)
|
||||
|
||||
toolkit.success(result, render_output=_render_team_get_output)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import typer
|
||||
|
||||
from fastapi_cloud_cli.commands.tokens.create import create_token
|
||||
from fastapi_cloud_cli.commands.tokens.delete import delete_token
|
||||
from fastapi_cloud_cli.commands.tokens.list import list_tokens
|
||||
|
||||
tokens_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Manage deploy tokens for your app.",
|
||||
)
|
||||
tokens_app.command("create")(create_token)
|
||||
tokens_app.command("delete")(delete_token)
|
||||
tokens_app.command("list")(list_tokens)
|
||||
|
||||
__all__ = ["tokens_app"]
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, Field
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
DEFAULT_EXPIRES_IN_DAYS = 365
|
||||
|
||||
|
||||
class CreatedDeployToken(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
expired_at: str
|
||||
|
||||
|
||||
class DeployTokenCreateAPIResponse(CreatedDeployToken):
|
||||
value: str
|
||||
|
||||
|
||||
class StoredDeployTokenSecret(BaseModel):
|
||||
provider: Literal["file"] = "file"
|
||||
path: Path
|
||||
|
||||
|
||||
class DeployTokenCreateOutput(BaseModel):
|
||||
app_id: str
|
||||
token: CreatedDeployToken
|
||||
stored_secret: StoredDeployTokenSecret
|
||||
output_file: Annotated[Path, Field(exclude=True)]
|
||||
|
||||
|
||||
def _resolve_token_name(toolkit: FastAPIRichToolkit, *, name: str | None) -> str:
|
||||
if name is not None:
|
||||
return name
|
||||
|
||||
if toolkit.mode == "json":
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Deploy token name is required.",
|
||||
hint="Pass --name to choose a deploy token name.",
|
||||
)
|
||||
|
||||
return toolkit.input(
|
||||
"What's the deploy token name?",
|
||||
default="Deploy token",
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_output_file(
|
||||
toolkit: FastAPIRichToolkit, *, output_file: Path | None
|
||||
) -> Path:
|
||||
if output_file is not None:
|
||||
return output_file
|
||||
|
||||
toolkit.fail(
|
||||
"missing_required_input",
|
||||
"Output file is required.",
|
||||
hint="Pass --output-file to store the deploy token value.",
|
||||
)
|
||||
|
||||
|
||||
def _create_deploy_token(
|
||||
client: APIClient, *, app_id: str, name: str, expires_in_days: int
|
||||
) -> DeployTokenCreateAPIResponse:
|
||||
response = client.post(
|
||||
f"/apps/{app_id}/tokens",
|
||||
json={"name": name, "expires_in_days": expires_in_days},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return DeployTokenCreateAPIResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _write_token_value(output_file: Path, value: str) -> None:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_file.write_text(value, encoding="utf-8")
|
||||
output_file.chmod(0o600)
|
||||
|
||||
|
||||
def _render_deploy_token_create_output(
|
||||
data: DeployTokenCreateOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
toolkit.print(f"Created deploy token [bold]{data.token.name}[/bold]", bullet=False)
|
||||
toolkit.print(
|
||||
f"Stored deploy token value in [bold]{data.output_file}[/bold]",
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def create_token(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose deploy token should be created.",
|
||||
),
|
||||
] = None,
|
||||
name: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--name",
|
||||
help="Name of the deploy token to create.",
|
||||
),
|
||||
] = None,
|
||||
expires_in_days: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--expires-in-days",
|
||||
help="Number of days before the deploy token expires.",
|
||||
min=1,
|
||||
),
|
||||
] = DEFAULT_EXPIRES_IN_DAYS,
|
||||
output_file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--output-file",
|
||||
help="File path where the deploy token value should be stored.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a deploy token for an app.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
|
||||
|
||||
toolkit.print_title("deploy tokens")
|
||||
toolkit.print_line()
|
||||
|
||||
output_file = _resolve_output_file(toolkit, output_file=output_file)
|
||||
name_needs_prompt = name is None
|
||||
name = _resolve_token_name(toolkit, name=name)
|
||||
if name_needs_prompt:
|
||||
toolkit.print_line()
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Creating deploy token",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error creating deploy token. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
token = _create_deploy_token(
|
||||
client,
|
||||
app_id=target_app_id,
|
||||
name=name,
|
||||
expires_in_days=expires_in_days,
|
||||
)
|
||||
|
||||
_write_token_value(output_file, token.value)
|
||||
|
||||
toolkit.success(
|
||||
DeployTokenCreateOutput(
|
||||
app_id=target_app_id,
|
||||
token=CreatedDeployToken.model_validate(token),
|
||||
stored_secret=StoredDeployTokenSecret(path=output_file),
|
||||
output_file=output_file,
|
||||
),
|
||||
render_output=_render_deploy_token_create_output,
|
||||
)
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class DeployTokenDeleteOutput(BaseModel):
|
||||
token_id: str
|
||||
deleted: bool = True
|
||||
|
||||
|
||||
def _delete_deploy_token(client: APIClient, *, app_id: str, token_id: str) -> bool:
|
||||
response = client.delete(f"/apps/{app_id}/tokens/{token_id}")
|
||||
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _render_deploy_token_delete_output(
|
||||
data: DeployTokenDeleteOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
toolkit.print(
|
||||
f"Deleted deploy token [bold]{data.token_id}[/bold]",
|
||||
bullet=False,
|
||||
)
|
||||
|
||||
|
||||
def delete_token(
|
||||
token_id: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="ID of the deploy token to delete.",
|
||||
),
|
||||
],
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app that owns the deploy token.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Delete a deploy token for an app.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Deleting deploy token",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error deleting deploy token. Please try again later.",
|
||||
not_found_message="Deploy token not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
deleted = _delete_deploy_token(
|
||||
client,
|
||||
app_id=target_app_id,
|
||||
token_id=token_id,
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
message = (
|
||||
f"Deploy token {token_id} not found."
|
||||
if toolkit.mode == "json"
|
||||
else "Deploy token not found."
|
||||
)
|
||||
toolkit.fail(
|
||||
"not_found",
|
||||
message,
|
||||
hint="Run `fastapi cloud tokens list` to see available deploy tokens.",
|
||||
)
|
||||
|
||||
toolkit.success(
|
||||
DeployTokenDeleteOutput(token_id=token_id),
|
||||
render_output=_render_deploy_token_delete_output,
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
|
||||
class DeployToken(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: str
|
||||
expired_at: str
|
||||
|
||||
|
||||
class DeployTokensListAPIResponse(BaseModel):
|
||||
data: list[DeployToken]
|
||||
|
||||
|
||||
class DeployTokensListOutput(BaseModel):
|
||||
app_id: str
|
||||
tokens: list[DeployToken]
|
||||
|
||||
|
||||
def _get_deploy_tokens(client: APIClient, app_id: str) -> DeployTokensListAPIResponse:
|
||||
response = client.get(f"/apps/{app_id}/tokens")
|
||||
response.raise_for_status()
|
||||
|
||||
return DeployTokensListAPIResponse.model_validate(response.json())
|
||||
|
||||
|
||||
def _get_deploy_tokens_table(tokens: list[DeployToken]) -> Table:
|
||||
table = Table.grid(padding=(0, 2), pad_edge=False)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Expiration", no_wrap=True)
|
||||
table.add_column("ID", no_wrap=True, overflow="ignore")
|
||||
table.add_row(
|
||||
Text("Name", style="bold"),
|
||||
Text("Expiration", style="bold"),
|
||||
Text("ID", style="bold"),
|
||||
)
|
||||
table.add_row("", "", "")
|
||||
|
||||
for token in tokens:
|
||||
table.add_row(
|
||||
Text(token.name),
|
||||
Text(token.expired_at[:10], style="dim"),
|
||||
Text(token.id),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _render_deploy_tokens_list_output(
|
||||
data: DeployTokensListOutput, toolkit: RichToolkit
|
||||
) -> None:
|
||||
toolkit.print_title("deploy tokens")
|
||||
toolkit.print_line()
|
||||
|
||||
if not data.tokens:
|
||||
toolkit.print("No deploy tokens found.", bullet=False)
|
||||
return
|
||||
|
||||
toolkit.print(_get_deploy_tokens_table(data.tokens), bullet=False)
|
||||
|
||||
|
||||
def list_tokens(
|
||||
app_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--app-id",
|
||||
help="ID of the app whose deploy tokens should be listed.",
|
||||
),
|
||||
] = None,
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
List deploy tokens for an app.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
||||
)
|
||||
|
||||
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
|
||||
|
||||
with APIClient() as client:
|
||||
with toolkit.progress(
|
||||
title="Fetching deploy tokens",
|
||||
transient=True,
|
||||
) as progress:
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="Error fetching deploy tokens. Please try again later.",
|
||||
not_found_message="App not found.",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
tokens = _get_deploy_tokens(client=client, app_id=target_app_id)
|
||||
|
||||
toolkit.success(
|
||||
DeployTokensListOutput(app_id=target_app_id, tokens=tokens.data),
|
||||
render_output=_render_deploy_tokens_list_output,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from rich_toolkit import RichToolkit
|
||||
|
||||
from fastapi_cloud_cli.utils.api import APIClient
|
||||
from fastapi_cloud_cli.utils.auth import Identity
|
||||
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
||||
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhoAmIOutput(BaseModel):
|
||||
email: str | None = None
|
||||
has_deploy_token: bool
|
||||
|
||||
|
||||
def _render_whoami_output(data: WhoAmIOutput, toolkit: RichToolkit) -> None:
|
||||
toolkit.print(f"[bold]{data.email}[/bold]", emoji="⚡")
|
||||
|
||||
if data.has_deploy_token:
|
||||
toolkit.print(
|
||||
"[bold]Using API token from environment variable for "
|
||||
"[blue]`fastapi deploy`[/blue] command.[/bold]",
|
||||
emoji="⚡",
|
||||
)
|
||||
|
||||
|
||||
def whoami(
|
||||
json_output: JsonOutputOption = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Show the currently logged in user.
|
||||
"""
|
||||
identity = Identity()
|
||||
|
||||
with get_rich_toolkit(json_output=json_output) as toolkit:
|
||||
if not identity.is_logged_in():
|
||||
toolkit.fail(
|
||||
"not_logged_in",
|
||||
"No credentials found.",
|
||||
hint="Run [blue]`fastapi login`[/] or set [blue]FASTAPI_CLOUD_TOKEN.[/]",
|
||||
)
|
||||
|
||||
with (
|
||||
APIClient() as client,
|
||||
toolkit.progress(
|
||||
title="Fetching profile",
|
||||
transient=True,
|
||||
) as progress,
|
||||
):
|
||||
with client.handle_http_errors(
|
||||
progress,
|
||||
default_message="",
|
||||
toolkit=toolkit,
|
||||
):
|
||||
response = client.get("/users/me")
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
result = WhoAmIOutput(
|
||||
has_deploy_token=identity.has_deploy_token(), email=data["email"]
|
||||
)
|
||||
|
||||
toolkit.success(result, render_output=_render_whoami_output)
|
||||
Reference in New Issue
Block a user