From 68474bef474dbcfed761b9ad1f26f32022002043 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 9 Sep 2026 15:22:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Show=20backend=20build=20failure=20?= =?UTF-8?q?guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcake-Parent: main --- src/fastapi_cloud_cli/api/__init__.py | 5 + src/fastapi_cloud_cli/api/_models.py | 30 +- src/fastapi_cloud_cli/api/client.py | 6 + src/fastapi_cloud_cli/commands/deploy/wait.py | 29 +- src/fastapi_cloud_cli/commands/deployments.py | 158 ++++--- src/fastapi_cloud_cli/utils/build_logs.py | 14 + tests/test_api_client.py | 25 + tests/test_cli_deploy.py | 158 ++++++- tests/test_cli_deployments.py | 444 +++++++++++++++++- 9 files changed, 788 insertions(+), 81 deletions(-) create mode 100644 src/fastapi_cloud_cli/utils/build_logs.py diff --git a/src/fastapi_cloud_cli/api/__init__.py b/src/fastapi_cloud_cli/api/__init__.py index 771c68e6..2f79c730 100644 --- a/src/fastapi_cloud_cli/api/__init__.py +++ b/src/fastapi_cloud_cli/api/__init__.py @@ -4,12 +4,17 @@ from ._errors import get_http_error_code as get_http_error_code from ._errors import get_http_error_hint as get_http_error_hint from ._errors import handle_http_error as handle_http_error +from ._models import BUILD_FAILED_STATUSES as BUILD_FAILED_STATUSES from ._models import SUCCESSFUL_STATUSES as SUCCESSFUL_STATUSES +from ._models import TERMINAL_STATUSES as TERMINAL_STATUSES from ._models import AppLogEntry as AppLogEntry +from ._models import BuildFailure as BuildFailure +from ._models import BuildLogLineGeneric as BuildLogLineGeneric from ._models import BuildLogLineMessage as BuildLogLineMessage from ._models import CustomDomain as CustomDomain from ._models import CustomDomainRecord as CustomDomainRecord from ._models import CustomDomainStatus as CustomDomainStatus +from ._models import Deployment as Deployment from ._models import DeploymentStatus as DeploymentStatus from ._retry import STREAM_LOGS_MAX_RETRIES as STREAM_LOGS_MAX_RETRIES from .client import APIClient as APIClient diff --git a/src/fastapi_cloud_cli/api/_models.py b/src/fastapi_cloud_cli/api/_models.py index 0fce2cb1..3baa1c47 100644 --- a/src/fastapi_cloud_cli/api/_models.py +++ b/src/fastapi_cloud_cli/api/_models.py @@ -21,6 +21,13 @@ class BuildLogLineMessage(BaseModel): id: str | None = None +class BuildFailure(BaseModel): + error_code: str + error_title: str + error_message: str + error_hint: str + + BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter( Annotated[BuildLogLine, Field(discriminator="type")] @@ -125,16 +132,29 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: }[status] +class Deployment(BaseModel): + id: str + app_id: str + slug: str + status: DeploymentStatus + created_at: str + url: str | None = None + dashboard_url: str | None = None + failure: BuildFailure | None = None + + SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped} -FAILED_STATUSES = { +BUILD_FAILED_STATUSES = { + DeploymentStatus.building_image_failed, + DeploymentStatus.building_image_failed_timeout, + DeploymentStatus.extracting_failed, + DeploymentStatus.extracting_failed_archive_too_large, +} +FAILED_STATUSES = BUILD_FAILED_STATUSES | { DeploymentStatus.failed, DeploymentStatus.verifying_failed, DeploymentStatus.verification_failed_oom, DeploymentStatus.deploying_failed, DeploymentStatus.deploying_skipped, - DeploymentStatus.building_image_failed, - DeploymentStatus.building_image_failed_timeout, - DeploymentStatus.extracting_failed, - DeploymentStatus.extracting_failed_archive_too_large, } TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index 50f3e098..e28a9d73 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -30,6 +30,7 @@ BuildLogLine, CustomDomain, CustomDomainsAPIResponse, + Deployment, DeploymentStatus, ) from ._retry import ( @@ -136,6 +137,11 @@ def handle_http_errors( raise typer.Exit(1) from None + def get_deployment(self, deployment_id: str) -> Deployment: + response = self.get(f"/deployments/{deployment_id}") + response.raise_for_status() + return Deployment.model_validate(response.json()) + def get_custom_domains(self, *, app_id: str) -> CustomDomainsAPIResponse: response = self.get(f"/apps/{app_id}/custom-domains") response.raise_for_status() diff --git a/src/fastapi_cloud_cli/commands/deploy/wait.py b/src/fastapi_cloud_cli/commands/deploy/wait.py index be8d8bfd..977f1034 100644 --- a/src/fastapi_cloud_cli/commands/deploy/wait.py +++ b/src/fastapi_cloud_cli/commands/deploy/wait.py @@ -1,19 +1,25 @@ +import contextlib import time from itertools import cycle from textwrap import dedent import typer +from httpx import HTTPError from rich.text import Text from rich_toolkit import RichToolkit from fastapi_cloud_cli.api import ( SUCCESSFUL_STATUSES, APIClient, + BuildFailure, DeploymentStatus, StreamLogError, TooManyRetriesError, ) from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse +from fastapi_cloud_cli.utils.build_logs import print_build_error +from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit +from fastapi_cloud_cli.utils.execution import is_ci_enabled # (bullet emoji, message) — the emoji replaces the progress animation WAITING_MESSAGES = [ @@ -89,7 +95,7 @@ def _verify_deployment( def _wait_for_deployment( - toolkit: RichToolkit, + toolkit: FastAPIRichToolkit, client: APIClient, app_id: str, deployment: CreateDeploymentResponse, @@ -107,12 +113,15 @@ def _wait_for_deployment( "Checking the status of your deployment", inline_logs=True, lines_to_show=20, + # Keep the log panel replaceable by a diagnosis, but retain CI logs. + preserve_logs=is_ci_enabled(), emoji="👀", done_emoji="🚀", ) as progress, ): build_complete = False build_failed = False + build_error: BuildFailure | None = None try: for log in client.stream_build_logs(deployment.id): @@ -128,6 +137,12 @@ def _wait_for_deployment( if log.type == "failed": build_failed = True + build_error = None + + with contextlib.suppress(HTTPError): + build_error = client.get_deployment(deployment.id).failure + + progress.transient = build_error is not None # the headline comes from the title once there are log # lines, and from current_message when there are none progress.title = "Build failed" @@ -157,10 +172,16 @@ def _wait_for_deployment( raise typer.Exit(1) from None if build_failed: - toolkit.print_line() + if build_error is not None: + print_build_error(toolkit, build_error) + toolkit.print_line() + message = "Check out the logs at" + else: + toolkit.print_line() + message = "Oh no! Something went wrong. Check out the logs at" toolkit.print( - f"Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]", - emoji="😔", + f"{message} [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]", + emoji="👀" if build_error is not None else "😔", ) raise typer.Exit(1) diff --git a/src/fastapi_cloud_cli/commands/deployments.py b/src/fastapi_cloud_cli/commands/deployments.py index 92fbd254..77ce992b 100644 --- a/src/fastapi_cloud_cli/commands/deployments.py +++ b/src/fastapi_cloud_cli/commands/deployments.py @@ -5,14 +5,19 @@ import typer from httpx import HTTPError from pydantic import BaseModel +from rich.markup import escape from rich.table import Table from rich.text import Text from rich_toolkit import RichToolkit from fastapi_cloud_cli.api import ( + BUILD_FAILED_STATUSES, + TERMINAL_STATUSES, APIClient, + BuildFailure, + BuildLogLineGeneric, BuildLogLineMessage, - DeploymentStatus, + Deployment, StreamLogError, TooManyRetriesError, get_http_error_code, @@ -35,16 +40,6 @@ DEFAULT_OFFSET = 0 -class Deployment(BaseModel): - id: str - app_id: str - slug: str - status: DeploymentStatus - created_at: str - url: str | None = None - dashboard_url: str | None = None - - class DeploymentsListAPIResponse(BaseModel): data: list[Deployment] count: int @@ -70,6 +65,7 @@ class BuildLogsOutput(BaseModel): deployment_id: str failed: bool logs: list[BuildLogOutput] + failure: BuildFailure | None = None def _get_deployments( @@ -94,13 +90,6 @@ def _get_deployments( ) -def _get_deployment(client: APIClient, *, deployment_id: str) -> DeploymentGetOutput: - response = client.get(f"/deployments/{deployment_id}") - response.raise_for_status() - - return DeploymentGetOutput(deployment=Deployment.model_validate(response.json())) - - def _render_deployments_list_output( data: DeploymentsListOutput, toolkit: RichToolkit ) -> None: @@ -129,7 +118,7 @@ def _render_deployments_list_output( def _render_deployment_get_output( - data: DeploymentGetOutput, toolkit: RichToolkit + data: DeploymentGetOutput, toolkit: FastAPIRichToolkit ) -> None: deployment = data.deployment @@ -164,6 +153,23 @@ def _render_deployment_get_output( ) ) + if deployment.failure is not None: + toolkit.print_line() + _print_deployment_failure(toolkit, deployment.failure) + + +def _print_deployment_failure( + toolkit: FastAPIRichToolkit, failure: BuildFailure +) -> None: + toolkit.print("This deployment failed with the following error:", emoji="🚨") + toolkit.print_line() + toolkit.print(Text(failure.error_title, style="bold")) + toolkit.print_line() + toolkit.print(Text(failure.error_message)) + if failure.error_hint: + toolkit.print_line() + toolkit.print_hint(escape(failure.error_hint)) + def _print_build_log_json( deployment_id: str, @@ -171,12 +177,14 @@ def _print_build_log_json( *, log_id: str | None, message: str | None = None, + failure: BuildFailure | None = None, ) -> None: record = { "type": record_type, "deployment_id": deployment_id, "id": log_id, "message": message, + "failure": failure.model_dump(mode="json") if failure is not None else None, } typer.echo( @@ -191,22 +199,31 @@ def _print_build_log_json( def _print_build_log_line(toolkit: RichToolkit, message: str) -> None: - toolkit.print(Text.from_ansi(message.rstrip()), emoji=BUILD_LOG_BULLET) + text = Text.from_ansi(message.rstrip()) + + if not text.plain.strip(): + # Keep the log marker when the style would otherwise omit an empty line. + text = Text("\u200b") + + toolkit.print(text, emoji=BUILD_LOG_BULLET) def _render_build_logs_output( data: BuildLogsOutput, toolkit: FastAPIRichToolkit ) -> None: - if not data.logs: - toolkit.print("No build logs found.") - return - for log in data.logs: _print_build_log_line(toolkit, log.message) - if data.failed: + if data.failure: + if data.logs: + toolkit.print_line() + + _print_deployment_failure(toolkit, data.failure) + elif data.failed: toolkit.print_line() toolkit.print_error("Build failed.") + elif not data.logs: + toolkit.print("No build logs found.") def _stream_build_logs( @@ -214,9 +231,12 @@ def _stream_build_logs( client: APIClient, deployment_id: str, ) -> bool: - failed = False + deployment = client.get_deployment(deployment_id) + terminal_log: BuildLogLineGeneric | None = None - for log in client.stream_build_logs(deployment_id, follow=True): + for log in client.stream_build_logs( + deployment_id, follow=deployment.status not in TERMINAL_STATUSES + ): if isinstance(log, BuildLogLineMessage): if toolkit.mode == "json": _print_build_log_json( @@ -227,26 +247,30 @@ def _stream_build_logs( ) else: _print_build_log_line(toolkit, log.message) - - elif log.type == "complete": - if toolkit.mode == "json": - _print_build_log_json( - deployment_id, - "complete", - log_id=log.id, - ) - - elif log.type == "failed": - failed = True - if toolkit.mode == "json": - _print_build_log_json( - deployment_id, - "failed", - log_id=log.id, - ) - else: - toolkit.print_line() - toolkit.print_error("Build failed.") + else: + terminal_log = log + + if deployment.status not in TERMINAL_STATUSES: + deployment = client.get_deployment(deployment_id) + + failed = ( + terminal_log is not None and terminal_log.type == "failed" + ) or deployment.status in BUILD_FAILED_STATUSES + + if toolkit.mode == "json": + if failed or terminal_log is not None or deployment.status in TERMINAL_STATUSES: + _print_build_log_json( + deployment_id, + "failed" if failed else "complete", + log_id=terminal_log.id if terminal_log is not None else None, + failure=deployment.failure, + ) + elif deployment.failure is not None: + toolkit.print_line() + _print_deployment_failure(toolkit, deployment.failure) + elif failed: + toolkit.print_line() + toolkit.print_error("Build failed.") return failed @@ -262,7 +286,13 @@ def _fetch_build_logs(client: APIClient, deployment_id: str) -> BuildLogsOutput: elif log.type == "failed": failed = True - return BuildLogsOutput(deployment_id=deployment_id, failed=failed, logs=logs) + deployment = client.get_deployment(deployment_id) + return BuildLogsOutput( + deployment_id=deployment_id, + failed=failed or deployment.status in BUILD_FAILED_STATUSES, + logs=logs, + failure=deployment.failure, + ) def _handle_build_log_error( @@ -338,22 +368,24 @@ def get_deployment( resolve_app_id_or_fail(toolkit, app_id=app_id) with APIClient() as client: - with toolkit.progress( - title="Fetching deployment", - transient=True, - ) as progress: - with client.handle_http_errors( + with ( + toolkit.progress( + title="Fetching deployment", + transient=True, + ) as progress, + client.handle_http_errors( progress, default_message="Error fetching deployment. Please try again later.", not_found_message="Deployment not found.", toolkit=toolkit, - ): - result = _get_deployment( - client, - deployment_id=deployment_id, - ) + ), + ): + deployment = client.get_deployment(deployment_id) - toolkit.success(result, render_output=_render_deployment_get_output) + toolkit.success( + DeploymentGetOutput(deployment=deployment), + render_output=_render_deployment_get_output, + ) @deployments_app.command("build-logs", cls=UserCommand) @@ -407,6 +439,14 @@ def build_logs( return except StreamLogError as e: _handle_build_log_error(toolkit, e) + except HTTPError as e: + code = get_http_error_code(e) + toolkit.fail( + code, + handle_http_error(e, not_found_message="Deployment not found."), + hint=get_http_error_hint(code), + render_output=_render_build_log_error, + ) except (TooManyRetriesError, TimeoutError): message = "Lost connection to build log stream. Please try again later." diff --git a/src/fastapi_cloud_cli/utils/build_logs.py b/src/fastapi_cloud_cli/utils/build_logs.py new file mode 100644 index 00000000..c5533cbd --- /dev/null +++ b/src/fastapi_cloud_cli/utils/build_logs.py @@ -0,0 +1,14 @@ +from rich.markup import escape +from rich.text import Text + +from fastapi_cloud_cli.api import BuildFailure +from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit + + +def print_build_error(toolkit: FastAPIRichToolkit, error: BuildFailure) -> None: + toolkit.print(Text(error.error_title, style="bold red"), emoji="🚨") + toolkit.print_line() + toolkit.print(Text(error.error_message)) + if error.error_hint: + toolkit.print_line() + toolkit.print_hint(escape(error.error_hint)) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 2cd97f32..2f1c053f 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -455,3 +455,28 @@ def test_poll_deployment_status_timeout(client: APIClient, deployment_id: str) - pytest.raises(TimeoutError, match="timed out"), ): client.poll_deployment_status(deployment_id) + + +def test_get_deployment_with_persisted_failure( + respx_mock: respx.MockRouter, + client: APIClient, + deployment_id: str, +) -> None: + deployment = { + "id": deployment_id, + "app_id": "123", + "slug": "demo-build", + "created_at": "2026-09-10T12:00:00Z", + "status": "building_image_failed", + "url": "https://demo.fastapicloud.app", + "dashboard_url": "https://dashboard.fastapicloud.com/demo-build", + "failure": { + "error_code": "future_build_error", + "error_title": "A new build error", + "error_message": "Guidance supplied by the backend.", + "error_hint": "", + }, + } + respx_mock.get(f"/deployments/{deployment_id}").respond(200, json=deployment) + + assert client.get_deployment(deployment_id).model_dump(mode="json") == deployment diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 4fc9485c..18aad5fe 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -12,15 +12,29 @@ import respx import typer from httpx import Response +from inline_snapshot import snapshot from rich_toolkit.progress import Progress from typer.testing import CliRunner, Result -from fastapi_cloud_cli.api import StreamLogError, TooManyRetriesError +from fastapi_cloud_cli.api import ( + APIClient, + DeploymentStatus, + StreamLogError, + TooManyRetriesError, +) from fastapi_cloud_cli.cli import app from fastapi_cloud_cli.commands.deploy import wait +from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse from fastapi_cloud_cli.config import Settings +from fastapi_cloud_cli.utils.cli import get_rich_toolkit from tests.conftest import ConfiguredApp -from tests.utils import Keys, build_logs_response, changing_dir, create_jwt_token +from tests.utils import ( + Keys, + SnapshotCliRunner, + build_logs_response, + changing_dir, + create_jwt_token, +) runner = CliRunner() @@ -79,6 +93,7 @@ def _get_random_deployment( "status": status, "url": "http://test.com", "dashboard_url": "http://test.com", + "created_at": "2026-09-10T12:00:00Z", } @@ -1113,10 +1128,17 @@ def test_exits_with_error_when_deployment_fails_to_build( return_value=Response(200) ) + respx_mock.get(f"/deployments/{deployment_data['id']}").respond( + 200, + json={**deployment_data, "status": "building_image_failed", "failure": None}, + ) respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( return_value=Response( 200, - json={"type": "failed"}, + content=build_logs_response( + {"type": "message", "message": "Original build failure", "id": "1"}, + {"type": "failed", "id": "2"}, + ), ) ) @@ -1133,6 +1155,7 @@ def test_exits_with_error_when_deployment_fails_to_build( assert result.exit_code == 1 + assert "Original build failure" in result.output assert "Oh no! Something went wrong" in result.output assert deployment_data["dashboard_url"] in result.output @@ -1169,6 +1192,7 @@ def test_shows_error_when_deployment_build_fails( return_value=Response(200) ) + respx_mock.get(f"/deployments/{deployment_data['id']}").respond(503) respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( return_value=Response( 200, @@ -2642,3 +2666,131 @@ def test_invalid_large_file_threshold( assert result.exit_code == 2 assert "Invalid value for '--large-file-threshold'" in result.output + + +@pytest.mark.respx +@pytest.mark.parametrize("ci", [False, True]) +def test_deploy_shows_backend_build_failure_diagnostic( + logged_in_cli: None, + tmp_path: Path, + respx_mock: respx.MockRouter, + ci: bool, +) -> None: + app_data: RandomApp = { + "id": "123", + "name": "demo", + "slug": "demo", + "team_id": "456", + "directory": None, + } + deployment_data = { + "id": "789", + "app_id": "123", + "slug": "demo-build", + "status": "waiting_upload", + "url": "https://demo.fastapicloud.app", + "dashboard_url": "https://dashboard.fastapicloud.com/demo-build", + } + _mock_deploy_until_upload( + respx_mock, tmp_path, app_data, deployment_data, Response(200) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").respond( + 200, json={**deployment_data, "status": "ready_for_build"} + ) + respx_mock.get(f"/deployments/{deployment_data['id']}").respond( + 200, + json={ + **deployment_data, + "created_at": "2026-09-10T12:00:00Z", + "status": "building_image_failed", + "failure": { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + }, + }, + ) + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").respond( + 200, + content=build_logs_response( + {"type": "message", "id": "1", "message": "Installing dependencies"}, + {"type": "message", "id": "3", "message": "Cleaning up..."}, + {"type": "failed", "id": "4"}, + ), + ) + + with changing_dir(tmp_path): + result = SnapshotCliRunner().invoke(app, ["deploy"], env={"CI": str(ci)}) + + assert result.exit_code == 1 + assert ("Installing dependencies" in result.output) == ci + assert ("Cleaning up..." in result.output) == ci + assert result.output.count("Your lockfile is out of date") == 1 + assert "The lockfile does not match your project dependencies." in result.output + assert "Run `uv lock` and commit the updated lockfile." in result.output + assert deployment_data["dashboard_url"] in result.output + assert "Something went wrong" not in result.output + assert "Building and pushing the app image failed" not in result.output + assert "error: Your lockfile is out of date" not in result.output + assert "Build failed" not in result.output + + +@pytest.mark.respx +def test_diagnosed_build_failure_replaces_log_panel( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + deployment = CreateDeploymentResponse( + id="789", + app_id="123", + slug="demo-build", + status=DeploymentStatus.ready_for_build, + url="https://demo.fastapicloud.app", + dashboard_url="https://dashboard.fastapicloud.com/demo-build", + ) + respx_mock.get(f"/deployments/{deployment.id}").respond( + 200, + json={ + **deployment.model_dump(mode="json"), + "created_at": "2026-09-10T12:00:00Z", + "status": "building_image_failed", + "failure": { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + }, + }, + ) + respx_mock.get(f"/deployments/{deployment.id}/build-logs").respond( + 200, + content=build_logs_response( + { + "type": "message", + "id": "1", + "message": "The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.\n", + }, + {"type": "message", "id": "2", "message": "\n"}, + {"type": "failed", "id": "5"}, + ), + ) + command = typer.Typer() + + @command.command() + def wait_for_build() -> None: + with get_rich_toolkit() as toolkit, APIClient() as client: + wait._wait_for_deployment(toolkit, client, deployment.app_id, deployment) + + result = SnapshotCliRunner().invoke(command) + + assert result.exit_code == 1 + assert result.output == snapshot("""\ +🚨 Your lockfile is out of date + + The lockfile does not match your project dependencies. + + hint: Run `uv lock` and commit the updated lockfile. + +👀 Check out the logs at https://dashboard.fastapicloud.com/demo-build\ +""") diff --git a/tests/test_cli_deployments.py b/tests/test_cli_deployments.py index c3c970d1..47a30959 100644 --- a/tests/test_cli_deployments.py +++ b/tests/test_cli_deployments.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timezone +from typing import Any from unittest.mock import patch import httpx @@ -17,17 +18,34 @@ runner = SnapshotCliRunner() +@pytest.fixture +def build_logs_deployment(respx_mock: respx.MockRouter) -> dict[str, Any]: + deployment: dict[str, Any] = { + "id": "00000000-0000-4000-8000-000000000003", + "app_id": "00000000-0000-4000-8000-000000000002", + "slug": "api-build", + "status": "building_image", + "created_at": "2026-05-22T10:00:00Z", + "failure": None, + } + respx_mock.get(f"/deployments/{deployment['id']}").mock( + side_effect=lambda request: Response(200, json=deployment) + ) + return deployment + + @pytest.mark.respx def test_lists_deployments_as_json_with_app_id_and_pagination_params( logged_in_cli: None, respx_mock: respx.MockRouter, ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522", @@ -70,11 +88,12 @@ def test_lists_deployments_as_json_uses_linked_app( respx_mock: respx.MockRouter, configured_app: ConfiguredApp, ) -> None: - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": configured_app.app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522", @@ -106,11 +125,12 @@ def test_lists_deployments_human_output_shows_id_status_and_created( respx_mock: respx.MockRouter, ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522", @@ -226,11 +246,12 @@ def test_gets_deployment_as_json_with_app_id( respx_mock: respx.MockRouter, ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522", @@ -262,11 +283,12 @@ def test_gets_deployment_as_json_uses_linked_app( respx_mock: respx.MockRouter, configured_app: ConfiguredApp, ) -> None: - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": configured_app.app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522", @@ -309,11 +331,12 @@ def test_gets_deployment_in_human_output( respx_mock: respx.MockRouter, ) -> None: app_id = "00000000-0000-4000-8000-000000000002" - deployment = { + deployment: dict[str, Any] = { "id": "00000000-0000-4000-8000-000000000003", "app_id": app_id, "slug": "api-20260522", "status": "success", + "failure": None, "created_at": "2026-05-22T10:00:00Z", "url": "https://api.fastapicloud.app", "dashboard_url": "https://dashboard.example.com/d/api-20260522", @@ -345,6 +368,7 @@ def test_gets_deployment_in_human_output( @pytest.mark.respx def test_gets_build_logs_no_follow_as_json( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -375,6 +399,7 @@ def test_gets_build_logs_no_follow_as_json( assert json.loads(result.stdout) == { "data": { "deployment_id": deployment_id, + "failure": None, "failed": False, "logs": [ { @@ -394,6 +419,7 @@ def test_gets_build_logs_no_follow_as_json( @pytest.mark.respx def test_gets_failed_build_logs_no_follow_as_json_exits_nonzero( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -420,6 +446,7 @@ def test_gets_failed_build_logs_no_follow_as_json_exits_nonzero( assert json.loads(result.stdout) == { "data": { "deployment_id": deployment_id, + "failure": None, "failed": True, "logs": [ { @@ -435,6 +462,7 @@ def test_gets_failed_build_logs_no_follow_as_json_exits_nonzero( @pytest.mark.respx def test_gets_build_logs_no_follow_in_human_output( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -465,6 +493,7 @@ def test_gets_build_logs_no_follow_in_human_output( @pytest.mark.respx def test_gets_build_logs_no_follow_in_human_output_empty( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -484,6 +513,7 @@ def test_gets_build_logs_no_follow_in_human_output_empty( @pytest.mark.respx def test_gets_failed_build_logs_no_follow_in_human_output_exits_nonzero( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -514,6 +544,7 @@ def test_gets_failed_build_logs_no_follow_in_human_output_exits_nonzero( @pytest.mark.respx def test_streams_build_logs_in_human_output( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -544,6 +575,7 @@ def test_streams_build_logs_in_human_output( @pytest.mark.respx def test_streams_failed_build_logs_in_human_output_exits_nonzero( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -574,6 +606,7 @@ def test_streams_failed_build_logs_in_human_output_exits_nonzero( @pytest.mark.respx def test_streams_build_logs_as_json_ndjson( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -639,6 +672,7 @@ def test_build_logs_json_returns_not_logged_in_when_logged_out( def test_streaming_build_logs_handles_keyboard_interrupt( logged_in_cli: None, + build_logs_deployment: dict[str, Any], ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -654,7 +688,10 @@ def test_streaming_build_logs_handles_keyboard_interrupt( assert result.exit_code == 0 -def test_build_logs_handles_not_found_stream_error(logged_in_cli: None) -> None: +def test_build_logs_handles_not_found_stream_error( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" with patch( @@ -670,7 +707,10 @@ def test_build_logs_handles_not_found_stream_error(logged_in_cli: None) -> None: assert "Deployment not found." in result.output -def test_build_logs_handles_http_stream_error_with_hint(logged_in_cli: None) -> None: +def test_build_logs_handles_http_stream_error_with_hint( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" def raise_stream_error(*args: object, **kwargs: object) -> None: @@ -699,7 +739,10 @@ def raise_stream_error(*args: object, **kwargs: object) -> None: assert "fastapi cloud login" in result.output -def test_build_logs_handles_generic_stream_error(logged_in_cli: None) -> None: +def test_build_logs_handles_generic_stream_error( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" with patch( @@ -715,7 +758,10 @@ def test_build_logs_handles_generic_stream_error(logged_in_cli: None) -> None: assert "Error streaming build logs: Log storage unavailable" in result.output -def test_build_logs_handles_connection_loss(logged_in_cli: None) -> None: +def test_build_logs_handles_connection_loss( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" with patch( @@ -735,6 +781,7 @@ def test_build_logs_handles_connection_loss(logged_in_cli: None) -> None: @pytest.mark.respx def test_streams_failed_build_logs_as_json_ndjson_exits_nonzero( logged_in_cli: None, + build_logs_deployment: dict[str, Any], respx_mock: respx.MockRouter, ) -> None: deployment_id = "00000000-0000-4000-8000-000000000003" @@ -772,3 +819,380 @@ def test_streams_failed_build_logs_as_json_ndjson_exits_nonzero( }, ] assert result.stderr == "" + + +@pytest.mark.respx +@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) +def test_shows_build_failure_diagnostic_in_human_output( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, + follow_option: str, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + build_logs_deployment.update( + status="building_image_failed", + failure={ + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + }, + ) + if follow_option == "--follow": + respx_mock.get(f"/deployments/{deployment_id}").mock( + side_effect=[ + Response( + 200, + json={ + **build_logs_deployment, + "status": "building_image", + "failure": None, + }, + ), + Response(200, json=build_logs_deployment), + ] + ) + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, + content=build_logs_response( + {"type": "message", "id": "1", "message": "Installing dependencies"}, + {"type": "message", "id": "2", "message": "\n"}, + {"type": "message", "id": "3", "message": "Cleaning up..."}, + {"type": "failed", "id": "4"}, + ), + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, follow_option] + ) + + assert result.exit_code == 1 + if follow_option == "--no-follow": + assert result.output == snapshot("""\ +📜 Fetching build logs for 00000000-0000-4000-8000-000000000003... + +▕ Installing dependencies +▕ +▕ Cleaning up... + +🚨 This deployment failed with the following error: + + Your lockfile is out of date + + The lockfile does not match your project dependencies. + + hint: Run `uv lock` and commit the updated lockfile.\ +""") + else: + assert result.output == snapshot("""\ +📡 Streaming build logs for 00000000-0000-4000-8000-000000000003... + +▕ Installing dependencies +▕ +▕ Cleaning up... + +🚨 This deployment failed with the following error: + + Your lockfile is out of date + + The lockfile does not match your project dependencies. + + hint: Run `uv lock` and commit the updated lockfile.\ +""") + + +@pytest.mark.respx +def test_gets_build_failure_diagnostic_without_logs_or_hint( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + build_logs_deployment.update( + status="building_image_failed", + failure={ + "error_code": "future_build_error", + "error_title": "Cannot install project[dev]", + "error_message": "The dependency project[dev] could not be installed.", + "error_hint": "", + }, + ) + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, + content=build_logs_response({"type": "timeout"}), + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, "--no-follow"] + ) + + assert result.exit_code == 1 + assert result.output == snapshot("""\ +📜 Fetching build logs for 00000000-0000-4000-8000-000000000003... + +🚨 This deployment failed with the following error: + + Cannot install project[dev] + + The dependency project[dev] could not be installed.\ +""") + + +@pytest.mark.respx +@pytest.mark.parametrize("hint", ["Run `uv lock` and commit the updated lockfile.", ""]) +def test_streams_build_failure_diagnostic_as_json( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, + hint: str, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + failure = { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": hint, + } + build_logs_deployment.update(status="building_image_failed", failure=failure) + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, + content=build_logs_response( + { + "type": "message", + "id": "0", + "message": "Error: Building and pushing the app image failed.\n", + }, + {"type": "message", "id": "2", "message": "Cleaning up..."}, + {"type": "failed", "id": "3"}, + ), + ) + + result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) + + assert result.exit_code == 1 + assert [json.loads(line) for line in result.stdout.splitlines()] == [ + { + "type": "log", + "deployment_id": deployment_id, + "id": "0", + "message": "Error: Building and pushing the app image failed.\n", + }, + { + "type": "log", + "deployment_id": deployment_id, + "id": "2", + "message": "Cleaning up...", + }, + { + "type": "failed", + "deployment_id": deployment_id, + "id": "3", + "failure": failure, + }, + ] + assert result.stderr == "" + + +@pytest.mark.respx +def test_gets_build_failure_diagnostic_as_json( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + failure = { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + } + build_logs_deployment.update(status="building_image_failed", failure=failure) + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, + content=build_logs_response( + { + "type": "message", + "id": "0", + "message": "Error: Building and pushing the app image failed.\n", + }, + {"type": "failed", "id": "2"}, + ), + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, "--no-follow", "--json"] + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "data": { + "deployment_id": deployment_id, + "failed": True, + "logs": [ + { + "id": "0", + "message": "Error: Building and pushing the app image failed.\n", + }, + ], + "failure": failure, + } + } + assert result.stderr == "" + + +@pytest.mark.respx +@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) +def test_generic_build_failure_without_logs( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, + follow_option: str, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, + content=build_logs_response( + { + "type": "message", + "id": "0", + "message": "Error: Building and pushing the app image failed.\n", + }, + {"type": "failed", "id": "1"}, + ), + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, follow_option] + ) + + assert result.exit_code == 1 + assert "Build failed." in result.output + assert "Error: Building and pushing the app image failed." in result.output + assert "No build logs found." not in result.output + + +@pytest.mark.respx +@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) +def test_build_logs_shows_persisted_failure_after_logs_expire( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, + follow_option: str, +) -> None: + deployment_id = build_logs_deployment["id"] + failure = { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + } + build_logs_deployment.update(status="building_image_failed", failure=failure) + respx_mock.get(f"/deployments/{deployment_id}/build-logs", params__eq={}).respond( + 200, content=build_logs_response({"type": "timeout"}) + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, follow_option] + ) + + assert result.exit_code == 1 + assert failure["error_title"] in result.output + assert failure["error_message"] in result.output + assert failure["error_hint"] in result.output + assert "No build logs found." not in result.output + + +@pytest.mark.respx +@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) +def test_build_logs_does_not_report_readiness_failure_as_build_failure( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], + respx_mock: respx.MockRouter, + follow_option: str, +) -> None: + deployment_id = build_logs_deployment["id"] + build_logs_deployment["status"] = "verifying_failed" + respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( + 200, content=build_logs_response({"type": "complete", "id": "1"}) + ) + + result = runner.invoke( + app, ["deployments", "build-logs", deployment_id, follow_option] + ) + + assert result.exit_code == 0 + assert "Build failed" not in result.output + + +@pytest.mark.respx +def test_get_deployment_includes_persisted_failure_in_json( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: + failure = { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + } + build_logs_deployment.update(status="building_image_failed", failure=failure) + + result = runner.invoke( + app, + [ + "deployments", + "get", + build_logs_deployment["id"], + "--app-id", + build_logs_deployment["app_id"], + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["data"]["deployment"]["failure"] == failure + + +@pytest.mark.respx +def test_get_deployment_shows_persisted_failure( + logged_in_cli: None, + build_logs_deployment: dict[str, Any], +) -> None: + failure = { + "error_code": "uv_lockfile_outdated", + "error_title": "Your lockfile is out of date", + "error_message": "The lockfile does not match your project dependencies.", + "error_hint": "Run `uv lock` and commit the updated lockfile.", + } + build_logs_deployment.update(status="building_image_failed", failure=failure) + + result = runner.invoke( + app, + [ + "deployments", + "get", + build_logs_deployment["id"], + "--app-id", + build_logs_deployment["app_id"], + ], + ) + + assert result.exit_code == 0 + assert failure["error_title"] in result.output + assert failure["error_message"] in result.output + assert failure["error_hint"] in result.output + + +@pytest.mark.respx +def test_build_logs_handles_deployment_lookup_error( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + deployment_id = "00000000-0000-4000-8000-000000000003" + respx_mock.get(f"/deployments/{deployment_id}").respond(404) + + result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"]["message"] == "Deployment not found."