diff --git a/src/fastapi_cloud_cli/commands/deploy/cloud.py b/src/fastapi_cloud_cli/commands/deploy/cloud.py index 46684f1..d02d9f5 100644 --- a/src/fastapi_cloud_cli/commands/deploy/cloud.py +++ b/src/fastapi_cloud_cli/commands/deploy/cloud.py @@ -1,6 +1,14 @@ from pydantic import BaseModel -from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus +from fastapi_cloud_cli.utils.api import ( + APIClient, + DeploymentStatus, + _get_response_error_message, +) + + +class ArchiveTooLargeError(Exception): + pass class Team(BaseModel): @@ -57,8 +65,20 @@ def _create_app( return AppResponse.model_validate(response.json()) -def _create_deployment(client: APIClient, app_id: str) -> CreateDeploymentResponse: - response = client.post(f"/apps/{app_id}/deployments/") +def _create_deployment( + client: APIClient, app_id: str, archive_size_bytes: int +) -> CreateDeploymentResponse: + response = client.post( + f"/apps/{app_id}/deployments/", + json={"archive_size_bytes": archive_size_bytes}, + ) + + if response.status_code == 413: + raise ArchiveTooLargeError( + _get_response_error_message(response) + or "The app source code exceeds the maximum allowed size." + ) + response.raise_for_status() return CreateDeploymentResponse.model_validate(response.json()) diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index 89dcafa..dbc2e13 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -9,6 +9,7 @@ from fastapi_cloud_cli.commands.deploy.archive import _get_large_files, archive from fastapi_cloud_cli.commands.deploy.cloud import ( AppResponse, + ArchiveTooLargeError, CreateDeploymentResponse, _create_deployment, _get_app, @@ -313,6 +314,7 @@ def deploy( logger.debug("Creating archive for deployment") archive_path = Path(temp_dir) / "archive.tar" archive(path_to_deploy, archive_path) + archive_size = archive_path.stat().st_size with ( toolkit.progress( @@ -324,7 +326,22 @@ def deploy( client.handle_http_errors(progress, toolkit=toolkit), ): logger.debug("Creating deployment for app: %s", app.id) - deployment = _create_deployment(client=client, app_id=app.id) + + try: + deployment = _create_deployment( + client=client, + app_id=app.id, + archive_size_bytes=archive_size, + ) + except ArchiveTooLargeError as e: + toolkit.fail( + "invalid_input", + str(e), + hint=( + "You can exclude files from the deployment " + "with a .fastapicloudignore file." + ), + ) try: progress.log( @@ -335,6 +352,7 @@ def deploy( fastapi_client=client, deployment_id=deployment.id, archive_path=archive_path, + archive_size=archive_size, progress=progress, ) diff --git a/src/fastapi_cloud_cli/commands/deploy/upload.py b/src/fastapi_cloud_cli/commands/deploy/upload.py index 10b8c48..2fcd1d6 100644 --- a/src/fastapi_cloud_cli/commands/deploy/upload.py +++ b/src/fastapi_cloud_cli/commands/deploy/upload.py @@ -43,9 +43,9 @@ def _upload_deployment( fastapi_client: APIClient, deployment_id: str, archive_path: Path, + archive_size: int, progress: Progress, ) -> CreateDeploymentResponse: - archive_size = archive_path.stat().st_size archive_size_str = _format_size(archive_size) progress.log(f"Uploading deployment ({archive_size_str})...") @@ -79,6 +79,9 @@ def progress_callback(bytes_read: int) -> None: files={"file": cast(BinaryIO, archive_file_with_progress)}, ) + if upload_response.is_error: + logger.debug("File upload failed with response: %s", upload_response.text) + upload_response.raise_for_status() logger.debug("File upload completed successfully") diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 35bf364..ec021ef 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -146,6 +146,7 @@ class DeploymentStatus(str, Enum): building = "building" extracting = "extracting" extracting_failed = "extracting_failed" + extracting_failed_archive_too_large = "extracting_failed_archive_too_large" building_image = "building_image" building_image_failed = "building_image_failed" deploying = "deploying" @@ -166,6 +167,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: cls.building: "Building", cls.extracting: "Extracting Upload", cls.extracting_failed: "Extraction Failed", + cls.extracting_failed_archive_too_large: "Archive Too Large", cls.building_image: "Building Image", cls.building_image_failed: "Build Failed", cls.deploying: "Deploying Image", @@ -186,6 +188,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: DeploymentStatus.deploying_failed, DeploymentStatus.building_image_failed, DeploymentStatus.extracting_failed, + DeploymentStatus.extracting_failed_archive_too_large, } TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 8039070..72be234 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1672,6 +1672,142 @@ def test_cancel_upload_swallows_exceptions( assert "HTTPStatusError" not in result.output +def _mock_deploy_until_upload( + respx_mock: respx.MockRouter, + tmp_path: Path, + app_data: RandomApp, + deployment_data: dict[str, str], + upload_response: Response, +) -> None: + app_id = app_data["id"] + + config_path = tmp_path / ".fastapicloud" / "cloud.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') + + respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) + respx_mock.post(f"/apps/{app_id}/deployments/").mock( + return_value=Response(201, json=deployment_data) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( + return_value=Response( + 200, + json={"url": "http://test.com", "fields": {"key": "value"}}, + ) + ) + respx_mock.post("http://test.com", data={"key": "value"}).mock( + return_value=upload_response + ) + + +def _mock_deploy_until_deployment_creation( + respx_mock: respx.MockRouter, + tmp_path: Path, + app_data: RandomApp, + creation_response: Response, +) -> respx.Route: + app_id = app_data["id"] + + config_path = tmp_path / ".fastapicloud" / "cloud.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') + + respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) + + return respx_mock.post(f"/apps/{app_id}/deployments/").mock( + return_value=creation_response + ) + + +@pytest.mark.respx +def test_deploy_shows_error_when_creation_rejects_archive_size( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + + # no upload routes are mocked, any upload attempt would fail the test + create_deployment_route = _mock_deploy_until_deployment_creation( + respx_mock, + tmp_path, + app_data, + Response( + 413, + json={ + "detail": "App source code exceeds the maximum allowed size of 1000.0 MB" + }, + ), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + output = " ".join(result.output.split()) + + assert result.exit_code == 1 + assert "App source code exceeds the maximum allowed size of 1000.0 MB" in output + assert ".fastapicloudignore" in output + assert "Something went wrong" not in output + + request_body = json.loads(create_deployment_route.calls.last.request.content) + assert request_body["archive_size_bytes"] > 0 + + +@pytest.mark.respx +def test_deploy_json_shows_error_when_creation_rejects_archive_size( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + + _mock_deploy_until_deployment_creation( + respx_mock, + tmp_path, + app_data, + Response( + 413, + json={ + "detail": "App source code exceeds the maximum allowed size of 1000.0 MB" + }, + ), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + + error = json.loads(result.stdout)["error"] + assert error["code"] == "invalid_input" + assert ( + error["message"] + == "App source code exceeds the maximum allowed size of 1000.0 MB" + ) + assert error["hint"] == ( + "You can exclude files from the deployment with a .fastapicloudignore file." + ) + + +@pytest.mark.respx +def test_deploy_shows_error_when_upload_fails( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + _mock_deploy_until_upload( + respx_mock, + tmp_path, + app_data, + deployment_data, + Response(400, text="not an xml body"), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 1 + assert "Something went wrong" in result.output + + @pytest.mark.respx def test_deploy_successfully_with_token( logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter