Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/fastapi_cloud_cli/commands/deploy/cloud.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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())
Expand Down
20 changes: 19 additions & 1 deletion src/fastapi_cloud_cli/commands/deploy/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -335,6 +352,7 @@ def deploy(
fastapi_client=client,
deployment_id=deployment.id,
archive_path=archive_path,
archive_size=archive_size,
progress=progress,
)

Expand Down
5 changes: 4 additions & 1 deletion src/fastapi_cloud_cli/commands/deploy/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})...")
Expand Down Expand Up @@ -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")

Expand Down
3 changes: 3 additions & 0 deletions src/fastapi_cloud_cli/utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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

Expand Down
136 changes: 136 additions & 0 deletions tests/test_cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading