Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/fastapi_cloud_cli/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from ._models import SUCCESSFUL_STATUSES as SUCCESSFUL_STATUSES
from ._models import AppLogEntry as AppLogEntry
from ._models import BuildLogLineMessage as BuildLogLineMessage
from ._models import CustomDomain as CustomDomain
from ._models import CustomDomainRecord as CustomDomainRecord
from ._models import CustomDomainStatus as CustomDomainStatus
from ._models import DeploymentStatus as DeploymentStatus
from ._retry import STREAM_LOGS_MAX_RETRIES as STREAM_LOGS_MAX_RETRIES
from .client import APIClient as APIClient
46 changes: 46 additions & 0 deletions src/fastapi_cloud_cli/api/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,52 @@ class BuildLogLineMessage(BaseModel):
)


class CustomDomainStatus(str, Enum):
internal_dcv_pending = "internal_dcv_pending"
internal_dcv_missing = "internal_dcv_missing"
internal_dcv_invalid = "internal_dcv_invalid"
internal_dcv_timeout = "internal_dcv_timeout"
internal_dcv_revoked = "internal_dcv_revoked"
external_dcv_pending = "external_dcv_pending"
external_dcv_proxied = "external_dcv_proxied"
external_dcv_secured = "external_dcv_secured"
external_dcv_blocked = "external_dcv_blocked"
external_dcv_timeout = "external_dcv_timeout"
origin_setup_pending = "origin_setup_pending"
origin_setup_missing = "origin_setup_missing"
origin_setup_invalid = "origin_setup_invalid"
origin_setup_timeout = "origin_setup_timeout"
origin_setup_success = "origin_setup_success"
origin_setup_removed = "origin_setup_removed"


class CustomDomainRecord(BaseModel):
type: Literal["TXT", "CNAME", "A"]
name: str | None
value: str | None


class CustomDomain(BaseModel):
id: str
name: str
status: CustomDomainStatus
setup_in_progress: bool
setup_failed: bool
setup_successful: bool
is_using_pre_validation: bool
dns_records: list[CustomDomainRecord]
created_at: str
updated_at: str
setup_started_at: str | None
setup_checked_at: str | None
app_id: str


class CustomDomainsAPIResponse(BaseModel):
data: list[CustomDomain]
count: int


