From 62f3c76854ca68ba6be225dab3c06f2634cd645d Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 8 Oct 2026 09:39:12 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A5=85=20Stop=20deploys=20that=20can'?= =?UTF-8?q?t=20work=20and=20report=20unconfirmed=20ones=20honestly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop before uploading when the app's directory doesn't exist in the deployed folder. Apps connected to GitHub get a hint to deploy from the repository root. --force skips the check. - Stop when there's nothing to upload. .fastapicloud, the local app link, is no longer uploaded, so a folder holding only it counts as empty. - Ask before deploying the home directory; refuse in CI and --json. - In CI, fail with a hint when no app ID is configured instead of prompting. - Treat degraded, degraded_oom and expired as failed deployments, and exit 3 instead of 0 when verification can't be confirmed. - Require rich-toolkit 0.20.6, where confirm(default=False) starts on "No". Prompts that default to "No" (home directory, env delete, domain remove, resource disconnect, workflow overwrite) now decline on Enter. Part of fastapilabs/cloud#976 Shortcake-Parent: main --- src/fastapi_cloud_cli/api/_models.py | 3 + .../commands/deploy/archive.py | 6 +- .../commands/deploy/cloud.py | 1 + .../commands/deploy/command.py | 80 +++++++- src/fastapi_cloud_cli/commands/deploy/wait.py | 22 +- tests/test_cli_deploy.py | 189 +++++++++++++++++- 6 files changed, 285 insertions(+), 16 deletions(-) diff --git a/src/fastapi_cloud_cli/api/_models.py b/src/fastapi_cloud_cli/api/_models.py index 28899868..596525b8 100644 --- a/src/fastapi_cloud_cli/api/_models.py +++ b/src/fastapi_cloud_cli/api/_models.py @@ -185,5 +185,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 diff --git a/src/fastapi_cloud_cli/commands/deploy/archive.py b/src/fastapi_cloud_cli/commands/deploy/archive.py index 795dcf01..0933d296 100644 --- a/src/fastapi_cloud_cli/commands/deploy/archive.py +++ b/src/fastapi_cloud_cli/commands/deploy/archive.py @@ -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): @@ -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) @@ -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]]: diff --git a/src/fastapi_cloud_cli/commands/deploy/cloud.py b/src/fastapi_cloud_cli/commands/deploy/cloud.py index 2f28132b..fabc0ac5 100644 --- a/src/fastapi_cloud_cli/commands/deploy/cloud.py +++ b/src/fastapi_cloud_cli/commands/deploy/cloud.py @@ -21,6 +21,7 @@ class AppResponse(BaseModel): id: str slug: str directory: str | None + github_repository: str | None = None class CreateDeploymentResponse(BaseModel): diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index b5fecd0d..52a75d36 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -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[ @@ -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. @@ -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: @@ -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.", @@ -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 ) @@ -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", diff --git a/src/fastapi_cloud_cli/commands/deploy/wait.py b/src/fastapi_cloud_cli/commands/deploy/wait.py index 977f1034..2e304f7f 100644 --- a/src/fastapi_cloud_cli/commands/deploy/wait.py +++ b/src/fastapi_cloud_cli/commands/deploy/wait.py @@ -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..."), @@ -55,6 +59,7 @@ def _verify_deployment( deployment: CreateDeploymentResponse, ) -> None: failed_status: str | None = None + unconfirmed = False with toolkit.progress( title="Verifying deployment...", @@ -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() diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 18aad5fe..2562ab50 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -41,6 +41,14 @@ assets_path = Path(__file__).parent / "assets" +@pytest.fixture(autouse=True) +def app_source(tmp_path: Path) -> Path: + # Deploy refuses to upload an empty folder; give every test an app file. + main_path = tmp_path / "main.py" + main_path.write_text("from fastapi import FastAPI\n\napp = FastAPI()\n") + return main_path + + def _get_random_team(name: str | None = None) -> dict[str, str]: name = name or "".join(random.choices(string.ascii_lowercase, k=10)) slug = "".join(random.choices(string.ascii_lowercase, k=10)) @@ -821,6 +829,7 @@ def test_updates_app_directory_via_api_when_changed( ) updated_app_data = {**app_data, "directory": "src"} + (tmp_path / "src").mkdir() patch_route = respx_mock.patch( f"/apps/{app_data['id']}", json={"directory": "src"} @@ -888,6 +897,7 @@ def test_does_not_update_app_directory_when_unchanged( Keys.ENTER, # Accept pre-filled directory (unchanged) Keys.ENTER, # Confirm deployment ] + (tmp_path / "backend").mkdir() team = _get_random_team() @@ -2412,7 +2422,7 @@ def test_polling_timeout_shows_dashboard_link( ): result = runner.invoke(app, ["deploy"]) - assert result.exit_code == 0 + assert result.exit_code == 3 assert "Could not confirm deployment status" in result.output assert deployment_data["dashboard_url"] in result.output @@ -2794,3 +2804,180 @@ def wait_for_build() -> None: 👀 Check out the logs at https://dashboard.fastapicloud.com/demo-build\ """) + + +def _mock_configured_app( + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, + **app_fields: str | None, +) -> None: + app_data = {**_get_random_app(), **app_fields, "id": configured_app.app_id} + respx_mock.get(f"/apps/{configured_app.app_id}").mock( + return_value=Response(200, json=app_data) + ) + + +@pytest.mark.respx +def test_deploy_fails_when_app_directory_is_missing( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app(respx_mock, configured_app, directory="backend") + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": ( + f"The app directory `backend` doesn't exist in {configured_app.path}." + ), + "hint": ( + "Deploy from the folder that contains it, update the app directory " + "with `fastapi cloud apps update --directory`, or use --force." + ), + } + } + + +@pytest.mark.respx +def test_deploy_points_linked_apps_to_the_repository_root( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app( + respx_mock, + configured_app, + directory="backend", + github_repository="acme/shop", + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"]["hint"] == ( + "This app is connected to acme/shop and builds from `backend` in that " + f"repository. Run `fastapi deploy --app-id {configured_app.app_id}` from " + "the repository root, or use --force." + ) + + +@pytest.mark.respx +def test_deploy_with_force_skips_the_app_directory_check( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app(respx_mock, configured_app, directory="backend") + deployment_data = _get_random_deployment(app_id=configured_app.app_id) + + respx_mock.post(f"/apps/{configured_app.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=Response(200) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response( + 200, json={**deployment_data, "status": "ready_for_build"} + ) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json", "--force"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["data"]["deployment_id"] == deployment_data["id"] + + +@pytest.mark.respx +def test_deploy_fails_when_there_is_nothing_to_upload( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, + app_source: Path, +) -> None: + # Only the local .fastapicloud link is left, and it isn't uploaded. + app_source.unlink() + _mock_configured_app(respx_mock, configured_app) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == { + "code": "invalid_input", + "message": f"Nothing to deploy: no files to upload in {configured_app.path}.", + "hint": ( + "Check the path, and that .gitignore or .fastapicloudignore don't " + "exclude your app's files." + ), + } + + +def test_deploy_on_ci_without_app_id_fails_instead_of_prompting( + logged_in_cli: None, tmp_path: Path +) -> None: + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"], env={"CI": "true"}) + + assert result.exit_code == 1 + assert "App ID is required." in result.output + + +def test_deploy_on_ci_refuses_the_home_directory( + logged_in_cli: None, tmp_path: Path +) -> None: + with changing_dir(tmp_path), patch("pathlib.Path.home", return_value=tmp_path): + result = runner.invoke(app, ["deploy"], env={"CI": "true"}) + + assert result.exit_code == 1 + assert "Refusing to deploy your home directory." in result.output + + +def test_deploy_asks_before_deploying_the_home_directory( + logged_in_cli: None, tmp_path: Path +) -> None: + with ( + changing_dir(tmp_path), + patch("pathlib.Path.home", return_value=tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = [Keys.ENTER] # Accept the default: "No" + + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + assert "You are deploying your home directory" in result.output + assert "Deployment cancelled." in result.output + + +@pytest.mark.respx +def test_degraded_deployment_fails_verification( + 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"]) + + _setup_deployment_mocks( + respx_mock, app_data["id"], "some-team-id", deployment_data, tmp_path + ) + respx_mock.get(f"/deployments/{deployment_data['id']}").mock( + return_value=Response(200, json={**deployment_data, "status": "degraded"}) + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 1 + assert "Degraded" in result.output From fe465f3d31285238c8ee0b06d100a9cfcd2602b7 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 5 Nov 2026 11:14:38 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Cover=20confirmed=20home-direct?= =?UTF-8?q?ory=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_cli_deploy.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 2562ab50..c8d10f7e 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -2962,6 +2962,40 @@ def test_deploy_asks_before_deploying_the_home_directory( assert "Deployment cancelled." in result.output +@pytest.mark.respx +def test_deploy_continues_when_home_directory_is_confirmed( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + deployment_data = _get_random_deployment(app_id=configured_app.app_id) + _setup_deployment_mocks( + respx_mock, + configured_app.app_id, + configured_app.team_id, + deployment_data, + configured_app.path, + ) + respx_mock.get(f"/deployments/{deployment_data['id']}").respond( + 200, json={**deployment_data, "status": "success"} + ) + + with ( + changing_dir(configured_app.path), + patch("pathlib.Path.home", return_value=configured_app.path), + patch( + "rich_toolkit.container.getchar", + side_effect=[Keys.RIGHT_ARROW, Keys.ENTER], + ), + ): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + assert "You are deploying your home directory" in result.output + assert "Deployment uploaded successfully!" in result.output + assert "Deployment cancelled." not in result.output + + @pytest.mark.respx def test_degraded_deployment_fails_verification( logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter