Skip to content

Commit 48c6ad3

Browse files
committed
✨ Wait for verification after building
Shortcake-Parent: update-how-we-show-messages
1 parent edef519 commit 48c6ad3

8 files changed

Lines changed: 489 additions & 115 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"uvicorn[standard] >= 0.17.6",
3333
"rignore >= 0.5.1",
3434
"httpx >= 0.27.0",
35-
"rich-toolkit >= 0.19.6",
35+
"rich-toolkit >= 0.19.7",
3636
"pydantic[email] >= 2.7.4; python_version < '3.13'",
3737
"pydantic[email] >= 2.8.0; python_version == '3.13'",
3838
"pydantic[email] >= 2.12.0; python_version >= '3.14'",

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 89 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import subprocess
55
import tempfile
66
import time
7-
from enum import Enum
87
from itertools import cycle
98
from pathlib import Path, PurePosixPath
109
from textwrap import dedent
@@ -20,7 +19,13 @@
2019
from rich_toolkit.menu import Option
2120

2221
from fastapi_cloud_cli.commands.login import login
23-
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
22+
from fastapi_cloud_cli.utils.api import (
23+
SUCCESSFUL_STATUSES,
24+
APIClient,
25+
DeploymentStatus,
26+
StreamLogError,
27+
TooManyRetriesError,
28+
)
2429
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2530
from fastapi_cloud_cli.utils.auth import Identity
2631
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -174,42 +179,6 @@ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppRespon
174179
return AppResponse.model_validate(response.json())
175180

176181

177-
class DeploymentStatus(str, Enum):
178-
waiting_upload = "waiting_upload"
179-
ready_for_build = "ready_for_build"
180-
building = "building"
181-
extracting = "extracting"
182-
extracting_failed = "extracting_failed"
183-
building_image = "building_image"
184-
building_image_failed = "building_image_failed"
185-
deploying = "deploying"
186-
deploying_failed = "deploying_failed"
187-
verifying = "verifying"
188-
verifying_failed = "verifying_failed"
189-
verifying_skipped = "verifying_skipped"
190-
success = "success"
191-
failed = "failed"
192-
193-
@classmethod
194-
def to_human_readable(cls, status: "DeploymentStatus") -> str:
195-
return {
196-
cls.waiting_upload: "Waiting for upload",
197-
cls.ready_for_build: "Ready for build",
198-
cls.building: "Building",
199-
cls.extracting: "Extracting",
200-
cls.extracting_failed: "Extracting failed",
201-
cls.building_image: "Building image",
202-
cls.building_image_failed: "Build failed",
203-
cls.deploying: "Deploying",
204-
cls.deploying_failed: "Deploying failed",
205-
cls.verifying: "Verifying",
206-
cls.verifying_failed: "Verifying failed",
207-
cls.verifying_skipped: "Verification skipped",
208-
cls.success: "Success",
209-
cls.failed: "Failed",
210-
}[status]
211-
212-
213182
class CreateDeploymentResponse(BaseModel):
214183
id: str
215184
app_id: str
@@ -440,6 +409,42 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
440409
return app_config
441410

442411

412+
def _verify_deployment(
413+
toolkit: RichToolkit,
414+
client: APIClient,
415+
app_id: str,
416+
deployment: CreateDeploymentResponse,
417+
) -> None:
418+
with toolkit.progress(
419+
title="Verifying deployment...",
420+
inline_logs=True,
421+
done_emoji="✅",
422+
) as progress:
423+
try:
424+
final_status = client.poll_deployment_status(app_id, deployment.id)
425+
except TimeoutError:
426+
progress.metadata["done_emoji"] = "⚠️"
427+
progress.current_message = (
428+
f"Could not confirm deployment status. "
429+
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
430+
)
431+
return
432+
433+
if final_status in SUCCESSFUL_STATUSES:
434+
progress.current_message = f"Ready the chicken! 🐔 Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
435+
else:
436+
progress.metadata["done_emoji"] = "❌"
437+
progress.current_message = "Deployment failed"
438+
439+
human_status = DeploymentStatus.to_human_readable(final_status)
440+
441+
progress.log(
442+
f"😔 Oh no! Deployment failed: {human_status}. "
443+
f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
444+
)
445+
raise typer.Exit(1)
446+
447+
443448
def _wait_for_deployment(
444449
toolkit: RichToolkit, app_id: str, deployment: CreateDeploymentResponse
445450
) -> None:
@@ -451,73 +456,66 @@ def _wait_for_deployment(
451456
)
452457
toolkit.print_line()
453458