class DeploymentStatus(str, Enum):
waiting_upload = "waiting_upload"
upload_cancelled = "upload_cancelled"
Expand Down
7 changes: 7 additions & 0 deletions src/fastapi_cloud_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
AppLogEntry,
BuildLogAdapter,
BuildLogLine,
CustomDomainsAPIResponse,
DeploymentStatus,
)
from ._retry import (
Expand Down Expand Up @@ -134,6 +135,12 @@ def handle_http_errors(

raise typer.Exit(1) from None

def get_custom_domains(self, *, app_id: str) -> CustomDomainsAPIResponse:
response = self.get(f"/apps/{app_id}/custom-domains")
response.raise_for_status()

return CustomDomainsAPIResponse.model_validate(response.json())

@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
def stream_build_logs(
self, deployment_id: str, *, follow: bool = True
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_cloud_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .commands.ci import ci_app
from .commands.deploy import deploy
from .commands.deployments import deployments_app
from .commands.domains import domains_app
from .commands.env import env_app
from .commands.integrations import integrations_app
from .commands.login import login
Expand Down Expand Up @@ -74,6 +75,7 @@ def cloud_main(
cloud_app.add_typer(apps_app, name="apps")
cloud_app.add_typer(ci_app, name="ci")
cloud_app.add_typer(deployments_app, name="deployments")
cloud_app.add_typer(domains_app, name="domains")
cloud_app.add_typer(integrations_app, name="integrations")
cloud_app.add_typer(teams_app, name="teams")
cloud_app.add_typer(tokens_app, name="tokens")
Expand Down
11 changes: 11 additions & 0 deletions src/fastapi_cloud_cli/commands/domains/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import typer

from fastapi_cloud_cli.commands.domains.list import list_domains

domains_app = typer.Typer(
no_args_is_help=True,
help="Manage the custom domains of your app.",
)
domains_app.command("list")(list_domains)

__all__ = ["domains_app"]
84 changes: 84 additions & 0 deletions src/fastapi_cloud_cli/commands/domains/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from typing import Annotated, Any

import typer
from pydantic import BaseModel
from rich_toolkit import RichToolkit

from fastapi_cloud_cli.api import APIClient, CustomDomain
from fastapi_cloud_cli.commands.domains.rendering import get_custom_domains_table
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 CustomDomainsListOutput(BaseModel):
app_id: str
domains: list[CustomDomain]
total_count: int


def _render_custom_domains_list_output(
data: CustomDomainsListOutput,
toolkit: RichToolkit,
) -> None:
toolkit.print_title("custom domains")
toolkit.print_line()

if not data.domains:
toolkit.print("No custom domains found.", bullet=False)
return

toolkit.print(get_custom_domains_table(data.domains), bullet=False)


def list_domains(
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose custom domains should be listed.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
List custom domains 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`.",
)

app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)

with APIClient() as client:
with (
toolkit.progress(
title="Fetching custom domains",
transient=True,
) as progress,
client.handle_http_errors(
progress,
default_message=(
"Error fetching custom domains. Please try again later."
),
not_found_message="App not found.",
toolkit=toolkit,
),
):
response = client.get_custom_domains(app_id=app_id)

toolkit.success(
CustomDomainsListOutput(
app_id=app_id,
domains=response.data,
total_count=response.count,
),
render_output=_render_custom_domains_list_output,
)
175 changes: 175 additions & 0 deletions src/fastapi_cloud_cli/commands/domains/rendering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from dataclasses import dataclass

from rich.table import Table
from rich.text import Text

from fastapi_cloud_cli.api import CustomDomain, CustomDomainStatus
from fastapi_cloud_cli.utils.dates import format_last_updated


@dataclass(frozen=True)
class DomainStatusMetadata:
label: str
title: str
description: str


DOMAIN_STATUS: dict[CustomDomainStatus, DomainStatusMetadata] = {
CustomDomainStatus.internal_dcv_pending: DomainStatusMetadata(
label="Pending",
title="Waiting domain verification",
description=(
"We are checking your DNS configuration to confirm domain ownership. "
"This usually takes a few minutes."
),
),
CustomDomainStatus.internal_dcv_missing: DomainStatusMetadata(
label="Pending",
title="Waiting domain verification",
description=(
"Your domain is missing the required DNS records. Add the records "
"shown below to continue verification."
),
),
CustomDomainStatus.internal_dcv_invalid: DomainStatusMetadata(
label="Needs attention",
title="Verification needed",
description=(
"The DNS records were found but don't match the expected values. "
"Double-check your provider settings."
),
),
CustomDomainStatus.internal_dcv_timeout: DomainStatusMetadata(
label="Domain verification timed out",
title="Restart domain verification",
description=(
"We couldn't verify the domain in time, it's possible the DNS changes "
"may still be propagating. Please restart the domain verification process."
),
),
CustomDomainStatus.internal_dcv_revoked: DomainStatusMetadata(
label="Domain verification revoked",
title="Verification needed",
description=(
"Domain ownership could no longer be verified. This may happen if DNS "
"records were removed or changed."
),
),
CustomDomainStatus.external_dcv_pending: DomainStatusMetadata(
label="Setting up domain",
title="Issuing certificates",
description=(
"Your domain is being configured and a TLS certificate is being requested."
),
),
CustomDomainStatus.external_dcv_proxied: DomainStatusMetadata(
label="Domain active, securing TLS",
title="Issuing certificates",
description=(
"Traffic is being routed correctly. We're finalizing TLS certificate issuance."
),
),
CustomDomainStatus.external_dcv_secured: DomainStatusMetadata(
label="TLS certificate issued",
title="Issuing certificates",
description=(
"A TLS certificate has been issued and is being applied to your domain."
),
),
CustomDomainStatus.external_dcv_blocked: DomainStatusMetadata(
label="Domain blocked by provider",
title="Verification needed",
description=(
"This domain has been restricted and domain verification cannot proceed. "
"Please contact support for more information."
),
),
CustomDomainStatus.external_dcv_timeout: DomainStatusMetadata(
label="Domain setup timed out",
title="Restart domain verification",
description=(
"The domain setup took too long to complete. This is often caused by slow "
"DNS propagation. Please restart the domain verification process."
),
),
CustomDomainStatus.origin_setup_pending: DomainStatusMetadata(
label="Validating",
title="Validating DNS records",
description=(
"Add the following records to your authoritative DNS server to start "
"sending traffic to your app."
),
),
CustomDomainStatus.origin_setup_missing: DomainStatusMetadata(
label="Missing",
title="DNS records missing",
description=(
"Your domain is missing required DNS records. Add the records shown below "
"to your authoritative DNS server."
),
),
CustomDomainStatus.origin_setup_invalid: DomainStatusMetadata(
label="Needs attention",
title="DNS records invalid",
description=(
"The DNS records were found but don't match the expected values. "
"Double-check your DNS records."
),
),
CustomDomainStatus.origin_setup_timeout: DomainStatusMetadata(
label="Timeout",
title="Restart domain setup",
description=(
"The domain setup couldn't be validated in time. Please restart the "
"domain setup process."
),
),
CustomDomainStatus.origin_setup_success: DomainStatusMetadata(
label="Live",
title="Live",
description=(
"Your domain is fully configured, secured with TLS, and set up to route "
"traffic to your app."
),
),
CustomDomainStatus.origin_setup_removed: DomainStatusMetadata(
label="Removed",
title="DNS records removed",
description=(
"The required DNS records were removed after being valid. Your domain is "
"no longer active and needs to be set up again."
),
),
}


def get_setup_mode_label(domain: CustomDomain) -> str:
if domain.is_using_pre_validation:
return "Zero-downtime"

return "Standard"


def get_custom_domains_table(domains: list[CustomDomain]) -> Table:
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("Domain", no_wrap=True)
table.add_column("Status", no_wrap=True)
table.add_column("Setup mode", no_wrap=True)
table.add_column("Last check", no_wrap=True)
table.add_row(
Text("Domain", style="bold"),
Text("Status", style="bold"),
Text("Setup mode", style="bold"),
Text("Last check", style="bold"),
)
table.add_row("", "", "", "")

for domain in domains:
table.add_row(
Text(domain.name),
Text(DOMAIN_STATUS[domain.status].label),
Text(get_setup_mode_label(domain)),
Text(format_last_updated(domain.setup_checked_at), style="dim"),
)

return table
Loading