Skip to content
Draft
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies = [
"uvicorn[standard] >= 0.17.6",
"rignore >= 0.5.1",
"httpx >= 0.27.0",
"rich-toolkit>=0.20.5",
"rich-toolkit>=0.20.6",
"pydantic[email] >= 2.7.4; python_version < '3.13'",
"pydantic[email] >= 2.8.0; python_version == '3.13'",
"pydantic[email] >= 2.12.0; python_version >= '3.14'",
Expand Down
3 changes: 3 additions & 0 deletions src/fastapi_cloud_cli/api/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,5 +172,8 @@ class Deployment(BaseModel):
DeploymentStatus.verification_failed_oom,
DeploymentStatus.deploying_failed,
DeploymentStatus.deploying_skipped,
DeploymentStatus.degraded,
DeploymentStatus.degraded_oom,
DeploymentStatus.expired,
}
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
6 changes: 4 additions & 2 deletions src/fastapi_cloud_cli/commands/deploy/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def _should_exclude_entry(path: Path) -> bool:
".git",
".gitignore",
".fastapicloudignore",
".fastapicloud",
]

if any(part in path.parts for part in parts_to_exclude):
Expand All @@ -80,7 +81,8 @@ def _rignore_walk(path: Path) -> rignore.Walker:
)


def archive(path: Path, tar_path: Path) -> Path:
def archive(path: Path, tar_path: Path) -> int:
"""Write the files to upload into `tar_path` and return how many there were."""
logger.debug("Starting archive creation for path: %s", path)
files = _rignore_walk(path)

Expand All @@ -98,7 +100,7 @@ def archive(path: Path, tar_path: Path) -> Path:
file_count += 1

logger.debug("Archive created successfully with %s files", file_count)
return tar_path
return file_count


def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
Expand Down
1 change: 1 addition & 0 deletions src/fastapi_cloud_cli/commands/deploy/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class AppResponse(BaseModel):
id: str
slug: str
directory: str | None
github_repository: str | None = None


class CreateDeploymentResponse(BaseModel):
Expand Down
80 changes: 74 additions & 6 deletions src/fastapi_cloud_cli/commands/deploy/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,61 @@ def _render_linked_app_not_found(
)


def _confirm_home_directory_deploy(
toolkit: FastAPIRichToolkit, *, json_output: bool
) -> None:
if json_output or is_ci_enabled():
toolkit.fail(
"invalid_input",
"Refusing to deploy your home directory.",
hint="Pass the directory of your app, or use --force.",
)

if not toolkit.confirm(
"You are deploying your home directory. Do you want to continue?",
default=False,
emoji="🚨",
):
toolkit.print_line()
toolkit.print("Deployment cancelled.")
raise typer.Exit(0)

toolkit.print_line()


def _check_app_directory(
toolkit: FastAPIRichToolkit, *, app: AppResponse, path_to_deploy: Path
) -> None:
"""Stop before uploading when the app's directory isn't in the upload."""
if app.directory is None or (path_to_deploy / app.directory).is_dir():
return

if app.github_repository:
hint = (
f"This app is connected to {app.github_repository} and builds from "
f"`{app.directory}` in that repository. Run "
f"`fastapi deploy --app-id {app.id}` from the repository root, or use --force."
)
else:
hint = (
"Deploy from the folder that contains it, update the app directory "
"with `fastapi cloud apps update --directory`, or use --force."
)

toolkit.fail(
"invalid_input",
f"The app directory `{app.directory}` doesn't exist in {path_to_deploy}.",
hint=hint,
)