454-
toolkit.print(
455-
f"You can also check the status at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
456-
)
457-
toolkit.print_line()
458-
459459
time_elapsed = 0.0
460460

461461
started_at = time.monotonic()
462462

463463
last_message_changed_at = time.monotonic()
464464

465-
with (
466-
toolkit.progress(
465+
build_complete = False
466+
467+
with APIClient() as client:
468+
with toolkit.progress(
467469
next(messages),
468470
inline_logs=True,
469471
lines_to_show=20,
470472
done_emoji="🚀",
471-
) as progress,
472-
APIClient() as client,
473-
):
474-
try:
475-
for log in client.stream_build_logs(deployment.id):
476-
time_elapsed = time.monotonic() - started_at
477-
478-
if log.type == "message":
479-
progress.log(Text.from_ansi(log.message.rstrip()))
480-
481-
if log.type == "complete":
482-
progress.title = "Build complete!"
483-
progress.log("")
484-
progress.log(
485-
f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
486-
)
487-
488-
progress.log("")
489-
490-
progress.log(
491-
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
492-
)
493-
494-
break
495-
496-
if log.type == "failed":
497-
progress.log("")
498-
progress.log(
499-
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
500-
)
501-
raise typer.Exit(1)
502-
503-
if time_elapsed > 30:
504-
messages = cycle(LONG_WAIT_MESSAGES)
505-
506-
if (time.monotonic() - last_message_changed_at) > 2:
507-
progress.title = next(messages)
473+
) as progress:
474+
try:
475+
for log in client.stream_build_logs(deployment.id):
476+
time_elapsed = time.monotonic() - started_at
477+
478+
if log.type == "message":
479+
progress.log(Text.from_ansi(log.message.rstrip()))
480+
481+
if log.type == "complete":
482+
build_complete = True
483+
progress.title = "Build complete!"
484+
break
485+
486+
if log.type == "failed":
487+
progress.log("")
488+
progress.log(
489+
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
490+
)
491+
raise typer.Exit(1)
492+
493+
if time_elapsed > 30:
494+
messages = cycle(LONG_WAIT_MESSAGES)
495+
496+
if (time.monotonic() - last_message_changed_at) > 2:
497+
progress.title = next(messages)
498+
499+
last_message_changed_at = time.monotonic()
500+
501+
except KeyboardInterrupt:
502+
progress.title = "Cancelled"
503+
raise
504+
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
505+
progress.set_error(
506+
dedent(f"""
507+
[error]Build log streaming failed: {e}[/]
508508
509-
last_message_changed_at = time.monotonic()
509+
Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
510+
""").strip()
511+
)
510512

511-
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
512-
progress.set_error(
513-
dedent(f"""
514-
[error]Build log streaming failed: {e}[/]
513+
raise typer.Exit(1) from None
515514

516-
Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
517-
""").strip()
518-
)
515+
if build_complete:
516+
toolkit.print_line()
519517

520-
raise typer.Exit(1) from None
518+
_verify_deployment(toolkit, client, app_id, deployment)
521519

522520

523521
class SignupToWaitingList(BaseModel):

