From 659fd6cf9d83c519ac18ba3f2909a98eafc8d65c Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Thu, 12 Dec 2024 13:48:36 -0500 Subject: [PATCH] Add support for snow app run from release channel --- RELEASE-NOTES.md | 1 + .../cli/_plugins/nativeapp/commands.py | 7 + .../nativeapp/entities/application.py | 46 ++ .../nativeapp/release_directive/commands.py | 2 +- .../cli/_plugins/nativeapp/sf_sql_facade.py | 68 ++- tests/__snapshots__/test_help_messages.ambr | 12 + tests/nativeapp/test_event_sharing.py | 18 +- tests/nativeapp/test_run_processor.py | 534 ++++++++++++++++++ tests/nativeapp/test_sf_sql_facade.py | 155 ++++- tests_integration/nativeapp/test_init_run.py | 72 +++ 10 files changed, 889 insertions(+), 26 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index d2b0da52b7..964164cfe2 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -25,6 +25,7 @@ * `snow app release-directive unset` * Add support for release channels feature in native app version creation/drop. * `snow app version create` now returns version, patch, and label in JSON format. +* Add ability to specify release channel when creating application instance from release directive: `snow app run --from-release-directive --channel=` ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. diff --git a/src/snowflake/cli/_plugins/nativeapp/commands.py b/src/snowflake/cli/_plugins/nativeapp/commands.py index 411067ac5e..beb62bfee7 100644 --- a/src/snowflake/cli/_plugins/nativeapp/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/commands.py @@ -151,6 +151,12 @@ def app_run( The command fails if no release directive exists for your Snowflake account for a given application package, which is determined from the project definition file. Default: unset.""", is_flag=True, ), + channel: str = typer.Option( + None, + show_default=False, + help=f"""The name of the release channel to use when creating or upgrading an application instance from a release directive. + Requires the `--from-release-directive` flag to be set. If unset, the default channel will be used.""", + ), interactive: bool = InteractiveOption, force: Optional[bool] = ForceOption, validate: bool = ValidateOption, @@ -179,6 +185,7 @@ def app_run( paths=[], interactive=interactive, force=force, + release_channel=channel, ) app = ws.get_entity(app_id) return MessageResult( diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application.py b/src/snowflake/cli/_plugins/nativeapp/entities/application.py index 5057f86c60..bd2096ba1f 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application.py @@ -26,6 +26,7 @@ from snowflake.cli._plugins.nativeapp.constants import ( ALLOWED_SPECIAL_COMMENTS, COMMENT_COL, + DEFAULT_CHANNEL, OWNER_COL, ) from snowflake.cli._plugins.nativeapp.entities.application_package import ( @@ -85,6 +86,8 @@ append_test_resource_suffix, extract_schema, identifier_for_url, + identifier_in_list, + same_identifiers, to_identifier, unquote_identifier, ) @@ -329,6 +332,7 @@ def action_deploy( prune: bool, recursive: bool, paths: List[Path], + release_channel: Optional[str] = None, validate: bool = ValidateOption, stage_fqn: Optional[str] = None, interactive: bool = InteractiveOption, @@ -356,15 +360,25 @@ def action_deploy( # same-account release directive if from_release_directive: + release_channel = _get_verified_release_channel( + package_entity, release_channel + ) + self.create_or_upgrade_app( package=package_entity, stage_fqn=stage_fqn, install_method=SameAccountInstallMethod.release_directive(), + release_channel=release_channel, policy=policy, interactive=interactive, ) return + if release_channel: + raise UsageError( + f"Release channel is only supported when --from-release-directive is used." + ) + # versioned dev if version: try: @@ -603,6 +617,7 @@ def _upgrade_app( event_sharing: EventSharingHandler, policy: PolicyBase, interactive: bool, + release_channel: Optional[str] = None, ) -> list[tuple[str]] | None: self.console.step(f"Upgrading existing application object {self.name}.") @@ -613,6 +628,7 @@ def _upgrade_app( stage_fqn=stage_fqn, debug_mode=self.debug, should_authorize_event_sharing=event_sharing.should_authorize_event_sharing(), + release_channel=release_channel, role=self.role, warehouse=self.warehouse, ) @@ -627,6 +643,7 @@ def _create_app( install_method: SameAccountInstallMethod, event_sharing: EventSharingHandler, package: ApplicationPackageEntity, + release_channel: Optional[str] = None, ) -> list[tuple[str]]: self.console.step(f"Creating new application object {self.name} in account.") @@ -665,6 +682,7 @@ def _create_app( should_authorize_event_sharing=event_sharing.should_authorize_event_sharing(), role=self.role, warehouse=self.warehouse, + release_channel=release_channel, ) @span("update_app_object") @@ -675,6 +693,7 @@ def create_or_upgrade_app( install_method: SameAccountInstallMethod, policy: PolicyBase, interactive: bool, + release_channel: Optional[str] = None, ): event_sharing = EventSharingHandler( telemetry_definition=self.telemetry, @@ -699,6 +718,7 @@ def create_or_upgrade_app( event_sharing=event_sharing, policy=policy, interactive=interactive, + release_channel=release_channel, ) # 3. If no existing application found, or we performed a drop before the upgrade, we proceed to create @@ -708,6 +728,7 @@ def create_or_upgrade_app( install_method=install_method, event_sharing=event_sharing, package=package, + release_channel=release_channel, ) print_messages(self.console, create_or_upgrade_result) @@ -1004,3 +1025,28 @@ def _application_objects_to_str( def _application_object_to_str(obj: ApplicationOwnedObject) -> str: return f"({obj['type']}) {obj['name']}" + + +def _get_verified_release_channel( + package_entity: ApplicationPackageEntity, + release_channel: Optional[str], +) -> Optional[str]: + release_channel = release_channel or DEFAULT_CHANNEL + available_release_channels = get_snowflake_facade().show_release_channels( + package_entity.name, role=package_entity.role + ) + if available_release_channels: + release_channel_names = [c["name"] for c in available_release_channels] + if not identifier_in_list(release_channel, release_channel_names): + raise UsageError( + f"Release channel '{release_channel}' is not available for application package {package_entity.name}. Available release channels: ({', '.join(release_channel_names)})." + ) + else: + if same_identifiers(release_channel, DEFAULT_CHANNEL): + return None + else: + raise UsageError( + f"Release channels are not enabled for application package {package_entity.name}." + ) + + return release_channel diff --git a/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py b/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py index 2573bfdfe9..17cd351f65 100644 --- a/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py @@ -140,7 +140,7 @@ def release_directive_unset( show_default=False, help="Name of the release directive", ), - channel: Optional[str] = typer.Option( + channel: str = typer.Option( DEFAULT_CHANNEL, help="Name of the release channel to use", ), diff --git a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py index f3c164f4a8..9eea08d6e7 100644 --- a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +++ b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py @@ -15,12 +15,14 @@ import logging from contextlib import contextmanager +from functools import cache from textwrap import dedent from typing import Any, Dict, List from snowflake.cli._plugins.connection.util import UIParameter, get_ui_parameter from snowflake.cli._plugins.nativeapp.constants import ( AUTHORIZE_TELEMETRY_COL, + CHANNEL_COL, DEFAULT_DIRECTIVE, NAME_COL, SPECIAL_COMMENT, @@ -637,6 +639,7 @@ def upgrade_application( warehouse: str, debug_mode: bool | None, should_authorize_event_sharing: bool | None, + release_channel: str | None = None, ) -> list[tuple[str]]: """ Upgrades an application object using the provided clauses @@ -648,17 +651,36 @@ def upgrade_application( @param warehouse: Warehouse which is required to create an application object @param debug_mode: Whether to enable debug mode; None means not explicitly enabled or disabled @param should_authorize_event_sharing: Whether to enable event sharing; None means not explicitly enabled or disabled + @param release_channel [Optional]: Release channel to use when upgrading the application """ + + name = to_identifier(name) + release_channel = to_identifier(release_channel) if release_channel else None + install_method.ensure_app_usable( app_name=name, app_role=role, show_app_row=self.get_existing_app_info(name, role), ) + # If all the above checks are in order, proceed to upgrade + @cache # only cache within the scope of this method + def get_app_properties(): + return self.get_app_properties(name, role) + with self._use_role_optional(role), self._use_warehouse_optional(warehouse): try: using_clause = install_method.using_clause(stage_fqn) + if release_channel: + current_release_channel = get_app_properties().get( + CHANNEL_COL, "DEFAULT" + ) + if not same_identifiers(release_channel, current_release_channel): + raise UpgradeApplicationRestrictionError( + f"Cannot upgrade application {name} from release channel {release_channel} because application is already on a different channel." + ) + upgrade_cursor = self._sql_executor.execute_query( f"alter application {name} upgrade {using_clause}", ) @@ -669,6 +691,9 @@ def upgrade_application( self._sql_executor.execute_query( f"alter application {name} set debug_mode = {debug_mode}" ) + + except UpgradeApplicationRestrictionError as err: + raise err except ProgrammingError as err: if err.errno in UPGRADE_RESTRICTION_CODES: raise UpgradeApplicationRestrictionError(err.msg) from err @@ -687,7 +712,7 @@ def upgrade_application( # Only update event sharing if the current value is different as the one we want to set if should_authorize_event_sharing is not None: current_authorize_event_sharing = ( - self.get_app_properties(name, role) + get_app_properties() .get(AUTHORIZE_TELEMETRY_COL, "false") .lower() == "true" @@ -733,6 +758,7 @@ def create_application( warehouse: str, debug_mode: bool | None, should_authorize_event_sharing: bool | None, + release_channel: str | None = None, ) -> list[tuple[str]]: """ Creates a new application object using an application package, @@ -746,7 +772,11 @@ def create_application( @param warehouse: Warehouse which is required to create an application object @param debug_mode: Whether to enable debug mode; None means not explicitly enabled or disabled @param should_authorize_event_sharing: Whether to enable event sharing; None means not explicitly enabled or disabled + @param release_channel [Optional]: Release channel to use when creating the application """ + package_name = to_identifier(package_name) + name = to_identifier(name) + release_channel = to_identifier(release_channel) if release_channel else None # by default, applications are created in debug mode when possible; # this can be overridden in the project definition @@ -761,18 +791,28 @@ def create_application( "Setting AUTHORIZE_TELEMETRY_EVENT_SHARING to %s", should_authorize_event_sharing, ) - authorize_telemetry_clause = f" AUTHORIZE_TELEMETRY_EVENT_SHARING = {str(should_authorize_event_sharing).upper()}" + authorize_telemetry_clause = f"AUTHORIZE_TELEMETRY_EVENT_SHARING = {str(should_authorize_event_sharing).upper()}" using_clause = install_method.using_clause(stage_fqn) + release_channel_clause = ( + f"using release channel {release_channel}" if release_channel else "" + ) + with self._use_role_optional(role), self._use_warehouse_optional(warehouse): try: create_cursor = self._sql_executor.execute_query( dedent( - f"""\ - create application {name} - from application package {package_name} {using_clause} {debug_mode_clause}{authorize_telemetry_clause} - comment = {SPECIAL_COMMENT} - """ + _strip_empty_lines( + f"""\ + create application {name} + from application package {package_name} + {using_clause} + {release_channel_clause} + {debug_mode_clause} + {authorize_telemetry_clause} + comment = {SPECIAL_COMMENT} + """ + ) ), ) except ProgrammingError as err: @@ -823,10 +863,10 @@ def create_application_package( dedent( _strip_empty_lines( f"""\ - create application package {package_name} - comment = {SPECIAL_COMMENT} - distribution = {distribution} - {enable_release_channels_clause} + create application package {package_name} + comment = {SPECIAL_COMMENT} + distribution = {distribution} + {enable_release_channels_clause} """ ) ) @@ -862,9 +902,9 @@ def alter_application_package_properties( self._sql_executor.execute_query( dedent( f"""\ - alter application package {package_name} - set enable_release_channels = {str(enable_release_channels).lower()} - """ + alter application package {package_name} + set enable_release_channels = {str(enable_release_channels).lower()} + """ ) ) except ProgrammingError as err: diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index f78ec957d5..e367fb918f 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -983,6 +983,18 @@ | determined from the | | project definition | | file. Default: unset. | + | --channel TEXT The name of the | + | release channel to | + | use when creating or | + | upgrading an | + | application instance | + | from a release | + | directive. Requires | + | the | + | --from-release-direc… | + | flag to be set. If | + | unset, the default | + | channel will be used. | | --interactive --no-interactive When enabled, this | | option displays | | prompts even if the | diff --git a/tests/nativeapp/test_event_sharing.py b/tests/nativeapp/test_event_sharing.py index 8da67f6c29..f6b1169a77 100644 --- a/tests/nativeapp/test_event_sharing.py +++ b/tests/nativeapp/test_event_sharing.py @@ -294,14 +294,17 @@ def _setup_mocks_for_create_app( mock.call( name=DEFAULT_APP_ID, package_name=DEFAULT_PKG_ID, - install_method=SameAccountInstallMethod.release_directive() - if is_prod - else SameAccountInstallMethod.unversioned_dev(), + install_method=( + SameAccountInstallMethod.release_directive() + if is_prod + else SameAccountInstallMethod.unversioned_dev() + ), stage_fqn=DEFAULT_STAGE_FQN, debug_mode=None, should_authorize_event_sharing=expected_authorize_telemetry_flag, role="app_role", warehouse="app_warehouse", + release_channel=None, ) ] @@ -397,14 +400,17 @@ def _setup_mocks_for_upgrade_app( mock_sql_facade_upgrade_application_expected = [ mock.call( name=DEFAULT_APP_ID, - install_method=SameAccountInstallMethod.release_directive() - if is_prod - else SameAccountInstallMethod.unversioned_dev(), + install_method=( + SameAccountInstallMethod.release_directive() + if is_prod + else SameAccountInstallMethod.unversioned_dev() + ), stage_fqn=DEFAULT_STAGE_FQN, debug_mode=None, should_authorize_event_sharing=expected_authorize_telemetry_flag, role="app_role", warehouse="app_warehouse", + release_channel=None, ) ] return [*mock_execute_query_expected, *mock_sql_facade_upgrade_application_expected] diff --git a/tests/nativeapp/test_run_processor.py b/tests/nativeapp/test_run_processor.py index 8d2bb4c545..3b478bad2e 100644 --- a/tests/nativeapp/test_run_processor.py +++ b/tests/nativeapp/test_run_processor.py @@ -84,6 +84,7 @@ SQL_FACADE_GET_EVENT_DEFINITIONS, SQL_FACADE_GET_EXISTING_APP_INFO, SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE, + SQL_FACADE_SHOW_RELEASE_CHANNELS, SQL_FACADE_UPGRADE_APPLICATION, TYPER_CONFIRM, mock_execute_helper, @@ -274,6 +275,7 @@ def test_create_dev_app_w_warehouse_access_exception( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ mock.call( @@ -345,6 +347,7 @@ def test_create_dev_app_create_new_w_no_additional_privileges( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] mock_sql_facade_get_event_definitions.assert_called_once_with( @@ -418,6 +421,7 @@ def test_create_or_upgrade_dev_app_with_warning( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] mock_sql_facade_upgrade_application.assert_not_called() @@ -432,6 +436,7 @@ def test_create_or_upgrade_dev_app_with_warning( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -486,6 +491,7 @@ def test_create_dev_app_create_new_with_additional_privileges( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ @@ -563,6 +569,7 @@ def test_create_dev_app_create_new_w_missing_warehouse_exception( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -674,6 +681,7 @@ def test_create_dev_app_incorrect_owner( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -727,6 +735,7 @@ def test_create_dev_app_no_diff_changes( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] mock_sql_facade_get_event_definitions.assert_called_once_with( @@ -783,6 +792,7 @@ def test_create_dev_app_w_diff_changes( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] mock_sql_facade_get_event_definitions.assert_called_once_with( @@ -907,6 +917,7 @@ def test_create_dev_app_create_new_quoted( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] mock_sql_facade_get_event_definitions.assert_called_once_with( @@ -964,6 +975,7 @@ def test_create_dev_app_create_new_quoted_override( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) mock_sql_facade_get_event_definitions.assert_called_once_with( '"My Application"', DEFAULT_ROLE @@ -1046,6 +1058,7 @@ def test_create_dev_app_recreate_app_when_orphaned( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_create_application.mock_calls == [ @@ -1058,6 +1071,7 @@ def test_create_dev_app_recreate_app_when_orphaned( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ @@ -1185,6 +1199,7 @@ def test_create_dev_app_recreate_app_when_orphaned_requires_cascade( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -1198,6 +1213,7 @@ def test_create_dev_app_recreate_app_when_orphaned_requires_cascade( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -1322,6 +1338,7 @@ def test_create_dev_app_recreate_app_when_orphaned_requires_cascade_unknown_obje should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_create_application.mock_calls == [ @@ -1334,6 +1351,7 @@ def test_create_dev_app_recreate_app_when_orphaned_requires_cascade_unknown_obje should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ @@ -1479,6 +1497,7 @@ def test_upgrade_app_incorrect_owner( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -1534,6 +1553,7 @@ def test_upgrade_app_succeeds( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) mock_sql_facade_get_event_definitions.assert_called_once_with( DEFAULT_APP_ID, DEFAULT_ROLE @@ -1593,6 +1613,7 @@ def test_upgrade_app_fails_generic_error( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -1674,6 +1695,7 @@ def test_upgrade_app_fails_upgrade_restriction_error( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_execute.mock_calls == expected @@ -1754,6 +1776,7 @@ def test_versioned_app_upgrade_to_unversioned( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) mock_sql_facade_create_application.assert_called_with( @@ -1765,6 +1788,7 @@ def test_versioned_app_upgrade_to_unversioned( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ @@ -1873,6 +1897,7 @@ def test_upgrade_app_fails_drop_fails( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -1954,6 +1979,7 @@ def test_upgrade_app_recreate_app( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_create_application.mock_calls == [ @@ -1966,6 +1992,7 @@ def test_upgrade_app_recreate_app( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ @@ -2135,6 +2162,7 @@ def test_upgrade_app_recreate_app_from_version( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] assert mock_sql_facade_create_application.mock_calls == [ @@ -2147,6 +2175,7 @@ def test_upgrade_app_recreate_app_from_version( should_authorize_event_sharing=None, role=DEFAULT_ROLE, warehouse=DEFAULT_WAREHOUSE, + release_channel=None, ) ] @@ -2179,6 +2208,511 @@ def test_upgrade_app_recreate_app_from_version( ) +@mock.patch( + APP_PACKAGE_ENTITY_GET_EXISTING_VERSION_INFO, + return_value={"key": "val"}, +) +@mock.patch(SQL_FACADE_CREATE_APPLICATION) +@mock.patch(SQL_FACADE_UPGRADE_APPLICATION) +@mock.patch(SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE) +@mock.patch(SQL_FACADE_GET_EVENT_DEFINITIONS) +@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_GET_EXISTING_APP_INFO) +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch( + f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True +) +@mock_connection() +@mock.patch( + GET_UI_PARAMETERS, + return_value={ + UIParameter.NA_EVENT_SHARING_V2: False, + UIParameter.NA_ENFORCE_MANDATORY_FILTERS: False, + }, +) +@pytest.mark.parametrize("policy_param", [allow_always_policy, ask_always_policy]) +def test_run_app_from_release_directive_with_channel( + mock_param, + mock_conn, + mock_typer_confirm, + mock_show_release_channels, + mock_get_existing_app_info, + mock_execute, + mock_sql_facade_get_event_definitions, + mock_sql_facade_grant_privileges_to_role, + mock_sql_facade_upgrade_application, + mock_sql_facade_create_application, + mock_existing, + policy_param, + temp_dir, + mock_cursor, + mock_bundle_map, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + } + mock_show_release_channels.return_value = [{"name": "my_channel"}] + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([("old_role",)], []), + mock.call("select current_role()"), + ), + (None, mock.call("use role app_role")), + (None, mock.call("drop application myapp")), + (None, mock.call("use role old_role")), + ] + ) + mock_conn.return_value = MockConnectionCtx() + mock_execute.side_effect = side_effects + mock_sql_facade_upgrade_application.side_effect = ( + UpgradeApplicationRestrictionError(DEFAULT_USER_INPUT_ERROR_MESSAGE) + ) + mock_sql_facade_create_application.side_effect = mock_cursor( + [[(DEFAULT_CREATE_SUCCESS_MESSAGE,)]], [] + ) + + setup_project_file(os.getcwd()) + + wm = _get_wm() + wm.perform_action( + "app_pkg", + EntityActions.BUNDLE, + ) + wm.perform_action( + "myapp", + EntityActions.DEPLOY, + from_release_directive=True, + prune=True, + recursive=True, + paths=[], + validate=False, + version="v1", + release_channel="my_channel", + ) + assert mock_execute.mock_calls == expected + assert mock_sql_facade_upgrade_application.mock_calls == [ + mock.call( + name=DEFAULT_APP_ID, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=DEFAULT_STAGE_FQN, + debug_mode=True, + should_authorize_event_sharing=None, + role=DEFAULT_ROLE, + warehouse=DEFAULT_WAREHOUSE, + release_channel="my_channel", + ) + ] + assert mock_sql_facade_create_application.mock_calls == [ + mock.call( + name=DEFAULT_APP_ID, + package_name=DEFAULT_PKG_ID, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=DEFAULT_STAGE_FQN, + debug_mode=True, + should_authorize_event_sharing=None, + role=DEFAULT_ROLE, + warehouse=DEFAULT_WAREHOUSE, + release_channel="my_channel", + ) + ] + + assert mock_sql_facade_grant_privileges_to_role.mock_calls == [ + mock.call( + privileges=["install", "develop"], + object_type=ObjectType.APPLICATION_PACKAGE, + object_identifier="app_pkg", + role_to_grant="app_role", + role_to_use="package_role", + ), + mock.call( + privileges=["usage"], + object_type=ObjectType.SCHEMA, + object_identifier="app_pkg.app_src", + role_to_grant="app_role", + role_to_use="package_role", + ), + mock.call( + privileges=["read"], + object_type=ObjectType.STAGE, + object_identifier="app_pkg.app_src.stage", + role_to_grant="app_role", + role_to_use="package_role", + ), + ] + + mock_sql_facade_get_event_definitions.assert_called_once_with( + DEFAULT_APP_ID, DEFAULT_ROLE + ) + + +@mock.patch( + APP_PACKAGE_ENTITY_GET_EXISTING_VERSION_INFO, + return_value={"key": "val"}, +) +@mock.patch(SQL_FACADE_CREATE_APPLICATION) +@mock.patch(SQL_FACADE_UPGRADE_APPLICATION) +@mock.patch(SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE) +@mock.patch(SQL_FACADE_GET_EVENT_DEFINITIONS) +@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_GET_EXISTING_APP_INFO) +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch( + f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True +) +@mock_connection() +@mock.patch( + GET_UI_PARAMETERS, + return_value={ + UIParameter.NA_EVENT_SHARING_V2: False, + UIParameter.NA_ENFORCE_MANDATORY_FILTERS: False, + }, +) +def test_run_app_from_release_directive_with_channel_but_not_from_release_directive( + mock_param, + mock_conn, + mock_typer_confirm, + mock_show_release_channels, + mock_get_existing_app_info, + mock_execute, + mock_sql_facade_get_event_definitions, + mock_sql_facade_grant_privileges_to_role, + mock_sql_facade_upgrade_application, + mock_sql_facade_create_application, + mock_existing, + temp_dir, + mock_cursor, + mock_bundle_map, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + } + mock_show_release_channels.return_value = [] + mock_conn.return_value = MockConnectionCtx() + mock_sql_facade_upgrade_application.side_effect = ( + UpgradeApplicationRestrictionError(DEFAULT_USER_INPUT_ERROR_MESSAGE) + ) + mock_sql_facade_create_application.side_effect = mock_cursor( + [[(DEFAULT_CREATE_SUCCESS_MESSAGE,)]], [] + ) + + setup_project_file(os.getcwd()) + + wm = _get_wm() + wm.perform_action( + "app_pkg", + EntityActions.BUNDLE, + ) + with pytest.raises(UsageError) as err: + wm.perform_action( + "myapp", + EntityActions.DEPLOY, + from_release_directive=False, + prune=True, + recursive=True, + paths=[], + validate=False, + version="v1", + release_channel="my_channel", + ) + + assert ( + str(err.value) + == "Release channel is only supported when --from-release-directive is used." + ) + mock_sql_facade_upgrade_application.assert_not_called() + mock_sql_facade_create_application.assert_not_called() + + +# Provide a release channel that is not in the list of available release channels -> error: +@mock.patch( + APP_PACKAGE_ENTITY_GET_EXISTING_VERSION_INFO, + return_value={"key": "val"}, +) +@mock.patch(SQL_FACADE_CREATE_APPLICATION) +@mock.patch(SQL_FACADE_UPGRADE_APPLICATION) +@mock.patch(SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE) +@mock.patch(SQL_FACADE_GET_EVENT_DEFINITIONS) +@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_GET_EXISTING_APP_INFO) +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch( + f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True +) +@mock_connection() +@mock.patch( + GET_UI_PARAMETERS, + return_value={ + UIParameter.NA_EVENT_SHARING_V2: False, + UIParameter.NA_ENFORCE_MANDATORY_FILTERS: False, + }, +) +def test_run_app_from_release_directive_with_channel_not_in_list( + mock_param, + mock_conn, + mock_typer_confirm, + mock_show_release_channels, + mock_get_existing_app_info, + mock_execute, + mock_sql_facade_get_event_definitions, + mock_sql_facade_grant_privileges_to_role, + mock_sql_facade_upgrade_application, + mock_sql_facade_create_application, + mock_existing, + temp_dir, + mock_cursor, + mock_bundle_map, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + } + mock_show_release_channels.return_value = [ + {"name": "channel1"}, + {"name": "channel2"}, + ] + mock_conn.return_value = MockConnectionCtx() + mock_sql_facade_upgrade_application.side_effect = ( + UpgradeApplicationRestrictionError(DEFAULT_USER_INPUT_ERROR_MESSAGE) + ) + mock_sql_facade_create_application.side_effect = mock_cursor( + [[(DEFAULT_CREATE_SUCCESS_MESSAGE,)]], [] + ) + + setup_project_file(os.getcwd()) + + wm = _get_wm() + wm.perform_action( + "app_pkg", + EntityActions.BUNDLE, + ) + with pytest.raises(UsageError) as err: + wm.perform_action( + "myapp", + EntityActions.DEPLOY, + from_release_directive=True, + prune=True, + recursive=True, + paths=[], + validate=False, + version="v1", + release_channel="unknown_channel", + ) + + assert ( + str(err.value) + == "Release channel 'unknown_channel' is not available for application package app_pkg. Available release channels: (channel1, channel2)." + ) + mock_sql_facade_upgrade_application.assert_not_called() + mock_sql_facade_create_application.assert_not_called() + + +@mock.patch( + APP_PACKAGE_ENTITY_GET_EXISTING_VERSION_INFO, + return_value={"key": "val"}, +) +@mock.patch(SQL_FACADE_CREATE_APPLICATION) +@mock.patch(SQL_FACADE_UPGRADE_APPLICATION) +@mock.patch(SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE) +@mock.patch(SQL_FACADE_GET_EVENT_DEFINITIONS) +@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_GET_EXISTING_APP_INFO) +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch( + f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True +) +@mock_connection() +@mock.patch( + GET_UI_PARAMETERS, + return_value={ + UIParameter.NA_EVENT_SHARING_V2: False, + UIParameter.NA_ENFORCE_MANDATORY_FILTERS: False, + }, +) +def test_run_app_from_release_directive_with_non_default_channel_but_release_channels_not_enabled( + mock_param, + mock_conn, + mock_typer_confirm, + mock_show_release_channels, + mock_get_existing_app_info, + mock_execute, + mock_sql_facade_get_event_definitions, + mock_sql_facade_grant_privileges_to_role, + mock_sql_facade_upgrade_application, + mock_sql_facade_create_application, + mock_existing, + temp_dir, + mock_cursor, + mock_bundle_map, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + } + mock_show_release_channels.return_value = [] + mock_conn.return_value = MockConnectionCtx() + mock_sql_facade_upgrade_application.side_effect = ( + UpgradeApplicationRestrictionError(DEFAULT_USER_INPUT_ERROR_MESSAGE) + ) + mock_sql_facade_create_application.side_effect = mock_cursor( + [[(DEFAULT_CREATE_SUCCESS_MESSAGE,)]], [] + ) + + setup_project_file(os.getcwd()) + + wm = _get_wm() + wm.perform_action( + "app_pkg", + EntityActions.BUNDLE, + ) + with pytest.raises(UsageError) as err: + wm.perform_action( + "myapp", + EntityActions.DEPLOY, + from_release_directive=True, + prune=True, + recursive=True, + paths=[], + validate=False, + version="v1", + release_channel="my_channel", + ) + + assert ( + str(err.value) + == "Release channels are not enabled for application package app_pkg." + ) + mock_sql_facade_upgrade_application.assert_not_called() + mock_sql_facade_create_application.assert_not_called() + + +# test with default release channel when release channels not enabled -> success: +@mock.patch( + APP_PACKAGE_ENTITY_GET_EXISTING_VERSION_INFO, + return_value={"key": "val"}, +) +@mock.patch(SQL_FACADE_CREATE_APPLICATION) +@mock.patch(SQL_FACADE_UPGRADE_APPLICATION) +@mock.patch(SQL_FACADE_GRANT_PRIVILEGES_TO_ROLE) +@mock.patch(SQL_FACADE_GET_EVENT_DEFINITIONS) +@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_GET_EXISTING_APP_INFO) +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch( + f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True +) +@mock_connection() +@mock.patch( + GET_UI_PARAMETERS, + return_value={ + UIParameter.NA_EVENT_SHARING_V2: False, + UIParameter.NA_ENFORCE_MANDATORY_FILTERS: False, + }, +) +def test_run_app_from_release_directive_with_default_channel_when_release_channels_not_enabled( + mock_param, + mock_conn, + mock_typer_confirm, + mock_show_release_channels, + mock_get_existing_app_info, + mock_execute, + mock_sql_facade_get_event_definitions, + mock_sql_facade_grant_privileges_to_role, + mock_sql_facade_upgrade_application, + mock_sql_facade_create_application, + mock_existing, + temp_dir, + mock_cursor, + mock_bundle_map, +): + mock_get_existing_app_info.return_value = { + "name": "myapp", + "comment": SPECIAL_COMMENT, + "owner": "app_role", + } + mock_show_release_channels.return_value = [] + mock_conn.return_value = MockConnectionCtx() + mock_sql_facade_upgrade_application.side_effect = ( + UpgradeApplicationRestrictionError(DEFAULT_USER_INPUT_ERROR_MESSAGE) + ) + mock_sql_facade_create_application.side_effect = mock_cursor( + [[(DEFAULT_CREATE_SUCCESS_MESSAGE,)]], [] + ) + + setup_project_file(os.getcwd()) + + wm = _get_wm() + wm.perform_action( + "app_pkg", + EntityActions.BUNDLE, + ) + wm.perform_action( + "myapp", + EntityActions.DEPLOY, + from_release_directive=True, + prune=True, + recursive=True, + paths=[], + validate=False, + version="v1", + release_channel="default", + ) + + mock_sql_facade_upgrade_application.assert_called_once_with( + name=DEFAULT_APP_ID, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=DEFAULT_STAGE_FQN, + debug_mode=True, + should_authorize_event_sharing=None, + role=DEFAULT_ROLE, + warehouse=DEFAULT_WAREHOUSE, + release_channel=None, + ) + mock_sql_facade_create_application.assert_called_once_with( + name=DEFAULT_APP_ID, + package_name=DEFAULT_PKG_ID, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=DEFAULT_STAGE_FQN, + debug_mode=True, + should_authorize_event_sharing=None, + role=DEFAULT_ROLE, + warehouse=DEFAULT_WAREHOUSE, + release_channel=None, + ) + + mock_sql_facade_grant_privileges_to_role.assert_has_calls( + [ + mock.call( + privileges=["install", "develop"], + object_type=ObjectType.APPLICATION_PACKAGE, + object_identifier="app_pkg", + role_to_grant="app_role", + role_to_use="package_role", + ), + mock.call( + privileges=["usage"], + object_type=ObjectType.SCHEMA, + object_identifier="app_pkg.app_src", + role_to_grant="app_role", + role_to_use="package_role", + ), + mock.call( + privileges=["read"], + object_type=ObjectType.STAGE, + object_identifier="app_pkg.app_src.stage", + role_to_grant="app_role", + role_to_use="package_role", + ), + ] + ) + + # Test get_existing_version_info returns version info correctly @mock.patch(SQL_EXECUTOR_EXECUTE) def test_get_existing_version_info( diff --git a/tests/nativeapp/test_sf_sql_facade.py b/tests/nativeapp/test_sf_sql_facade.py index 8d8107249c..5835c39d9c 100644 --- a/tests/nativeapp/test_sf_sql_facade.py +++ b/tests/nativeapp/test_sf_sql_facade.py @@ -20,6 +20,7 @@ from snowflake.cli._plugins.connection.util import UIParameter from snowflake.cli._plugins.nativeapp.constants import ( AUTHORIZE_TELEMETRY_COL, + CHANNEL_COL, COMMENT_COL, NAME_COL, SPECIAL_COMMENT, @@ -34,6 +35,7 @@ InvalidSQLError, UnknownConnectorError, UnknownSQLError, + UpgradeApplicationRestrictionError, UserInputError, UserScriptError, ) @@ -2063,6 +2065,91 @@ def test_upgrade_application_converts_unexpected_programmingerrors_to_unclassifi assert_programmingerror_cause_with_errno(err, SQL_COMPILATION_ERROR) +def test_upgrade_application_with_release_channel_same_as_app_properties( + mock_get_app_properties, + mock_get_existing_app_info, + mock_use_warehouse, + mock_use_role, + mock_execute_query, + mock_cursor, +): + app_name = "test_app" + stage_fqn = "app_pkg.app_src.stage" + role = "test_role" + warehouse = "test_warehouse" + release_channel = "test_channel" + mock_get_app_properties.return_value = { + COMMENT_COL: SPECIAL_COMMENT, + AUTHORIZE_TELEMETRY_COL: "true", + CHANNEL_COL: release_channel, + } + + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([], []), + mock.call(f"alter application {app_name} upgrade "), + ) + ] + ) + mock_execute_query.side_effect = side_effects + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + (mock_use_warehouse, mock.call(warehouse)), + ] + expected_execute_query = [(mock_execute_query, call) for call in expected] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.upgrade_application( + name=app_name, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=stage_fqn, + debug_mode=True, + should_authorize_event_sharing=True, + role=role, + warehouse=warehouse, + release_channel=release_channel, + ) + + +def test_upgrade_application_with_release_channel_not_same_as_app_properties_then_upgrade_error( + mock_get_app_properties, + mock_get_existing_app_info, + mock_use_warehouse, + mock_use_role, + mock_execute_query, + mock_cursor, +): + app_name = "test_app" + stage_fqn = "app_pkg.app_src.stage" + role = "test_role" + warehouse = "test_warehouse" + release_channel = "test_channel" + mock_get_app_properties.return_value = { + COMMENT_COL: SPECIAL_COMMENT, + AUTHORIZE_TELEMETRY_COL: "true", + CHANNEL_COL: "different_channel", + } + + with pytest.raises(UpgradeApplicationRestrictionError) as err: + sql_facade.upgrade_application( + name=app_name, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=stage_fqn, + debug_mode=True, + should_authorize_event_sharing=True, + role=role, + warehouse=warehouse, + release_channel=release_channel, + ) + + assert ( + str(err.value) + == f"Cannot upgrade application {app_name} from release channel {release_channel} because application is already on a different channel." + ) + + def test_create_application_with_minimal_clauses( mock_use_warehouse, mock_use_role, @@ -2083,7 +2170,7 @@ def test_create_application_with_minimal_clauses( dedent( f"""\ create application {app_name} - from application package {pkg_name} + from application package {pkg_name} comment = {SPECIAL_COMMENT} """ ) @@ -2132,7 +2219,10 @@ def test_create_application_with_all_clauses( dedent( f"""\ create application {app_name} - from application package {pkg_name} using @{stage_fqn} debug_mode = True AUTHORIZE_TELEMETRY_EVENT_SHARING = TRUE + from application package {pkg_name} + using @{stage_fqn} + debug_mode = True + AUTHORIZE_TELEMETRY_EVENT_SHARING = TRUE comment = {SPECIAL_COMMENT} """ ) @@ -2182,7 +2272,7 @@ def test_create_application_converts_expected_programmingerrors_to_user_errors( dedent( f"""\ create application {app_name} - from application package {pkg_name} + from application package {pkg_name} comment = {SPECIAL_COMMENT} """ ) @@ -2241,7 +2331,10 @@ def test_create_application_special_message_for_event_sharing_error( dedent( f"""\ create application {app_name} - from application package {pkg_name} using version "3" patch 1 debug_mode = False AUTHORIZE_TELEMETRY_EVENT_SHARING = FALSE + from application package {pkg_name} + using version "3" patch 1 + debug_mode = False + AUTHORIZE_TELEMETRY_EVENT_SHARING = FALSE comment = {SPECIAL_COMMENT} """ ) @@ -2299,7 +2392,7 @@ def test_create_application_converts_unexpected_programmingerrors_to_unclassifie dedent( f"""\ create application {app_name} - from application package {pkg_name} + from application package {pkg_name} comment = {SPECIAL_COMMENT} """ ) @@ -2333,6 +2426,58 @@ def test_create_application_converts_unexpected_programmingerrors_to_unclassifie assert_programmingerror_cause_with_errno(err, SQL_COMPILATION_ERROR) +def test_create_application_with_release_channel( + mock_use_warehouse, + mock_use_role, + mock_execute_query, + mock_cursor, +): + app_name = "test_app" + pkg_name = "test_pkg" + stage_fqn = "app_pkg.app_src.stage" + role = "test_role" + warehouse = "test_warehouse" + release_channel = "test_channel" + + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([], []), + mock.call( + dedent( + f"""\ + create application {app_name} + from application package {pkg_name} + using release channel {release_channel} + comment = {SPECIAL_COMMENT} + """ + ) + ), + ) + ] + ) + mock_execute_query.side_effect = side_effects + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + (mock_use_warehouse, mock.call(warehouse)), + ] + expected_execute_query = [(mock_execute_query, call) for call in expected] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.create_application( + name=app_name, + package_name=pkg_name, + install_method=SameAccountInstallMethod.release_directive(), + stage_fqn=stage_fqn, + debug_mode=None, + should_authorize_event_sharing=None, + role=role, + warehouse=warehouse, + release_channel=release_channel, + ) + + @pytest.mark.parametrize( "pkg_name, sanitized_pkg_name", [("test_pkg", "test_pkg"), ("test.pkg", '"test.pkg"')], diff --git a/tests_integration/nativeapp/test_init_run.py b/tests_integration/nativeapp/test_init_run.py index 57c9488d6d..9a96656964 100644 --- a/tests_integration/nativeapp/test_init_run.py +++ b/tests_integration/nativeapp/test_init_run.py @@ -494,3 +494,75 @@ def test_nativeapp_force_cross_upgrade( assert result.exit_code == 0 if is_cross_upgrade: assert f"Dropping application object {app_name}." in result.output + + +@pytest.mark.integration +@pytest.mark.parametrize( + "test_project", + [ + "napp_init_v2", + ], +) +def test_nativeapp_upgrade_from_release_directive_and_default_channel( + test_project, + nativeapp_project_directory, + runner, +): + + with nativeapp_project_directory(test_project): + # Create version + result = runner.invoke_with_connection(["app", "version", "create", "v1"]) + assert result.exit_code == 0 + + # Set default release directive + result = runner.invoke_with_connection( + ["app", "release-directive", "set", "default", "--version=v1", "--patch=0"] + ) + assert result.exit_code == 0 + + # Initial create + result = runner.invoke_with_connection(["app", "run"]) + assert result.exit_code == 0 + + # (Cross-)upgrade + result = runner.invoke_with_connection( + [ + "app", + "run", + "--from-release-directive", + "--channel", + "default", + "--force", + ] + ) + assert result.exit_code == 0 + + +@pytest.mark.integration +@pytest.mark.parametrize( + "test_project", + [ + "napp_init_v2", + ], +) +def test_nativeapp_create_from_release_directive_and_default_channel( + test_project, + nativeapp_project_directory, + runner, +): + with nativeapp_project_directory(test_project): + # Create version + result = runner.invoke_with_connection(["app", "version", "create", "v1"]) + assert result.exit_code == 0 + + # Set default release directive + result = runner.invoke_with_connection( + ["app", "release-directive", "set", "default", "--version=v1", "--patch=0"] + ) + assert result.exit_code == 0 + + # Initial create + result = runner.invoke_with_connection( + ["app", "run", "--from-release-directive", "--channel", "default"] + ) + assert result.exit_code == 0