@app.command()
@cloud_app.command()
def deploy(
path: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
)
help="Path to the directory to deploy (defaults to current directory)"
),
] = None,
skip_wait: Annotated[
Expand All @@ -131,6 +176,13 @@ def deploy(
),
] = 10,
json_output: JsonOutputOption = False,
force: Annotated[
bool,
typer.Option(
"--force",
help="Deploy even if the app directory is missing or the path is your home directory",
),
] = False,
) -> Any:
"""
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud.
Expand Down Expand Up @@ -212,6 +264,9 @@ def deploy(
path_to_deploy = path or Path.cwd()
logger.debug("Deploying from path: %s", path_to_deploy)

if not force and path_to_deploy.resolve() == Path.home().resolve():
_confirm_home_directory_deploy(toolkit, json_output=json_output)

app_config = get_app_config(path_to_deploy)

if app_config and provided_app_id and app_config.app_id != provided_app_id:
Expand All @@ -230,7 +285,7 @@ def deploy(
elif app_config:
target_app_id = app_config.app_id
else:
if json_output:
if json_output or is_ci_enabled():
toolkit.fail(
"missing_required_input",
"App ID is required.",
Expand Down Expand Up @@ -281,6 +336,9 @@ def deploy(

app = cast(AppResponse, app)

if not force:
_check_app_directory(toolkit, app=app, path_to_deploy=path_to_deploy)

large_files = _get_large_files(
path_to_deploy, threshold_mb=large_file_threshold
)
Expand Down Expand Up @@ -316,9 +374,19 @@ def deploy(
with tempfile.TemporaryDirectory() as temp_dir:
logger.debug("Creating archive for deployment")
archive_path = Path(temp_dir) / "archive.tar"
archive(path_to_deploy, archive_path)
file_count = archive(path_to_deploy, archive_path)
archive_size = archive_path.stat().st_size

if file_count == 0:
toolkit.fail(
"invalid_input",
f"Nothing to deploy: no files to upload in {path_to_deploy}.",
hint=(
"Check the path, and that .gitignore or "
".fastapicloudignore don't exclude your app's files."
),
)

with (
toolkit.progress(
title="Creating deployment",
Expand Down
22 changes: 15 additions & 7 deletions src/fastapi_cloud_cli/commands/deploy/wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
from fastapi_cloud_cli.utils.execution import is_ci_enabled

# The build finished but we couldn't confirm the deployment came up. Distinct
# from 1 so scripts can tell "still verifying" from "failed".
UNCONFIRMED_EXIT_CODE = 3

# (bullet emoji, message) — the emoji replaces the progress animation
WAITING_MESSAGES = [
("🚀", "Preparing for liftoff! Almost there..."),
Expand Down Expand Up @@ -55,6 +59,7 @@ def _verify_deployment(
deployment: CreateDeploymentResponse,
) -> None:
failed_status: str | None = None
unconfirmed = False

with toolkit.progress(
title="Verifying deployment...",
Expand All @@ -69,15 +74,18 @@ def _verify_deployment(
f"Could not confirm deployment status. "
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
)
return

if final_status in SUCCESSFUL_STATUSES:
progress.current_message = "Ready the chicken! 🐔"
unconfirmed = True
else:
progress.metadata["done_emoji"] = "❌"
progress.current_message = "Deployment failed"
if final_status in SUCCESSFUL_STATUSES:
progress.current_message = "Ready the chicken! 🐔"
else:
progress.metadata["done_emoji"] = "❌"
progress.current_message = "Deployment failed"

failed_status = DeploymentStatus.to_human_readable(final_status)

failed_status = DeploymentStatus.to_human_readable(final_status)
if unconfirmed:
raise typer.Exit(UNCONFIRMED_EXIT_CODE)

if failed_status is not None:
toolkit.print_line()
Expand Down
12 changes: 7 additions & 5 deletions tests/domains/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,9 @@ def test_domains_remove_warns_and_confirms(
return_value=Response(200, json={"message": "Custom domain deleted"})
)

with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]):
with patch(
"rich_toolkit.container.getchar", side_effect=[Keys.LEFT_ARROW, Keys.ENTER]
):
result = runner.invoke(
app,
["domains", "remove", domain["name"], "--app-id", APP_ID],
Expand All @@ -967,6 +969,9 @@ def test_domains_remove_warns_and_confirms(
⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at
your provider will not be changed.

Remove api.example.com?
○ Yes ● No

Remove api.example.com?
● Yes ○ No

Expand Down Expand Up @@ -1023,7 +1028,7 @@ def test_domains_remove_can_be_cancelled(

with patch(
"rich_toolkit.container.getchar",
side_effect=[Keys.RIGHT_ARROW, Keys.ENTER],
side_effect=[Keys.ENTER], # Default: "No"
):
result = runner.invoke(
app,
Expand All @@ -1037,9 +1042,6 @@ def test_domains_remove_can_be_cancelled(
⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at
your provider will not be changed.

Remove api.example.com?
● Yes ○ No

Remove api.example.com?
○ Yes ● No

Expand Down
17 changes: 11 additions & 6 deletions tests/integrations/resources/test_disconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def test_disconnect_resource_allows_interactive_selection(
changing_dir(tmp_path),
patch(
"rich_toolkit.container.getchar",
side_effect=[Keys.ENTER, Keys.ENTER],
side_effect=[Keys.ENTER, Keys.LEFT_ARROW, Keys.ENTER],
),
):
result = runner.invoke(app, ["integrations", "resources", "disconnect"])
Expand All @@ -238,6 +238,9 @@ def test_disconnect_resource_allows_interactive_selection(
💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be
removed from the app. The Neon resource itself will not be deleted.

Disconnect production?
○ Yes ● No

Disconnect production?
● Yes ○ No

Expand All @@ -264,7 +267,9 @@ def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided(
return_value=Response(204)
)

with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]):
with patch(
"rich_toolkit.container.getchar", side_effect=[Keys.LEFT_ARROW, Keys.ENTER]
):
result = runner.invoke(
app,
[
Expand All @@ -284,6 +289,9 @@ def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided(
💡 Managed environment variables will be removed from the app. The Neon
resource itself will not be deleted.

Disconnect production?
○ Yes ● No

Disconnect production?
● Yes ○ No

Expand All @@ -304,7 +312,7 @@ def test_disconnect_resource_can_be_cancelled(

with patch(
"rich_toolkit.container.getchar",
side_effect=[Keys.RIGHT_ARROW, Keys.ENTER],
side_effect=[Keys.ENTER], # Default: "No"
):
result = runner.invoke(
app,
Expand All @@ -325,9 +333,6 @@ def test_disconnect_resource_can_be_cancelled(
💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be
removed from the app. The Neon resource itself will not be deleted.

Disconnect production?
● Yes ○ No

Disconnect production?
○ Yes ● No

Expand Down
Loading
Loading