diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 89347e2..a3c8a88 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -557,12 +557,22 @@ def deploy( skip_wait: Annotated[ bool, typer.Option("--no-wait", help="Skip waiting for deployment status") ] = False, + provided_app_id: Annotated[ + Union[str, None], + typer.Option( + "--app-id", + help="Application ID to deploy to", + envvar="FASTAPI_CLOUD_APP_ID", + ), + ] = None, ) -> Any: """ Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀 """ logger.debug("Deploy command started") - logger.debug("Deploy path: %s, skip_wait: %s", path, skip_wait) + logger.debug( + "Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id + ) identity = Identity() @@ -604,19 +614,43 @@ def deploy( app_config = get_app_config(path_to_deploy) - if not app_config: + if app_config and provided_app_id and app_config.app_id != provided_app_id: + toolkit.print( + f"[error]Error: Provided app ID ({provided_app_id}) does not match the local " + f"config ({app_config.app_id}).[/]" + ) + toolkit.print_line() + toolkit.print( + "Run [bold]fastapi cloud unlink[/] to remove the local config, " + "or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app.", + tag="tip", + ) + + raise typer.Exit(1) from None + + if provided_app_id: + target_app_id = provided_app_id + elif app_config: + target_app_id = app_config.app_id + else: logger.debug("No app config found, configuring new app") + app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy) toolkit.print_line() + + target_app_id = app_config.app_id + + if provided_app_id: + toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...") else: - logger.debug("Existing app config found, proceeding with deployment") toolkit.print("Deploying app...") - toolkit.print_line() + + toolkit.print_line() with toolkit.progress("Checking app...", transient=True) as progress: with handle_http_errors(progress): - logger.debug("Checking app with ID: %s", app_config.app_id) - app = _get_app(app_config.app_id) + logger.debug("Checking app with ID: %s", target_app_id) + app = _get_app(target_app_id) if not app: logger.debug("App not found in API") @@ -626,10 +660,12 @@ def deploy( if not app: toolkit.print_line() - toolkit.print( - "If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.", - tag="tip", - ) + + if not provided_app_id: + toolkit.print( + "If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.", + tag="tip", + ) raise typer.Exit(1) with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 8f8382b..72ea419 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1216,3 +1216,188 @@ def test_deploy_with_token_fails( "The specified token is not valid. Make sure to use a valid token." in result.output ) + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_deploy_with_app_id_arg( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + app_id = app_data["id"] + deployment_data = _get_random_deployment(app_id=app_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=Response(200) + ) + + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response(200) + ) + + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( + return_value=Response( + 200, + content=build_logs_response( + {"type": "message", "message": "Building...", "id": "1"}, + {"type": "complete"}, + ), + ) + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--app-id", app_id]) + + assert result.exit_code == 0 + assert f"Deploying to app {app_id}" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_deploy_with_app_id_from_env_var( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + app_id = app_data["id"] + deployment_data = _get_random_deployment(app_id=app_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=Response(200) + ) + + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response(200) + ) + + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( + return_value=Response( + 200, + content=build_logs_response( + {"type": "message", "message": "Building...", "id": "1"}, + {"type": "complete"}, + ), + ) + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id}) + + assert result.exit_code == 0 + assert f"Deploying to app {app_id}" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_deploy_with_app_id_matching_local_config( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + app_id = app_data["id"] + team_id = "some-team-id" + deployment_data = _get_random_deployment(app_id=app_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": "{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=Response(200) + ) + + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response(200) + ) + + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( + return_value=Response( + 200, + content=build_logs_response( + {"type": "message", "message": "Building...", "id": "1"}, + {"type": "complete"}, + ), + ) + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--app-id", app_id]) + + assert result.exit_code == 0 + # Should NOT show mismatch warning + assert "does not match" not in result.output + assert f"Deploying to app {app_id}" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_deploy_with_app_id_mismatch_fails( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + local_app_data = _get_random_app() + local_app_id = local_app_data["id"] + team_id = "some-team-id" + + config_path = tmp_path / ".fastapicloud" / "cloud.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f'{{"app_id": "{local_app_id}", "team_id": "{team_id}"}}') + + cli_app_id = "different-app-id" + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--app-id", cli_app_id]) + + assert result.exit_code == 1 + assert "does not match" in result.output + assert "fastapi cloud unlink" in result.output + assert "FASTAPI_CLOUD_APP_ID" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_deploy_with_app_id_arg_app_not_found( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_id = "nonexistent-app-id" + + respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--app-id", app_id]) + + assert result.exit_code == 1 + assert "App not found" in result.output + # Should NOT show unlink tip when using --app-id + assert "unlink" not in result.output