Coverage for src/fastapi_cloud_cli/commands/domains/rendering.py: 100%
108 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1from dataclasses import dataclass 1+,-./:;
3from rich.table import Table 1+,-./:;
4from rich.text import Text 1+,-./:;
5from rich_toolkit import RichToolkit 1+,-./:;
7from fastapi_cloud_cli.api import ( 1+,-./:;
8 CustomDomain,
9 CustomDomainRecord,
10 CustomDomainStatus,
11)
12from fastapi_cloud_cli.commands.domains._setup import ( 1+,-./:;
13 ATTENTION_STATUSES,
14 SetupStep,
15 get_setup_steps,
16)
17from fastapi_cloud_cli.utils.dates import format_last_updated 1+,-./:;
20@dataclass(frozen=True) 1+,-./:;
21class DomainStatusMetadata: 1+,-./:;
22 label: str 1+,-./:;
23 title: str 1+,-./:;
24 description: str 1+,-./:;
25 emoji: str = "⏳" 1+,-./:;
28DOMAIN_STATUS: dict[CustomDomainStatus, DomainStatusMetadata] = { 1+,-./:;
29 CustomDomainStatus.internal_dcv_pending: DomainStatusMetadata(
30 label="Pending",
31 title="Waiting domain verification",
32 description=(
33 "We are checking your DNS configuration to confirm domain ownership. "
34 "This usually takes a few minutes."
35 ),
36 ),
37 CustomDomainStatus.internal_dcv_missing: DomainStatusMetadata(
38 label="Pending",
39 title="Waiting domain verification",
40 description=(
41 "Your domain is missing the required DNS records. Add the records "
42 "shown below to continue verification."
43 ),
44 ),
45 CustomDomainStatus.internal_dcv_invalid: DomainStatusMetadata(
46 label="Needs attention",
47 title="Verification needed",
48 description=(
49 "The DNS records were found but don't match the expected values. "
50 "Double-check your provider settings."
51 ),
52 emoji="⚠️",
53 ),
54 CustomDomainStatus.internal_dcv_timeout: DomainStatusMetadata(
55 label="Domain verification timed out",
56 title="Restart domain verification",
57 description=(
58 "We couldn't verify the domain in time, it's possible the DNS changes "
59 "may still be propagating. Please restart the domain verification process."
60 ),
61 emoji="⚠️",
62 ),
63 CustomDomainStatus.internal_dcv_revoked: DomainStatusMetadata(
64 label="Domain verification revoked",
65 title="Verification needed",
66 description=(
67 "Domain ownership could no longer be verified. This may happen if DNS "
68 "records were removed or changed."
69 ),
70 emoji="⚠️",
71 ),
72 CustomDomainStatus.external_dcv_pending: DomainStatusMetadata(
73 label="Setting up domain",
74 title="Issuing certificates",
75 description=(
76 "Your domain is being configured and a TLS certificate is being requested."
77 ),
78 ),
79 CustomDomainStatus.external_dcv_proxied: DomainStatusMetadata(
80 label="Domain active, securing TLS",
81 title="Issuing certificates",
82 description=(
83 "Traffic is being routed correctly. We're finalizing TLS certificate issuance."
84 ),
85 ),
86 CustomDomainStatus.external_dcv_secured: DomainStatusMetadata(
87 label="TLS certificate issued",
88 title="Issuing certificates",
89 description=(
90 "A TLS certificate has been issued and is being applied to your domain."
91 ),
92 ),
93 CustomDomainStatus.external_dcv_blocked: DomainStatusMetadata(
94 label="Domain blocked by provider",
95 title="Verification needed",
96 description=(
97 "This domain has been restricted and domain verification cannot proceed. "
98 "Please contact support for more information."
99 ),
100 emoji="⚠️",
101 ),
102 CustomDomainStatus.external_dcv_timeout: DomainStatusMetadata(
103 label="Domain setup timed out",
104 title="Restart domain verification",
105 description=(
106 "The domain setup took too long to complete. This is often caused by slow "
107 "DNS propagation. Please restart the domain verification process."
108 ),
109 emoji="⚠️",
110 ),
111 CustomDomainStatus.origin_setup_pending: DomainStatusMetadata(
112 label="Validating",
113 title="Validating DNS records",
114 description=(
115 "Add the following records to your authoritative DNS server to start "
116 "sending traffic to your app."
117 ),
118 ),
119 CustomDomainStatus.origin_setup_missing: DomainStatusMetadata(
120 label="Missing",
121 title="DNS records missing",
122 description=(
123 "Your domain is missing required DNS records. Add the records shown below "
124 "to your authoritative DNS server."
125 ),
126 ),
127 CustomDomainStatus.origin_setup_invalid: DomainStatusMetadata(
128 label="Needs attention",
129 title="DNS records invalid",
130 description=(
131 "The DNS records were found but don't match the expected values. "
132 "Double-check your DNS records."
133 ),
134 emoji="⚠️",
135 ),
136 CustomDomainStatus.origin_setup_timeout: DomainStatusMetadata(
137 label="Timeout",
138 title="Restart domain setup",
139 description=(
140 "The domain setup couldn't be validated in time. Please restart the "
141 "domain setup process."
142 ),
143 emoji="⚠️",
144 ),
145 CustomDomainStatus.origin_setup_success: DomainStatusMetadata(
146 label="Live",
147 title="Live",
148 description=(
149 "Your domain is fully configured, secured with TLS, and set up to route "
150 "traffic to your app."
151 ),
152 emoji="✅",
153 ),
154 CustomDomainStatus.origin_setup_removed: DomainStatusMetadata(
155 label="Removed",
156 title="DNS records removed",
157 description=(
158 "The required DNS records were removed after being valid. Your domain is "
159 "no longer active and needs to be set up again."
160 ),
161 emoji="⚠️",
162 ),
163}
166def get_setup_mode_label(domain: CustomDomain) -> str: 1+,-./:;
167 if domain.is_using_pre_validation: 1=?@[]^_
168 return "Zero-downtime" 1=?@[]^_
170 return "Standard" 1=?@[]^_
173def get_custom_domains_table(domains: list[CustomDomain]) -> Table: 1+,-./:;
174 table = Table.grid(padding=(0, 2), pad_edge=False) 1=?@[]^_
175 table.add_column("Domain", no_wrap=True) 1=?@[]^_
176 table.add_column("Status", no_wrap=True) 1=?@[]^_
177 table.add_column("Setup mode", no_wrap=True) 1=?@[]^_
178 table.add_column("Last check", no_wrap=True) 1=?@[]^_
179 table.add_row( 1=?@[]^_
180 Text("Domain", style="bold"),
181 Text("Status", style="bold"),
182 Text("Setup mode", style="bold"),
183 Text("Last check", style="bold"),
184 )
185 table.add_row("", "", "", "") 1=?@[]^_
187 for domain in domains: 1=?@[]^_
188 table.add_row( 1=?@[]^_
189 Text(domain.name),
190 Text(DOMAIN_STATUS[domain.status].label),
191 Text(get_setup_mode_label(domain)),
192 Text(format_last_updated(domain.setup_checked_at), style="dim"),
193 )
195 return table 1=?@[]^_
198STEP_NUMBER_EMOJIS = { 1+,-./:;
199 1: "1️⃣",
200 2: "2️⃣",
201 3: "3️⃣",
202}
205def _get_dns_records_table(records: list[CustomDomainRecord]) -> Table: 1+,-./:;
206 if len(records) == 1: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
207 record = records[0] 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
208 generating = Text("Generating; check again shortly", style="dim italic") 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
209 table = Table.grid(padding=(0, 2), pad_edge=False) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
210 table.add_column(style="dim", no_wrap=True) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
211 table.add_column(overflow="fold") 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
212 table.add_row("type", record.type) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
213 table.add_row( 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
214 "name",
215 Text(record.name) if record.name is not None else generating,
216 )
217 table.add_row( 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
218 "value",
219 Text(record.value) if record.value is not None else generating,
220 )
221 return table 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
223 table = Table.grid(padding=(0, 2), pad_edge=False) 1abcdefg
224 table.add_column("Type", no_wrap=True) 1abcdefg
225 table.add_column("Name", overflow="fold") 1abcdefg
226 table.add_column("Value", overflow="fold") 1abcdefg
227 table.add_row( 1abcdefg
228 Text("Type", style="bold"),
229 Text("Name", style="bold"),
230 Text("Value", style="bold"),
231 )
233 generating = Text("Generating; check again shortly", style="dim italic") 1abcdefg
234 for record in records: 1abcdefg
235 table.add_row( 1abcdefg
236 Text(record.type),
237 Text(record.name) if record.name is not None else generating,
238 Text(record.value) if record.value is not None else generating,
239 )
241 return table 1abcdefg
244def _get_concise_step_description( 1+,-./:;
245 step: SetupStep,
246 records: list[CustomDomainRecord],
247) -> str:
248 if not records: 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
249 return step.description 1456789!#$%'()*
251 records_label = ( 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
252 f"this {records[0].type} record" if len(records) == 1 else "these records"
253 )
254 if step.id == "combined": 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
255 return ( 1JKvhoLMwipNOxjqPQykrRSzlsTUAmtVWBnu
256 "[bold]To verify ownership and route traffic[/bold], add "
257 f"{records_label} at your DNS provider:"
258 )
259 return f"Add {records_label} at your DNS provider:" 1XaCYbDZcE0dF1eG2fH3gI
262def _render_setup_step( 1+,-./:;
263 step: SetupStep,
264 toolkit: RichToolkit,
265 *,
266 number: int,
267 show_number: bool,
268) -> None:
269 if step.status == "verified": 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
270 toolkit.print(f"[bold]{step.title}[/bold]", emoji="✅") 1abcdefg
271 return 1abcdefg
273 records = step.records if step.status != "locked" else [] 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
274 hint_emoji = "" if show_number else "💡" 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
276 if show_number: 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
277 toolkit.print( 1XaCYbDZcE0dF1eG2fH3gI
278 f"[bold]{step.title}[/bold]",
279 emoji=STEP_NUMBER_EMOJIS[number],
280 )
282 if step.status == "locked": 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
283 return 1XaCYbDZcE0dF1eG2fH3gI
285 toolkit.print(_get_concise_step_description(step, records)) 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
287 if not records: 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
288 return 1456789!#$%'()*
290 toolkit.print_line() 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
291 toolkit.print(_get_dns_records_table(records)) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
293 has_trailing_dot = any( 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
294 record.type == "CNAME" and (record.value or "").endswith(".")
295 for record in records
296 )
297 show_cloudflare_warning = any(record.type in {"CNAME", "A"} for record in records) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
299 if has_trailing_dot or show_cloudflare_warning: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
300 toolkit.print_line() 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu
302 if has_trailing_dot: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
303 toolkit.print( 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu
304 "Copy as shown; remove the trailing dot only if your provider rejects it.",
305 emoji=hint_emoji,
306 )
307 hint_emoji = "" 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu
309 if show_cloudflare_warning: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI
310 toolkit.print( 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu
311 "Cloudflare: use DNS only (gray cloud).",
312 emoji=hint_emoji,
313 )
316def render_custom_domain_setup( 1+,-./:;
317 domain: CustomDomain,
318 toolkit: RichToolkit,
319) -> None:
320 steps = get_setup_steps(domain) 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
321 show_number = len(steps) > 1 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
322 for number, step in enumerate(steps, start=1): 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
323 toolkit.print_line() 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
324 _render_setup_step( 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*
325 step,
326 toolkit,
327 number=number,
328 show_number=show_number,
329 )
332def _get_next_action(domain: CustomDomain) -> str | None: 1+,-./:;
333 if domain.setup_failed: 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
334 return ( 1opqrstu
335 "Correct the DNS records, then run "
336 f"`fastapi cloud domains restart {domain.name}`."
337 )
338 if domain.status in ATTENTION_STATUSES: 1a4vhC5b6wiD7c8xjE9d!ykF#e$zlG%f'AmH(g)BnI*
339 return "Correct the DNS records shown. We'll check again automatically." 1hijklmn
340 return None 1a4vC5b6wD7c8xE9d!yF#e$zG%f'AH(g)BI*
343def render_custom_domain_details( 1+,-./:;
344 domain: CustomDomain,
345 toolkit: RichToolkit,
346) -> None:
347 if domain.setup_successful: 2a 4 v h o ` C 5 b 6 w i p { D 7 c 8 x j q | E 9 d ! y k r } F # e $ z l s ~ G % f ' A m t abH ( g ) B n u bbI *
348 url = f"https://{domain.name}" 2` { | } ~ abbb
349 toolkit.print(Text(url, style=f"bold link {url}"), emoji="🌐") 2` { | } ~ abbb
350 toolkit.print_line() 2` { | } ~ abbb
351 toolkit.print("Your domain is live.", emoji="✅") 2` { | } ~ abbb
352 return 2` { | } ~ abbb
354 metadata = DOMAIN_STATUS[domain.status] 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
355 toolkit.print(Text(domain.name, style="bold"), emoji="🌐") 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
356 toolkit.print_line() 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
357 toolkit.print( 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
358 f"[bold]{metadata.title}[/bold]\n{metadata.description}",
359 emoji=metadata.emoji,
360 )
361 render_custom_domain_setup(domain, toolkit) 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
362 if next_action := _get_next_action(domain): 1a4vhoC5b6wipD7c8xjqE9d!ykrF#e$zlsG%f'AmtH(g)BnuI*
363 toolkit.print_line() 1hoipjqkrlsmtnu
364 toolkit.print(next_action) 1hoipjqkrlsmtnu