From 82d08aca8ae3f24934add1973ec01e74d83c1012 Mon Sep 17 00:00:00 2001 From: Francois Campbell Date: Wed, 26 Jun 2024 10:03:39 -0400 Subject: [PATCH] When running `snow app run` against an application whose package was dropped, offer to automatically tear it down (#1245) When an app package has been dropped but the app still exists, the app can't be interacted with. In this case, running snow app run fails to upgrade the app to the latest staged files. Since the app is effectively unusable at this point, the only option is to drop it. If the app owns any external resources (e.g. compute pools), the drop must use cascade. Unfortunately, since the app is unusable, show objects owned by application throws an error, so we have to handle that case and ask the user if they still want to drop the app without knowing what the objects are. If they say yes, we retry the drop with cascade. --- RELEASE-NOTES.md | 1 + .../cli/plugins/nativeapp/constants.py | 2 + .../cli/plugins/nativeapp/manager.py | 14 + .../cli/plugins/nativeapp/run_processor.py | 59 +++- .../plugins/nativeapp/teardown_processor.py | 15 - tests/nativeapp/test_run_processor.py | 297 +++++++++++++++++- tests_integration/nativeapp/test_init_run.py | 121 +++++++ 7 files changed, 473 insertions(+), 36 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 6bd1d632e9..3f498f478f 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -29,6 +29,7 @@ * Passing a directory to `snow app deploy` will now deploy any contained file or subfolder specified in the application's artifact rules * Fixes markup escaping errors in `snow sql` that may occur when users use unintentionally markup-like escape tags. * Fixed case where `snow app teardown` could leave behind orphan applications if they were not created by the Snowflake CLI +* Fixed case where `snow app run` could fail to run an existing application whose package was dropped by prompting to drop and recreate the application * Improve terminal output sanitization to avoid ASCII escape codes. * The `snow sql` command will show query text before executing it. * Improved stage diff output in `snow app` commands diff --git a/src/snowflake/cli/plugins/nativeapp/constants.py b/src/snowflake/cli/plugins/nativeapp/constants.py index 9522f56bf7..5b3790cd22 100644 --- a/src/snowflake/cli/plugins/nativeapp/constants.py +++ b/src/snowflake/cli/plugins/nativeapp/constants.py @@ -29,3 +29,5 @@ ERROR_MESSAGE_2003 = "does not exist or not authorized" ERROR_MESSAGE_2043 = "Object does not exist, or operation cannot be performed." ERROR_MESSAGE_606 = "No active warehouse selected in the current session." +ERROR_MESSAGE_093079 = "Application is no longer available for use" +ERROR_MESSAGE_093128 = "The application owns one or more objects within the account" diff --git a/src/snowflake/cli/plugins/nativeapp/manager.py b/src/snowflake/cli/plugins/nativeapp/manager.py index fde4ce8ba0..5c07889286 100644 --- a/src/snowflake/cli/plugins/nativeapp/manager.py +++ b/src/snowflake/cli/plugins/nativeapp/manager.py @@ -507,6 +507,20 @@ def get_objects_owned_by_application(self) -> List[ApplicationOwnedObject]: ).fetchall() return [{"name": row[1], "type": row[2]} for row in results] + def _application_objects_to_str( + self, application_objects: list[ApplicationOwnedObject] + ) -> str: + """ + Returns a list in an "(Object Type) Object Name" format. Database-level and schema-level object names are fully qualified: + (COMPUTE_POOL) POOL_NAME + (DATABASE) DB_NAME + (SCHEMA) DB_NAME.PUBLIC + ... + """ + return "\n".join( + [f"({obj['type']}) {obj['name']}" for obj in application_objects] + ) + def get_snowsight_url(self) -> str: """Returns the URL that can be used to visit this app via Snowsight.""" name = identifier_for_url(self.app_name) diff --git a/src/snowflake/cli/plugins/nativeapp/run_processor.py b/src/snowflake/cli/plugins/nativeapp/run_processor.py index 0910dc40c2..954fefdbbf 100644 --- a/src/snowflake/cli/plugins/nativeapp/run_processor.py +++ b/src/snowflake/cli/plugins/nativeapp/run_processor.py @@ -33,6 +33,8 @@ from snowflake.cli.plugins.nativeapp.constants import ( ALLOWED_SPECIAL_COMMENTS, COMMENT_COL, + ERROR_MESSAGE_093079, + ERROR_MESSAGE_093128, LOOSE_FILES_MAGIC_VERSION, PATCH_COL, SPECIAL_COMMENT, @@ -49,19 +51,25 @@ generic_sql_error_handler, ) from snowflake.cli.plugins.nativeapp.policy import PolicyBase -from snowflake.cli.plugins.stage.diff import DiffResult from snowflake.cli.plugins.stage.manager import StageManager from snowflake.connector import ProgrammingError from snowflake.connector.cursor import DictCursor, SnowflakeCursor -UPGRADE_RESTRICTION_CODES = {93044, 93055, 93045, 93046} +# Reasons why an `alter application ... upgrade` might fail +UPGRADE_RESTRICTION_CODES = { + 93044, # Cannot upgrade dev mode application from loose stage files to version + 93045, # Cannot upgrade dev mode application from version to loose stage files + 93046, # Operation only permitted on dev mode application + 93055, # Operation not supported on dev mode application + 93079, # App package access lost +} class NativeAppRunProcessor(NativeAppManager, NativeAppCommandProcessor): def __init__(self, project_definition: NativeApp, project_root: Path): super().__init__(project_definition, project_root) - def _create_dev_app(self, diff: DiffResult) -> None: + def _create_dev_app(self, policy: PolicyBase, is_interactive: bool = False) -> None: """ (Re-)creates the application object with our up-to-date stage. """ @@ -109,7 +117,11 @@ def _create_dev_app(self, diff: DiffResult) -> None: return except ProgrammingError as err: - generic_sql_error_handler(err) + if err.errno not in UPGRADE_RESTRICTION_CODES: + generic_sql_error_handler(err) + else: + cc.warning(err.msg) + self.drop_application_before_upgrade(policy, is_interactive) # 4. If no existing application object is found, create an application object using "files on a named stage" / stage dev mode. cc.step(f"Creating new application {self.app_name} in account.") @@ -220,11 +232,31 @@ def get_existing_version_info(self, version: str) -> Optional[dict]: generic_sql_error_handler(err=err, role=self.package_role) return None - def drop_application_before_upgrade(self, policy: PolicyBase, is_interactive: bool): + def drop_application_before_upgrade( + self, policy: PolicyBase, is_interactive: bool, cascade: bool = False + ): """ This method will attempt to drop an application object if a previous upgrade fails. """ - user_prompt = "Do you want the Snowflake CLI to drop the existing application object and recreate it?" + if cascade: + try: + if application_objects := self.get_objects_owned_by_application(): + application_objects_str = self._application_objects_to_str( + application_objects + ) + cc.message( + f"The following objects are owned by application {self.app_name} and need to be dropped:\n{application_objects_str}" + ) + except ProgrammingError as err: + if err.errno != 93079 and ERROR_MESSAGE_093079 not in err.msg: + generic_sql_error_handler(err) + cc.warning( + "The application owns other objects but they could not be determined." + ) + user_prompt = "Do you want the Snowflake CLI to drop these objects, then drop the existing application object and recreate it?" + else: + user_prompt = "Do you want the Snowflake CLI to drop the existing application object and recreate it?" + if not policy.should_proceed(user_prompt): if is_interactive: cc.message("Not upgrading the application object.") @@ -235,9 +267,16 @@ def drop_application_before_upgrade(self, policy: PolicyBase, is_interactive: bo ) raise typer.Exit(1) try: - self._execute_query(f"drop application {self.app_name}") + cascade_sql = " cascade" if cascade else "" + self._execute_query(f"drop application {self.app_name}{cascade_sql}") except ProgrammingError as err: - generic_sql_error_handler(err) + if (err.errno == 93128 or ERROR_MESSAGE_093128 in err.msg) and not cascade: + # We need to cascade the deletion, let's try again (only if we didn't try with cascade already) + return self.drop_application_before_upgrade( + policy, is_interactive, cascade=True + ) + else: + generic_sql_error_handler(err) def upgrade_app( self, @@ -363,7 +402,7 @@ def process( ) return - diff = self.deploy( + self.deploy( bundle_map=bundle_map, prune=True, recursive=True, validate=validate ) - self._create_dev_app(diff) + self._create_dev_app(policy=policy, is_interactive=is_interactive) diff --git a/src/snowflake/cli/plugins/nativeapp/teardown_processor.py b/src/snowflake/cli/plugins/nativeapp/teardown_processor.py index 99a108f807..86ea9405b9 100644 --- a/src/snowflake/cli/plugins/nativeapp/teardown_processor.py +++ b/src/snowflake/cli/plugins/nativeapp/teardown_processor.py @@ -32,7 +32,6 @@ CouldNotDropApplicationPackageWithVersions, ) from snowflake.cli.plugins.nativeapp.manager import ( - ApplicationOwnedObject, NativeAppCommandProcessor, NativeAppManager, ensure_correct_owner, @@ -65,20 +64,6 @@ def drop_generic_object( cc.message(f"Dropped {object_type} {object_name} successfully.") - def _application_objects_to_str( - self, application_objects: list[ApplicationOwnedObject] - ) -> str: - """ - Returns a list in an "(Object Type) Object Name" format. Database-level and schema-level object names are fully qualified: - (COMPUTE_POOL) POOL_NAME - (DATABASE) DB_NAME - (SCHEMA) DB_NAME.PUBLIC - ... - """ - return "\n".join( - [f"({obj['type']}) {obj['name']}" for obj in application_objects] - ) - def drop_application( self, auto_yes: bool, interactive: bool = False, cascade: Optional[bool] = None ): diff --git a/tests/nativeapp/test_run_processor.py b/tests/nativeapp/test_run_processor.py index e020884397..7c1eb62b5c 100644 --- a/tests/nativeapp/test_run_processor.py +++ b/tests/nativeapp/test_run_processor.py @@ -15,12 +15,15 @@ import os from textwrap import dedent from unittest import mock +from unittest.mock import MagicMock import pytest import typer from click import UsageError from snowflake.cli.api.project.definition_manager import DefinitionManager from snowflake.cli.plugins.nativeapp.constants import ( + ERROR_MESSAGE_093079, + ERROR_MESSAGE_093128, LOOSE_FILES_MAGIC_VERSION, SPECIAL_COMMENT, ) @@ -119,7 +122,7 @@ def test_create_dev_app_w_warehouse_access_exception( assert not mock_diff_result.has_changes() with pytest.raises(ProgrammingError) as err: - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected assert "Please grant usage privilege on warehouse to this role." in err.value.msg @@ -170,7 +173,7 @@ def test_create_dev_app_create_new_w_no_additional_privileges( run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -244,7 +247,7 @@ def test_create_dev_app_create_new_with_additional_privileges( run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute_query.mock_calls == mock_execute_query_expected assert mock_execute_queries.mock_calls == mock_execute_queries_expected @@ -299,7 +302,7 @@ def test_create_dev_app_create_new_w_missing_warehouse_exception( assert not mock_diff_result.has_changes() with pytest.raises(ProgrammingError) as err: - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert "Please provide a warehouse for the active session role" in err.value.msg assert mock_execute.mock_calls == expected @@ -360,7 +363,7 @@ def test_create_dev_app_incorrect_properties( with pytest.raises(ApplicationAlreadyExistsError): run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -403,7 +406,7 @@ def test_create_dev_app_incorrect_owner( with pytest.raises(UnexpectedOwnerError): run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -452,7 +455,7 @@ def test_create_dev_app_no_diff_changes( run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -500,7 +503,7 @@ def test_create_dev_app_w_diff_changes( run_processor = _get_na_run_processor() assert mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -551,7 +554,7 @@ def test_create_dev_app_recreate_w_missing_warehouse_exception( assert mock_diff_result.has_changes() with pytest.raises(ProgrammingError) as err: - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected assert "Please provide a warehouse for the active session role" in err.value.msg @@ -634,7 +637,7 @@ def test_create_dev_app_create_new_quoted( run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 assert mock_execute.mock_calls == expected @@ -688,7 +691,279 @@ def test_create_dev_app_create_new_quoted_override( run_processor = _get_na_run_processor() assert not mock_diff_result.has_changes() - run_processor._create_dev_app(mock_diff_result) # noqa: SLF001 + run_processor._create_dev_app(policy=MagicMock()) # noqa: SLF001 + assert mock_execute.mock_calls == expected + + +# Test run existing app info +# AND app package has been dropped +# AND user wants to drop app +# AND drop succeeds +# AND app is created successfully. +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +@mock.patch(RUN_PROCESSOR_GET_EXISTING_APP_INFO) +@mock_connection() +def test_create_dev_app_recreate_app_when_orphaned( + mock_conn, + mock_get_existing_app_info, + mock_execute, + temp_dir, + mock_cursor, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + "version": LOOSE_FILES_MAGIC_VERSION, + } + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([{"CURRENT_ROLE()": "old_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role app_role")), + (None, mock.call("use warehouse app_warehouse")), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093079, + errno=93079, + ), + mock.call( + "alter application myapp upgrade using @app_pkg.app_src.stage" + ), + ), + (None, mock.call("drop application myapp")), + ( + mock_cursor([{"CURRENT_ROLE()": "app_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role package_role")), + (None, mock.call("use role app_role")), + ( + None, + mock.call( + dedent( + f"""\ + create application myapp + from application package app_pkg + using @app_pkg.app_src.stage + debug_mode = True + comment = {SPECIAL_COMMENT} + """ + ) + ), + ), + (None, mock.call("use role old_role")), + ] + ) + mock_conn.return_value = MockConnectionCtx() + mock_execute.side_effect = side_effects + + current_working_directory = os.getcwd() + create_named_file( + file_name="snowflake.yml", + dir_name=current_working_directory, + contents=[mock_snowflake_yml_file], + ) + + run_processor = _get_na_run_processor() + run_processor._create_dev_app(allow_always_policy) # noqa: SLF001 + assert mock_execute.mock_calls == expected + + +# Test run existing app info +# AND app package has been dropped +# AND user wants to drop app +# AND drop requires cascade +# AND drop succeeds +# AND app is created successfully. +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +@mock.patch(RUN_PROCESSOR_GET_EXISTING_APP_INFO) +@mock_connection() +def test_create_dev_app_recreate_app_when_orphaned_requires_cascade( + mock_conn, + mock_get_existing_app_info, + mock_execute, + temp_dir, + mock_cursor, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + "version": LOOSE_FILES_MAGIC_VERSION, + } + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([{"CURRENT_ROLE()": "old_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role app_role")), + (None, mock.call("use warehouse app_warehouse")), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093079, + errno=93079, + ), + mock.call( + "alter application myapp upgrade using @app_pkg.app_src.stage" + ), + ), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093128, + errno=93128, + ), + mock.call("drop application myapp"), + ), + ( + mock_cursor([{"CURRENT_ROLE()": "app_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + ( + mock_cursor( + [ + [None, "mypool", "COMPUTE_POOL"], + ], + [], + ), + mock.call("show objects owned by application myapp"), + ), + (None, mock.call("drop application myapp cascade")), + ( + mock_cursor([{"CURRENT_ROLE()": "app_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role package_role")), + (None, mock.call("use role app_role")), + ( + None, + mock.call( + dedent( + f"""\ + create application myapp + from application package app_pkg + using @app_pkg.app_src.stage + debug_mode = True + comment = {SPECIAL_COMMENT} + """ + ) + ), + ), + (None, mock.call("use role old_role")), + ] + ) + mock_conn.return_value = MockConnectionCtx() + mock_execute.side_effect = side_effects + + current_working_directory = os.getcwd() + create_named_file( + file_name="snowflake.yml", + dir_name=current_working_directory, + contents=[mock_snowflake_yml_file], + ) + + run_processor = _get_na_run_processor() + run_processor._create_dev_app(allow_always_policy) # noqa: SLF001 + assert mock_execute.mock_calls == expected + + +# Test run existing app info +# AND app package has been dropped +# AND user wants to drop app +# AND drop requires cascade +# AND we can't see which objects are owned by the app +# AND drop succeeds +# AND app is created successfully. +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +@mock.patch(RUN_PROCESSOR_GET_EXISTING_APP_INFO) +@mock_connection() +def test_create_dev_app_recreate_app_when_orphaned_requires_cascade_unknown_objects( + mock_conn, + mock_get_existing_app_info, + mock_execute, + temp_dir, + mock_cursor, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + "version": LOOSE_FILES_MAGIC_VERSION, + } + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([{"CURRENT_ROLE()": "old_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role app_role")), + (None, mock.call("use warehouse app_warehouse")), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093079, + errno=93079, + ), + mock.call( + "alter application myapp upgrade using @app_pkg.app_src.stage" + ), + ), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093128, + errno=93128, + ), + mock.call("drop application myapp"), + ), + ( + mock_cursor([{"CURRENT_ROLE()": "app_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + ( + ProgrammingError( + msg=ERROR_MESSAGE_093079, + errno=93079, + ), + mock.call("show objects owned by application myapp"), + ), + (None, mock.call("drop application myapp cascade")), + ( + mock_cursor([{"CURRENT_ROLE()": "app_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role package_role")), + (None, mock.call("use role app_role")), + ( + None, + mock.call( + dedent( + f"""\ + create application myapp + from application package app_pkg + using @app_pkg.app_src.stage + debug_mode = True + comment = {SPECIAL_COMMENT} + """ + ) + ), + ), + (None, mock.call("use role old_role")), + ] + ) + mock_conn.return_value = MockConnectionCtx() + mock_execute.side_effect = side_effects + + current_working_directory = os.getcwd() + create_named_file( + file_name="snowflake.yml", + dir_name=current_working_directory, + contents=[mock_snowflake_yml_file], + ) + + run_processor = _get_na_run_processor() + run_processor._create_dev_app(allow_always_policy) # noqa: SLF001 assert mock_execute.mock_calls == expected diff --git a/tests_integration/nativeapp/test_init_run.py b/tests_integration/nativeapp/test_init_run.py index 081ba0e9d7..b2e35bc24f 100644 --- a/tests_integration/nativeapp/test_init_run.py +++ b/tests_integration/nativeapp/test_init_run.py @@ -470,3 +470,124 @@ def test_nativeapp_app_post_deploy(runner, snowflake_session, project_directory) env=TEST_ENV, ) assert result.exit_code == 0 + + +# Tests running an app whose package was dropped externally (requires dropping and recreating the app) +@pytest.mark.integration +@pytest.mark.parametrize("project_definition_files", ["integration"], indirect=True) +@pytest.mark.parametrize("force_flag", [True, False]) +def test_nativeapp_run_orphan( + runner, + snowflake_session, + project_definition_files: List[Path], + force_flag, +): + project_name = "integration" + project_dir = project_definition_files[0].parent + with pushd(project_dir): + result = runner.invoke_with_connection_json( + ["app", "run"], + env=TEST_ENV, + ) + assert result.exit_code == 0 + + try: + # app + package exist + package_name = f"{project_name}_pkg_{USER_NAME}".upper() + app_name = f"{project_name}_{USER_NAME}".upper() + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show applications like '{app_name}'" + ) + ), + dict(name=app_name, source=package_name), + ) + + result = runner.invoke_with_connection( + ["sql", "-q", f"drop application package {package_name}"], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + + # package doesn't exist, app not readable + package_name = f"{project_name}_pkg_{USER_NAME}".upper() + app_name = f"{project_name}_{USER_NAME}".upper() + assert not_contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + assert not_contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show applications like '{app_name}'" + ) + ), + dict(name=app_name, source=package_name), + ) + + if force_flag: + command = ["app", "run", "--force"] + _input = None + else: + command = ["app", "run", "--interactive"] # show prompt in tests + _input = "y\n" # yes to drop app + result = runner.invoke_with_connection(command, input=_input, env=TEST_ENV) + assert result.exit_code == 0, result.output + if not force_flag: + assert ( + "Do you want the Snowflake CLI to drop the existing application object and recreate it?" + in result.output + ), result.output + + # app + package exist + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show applications like '{app_name}'" + ) + ), + dict(name=app_name, source=package_name), + ) + + # make sure we always delete the app + result = runner.invoke_with_connection_json( + ["app", "teardown"], + env=TEST_ENV, + ) + assert result.exit_code == 0 + + finally: + # manually drop the application in case the test failed and it wasn't dropped + result = runner.invoke_with_connection( + ["sql", "-q", f"drop application if exists {app_name} cascade"], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + + # teardown is idempotent, so we can execute it again with no ill effects + result = runner.invoke_with_connection_json( + ["app", "teardown", "--force"], + env=TEST_ENV, + ) + assert result.exit_code == 0