diff --git a/src/fastapi_cloud_cli/api/__init__.py b/src/fastapi_cloud_cli/api/__init__.py index 74a65d3e..771c68e6 100644 --- a/src/fastapi_cloud_cli/api/__init__.py +++ b/src/fastapi_cloud_cli/api/__init__.py @@ -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 diff --git a/src/fastapi_cloud_cli/api/_models.py b/src/fastapi_cloud_cli/api/_models.py index 82a39e48..0fce2cb1 100644 --- a/src/fastapi_cloud_cli/api/_models.py +++ b/src/fastapi_cloud_cli/api/_models.py @@ -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" diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index d3eabde7..f5b04351 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -28,6 +28,7 @@ AppLogEntry, BuildLogAdapter, BuildLogLine, + CustomDomainsAPIResponse, DeploymentStatus, ) from ._retry import ( @@ -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 diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index c3ce9fe8..b6e67305 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -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 @@ -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") diff --git a/src/fastapi_cloud_cli/commands/domains/__init__.py b/src/fastapi_cloud_cli/commands/domains/__init__.py new file mode 100644 index 00000000..925700f1 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/__init__.py @@ -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"] diff --git a/src/fastapi_cloud_cli/commands/domains/list.py b/src/fastapi_cloud_cli/commands/domains/list.py new file mode 100644 index 00000000..6dc1e570 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/list.py @@ -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, + ) diff --git a/src/fastapi_cloud_cli/commands/domains/rendering.py b/src/fastapi_cloud_cli/commands/domains/rendering.py new file mode 100644 index 00000000..2b16d983 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/rendering.py @@ -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 diff --git a/tests/test_cli_domains.py b/tests/test_cli_domains.py new file mode 100644 index 00000000..870463f3 --- /dev/null +++ b/tests/test_cli_domains.py @@ -0,0 +1,170 @@ +import json +from datetime import datetime, timezone +from textwrap import dedent +from typing import Any + +import pytest +import respx +import time_machine +from httpx import Response +from typer.testing import CliRunner + +from fastapi_cloud_cli.api import CustomDomainStatus +from fastapi_cloud_cli.cli import cloud_app as app +from fastapi_cloud_cli.commands.domains.rendering import DOMAIN_STATUS +from tests.conftest import ConfiguredApp +from tests.utils import changing_dir + +runner = CliRunner() + +APP_ID = "00000000-0000-4000-8000-000000000002" +DOMAIN_ID = "00000000-0000-4000-8000-000000000003" + + +def _normalize_output(output: str) -> str: + return "\n".join(line.rstrip() for line in dedent(output).strip().splitlines()) + + +def custom_domain(**overrides: Any) -> dict[str, Any]: + return { + "id": DOMAIN_ID, + "name": "api.example.com", + "status": "internal_dcv_pending", + "setup_in_progress": True, + "setup_failed": False, + "setup_successful": False, + "is_using_pre_validation": False, + "dns_records": [ + { + "type": "CNAME", + "name": "api", + "value": f"{DOMAIN_ID}.endpoints.fastapicloud.dev.", + } + ], + "created_at": "2026-08-28T10:00:00Z", + "updated_at": "2026-08-28T10:01:00Z", + "setup_started_at": "2026-08-28T10:00:00Z", + "setup_checked_at": "2026-08-28T10:01:00Z", + "app_id": APP_ID, + **overrides, + } + + +def test_domains_list_requires_user_session(logged_out_cli: None) -> None: + result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID]) + + assert result.exit_code == 1 + assert "No credentials found." in result.output + assert "FASTAPI_CLOUD_TOKEN" not in result.output + + +def test_domains_list_requires_app_context(logged_in_cli: None) -> None: + result = runner.invoke(app, ["domains", "list", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "App ID is required.", + "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.", + } + } + + +@pytest.mark.respx +@time_machine.travel(datetime(2026, 9, 1, 10, 1, tzinfo=timezone.utc), tick=False) +def test_domains_list_uses_linked_app_and_renders_rows( + logged_in_cli: None, + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, +) -> None: + newest = custom_domain( + id="00000000-0000-4000-8000-000000000004", + name="new.example.com", + status="origin_setup_success", + setup_in_progress=False, + setup_successful=True, + is_using_pre_validation=True, + app_id=configured_app.app_id, + ) + oldest = custom_domain(name="old.example.com", app_id=configured_app.app_id) + respx_mock.get(f"/apps/{configured_app.app_id}/custom-domains").mock( + return_value=Response(200, json={"data": [newest, oldest], "count": 2}) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["domains", "list"]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains + + Domain Status Setup mode Last check + + new.example.com Live Zero-downtime 4 days ago + old.example.com Pending Standard 4 days ago + """ + ) + + +@pytest.mark.respx +def test_domains_list_returns_json( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + + result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": APP_ID, + "domains": [domain], + "total_count": 1, + } + } + + +@pytest.mark.respx +def test_domains_list_renders_empty_state( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [], "count": 0}) + ) + + result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID]) + + assert result.exit_code == 0 + assert "No custom domains found." in result.output + + +@pytest.mark.respx +def test_domains_list_surfaces_app_not_found_as_json( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(404, json={"detail": "App not found"}) + ) + + result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_found", + "message": "App not found", + "hint": None, + } + } + + +def test_domain_status_metadata_is_exhaustive() -> None: + assert set(DOMAIN_STATUS) == set(CustomDomainStatus)