src/fastapi_cloud_cli/utils/api.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Callable, Generator
55
from contextlib import contextmanager
66
from datetime import timedelta
7+
from enum import Enum
78
from functools import wraps
89
from typing import (
910
Annotated,
@@ -138,6 +139,56 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
138139
return decorator
139140

140141

142+
class DeploymentStatus(str, Enum):
143+
waiting_upload = "waiting_upload"
144+
ready_for_build = "ready_for_build"
145+
building = "building"
146+
extracting = "extracting"
147+
extracting_failed = "extracting_failed"
148+
building_image = "building_image"
149+
building_image_failed = "building_image_failed"
150+
deploying = "deploying"
151+
deploying_failed = "deploying_failed"
152+
verifying = "verifying"
153+
verifying_failed = "verifying_failed"
154+
verifying_skipped = "verifying_skipped"
155+
success = "success"
156+
failed = "failed"
157+
158+
@classmethod
159+
def to_human_readable(cls, status: "DeploymentStatus") -> str:
160+
return {
161+
cls.waiting_upload: "Waiting for upload",
162+
cls.ready_for_build: "Ready for build",
163+
cls.building: "Building",
164+
cls.extracting: "Extracting",
165+
cls.extracting_failed: "Extracting failed",
166+
cls.building_image: "Building image",
167+
cls.building_image_failed: "Build failed",
168+
cls.deploying: "Deploying",
169+
cls.deploying_failed: "Deploying failed",
170+
cls.verifying: "Verifying",
171+
cls.verifying_failed: "Verifying failed",
172+
cls.verifying_skipped: "Verification skipped",
173+
cls.success: "Success",
174+
cls.failed: "Failed",
175+
}[status]
176+
177+
178+
SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
179+
FAILED_STATUSES = {
180+
DeploymentStatus.failed,
181+
DeploymentStatus.verifying_failed,
182+
DeploymentStatus.deploying_failed,
183+
DeploymentStatus.building_image_failed,
184+
DeploymentStatus.extracting_failed,
185+
}
186+
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
187+
188+
POLL_INTERVAL = 2.0
189+
POLL_TIMEOUT = 1200.0
190+
191+
141192
class APIClient(httpx.Client):
142193
def __init__(self) -> None:
143194
settings = Settings.get()
@@ -241,3 +292,35 @@ def stream_app_logs(
241292
except ValidationError as e: # pragma: no cover
242293
logger.debug("Failed to parse log entry: %s - %s", data, e)
243294
continue
295+
296+
def poll_deployment_status(
297+
self,
298+
app_id: str,
299+
deployment_id: str,
300+
timeout: float = POLL_TIMEOUT,
301+
interval: float = POLL_INTERVAL,
302+
) -> DeploymentStatus:
303+
start = time.monotonic()
304+
consecutive_errors = 0
305+
max_errors = 5
306+
307+
while time.monotonic() - start < timeout:
308+
try:
309+
response = self.get(f"/apps/{app_id}/deployments/{deployment_id}")
310+
response.raise_for_status()
311+
status = DeploymentStatus(response.json()["status"])
312+
consecutive_errors = 0
313+
except httpx.HTTPError as e:
314+
consecutive_errors += 1
315+
logger.debug("Error polling deployment status: %s", e)
316+
if consecutive_errors >= max_errors:
317+
raise
318+
time.sleep(interval)
319+
continue
320+
321+
if status in TERMINAL_STATUSES:
322+
return status
323+
324+
time.sleep(interval)
325+
326+
raise TimeoutError("Deployment verification timed out")

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import contextlib
22
import logging
33
from collections.abc import Generator
4-
from typing import Any
4+
from typing import Any, Literal
55

66
import typer
77
from httpx import HTTPError, HTTPStatusError, ReadTimeout
@@ -24,9 +24,12 @@ def _get_tag_segments(
2424
metadata: dict[str, Any],
2525
is_animated: bool = False,
2626
done: bool = False,
27+
animation_status: Literal["started", "stopped", "error"] | None = None,
2728
) -> tuple[list[Segment], int]:
2829
if not is_animated:
29-
return super()._get_tag_segments(metadata, is_animated, done)
30+
return super()._get_tag_segments(
31+
metadata, is_animated, done, animation_status=animation_status
32+
)
3033

3134
emojis = [
3235
"🥚",
@@ -42,6 +45,9 @@ def _get_tag_segments(
4245
if done:
4346
tag = metadata.get("done_emoji", emojis[-1])
4447

48+
if animation_status == "error":
49+
tag = "⚠️"
50+
4551
left_padding = self.tag_width - 1
4652
left_padding = max(0, left_padding)
4753

0 commit comments

Comments
 (0)