From a44f54a8332b1ac92c3ed0b1542f369a33488b08 Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Wed, 18 Dec 2024 17:00:41 -0500 Subject: [PATCH] Add release channels add-accounts remove-accounts commands (#1955) --- RELEASE-NOTES.md | 1 + .../nativeapp/entities/application.py | 32 +-- .../nativeapp/entities/application_package.py | 179 ++++++++---- .../nativeapp/release_channel/commands.py | 69 +++++ .../cli/_plugins/nativeapp/sf_sql_facade.py | 96 ++++++- src/snowflake/cli/api/entities/common.py | 2 + src/snowflake/cli/api/errno.py | 1 + tests/__snapshots__/test_help_messages.ambr | 218 +++++++++++++- .../test_application_package_entity.py | 267 ++++++++++++++++-- tests/nativeapp/test_run_processor.py | 2 +- tests/nativeapp/test_sf_sql_facade.py | 183 ++++++++++++ tests/nativeapp/utils.py | 6 + 12 files changed, 943 insertions(+), 113 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index bdf2072b11..03b8dd9d7e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -28,6 +28,7 @@ * `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=` * Add ability to list release channels through `snow app release-channel list` command +* Add ability to add and remove accounts from release channels through `snow app release-channel add-accounts` and snow app release-channel remove-accounts` commands. ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application.py b/src/snowflake/cli/_plugins/nativeapp/entities/application.py index bd2096ba1f..b43d4ee8ea 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application.py @@ -26,7 +26,6 @@ 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 ( @@ -86,8 +85,6 @@ append_test_resource_suffix, extract_schema, identifier_for_url, - identifier_in_list, - same_identifiers, to_identifier, unquote_identifier, ) @@ -360,8 +357,8 @@ def action_deploy( # same-account release directive if from_release_directive: - release_channel = _get_verified_release_channel( - package_entity, release_channel + release_channel = package_entity.get_sanitized_release_channel( + release_channel ) self.create_or_upgrade_app( @@ -1025,28 +1022,3 @@ 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/entities/application_package.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py index af95ce2e10..389a5a8c82 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py @@ -8,7 +8,7 @@ from typing import Any, List, Literal, Optional, Set, Union import typer -from click import BadOptionUsage, ClickException +from click import BadOptionUsage, ClickException, UsageError from pydantic import Field, field_validator from snowflake.cli._plugins.connection.util import UIParameter from snowflake.cli._plugins.nativeapp.artifacts import ( @@ -97,7 +97,6 @@ VALID_IDENTIFIER_REGEX, append_test_resource_suffix, extract_schema, - identifier_in_list, identifier_to_show_like_pattern, same_identifiers, sql_match, @@ -622,6 +621,69 @@ def action_version_drop( f"Version {version} in application package {self.name} dropped successfully." ) + def _validate_target_accounts(self, accounts: list[str]) -> None: + """ + Validates the target accounts provided by the user. + """ + for account in accounts: + if not re.fullmatch( + f"{VALID_IDENTIFIER_REGEX}\\.{VALID_IDENTIFIER_REGEX}", account + ): + raise ClickException( + f"Target account {account} is not in a valid format. Make sure you provide the target account in the format 'org.account'." + ) + + def get_sanitized_release_channel( + self, release_channel: Optional[str] + ) -> Optional[str]: + """ + Sanitize the release channel name provided by the user and validate it against the available release channels. + + A return value of None indicates that release channels should not be used. Returns None if: + - Release channel is not provided + - Release channels are not enabled in the application package and the user provided the default release channel + """ + if not release_channel: + return None + + available_release_channels = get_snowflake_facade().show_release_channels( + self.name, self.role + ) + + if not available_release_channels and same_identifiers( + release_channel, DEFAULT_CHANNEL + ): + return None + + self.validate_release_channel(release_channel, available_release_channels) + return release_channel + + def validate_release_channel( + self, + release_channel: str, + available_release_channels: Optional[list[ReleaseChannel]] = None, + ) -> None: + """ + Validates the release channel provided by the user and make sure it is a valid release channel for the application package. + """ + + if available_release_channels is None: + available_release_channels = get_snowflake_facade().show_release_channels( + self.name, self.role + ) + if not available_release_channels: + raise UsageError( + f"Release channels are not enabled for application package {self.name}." + ) + for channel in available_release_channels: + if same_identifiers(release_channel, channel["name"]): + return + + raise UsageError( + f"Release channel {release_channel} is not available in application package {self.name}. " + f"Available release channels are: ({', '.join(channel['name'] for channel in available_release_channels)})." + ) + def action_release_directive_list( self, action_ctx: ActionContext, @@ -636,25 +698,7 @@ def action_release_directive_list( If `like` is provided, only release directives matching the SQL LIKE pattern are listed. """ - available_release_channels = get_snowflake_facade().show_release_channels( - self.name, self.role - ) - - # assume no release channel used if user selects default channel and release channels are not enabled - if ( - release_channel - and same_identifiers(release_channel, DEFAULT_CHANNEL) - and not available_release_channels - ): - release_channel = None - - release_channel_names = [c.get("name") for c in available_release_channels] - if release_channel and not identifier_in_list( - release_channel, release_channel_names - ): - raise ClickException( - f"Release channel {release_channel} does not exist in application package {self.name}." - ) + release_channel = self.get_sanitized_release_channel(release_channel) release_directives = get_snowflake_facade().show_release_directives( package_name=self.name, @@ -686,13 +730,7 @@ def action_release_directive_set( For non-default release directives, update the existing release directive if target accounts are not provided. """ if target_accounts: - for account in target_accounts: - if not re.fullmatch( - f"{VALID_IDENTIFIER_REGEX}\\.{VALID_IDENTIFIER_REGEX}", account - ): - raise ClickException( - f"Target account {account} is not in a valid format. Make sure you provide the target account in the format 'org.account'." - ) + self._validate_target_accounts(target_accounts) if target_accounts and same_identifiers(release_directive, DEFAULT_DIRECTIVE): raise BadOptionUsage( @@ -700,18 +738,7 @@ def action_release_directive_set( "Target accounts can only be specified for non-default named release directives.", ) - available_release_channels = get_snowflake_facade().show_release_channels( - self.name, self.role - ) - - release_channel_names = [c.get("name") for c in available_release_channels] - - if not same_identifiers( - release_channel, DEFAULT_CHANNEL - ) and not identifier_in_list(release_channel, release_channel_names): - raise ClickException( - f"Release channel {release_channel} does not exist in application package {self.name}." - ) + sanitized_release_channel = self.get_sanitized_release_channel(release_channel) if ( not same_identifiers(release_directive, DEFAULT_DIRECTIVE) @@ -722,7 +749,7 @@ def action_release_directive_set( get_snowflake_facade().modify_release_directive( package_name=self.name, release_directive=release_directive, - release_channel=release_channel, + release_channel=sanitized_release_channel, version=version, patch=patch, role=self.role, @@ -731,7 +758,7 @@ def action_release_directive_set( get_snowflake_facade().set_release_directive( package_name=self.name, release_directive=release_directive, - release_channel=release_channel if available_release_channels else None, + release_channel=sanitized_release_channel, target_accounts=target_accounts, version=version, patch=patch, @@ -739,7 +766,10 @@ def action_release_directive_set( ) def action_release_directive_unset( - self, action_ctx: ActionContext, release_directive: str, release_channel: str + self, + action_ctx: ActionContext, + release_directive: str, + release_channel: str, ): """ Unsets a release directive from the specified release channel. @@ -749,21 +779,10 @@ def action_release_directive_unset( "Cannot unset default release directive. Please specify a non-default release directive." ) - available_release_channels = get_snowflake_facade().show_release_channels( - self.name, self.role - ) - release_channel_names = [c.get("name") for c in available_release_channels] - if not same_identifiers( - release_channel, DEFAULT_CHANNEL - ) and not identifier_in_list(release_channel, release_channel_names): - raise ClickException( - f"Release channel {release_channel} does not exist in application package {self.name}." - ) - get_snowflake_facade().unset_release_directive( package_name=self.name, release_directive=release_directive, - release_channel=release_channel if available_release_channels else None, + release_channel=self.get_sanitized_release_channel(release_channel), role=self.role, ) @@ -861,6 +880,56 @@ def _bundle(self, action_ctx: ActionContext = None): return bundle_map + def action_release_channel_add_accounts( + self, + action_ctx: ActionContext, + release_channel: str, + target_accounts: list[str], + *args, + **kwargs, + ): + """ + Adds target accounts to a release channel. + """ + + if not target_accounts: + raise ClickException("No target accounts provided.") + + self.validate_release_channel(release_channel) + self._validate_target_accounts(target_accounts) + + get_snowflake_facade().add_accounts_to_release_channel( + package_name=self.name, + release_channel=release_channel, + target_accounts=target_accounts, + role=self.role, + ) + + def action_release_channel_remove_accounts( + self, + action_ctx: ActionContext, + release_channel: str, + target_accounts: list[str], + *args, + **kwargs, + ): + """ + Removes target accounts from a release channel. + """ + + if not target_accounts: + raise ClickException("No target accounts provided.") + + self.validate_release_channel(release_channel) + self._validate_target_accounts(target_accounts) + + get_snowflake_facade().remove_accounts_from_release_channel( + package_name=self.name, + release_channel=release_channel, + target_accounts=target_accounts, + role=self.role, + ) + def _bundle_children(self, action_ctx: ActionContext) -> List[str]: # Create _children directory children_artifacts_dir = self.children_artifacts_deploy_root diff --git a/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py b/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py index 4614f6e06b..54c484c768 100644 --- a/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py @@ -30,6 +30,7 @@ from snowflake.cli.api.output.types import ( CollectionResult, CommandResult, + MessageResult, ) app = SnowTyperFactory( @@ -69,3 +70,71 @@ def release_channel_list( if cli_context.output_format == OutputFormat.JSON: return CollectionResult(channels) + + +@app.command("add-accounts", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_channel_add_accounts( + channel: str = typer.Argument( + show_default=False, + help="The release channel to add accounts to.", + ), + target_accounts: list[str] = typer.Option( + show_default=False, + help="The accounts to add to the release channel. Format has to be `org1.account1,org2.account2`.", + ), + **options, +) -> CommandResult: + """ + Adds accounts to a release channel. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + ws.perform_action( + package_id, + EntityActions.RELEASE_CHANNEL_ADD_ACCOUNTS, + release_channel=channel, + target_accounts=target_accounts, + ) + + return MessageResult("Successfully added accounts to the release channel.") + + +@app.command("remove-accounts", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_channel_remove_accounts( + channel: str = typer.Argument( + show_default=False, + help="The release channel to remove accounts from.", + ), + target_accounts: list[str] = typer.Option( + show_default=False, + help="The accounts to remove from the release channel. Format has to be `org1.account1,org2.account2`.", + ), + **options, +) -> CommandResult: + """ + Removes accounts from a release channel. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + ws.perform_action( + package_id, + EntityActions.RELEASE_CHANNEL_REMOVE_ACCOUNTS, + release_channel=channel, + target_accounts=target_accounts, + ) + + return MessageResult("Successfully removed accounts from the release channel.") diff --git a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py index 4c30ead793..914c7b1605 100644 --- a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +++ b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py @@ -53,6 +53,7 @@ APPLICATION_REQUIRES_TELEMETRY_SHARING, CANNOT_DISABLE_MANDATORY_TELEMETRY, CANNOT_DISABLE_RELEASE_CHANNELS, + CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS, DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED, DOES_NOT_EXIST_OR_NOT_AUTHORIZED, INSUFFICIENT_PRIVILEGES, @@ -1159,7 +1160,8 @@ def show_release_channels( ) -> list[ReleaseChannel]: """ Show release channels in a package. - @param package_name: Name of the package + + @param package_name: Name of the application package @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. """ @@ -1208,6 +1210,98 @@ def show_release_channels( return results + def add_accounts_to_release_channel( + self, + package_name: str, + release_channel: str, + target_accounts: List[str], + role: str | None = None, + ): + """ + Adds accounts to a release channel. + + @param package_name: Name of the application package + @param release_channel: Name of the release channel + @param target_accounts: List of target accounts to add to the release channel + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ + + package_name = to_identifier(package_name) + release_channel = to_identifier(release_channel) + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query( + f"alter application package {package_name} modify release channel {release_channel} add accounts = ({','.join(target_accounts)})" + ) + except ProgrammingError as err: + if ( + err.errno == ACCOUNT_DOES_NOT_EXIST + or err.errno == ACCOUNT_HAS_TOO_MANY_QUALIFIERS + ): + raise UserInputError( + f"Invalid account passed in.\n{str(err.msg)}" + ) from err + if err.errno == CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS: + raise UserInputError( + f"Cannot modify accounts for release channel {release_channel} in application package {package_name}." + ) from err + handle_unclassified_error( + err, + f"Failed to add accounts to release channel {release_channel} in application package {package_name}.", + ) + except Exception as err: + handle_unclassified_error( + err, + f"Failed to add accounts to release channel {release_channel} in application package {package_name}.", + ) + + def remove_accounts_from_release_channel( + self, + package_name: str, + release_channel: str, + target_accounts: List[str], + role: str | None = None, + ): + """ + Removes accounts from a release channel. + + @param package_name: Name of the application package + @param release_channel: Name of the release channel + @param target_accounts: List of target accounts to remove from the release channel + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ + + package_name = to_identifier(package_name) + release_channel = to_identifier(release_channel) + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query( + f"alter application package {package_name} modify release channel {release_channel} remove accounts = ({','.join(target_accounts)})" + ) + except ProgrammingError as err: + if ( + err.errno == ACCOUNT_DOES_NOT_EXIST + or err.errno == ACCOUNT_HAS_TOO_MANY_QUALIFIERS + ): + raise UserInputError( + f"Invalid account passed in.\n{str(err.msg)}" + ) from err + if err.errno == CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS: + raise UserInputError( + f"Cannot modify accounts for release channel {release_channel} in application package {package_name}." + ) from err + handle_unclassified_error( + err, + f"Failed to remove accounts from release channel {release_channel} in application package {package_name}.", + ) + except Exception as err: + handle_unclassified_error( + err, + f"Failed to remove accounts from release channel {release_channel} in application package {package_name}.", + ) + def _strip_empty_lines(text: str) -> str: """ diff --git a/src/snowflake/cli/api/entities/common.py b/src/snowflake/cli/api/entities/common.py index c444dc0897..cbedb87825 100644 --- a/src/snowflake/cli/api/entities/common.py +++ b/src/snowflake/cli/api/entities/common.py @@ -22,6 +22,8 @@ class EntityActions(str, Enum): RELEASE_DIRECTIVE_LIST = "action_release_directive_list" RELEASE_CHANNEL_LIST = "action_release_channel_list" + RELEASE_CHANNEL_ADD_ACCOUNTS = "action_release_channel_add_accounts" + RELEASE_CHANNEL_REMOVE_ACCOUNTS = "action_release_channel_remove_accounts" RELEASE_CHANNEL_ADD_VERSION = "action_release_channel_add_version" RELEASE_CHANNEL_REMOVE_VERSION = "action_release_channel_remove_version" diff --git a/src/snowflake/cli/api/errno.py b/src/snowflake/cli/api/errno.py index 88796f2590..c13fdef719 100644 --- a/src/snowflake/cli/api/errno.py +++ b/src/snowflake/cli/api/errno.py @@ -68,6 +68,7 @@ VERSION_DOES_NOT_EXIST = 93031 ACCOUNT_DOES_NOT_EXIST = 1999 ACCOUNT_HAS_TOO_MANY_QUALIFIERS = 906 +CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS = 512017 ERR_JAVASCRIPT_EXECUTION = 100132 diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index 91f09a4acf..8583b2599a 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -578,6 +578,110 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages[app.release-channel.add-accounts] + ''' + + Usage: default app release-channel add-accounts [OPTIONS] CHANNEL + + Adds accounts to a release channel. + + +- Arguments ------------------------------------------------------------------+ + | * channel TEXT The release channel to add accounts to. | + | [required] | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | * --target-accounts TEXT The accounts to add to the release | + | channel. Format has to be | + | org1.account1,org2.account2. | + | [required] | + | --package-entity-id TEXT The ID of the package entity on which | + | to operate when definition_version is | + | 2 or higher. | + | --app-entity-id TEXT The ID of the application entity on | + | which to operate when | + | definition_version is 2 or higher. | + | --project -p TEXT Path where Snowflake project resides. | + | Defaults to current working directory. | + | --env TEXT String in format of key=value. | + | Overrides variables from env section | + | used for templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages[app.release-channel.list] @@ -678,6 +782,110 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages[app.release-channel.remove-accounts] + ''' + + Usage: default app release-channel remove-accounts [OPTIONS] CHANNEL + + Removes accounts from a release channel. + + +- Arguments ------------------------------------------------------------------+ + | * channel TEXT The release channel to remove accounts from. | + | [required] | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | * --target-accounts TEXT The accounts to remove from the | + | release channel. Format has to be | + | org1.account1,org2.account2. | + | [required] | + | --package-entity-id TEXT The ID of the package entity on which | + | to operate when definition_version is | + | 2 or higher. | + | --app-entity-id TEXT The ID of the application entity on | + | which to operate when | + | definition_version is 2 or higher. | + | --project -p TEXT Path where Snowflake project resides. | + | Defaults to current working directory. | + | --env TEXT String in format of key=value. | + | Overrides variables from env section | + | used for templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages[app.release-channel] @@ -691,7 +899,10 @@ | --help -h Show this message and exit. | +------------------------------------------------------------------------------+ +- Commands -------------------------------------------------------------------+ - | list Lists the release channels available for an application package. | + | add-accounts Adds accounts to a release channel. | + | list Lists the release channels available for an application | + | package. | + | remove-accounts Removes accounts from a release channel. | +------------------------------------------------------------------------------+ @@ -10253,7 +10464,10 @@ | --help -h Show this message and exit. | +------------------------------------------------------------------------------+ +- Commands -------------------------------------------------------------------+ - | list Lists the release channels available for an application package. | + | add-accounts Adds accounts to a release channel. | + | list Lists the release channels available for an application | + | package. | + | remove-accounts Removes accounts from a release channel. | +------------------------------------------------------------------------------+ diff --git a/tests/nativeapp/test_application_package_entity.py b/tests/nativeapp/test_application_package_entity.py index bbab822962..1185e7d700 100644 --- a/tests/nativeapp/test_application_package_entity.py +++ b/tests/nativeapp/test_application_package_entity.py @@ -20,7 +20,7 @@ import pytest import pytz import yaml -from click import ClickException +from click import ClickException, UsageError from snowflake.cli._plugins.connection.util import UIParameter from snowflake.cli._plugins.nativeapp.constants import ( LOOSE_FILES_MAGIC_VERSION, @@ -38,8 +38,10 @@ APP_PACKAGE_ENTITY, APPLICATION_PACKAGE_ENTITY_MODULE, SQL_EXECUTOR_EXECUTE, + SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL, SQL_FACADE_GET_UI_PARAMETER, SQL_FACADE_MODIFY_RELEASE_DIRECTIVE, + SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL, SQL_FACADE_SET_RELEASE_DIRECTIVE, SQL_FACADE_SHOW_RELEASE_CHANNELS, SQL_FACADE_SHOW_RELEASE_DIRECTIVES, @@ -226,9 +228,6 @@ def test_given_channels_disabled_and_no_directives_when_release_directive_list_t ) assert result == [] - show_release_channels.assert_called_once_with( - pkg_model.fqn.name, pkg_model.meta.role - ) show_release_directives.assert_called_once_with( package_name=pkg_model.fqn.name, @@ -254,9 +253,6 @@ def test_given_channels_disabled_and_directives_present_when_release_directive_l ) assert result == [{"name": "my_directive"}] - show_release_channels.assert_called_once_with( - pkg_model.fqn.name, pkg_model.meta.role - ) show_release_directives.assert_called_once_with( package_name=pkg_model.fqn.name, @@ -285,9 +281,6 @@ def test_given_multiple_directives_and_like_pattern_when_release_directive_list_ ) assert result == [{"name": "abcdef"}] - show_release_channels.assert_called_once_with( - pkg_model.fqn.name, pkg_model.meta.role - ) show_release_directives.assert_called_once_with( package_name=pkg_model.fqn.name, @@ -314,10 +307,6 @@ def test_given_channels_enabled_and_no_channel_specified_when_release_directive_ assert result == [{"name": "my_directive"}] - show_release_channels.assert_called_once_with( - pkg_model.fqn.name, pkg_model.meta.role - ) - show_release_directives.assert_called_once_with( package_name=pkg_model.fqn.name, role=pkg_model.meta.role, @@ -366,14 +355,14 @@ def test_given_channels_disabled_and_non_default_channel_selected_when_release_d pkg_model = application_package_entity._entity_model # noqa SLF001 pkg_model.meta.role = "package_role" - with pytest.raises(ClickException) as e: + with pytest.raises(UsageError) as e: application_package_entity.action_release_directive_list( action_ctx=action_context, release_channel="non_default", like="%%" ) assert ( str(e.value) - == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + == f"Release channels are not enabled for application package {pkg_model.fqn.name}." ) show_release_channels.assert_called_once_with( pkg_model.fqn.name, pkg_model.meta.role @@ -394,14 +383,14 @@ def test_given_channels_enabled_and_invalid_channel_selected_when_release_direct pkg_model = application_package_entity._entity_model # noqa SLF001 pkg_model.meta.role = "package_role" - with pytest.raises(ClickException) as e: + with pytest.raises(UsageError) as e: application_package_entity.action_release_directive_list( action_ctx=action_context, release_channel="invalid_channel", like="%%" ) assert ( str(e.value) - == f"Release channel invalid_channel does not exist in application package {pkg_model.fqn.name}." + == f"Release channel invalid_channel is not available in application package {pkg_model.fqn.name}. Available release channels are: (my_channel)." ) show_release_channels.assert_called_once_with( pkg_model.fqn.name, pkg_model.meta.role @@ -555,7 +544,7 @@ def test_given_no_channels_with_non_default_channel_used_when_release_directive_ pkg_model = application_package_entity._entity_model # noqa SLF001 pkg_model.meta.role = "package_role" - with pytest.raises(ClickException) as e: + with pytest.raises(UsageError) as e: application_package_entity.action_release_directive_set( action_ctx=action_context, version="1.0", @@ -567,7 +556,7 @@ def test_given_no_channels_with_non_default_channel_used_when_release_directive_ assert ( str(e.value) - == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + == f"Release channels are not enabled for application package {pkg_model.fqn.name}." ) show_release_channels.assert_called_once_with( @@ -776,7 +765,7 @@ def test_given_channels_disabled_and_non_default_channel_selected_when_release_d pkg_model = application_package_entity._entity_model # noqa SLF001 pkg_model.meta.role = "package_role" - with pytest.raises(ClickException) as e: + with pytest.raises(UsageError) as e: application_package_entity.action_release_directive_unset( action_ctx=action_context, release_channel="non_default", @@ -785,7 +774,7 @@ def test_given_channels_disabled_and_non_default_channel_selected_when_release_d assert ( str(e.value) - == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + == f"Release channels are not enabled for application package {pkg_model.fqn.name}." ) show_release_channels.assert_called_once_with( @@ -806,7 +795,7 @@ def test_given_channels_enabled_and_non_existing_channel_selected_when_release_d pkg_model = application_package_entity._entity_model # noqa SLF001 pkg_model.meta.role = "package_role" - with pytest.raises(ClickException) as e: + with pytest.raises(UsageError) as e: application_package_entity.action_release_directive_unset( action_ctx=action_context, release_channel="non_existing", @@ -815,7 +804,7 @@ def test_given_channels_enabled_and_non_existing_channel_selected_when_release_d assert ( str(e.value) - == f"Release channel non_existing does not exist in application package {pkg_model.fqn.name}." + == f"Release channel non_existing is not available in application package {pkg_model.fqn.name}. Available release channels are: (my_channel)." ) show_release_channels.assert_called_once_with( @@ -1022,3 +1011,233 @@ def test_given_release_channels_with_a_selected_channel_to_filter_when_list_rele assert result == [test_channel_1] assert capsys.readouterr().out == os_agnostic_snapshot + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL) +def test_given_release_channel_and_accounts_when_add_accounts_to_release_channel_then_success( + add_accounts_to_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + application_package_entity.action_release_channel_add_accounts( + action_ctx=action_context, + release_channel="test_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + add_accounts_to_release_channel.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel="test_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL) +def test_given_release_channels_disabled_when_add_accounts_to_release_channel_then_error( + add_accounts_to_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [] + + with pytest.raises(UsageError) as e: + application_package_entity.action_release_channel_add_accounts( + action_ctx=action_context, + release_channel="invalid_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + assert ( + str(e.value) + == f"Release channels are not enabled for application package {pkg_model.fqn.name}." + ) + + add_accounts_to_release_channel.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL) +def test_given_invalid_release_channel_when_add_accounts_to_release_channel_then_error( + add_accounts_to_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + with pytest.raises(UsageError) as e: + application_package_entity.action_release_channel_add_accounts( + action_ctx=action_context, + release_channel="invalid_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + assert ( + str(e.value) + == f"Release channel invalid_channel is not available in application package {pkg_model.fqn.name}. Available release channels are: (test_channel)." + ) + + add_accounts_to_release_channel.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL) +@pytest.mark.parametrize( + "account_name", ["org1", "org1.", ".account1", "org1.acc.ount1"] +) +def test_given_invalid_account_names_when_add_accounts_to_release_channel_then_error( + add_accounts_to_release_channel, + show_release_channels, + application_package_entity, + action_context, + account_name, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_channel_add_accounts( + action_ctx=action_context, + release_channel="test_channel", + target_accounts=[account_name], + ) + + assert ( + str(e.value) + == f"Target account {account_name} is not in a valid format. Make sure you provide the target account in the format 'org.account'." + ) + + add_accounts_to_release_channel.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL) +def test_given_release_channel_and_accounts_when_remove_accounts_from_release_channel_then_success( + remove_accounts_from_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + application_package_entity.action_release_channel_remove_accounts( + action_ctx=action_context, + release_channel="test_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + remove_accounts_from_release_channel.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel="test_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL) +def test_given_release_channel_disabled_when_remove_accounts_from_release_channel_then_error( + remove_accounts_from_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [] + + with pytest.raises(UsageError) as e: + application_package_entity.action_release_channel_remove_accounts( + action_ctx=action_context, + release_channel="invalid_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + assert ( + str(e.value) + == f"Release channels are not enabled for application package {pkg_model.fqn.name}." + ) + + remove_accounts_from_release_channel.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL) +def test_given_invalid_release_channel_when_remove_accounts_from_release_channel_then_error( + remove_accounts_from_release_channel, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + with pytest.raises(UsageError) as e: + application_package_entity.action_release_channel_remove_accounts( + action_ctx=action_context, + release_channel="invalid_channel", + target_accounts=["org1.acc1", "org2.acc2"], + ) + + assert ( + str(e.value) + == f"Release channel invalid_channel is not available in application package {pkg_model.fqn.name}. Available release channels are: (test_channel)." + ) + + remove_accounts_from_release_channel.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +@mock.patch(SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL) +@pytest.mark.parametrize( + "account_name", ["org1", "org1.", ".account1", "org1.acc.ount1"] +) +def test_given_invalid_account_names_when_remove_accounts_from_release_channel_then_error( + remove_accounts_from_release_channel, + show_release_channels, + application_package_entity, + action_context, + account_name, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [{"name": "test_channel"}] + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_channel_remove_accounts( + action_ctx=action_context, + release_channel="test_channel", + target_accounts=[account_name], + ) + + assert ( + str(e.value) + == f"Target account {account_name} is not in a valid format. Make sure you provide the target account in the format 'org.account'." + ) + + remove_accounts_from_release_channel.assert_not_called() diff --git a/tests/nativeapp/test_run_processor.py b/tests/nativeapp/test_run_processor.py index 3b478bad2e..90aa4b7cf8 100644 --- a/tests/nativeapp/test_run_processor.py +++ b/tests/nativeapp/test_run_processor.py @@ -2506,7 +2506,7 @@ def test_run_app_from_release_directive_with_channel_not_in_list( assert ( str(err.value) - == "Release channel 'unknown_channel' is not available for application package app_pkg. Available release channels: (channel1, channel2)." + == "Release channel unknown_channel is not available in application package app_pkg. Available release channels are: (channel1, channel2)." ) mock_sql_facade_upgrade_application.assert_not_called() mock_sql_facade_create_application.assert_not_called() diff --git a/tests/nativeapp/test_sf_sql_facade.py b/tests/nativeapp/test_sf_sql_facade.py index 7d678c669d..a5493c8192 100644 --- a/tests/nativeapp/test_sf_sql_facade.py +++ b/tests/nativeapp/test_sf_sql_facade.py @@ -51,6 +51,7 @@ APPLICATION_REQUIRES_TELEMETRY_SHARING, CANNOT_DISABLE_MANDATORY_TELEMETRY, CANNOT_DISABLE_RELEASE_CHANNELS, + CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS, DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED, DOES_NOT_EXIST_OR_NOT_AUTHORIZED, INSUFFICIENT_PRIVILEGES, @@ -3713,3 +3714,185 @@ def test_drop_version_from_package_with_error( sql_facade.drop_version_from_package( package_name=package_name, version=version, role=role ) + + +def test_add_accounts_to_release_channel_valid_input_then_success( + mock_use_role, mock_execute_query +): + package_name = "test_package" + release_channel = "test_channel" + accounts = ["org1.acc1", "org2.acc2"] + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + "alter application package test_package modify release channel test_channel add accounts = (org1.acc1,org2.acc2)" + ), + ), + ] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.add_accounts_to_release_channel( + package_name, release_channel, accounts, role + ) + + +def test_add_accounts_to_release_channel_with_special_chars_in_names( + mock_use_role, mock_execute_query +): + package_name = "test.package" + release_channel = "test.channel" + accounts = ["org1.acc1", "org2.acc2"] + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + 'alter application package "test.package" modify release channel "test.channel" add accounts = (org1.acc1,org2.acc2)' + ), + ), + ] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.add_accounts_to_release_channel( + package_name, release_channel, accounts, role + ) + + +@pytest.mark.parametrize( + "error_raised, error_caught, error_message", + [ + ( + ProgrammingError(errno=ACCOUNT_DOES_NOT_EXIST), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(errno=ACCOUNT_HAS_TOO_MANY_QUALIFIERS), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(errno=CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS), + UserInputError, + "Cannot modify accounts for release channel test_channel in application package test_package.", + ), + ( + ProgrammingError(), + InvalidSQLError, + "Failed to add accounts to release channel test_channel in application package test_package.", + ), + ], +) +@mock.patch(SQL_EXECUTOR_EXECUTE) +def test_add_accounts_to_release_channel_error( + mock_execute_query, error_raised, error_caught, error_message, mock_use_role +): + mock_execute_query.side_effect = error_raised + + with pytest.raises(error_caught) as err: + sql_facade.add_accounts_to_release_channel( + "test_package", "test_channel", ["org1.acc1"], "test_role" + ) + + assert error_message in str(err) + + +def test_remove_accounts_from_release_channel_valid_input_then_success( + mock_use_role, mock_execute_query +): + package_name = "test_package" + release_channel = "test_channel" + accounts = ["org1.acc1", "org2.acc2"] + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + "alter application package test_package modify release channel test_channel remove accounts = (org1.acc1,org2.acc2)" + ), + ), + ] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.remove_accounts_from_release_channel( + package_name, release_channel, accounts, role + ) + + +def test_remove_accounts_from_release_channel_with_special_chars_in_names( + mock_use_role, mock_execute_query +): + package_name = "test.package" + release_channel = "test.channel" + accounts = ["org1.acc1", "org2.acc2"] + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + 'alter application package "test.package" modify release channel "test.channel" remove accounts = (org1.acc1,org2.acc2)' + ), + ), + ] + + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.remove_accounts_from_release_channel( + package_name, release_channel, accounts, role + ) + + +@pytest.mark.parametrize( + "error_raised, error_caught, error_message", + [ + ( + ProgrammingError(errno=ACCOUNT_DOES_NOT_EXIST), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(errno=ACCOUNT_HAS_TOO_MANY_QUALIFIERS), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(errno=CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS), + UserInputError, + "Cannot modify accounts for release channel test_channel in application package test_package.", + ), + ( + ProgrammingError(), + InvalidSQLError, + "Failed to remove accounts from release channel test_channel in application package test_package.", + ), + ], +) +@mock.patch(SQL_EXECUTOR_EXECUTE) +def test_remove_accounts_from_release_channel_error( + mock_execute_query, error_raised, error_caught, error_message, mock_use_role +): + mock_execute_query.side_effect = error_raised + + with pytest.raises(error_caught) as err: + sql_facade.remove_accounts_from_release_channel( + "test_package", "test_channel", ["org1.acc1"], "test_role" + ) + + assert error_message in str(err) diff --git a/tests/nativeapp/utils.py b/tests/nativeapp/utils.py index dcaae91080..d3f7ee4d32 100644 --- a/tests/nativeapp/utils.py +++ b/tests/nativeapp/utils.py @@ -96,6 +96,12 @@ SQL_FACADE_SHOW_RELEASE_CHANNELS = f"{SQL_FACADE}.show_release_channels" SQL_FACADE_DROP_VERSION = f"{SQL_FACADE}.drop_version_from_package" SQL_FACADE_CREATE_VERSION = f"{SQL_FACADE}.create_version_in_package" +SQL_FACADE_ADD_ACCOUNTS_TO_RELEASE_CHANNEL = ( + f"{SQL_FACADE}.add_accounts_to_release_channel" +) +SQL_FACADE_REMOVE_ACCOUNTS_FROM_RELEASE_CHANNEL = ( + f"{SQL_FACADE}.remove_accounts_from_release_channel" +) mock_snowflake_yml_file = dedent( """\