Skip to content

Commit f72e506

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

4 files changed

Lines changed: 362 additions & 101 deletions

File tree

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 83 additions & 84 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
@@ -18,9 +17,16 @@
1817
from rich.text import Text
1918
from rich_toolkit import RichToolkit
2019
from rich_toolkit.menu import Option
20+
from rich_toolkit.progress import Progress
2121

2222
from fastapi_cloud_cli.commands.login import login
23-
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
23+
from fastapi_cloud_cli.utils.api import (
24+
SUCCESSFUL_STATUSES,
25+
APIClient,
26+
DeploymentStatus,
27+
StreamLogError,
28+
TooManyRetriesError,
29+
)
2430
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2531
from fastapi_cloud_cli.utils.auth import Identity
2632
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -174,42 +180,6 @@ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppRespon
174180
return AppResponse.model_validate(response.json())
175181

176182

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-
213183
class CreateDeploymentResponse(BaseModel):
214184
id: str
215185
app_id: str
@@ -440,6 +410,45 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
440410
return app_config
441411

442412

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

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-
459463
time_elapsed = 0.0
460464

461465
started_at = time.monotonic()
462466

463467
last_message_changed_at = time.monotonic()
464468

465-
with (
466-
toolkit.progress(
469+
build_complete = False
470+
471+
with APIClient() as client:
472+
with toolkit.progress(
467473
next(messages),
468474
inline_logs=True,
469475
lines_to_show=20,
470476
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+
) as progress:
478+
try:
479+
for log in client.stream_build_logs(deployment.id):
480+
time_elapsed = time.monotonic() - started_at
477481

478-
if log.type == "message":
479-
progress.log(Text.from_ansi(log.message.rstrip()))
482+
if log.type == "message":
483+
progress.log(Text.from_ansi(log.message.rstrip()))
480484

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-
)
485+
if log.type == "complete":
486+
build_complete = True
487+
progress.title = "Build complete!"
488+
break
487489

488-
progress.log("")
490+
if log.type == "failed":
491+
progress.log("")
492+
progress.log(
493+
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
494+
)
495+
raise typer.Exit(1)
489496

490-
progress.log(
491-
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
492-
)
497+
if time_elapsed > 30:
498+
messages = cycle(LONG_WAIT_MESSAGES)
493499

494-
break
500+
if (time.monotonic() - last_message_changed_at) > 2:
501+
progress.title = next(messages)
495502

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)
503+
last_message_changed_at = time.monotonic()
502504

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)
505+
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
506+
progress.set_error(
507+
dedent(f"""
508+
[error]Build log streaming failed: {e}[/]
508509
509-
last_message_changed_at = time.monotonic()
510+
Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
511+
""").strip()
512+
)
510513

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

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

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

522521

523522
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 = 120.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")

0 commit comments

Comments
 (0)