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

1from dataclasses import dataclass 1+,-./:;

2 

3from rich.table import Table 1+,-./:;

4from rich.text import Text 1+,-./:;

5from rich_toolkit import RichToolkit 1+,-./:;

6 

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+,-./:;

18 

19 

20@dataclass(frozen=True) 1+,-./:;

21class DomainStatusMetadata: 1+,-./:;

22 label: str 1+,-./:;

23 title: str 1+,-./:;

24 description: str 1+,-./:;

25 emoji: str = "⏳" 1+,-./:;

26 

27 

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} 

164 

165 

166def get_setup_mode_label(domain: CustomDomain) -> str: 1+,-./:;

167 if domain.is_using_pre_validation: 1=?@[]^_

168 return "Zero-downtime" 1=?@[]^_

169 

170 return "Standard" 1=?@[]^_

171 

172 

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=?@[]^_

186 

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 ) 

194 

195 return table 1=?@[]^_

196 

197 

198STEP_NUMBER_EMOJIS = { 1+,-./:;

199 1: "1️⃣", 

200 2: "2️⃣", 

201 3: "3️⃣", 

202} 

203 

204 

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

222 

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 ) 

232 

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 ) 

240 

241 return table 1abcdefg

242 

243 

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!#$%'()*

250 

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

260 

261 

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

272 

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*

275 

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 ) 

281 

282 if step.status == "locked": 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*

283 return 1XaCYbDZcE0dF1eG2fH3gI

284 

285 toolkit.print(_get_concise_step_description(step, records)) 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*

286 

287 if not records: 1JKXa4vhoC5LMYb6wipD7NOZc8xjqE9PQ0d!ykrF#RS1e$zlsG%TU2f'AmtH(VW3g)BnuI*

288 return 1456789!#$%'()*

289 

290 toolkit.print_line() 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI

291 toolkit.print(_get_dns_records_table(records)) 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI

292 

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

298 

299 if has_trailing_dot or show_cloudflare_warning: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI

300 toolkit.print_line() 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu

301 

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

308 

309 if show_cloudflare_warning: 1JKXavhoCLMYbwipDNOZcxjqEPQ0dykrFRS1ezlsGTU2fAmtHVW3gBnuI

310 toolkit.print( 1JKavhoLMbwipNOcxjqPQdykrRSezlsTUfAmtVWgBnu

311 "Cloudflare: use DNS only (gray cloud).", 

312 emoji=hint_emoji, 

313 ) 

314 

315 

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 ) 

330 

331 

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*

341 

342 

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

353 

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