Skip to content

Commit

Permalink
When running snow app run against an application whose package was …
Browse files Browse the repository at this point in the history
…dropped, offer to automatically tear it down.
  • Loading branch information
sfc-gh-fcampbell committed Jun 19, 2024
1 parent b575405 commit e823b38
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 36 deletions.
2 changes: 2 additions & 0 deletions src/snowflake/cli/plugins/nativeapp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
14 changes: 14 additions & 0 deletions src/snowflake/cli/plugins/nativeapp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,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)
Expand Down
62 changes: 52 additions & 10 deletions src/snowflake/cli/plugins/nativeapp/run_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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,
Expand All @@ -48,19 +50,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.
"""
Expand Down Expand Up @@ -107,7 +115,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.")
Expand Down Expand Up @@ -186,11 +198,34 @@ 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 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."
)
what_to_drop = "the existing application and its owned objects"
else:
what_to_drop = "the existing application object"

user_prompt = (
f"Do you want the Snowflake CLI to drop {what_to_drop} and recreate it?"
)
if not policy.should_proceed(user_prompt):
if is_interactive:
cc.message("Not upgrading the application object.")
Expand All @@ -201,9 +236,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,
Expand Down Expand Up @@ -329,7 +371,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)
15 changes: 0 additions & 15 deletions src/snowflake/cli/plugins/nativeapp/teardown_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
CouldNotDropApplicationPackageWithVersions,
)
from snowflake.cli.plugins.nativeapp.manager import (
ApplicationOwnedObject,
NativeAppCommandProcessor,
NativeAppManager,
ensure_correct_owner,
Expand Down Expand Up @@ -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
):
Expand Down
23 changes: 12 additions & 11 deletions tests/nativeapp/test_run_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
from textwrap import dedent
from unittest import mock
from unittest.mock import MagicMock

import pytest
import typer
Expand Down Expand Up @@ -119,7 +120,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
Expand Down Expand Up @@ -170,7 +171,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


Expand Down Expand Up @@ -244,7 +245,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

Expand Down Expand Up @@ -299,7 +300,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
Expand Down Expand Up @@ -360,7 +361,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

Expand Down Expand Up @@ -403,7 +404,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

Expand Down Expand Up @@ -452,7 +453,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


Expand Down Expand Up @@ -500,7 +501,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


Expand Down Expand Up @@ -551,7 +552,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
Expand Down Expand Up @@ -634,7 +635,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


Expand Down Expand Up @@ -688,7 +689,7 @@ 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


Expand Down

0 comments on commit e823b38

Please sign in to comment.