diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index 55955ac..50f3e09 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -164,6 +164,17 @@ def remove_custom_domain(self, *, app_id: str, domain_id: str) -> None: response = self.delete(f"/apps/{app_id}/custom-domains/{domain_id}") response.raise_for_status() + def restart_custom_domain_setup( + self, + *, + app_id: str, + domain_id: str, + ) -> CustomDomain: + response = self.post(f"/apps/{app_id}/custom-domains/{domain_id}/restart-setup") + response.raise_for_status() + + return CustomDomain.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/commands/domains/__init__.py b/src/fastapi_cloud_cli/commands/domains/__init__.py index 7e96643..33cabaf 100644 --- a/src/fastapi_cloud_cli/commands/domains/__init__.py +++ b/src/fastapi_cloud_cli/commands/domains/__init__.py @@ -4,6 +4,7 @@ from fastapi_cloud_cli.commands.domains.get import get_domain from fastapi_cloud_cli.commands.domains.list import list_domains from fastapi_cloud_cli.commands.domains.remove import remove_domain +from fastapi_cloud_cli.commands.domains.restart import restart_domain domains_app = typer.Typer( no_args_is_help=True, @@ -13,5 +14,6 @@ domains_app.command("get")(get_domain) domains_app.command("list")(list_domains) domains_app.command("remove")(remove_domain) +domains_app.command("restart")(restart_domain) __all__ = ["domains_app"] diff --git a/src/fastapi_cloud_cli/commands/domains/restart.py b/src/fastapi_cloud_cli/commands/domains/restart.py new file mode 100644 index 0000000..ebe7c4d --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/restart.py @@ -0,0 +1,157 @@ +from typing import Annotated, Any + +import typer +from pydantic import BaseModel, Field +from rich_toolkit import RichToolkit + +from fastapi_cloud_cli.api import APIClient, CustomDomain +from fastapi_cloud_cli.commands.domains._shared import ( + _find_custom_domain, + _select_custom_domain, +) +from fastapi_cloud_cli.commands.domains.rendering import render_custom_domain_details +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 CustomDomainRestartOutput(BaseModel): + app_id: str + domain: CustomDomain + show_title: Annotated[bool, Field(exclude=True)] = True + + +def _render_custom_domain_restart_output( + data: CustomDomainRestartOutput, + toolkit: RichToolkit, +) -> None: + if data.show_title: + toolkit.print_title("custom domains") + toolkit.print_line() + + toolkit.print( + f"Restarted verification for [bold]{data.domain.name}[/bold]", + emoji="🐔", + ) + toolkit.print_line() + render_custom_domain_details(data.domain, toolkit) + toolkit.print_line() + toolkit.print( + "[dim]hint: Run `fastapi cloud domains get " + f"{data.domain.name}` to check progress.[/dim]" + ) + + +def restart_domain( + domain: Annotated[ + str | None, + typer.Argument( + help="Hostname or ID of the custom domain whose setup should restart.", + ), + ] = None, + app_id: Annotated[ + str | None, + typer.Option( + "--app-id", + help="ID of the app that owns the custom domain.", + ), + ] = None, + json_output: JsonOutputOption = False, +) -> Any: + """ + Restart failed custom domain setup 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) + domain_was_provided = domain is not None + + if domain is None and toolkit.mode == "json": + toolkit.fail( + "missing_required_input", + "Custom domain is required.", + hint="Pass DOMAIN to choose a custom domain.", + ) + + 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, + ), + ): + domains = client.get_custom_domains(app_id=app_id).data + + selected_domain: CustomDomain | None + if domain is None: + toolkit.print_title("custom domains") + toolkit.print_line() + failed_domains = [domain for domain in domains if domain.setup_failed] + + if not failed_domains: + toolkit.print("No failed custom domains found.", bullet=False) + return + + selected_domain = _select_custom_domain( + toolkit, + failed_domains, + prompt="Select the custom domain to restart:", + ) + toolkit.print_line() + else: + selected_domain = _find_custom_domain(domains, domain) + if selected_domain is None: + toolkit.fail( + "not_found", + f"Custom domain {domain} not found.", + hint=( + "Run `fastapi cloud domains list` to see available " + "custom domains." + ), + ) + + assert selected_domain is not None + with ( + toolkit.progress( + title="Restarting custom domain setup", + transient=True, + ) as progress, + client.handle_http_errors( + progress, + default_message=( + "Error restarting custom domain setup. Please try again later." + ), + not_found_message="Custom domain not found.", + toolkit=toolkit, + ), + ): + restarted_domain = client.restart_custom_domain_setup( + app_id=app_id, + domain_id=selected_domain.id, + ) + + toolkit.success( + CustomDomainRestartOutput( + app_id=app_id, + domain=restarted_domain, + show_title=domain_was_provided, + ), + render_output=_render_custom_domain_restart_output, + ) diff --git a/tests/domains/test_cli.py b/tests/domains/test_cli.py index 08d412f..f6b56cd 100644 --- a/tests/domains/test_cli.py +++ b/tests/domains/test_cli.py @@ -7,7 +7,7 @@ import pytest import respx import time_machine -from httpx import Response +from httpx import ConnectError, Response from typer.testing import CliRunner from fastapi_cloud_cli.api import CustomDomainStatus @@ -59,8 +59,9 @@ def custom_domain(**overrides: Any) -> dict[str, Any]: ["get", "api.example.com"], ["add", "api.example.com", "--standard"], ["remove", "api.example.com", "--yes"], + ["restart", "api.example.com"], ], - ids=["list", "get", "add", "remove"], + ids=["list", "get", "add", "remove", "restart"], ) def test_domains_commands_require_user_session( command: list[str], @@ -1033,3 +1034,283 @@ def test_domains_remove_selector_handles_empty_collection( assert result.exit_code == 0 assert "No custom domains found." in result.output + + +def test_domains_restart_json_requires_domain(logged_in_cli: None) -> None: + result = runner.invoke( + app, + ["domains", "restart", "--app-id", APP_ID, "--json"], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "Custom domain is required.", + "hint": "Pass DOMAIN to choose a custom domain.", + } + } + + +@pytest.mark.respx +def test_domains_restart_returns_reset_domain_as_json( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + failed_domain = custom_domain( + status="internal_dcv_timeout", + setup_in_progress=False, + setup_failed=True, + ) + restarted_domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [failed_domain], "count": 1}) + ) + respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( + return_value=Response(200, json=restarted_domain) + ) + + result = runner.invoke( + app, + [ + "domains", + "restart", + " API.Example.COM. ", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": {"app_id": APP_ID, "domain": restarted_domain} + } + + +@pytest.mark.respx +def test_domains_restart_with_argument_renders_result( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + failed_domain = custom_domain( + status="internal_dcv_timeout", + setup_in_progress=False, + setup_failed=True, + ) + restarted_domain = custom_domain(dns_records=[]) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [failed_domain], "count": 1}) + ) + respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( + return_value=Response(200, json=restarted_domain) + ) + + result = runner.invoke( + app, + ["domains", "restart", failed_domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains + + 🐔 Restarted verification for api.example.com + + 🌐 api.example.com + + ⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. + + Add the record below. We'll verify ownership, issue your TLS certificate, + and route traffic automatically. + + hint: Run `fastapi cloud domains get api.example.com` to check progress. + """ + ) + + +@pytest.mark.respx +def test_domains_restart_selector_only_shows_failed_domains( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + live_domain = custom_domain( + id="00000000-0000-4000-8000-000000000004", + name="live.example.com", + status="origin_setup_success", + setup_in_progress=False, + setup_successful=True, + ) + failed_domain = custom_domain( + name="failed.example.com", + status="external_dcv_timeout", + setup_in_progress=False, + setup_failed=True, + ) + restarted_domain = custom_domain( + name="failed.example.com", + is_using_pre_validation=True, + dns_records=PREVALIDATION_RECORDS, + ) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response( + 200, + json={"data": [live_domain, failed_domain], "count": 2}, + ) + ) + respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( + return_value=Response(200, json=restarted_domain) + ) + + with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): + result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains + + Select the custom domain to restart: + ● failed.example.com + + Select the custom domain to restart: failed.example.com + + 🐔 Restarted verification for failed.example.com + + 🌐 failed.example.com + + ⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. + + 1️⃣ Prove ownership + Add this TXT record at your DNS provider: + + type TXT + name _fc-dcv.api + value ownership-value + + 2️⃣ Secure your domain + + 3️⃣ Switch traffic + + hint: Run `fastapi cloud domains get failed.example.com` to check progress. + """ + ) + + +@pytest.mark.respx +def test_domains_restart_selector_handles_no_failed_domains( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + live_domain = custom_domain( + status="origin_setup_success", + setup_in_progress=False, + setup_successful=True, + ) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [live_domain], "count": 1}) + ) + + result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) + + assert result.exit_code == 0 + assert "No failed custom domains found." in result.output + + +@pytest.mark.respx +def test_domains_restart_reports_not_found( + 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": [custom_domain()], "count": 1}) + ) + + result = runner.invoke( + app, + [ + "domains", + "restart", + "missing.example.com", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_found", + "message": "Custom domain missing.example.com not found.", + "hint": ( + "Run `fastapi cloud domains list` to see available custom domains." + ), + } + } + + +@pytest.mark.respx +def test_domains_restart_surfaces_backend_rejection_for_live_domain( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + live_domain = custom_domain( + status="origin_setup_success", + setup_in_progress=False, + setup_successful=True, + ) + message = "The custom domain setup has already been completed successfully" + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [live_domain], "count": 1}) + ) + respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( + return_value=Response(400, json={"detail": message}) + ) + + result = runner.invoke( + app, + [ + "domains", + "restart", + live_domain["name"], + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": message, + "hint": None, + } + } + + +@pytest.mark.respx +def test_domains_list_surfaces_network_errors( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + side_effect=ConnectError("Connection failed") + ) + + result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "network_error", + "message": "Error fetching custom domains. Please try again later.", + "hint": None, + } + }