From 5519f5a8ecc62fe705352c9be6d0d935be0e65f4 Mon Sep 17 00:00:00 2001 From: Francois Campbell Date: Wed, 12 Jun 2024 11:46:44 -0400 Subject: [PATCH] [SNOW-1462584] Implement setup script validation (#1161) Calls `system$validate_native_app_setup()` to validate the setup script in the stage on `snow app deploy`, `snow app run`, and the new `snow app validate`. The validation is done server-side, all we need to do is raise an error and show messages if the validation failed. For `snow app validate`, we use a scratch stage to avoid clobbering the app's dev stage in between deploys. This stage is deleted after every validation run and on snow app teardown to avoid leaving behind any garbage. --- RELEASE-NOTES.md | 3 + .../project/schemas/native_app/native_app.py | 4 + .../cli/plugins/nativeapp/commands.py | 31 +- .../cli/plugins/nativeapp/common_flags.py | 6 + .../cli/plugins/nativeapp/constants.py | 1 + .../cli/plugins/nativeapp/exceptions.py | 11 +- .../cli/plugins/nativeapp/manager.py | 91 ++++- .../cli/plugins/nativeapp/run_processor.py | 5 +- .../nativeapp/version/version_processor.py | 1 + tests/__snapshots__/test_help_messages.ambr | 86 +++- tests/nativeapp/test_manager.py | 366 +++++++++++++++++- tests/nativeapp/utils.py | 3 + tests/project/__snapshots__/test_config.ambr | 5 + tests_integration/nativeapp/test_deploy.py | 28 +- tests_integration/nativeapp/test_validate.py | 84 ++++ 15 files changed, 700 insertions(+), 25 deletions(-) create mode 100644 tests_integration/nativeapp/test_validate.py diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 52a81545a..9b3fd78d8 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -21,6 +21,9 @@ ## New additions * Added `snow app bundle` command that prepares a local folder in the project directory with artifacts to be uploaded to a stage as part of creating a Snowflake Native App. +* Added `snow app validate` command that validates the setup script SQL used to create a Snowflake Native App for syntax validity, invalid object references, and best practices + * Added new `native_app.scratch_stage` field to `snowflake.yml` schema to allow customizing the stage that the CLI uses to run the validation +* Changed `snow app deploy` and `snow app run` to trigger validation of the uploaded setup script SQL and block uploads on validation failure, pass `--no-validate` to disable * Changed `snow app version create --patch` to require an integer patch number, aligning with what Snowflake expects * Added `snow notebook` commands: * `snow notebook execute` enabling head-less execution of a notebook. diff --git a/src/snowflake/cli/api/project/schemas/native_app/native_app.py b/src/snowflake/cli/api/project/schemas/native_app/native_app.py index ee2550001..49c7847fd 100644 --- a/src/snowflake/cli/api/project/schemas/native_app/native_app.py +++ b/src/snowflake/cli/api/project/schemas/native_app/native_app.py @@ -46,6 +46,10 @@ class NativeApp(UpdatableModel): title="Identifier of the stage that stores the application artifacts.", default="app_src.stage", ) + scratch_stage: Optional[str] = Field( + title="Identifier of the stage that stores temporary scratch data used by the Snowflake CLI.", + default="app_src.stage_snowflake_cli_scratch", + ) package: Optional[Package] = Field(title="PackageSchema", default=None) application: Optional[Application] = Field(title="Application info", default=None) diff --git a/src/snowflake/cli/plugins/nativeapp/commands.py b/src/snowflake/cli/plugins/nativeapp/commands.py index 9a27a1d95..aab38bf10 100644 --- a/src/snowflake/cli/plugins/nativeapp/commands.py +++ b/src/snowflake/cli/plugins/nativeapp/commands.py @@ -24,13 +24,19 @@ with_project_definition, ) from snowflake.cli.api.commands.snow_typer import SnowTyperFactory +from snowflake.cli.api.output.formats import OutputFormat from snowflake.cli.api.output.types import ( CollectionResult, CommandResult, MessageResult, + ObjectResult, ) from snowflake.cli.api.secure_path import SecurePath -from snowflake.cli.plugins.nativeapp.common_flags import ForceOption, InteractiveOption +from snowflake.cli.plugins.nativeapp.common_flags import ( + ForceOption, + InteractiveOption, + ValidateOption, +) from snowflake.cli.plugins.nativeapp.init import ( OFFICIAL_TEMPLATES_GITHUB_URL, nativeapp_init, @@ -82,7 +88,7 @@ def app_init( ), template: str = typer.Option( None, - help="A specific template name within the template repo to use as template for the Native Apps project. Example: Default is basic if `--template-repo` is https://github.com/snowflakedb/native-apps-templates.git, and None if any other --template-repo is specified.", + help="A specific template name within the template repo to use as template for the Snowflake Native App project. Example: Default is basic if `--template-repo` is https://github.com/snowflakedb/native-apps-templates.git, and None if any other --template-repo is specified.", ), **options, ) -> CommandResult: @@ -175,6 +181,7 @@ def app_run( ), interactive: bool = InteractiveOption, force: Optional[bool] = ForceOption, + validate: bool = ValidateOption, **options, ) -> CommandResult: """ @@ -203,6 +210,7 @@ def app_run( patch=patch, from_release_directive=from_release_directive, is_interactive=is_interactive, + validate=validate, ) return MessageResult( f"Your application object ({processor.app_name}) is now available:\n" @@ -273,6 +281,7 @@ def app_deploy( show_default=False, help=f"""Paths, relative to the the project root, of files you want to upload to a stage. The paths must match one of the artifacts src pattern entries in snowflake.yml. If unspecified, the command syncs all local changes to the stage.""", ), + validate: bool = ValidateOption, **options, ) -> CommandResult: """ @@ -300,8 +309,26 @@ def app_deploy( prune=prune, recursive=recursive, local_paths_to_sync=files, + validate=validate, ) return MessageResult( f"Deployed successfully. Application package and stage are up-to-date." ) + + +@app.command("validate", requires_connection=True) +@with_project_definition("native_app") +def app_validate(**options): + """ + Validates a deployed Snowflake Native App's setup script. + """ + manager = NativeAppManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + if cli_context.output_format == OutputFormat.JSON: + return ObjectResult(manager.get_validation_result(use_scratch_stage=True)) + + manager.validate(use_scratch_stage=True) + return MessageResult("Snowflake Native App validation succeeded.") diff --git a/src/snowflake/cli/plugins/nativeapp/common_flags.py b/src/snowflake/cli/plugins/nativeapp/common_flags.py index 3d31f2514..434e36e9c 100644 --- a/src/snowflake/cli/plugins/nativeapp/common_flags.py +++ b/src/snowflake/cli/plugins/nativeapp/common_flags.py @@ -36,3 +36,9 @@ def interactive_callback(val): You should enable this option if interactive mode is not specified and if you want perform potentially destructive actions. Defaults to unset.""", is_flag=True, ) +ValidateOption = typer.Option( + True, + "--validate/--no-validate", + help="""When enabled, this option triggers validation of a deployed Snowflake Native App's setup script SQL""", + is_flag=True, +) diff --git a/src/snowflake/cli/plugins/nativeapp/constants.py b/src/snowflake/cli/plugins/nativeapp/constants.py index 2ada8100b..9522f56bf 100644 --- a/src/snowflake/cli/plugins/nativeapp/constants.py +++ b/src/snowflake/cli/plugins/nativeapp/constants.py @@ -26,5 +26,6 @@ INTERNAL_DISTRIBUTION = "internal" EXTERNAL_DISTRIBUTION = "external" +ERROR_MESSAGE_2003 = "does not exist or not authorized" ERROR_MESSAGE_2043 = "Object does not exist, or operation cannot be performed." ERROR_MESSAGE_606 = "No active warehouse selected in the current session." diff --git a/src/snowflake/cli/plugins/nativeapp/exceptions.py b/src/snowflake/cli/plugins/nativeapp/exceptions.py index 4a4a515e3..f7e674472 100644 --- a/src/snowflake/cli/plugins/nativeapp/exceptions.py +++ b/src/snowflake/cli/plugins/nativeapp/exceptions.py @@ -28,11 +28,11 @@ def __init__(self, name: str): class ApplicationPackageDoesNotExistError(ClickException): - """An application package of the specified name does not exist in the Snowflake account.""" + """An application package of the specified name does not exist in the Snowflake account or the current role isn't authorized.""" def __init__(self, name: str): super().__init__( - f"Application Package {name} does not exist in the Snowflake account." + f"Application Package {name} does not exist in the Snowflake account or not authorized." ) @@ -88,3 +88,10 @@ def __init__(self): """ ) ) + + +class SetupScriptFailedValidation(ClickException): + """Snowflake Native App setup script failed validation.""" + + def __init__(self): + super().__init__(self.__doc__) diff --git a/src/snowflake/cli/plugins/nativeapp/manager.py b/src/snowflake/cli/plugins/nativeapp/manager.py index 41482e2eb..35f47670d 100644 --- a/src/snowflake/cli/plugins/nativeapp/manager.py +++ b/src/snowflake/cli/plugins/nativeapp/manager.py @@ -14,6 +14,7 @@ from __future__ import annotations +import json import os from abc import ABC, abstractmethod from functools import cached_property @@ -51,6 +52,7 @@ ALLOWED_SPECIAL_COMMENTS, COMMENT_COL, ERROR_MESSAGE_606, + ERROR_MESSAGE_2003, ERROR_MESSAGE_2043, INTERNAL_DISTRIBUTION, NAME_COL, @@ -59,8 +61,10 @@ ) from snowflake.cli.plugins.nativeapp.exceptions import ( ApplicationPackageAlreadyExistsError, + ApplicationPackageDoesNotExistError, InvalidPackageScriptError, MissingPackageScriptError, + SetupScriptFailedValidation, UnexpectedOwnerError, ) from snowflake.cli.plugins.nativeapp.feature_flags import FeatureFlag @@ -73,6 +77,7 @@ sync_local_diff_with_stage, to_stage_path, ) +from snowflake.cli.plugins.stage.manager import StageManager from snowflake.connector import ProgrammingError from snowflake.connector.cursor import DictCursor @@ -196,6 +201,10 @@ def package_scripts(self) -> List[str]: def stage_fqn(self) -> str: return f"{self.package_name}.{self.definition.source_stage}" + @cached_property + def scratch_stage_fqn(self) -> str: + return f"{self.package_name}.{self.definition.scratch_stage}" + @cached_property def stage_schema(self) -> Optional[str]: return extract_schema(self.stage_fqn) @@ -349,6 +358,7 @@ def sync_deploy_root_with_stage( role: str, prune: bool, recursive: bool, + stage_fqn: str, local_paths_to_sync: List[Path] | None = None, ) -> DiffResult: """ @@ -356,13 +366,13 @@ def sync_deploy_root_with_stage( the local filesystem. Args: + bundle_map (BundleMap): The artifact mapping computed by the `build_bundle` function. role (str): The name of the role to use for queries and commands. prune (bool): Whether to prune artifacts from the stage that don't exist locally. recursive (bool): Whether to traverse directories recursively. + stage_fqn (str): The name of the stage to diff against and upload to. local_paths_to_sync (List[Path], optional): List of local paths to sync. Defaults to None to sync all local paths. Note that providing an empty list here is equivalent to None. - bundle_map: the artifact mapping computed during the `bundle` step. Required when local_paths_to_sync is - provided. Returns: A `DiffResult` instance describing the changes that were performed. @@ -370,14 +380,16 @@ def sync_deploy_root_with_stage( # Does a stage already exist within the application package, or we need to create one? # Using "if not exists" should take care of either case. - cc.step("Checking if stage exists, or creating a new one if none exists.") + cc.step( + f"Checking if stage {stage_fqn} exists, or creating a new one if none exists." + ) with self.use_role(role): self._execute_query( f"create schema if not exists {self.package_name}.{self.stage_schema}" ) self._execute_query( f""" - create stage if not exists {self.stage_fqn} + create stage if not exists {stage_fqn} encryption = (TYPE = 'SNOWFLAKE_SSE') DIRECTORY = (ENABLE = TRUE)""" ) @@ -387,7 +399,7 @@ def sync_deploy_root_with_stage( "Performing a diff between the Snowflake stage and your local deploy_root ('%s') directory." % self.deploy_root ) - diff: DiffResult = compute_stage_diff(self.deploy_root, self.stage_fqn) + diff: DiffResult = compute_stage_diff(self.deploy_root, stage_fqn) files_not_removed = [] if local_paths_to_sync: @@ -438,7 +450,7 @@ def sync_deploy_root_with_stage( role=role, deploy_root_path=self.deploy_root, diff_result=diff, - stage_fqn=self.stage_fqn, + stage_fqn=stage_fqn, ) return diff @@ -566,7 +578,9 @@ def deploy( bundle_map: BundleMap, prune: bool, recursive: bool, + stage_fqn: Optional[str] = None, local_paths_to_sync: List[Path] | None = None, + validate: bool = True, ) -> DiffResult: """app deploy process""" @@ -578,12 +592,77 @@ def deploy( self._apply_package_scripts() # 3. Upload files from deploy root local folder to the above stage + stage_fqn = stage_fqn or self.stage_fqn diff = self.sync_deploy_root_with_stage( bundle_map=bundle_map, role=self.package_role, prune=prune, recursive=recursive, + stage_fqn=stage_fqn, local_paths_to_sync=local_paths_to_sync, ) + if validate: + self.validate(use_scratch_stage=False) + return diff + + def validate(self, use_scratch_stage: bool = False): + """Validates Native App setup script SQL.""" + with cc.phase(f"Validating Snowflake Native App setup script."): + validation_result = self.get_validation_result(use_scratch_stage) + + # First print warnings, regardless of the outcome of validation + for warning in validation_result.get("warnings", []): + cc.warning(_validation_item_to_str(warning)) + + # Then print errors + for error in validation_result.get("errors", []): + # Print them as warnings for now since we're going to be + # revamping CLI output soon + cc.warning(_validation_item_to_str(error)) + + # Then raise an exception if validation failed + if validation_result["status"] == "FAIL": + raise SetupScriptFailedValidation() + + def get_validation_result(self, use_scratch_stage: bool): + """Call system$validate_native_app_setup() to validate deployed Native App setup script.""" + stage_fqn = self.stage_fqn + if use_scratch_stage: + stage_fqn = self.scratch_stage_fqn + bundle_map = self.build_bundle() + self.deploy( + bundle_map=bundle_map, + prune=True, + recursive=True, + stage_fqn=stage_fqn, + validate=False, + ) + prefixed_stage_fqn = StageManager.get_standard_stage_prefix(stage_fqn) + try: + cursor = self._execute_query( + f"call system$validate_native_app_setup('{prefixed_stage_fqn}')" + ) + except ProgrammingError as err: + if err.errno == 2003 and ERROR_MESSAGE_2003 in err.msg: + raise ApplicationPackageDoesNotExistError(self.package_name) + generic_sql_error_handler(err) + else: + if not cursor.rowcount: + raise SnowflakeSQLExecutionError() + return json.loads(cursor.fetchone()[0]) + finally: + if use_scratch_stage: + cc.step(f"Dropping stage {self.scratch_stage_fqn}.") + with self.use_role(self.package_role): + self._execute_query( + f"drop stage if exists {self.scratch_stage_fqn}" + ) + + +def _validation_item_to_str(item: dict[str, str | int]): + s = item["message"] + if item["errorCode"]: + s = f"{s} (error code {item['errorCode']})" + return s diff --git a/src/snowflake/cli/plugins/nativeapp/run_processor.py b/src/snowflake/cli/plugins/nativeapp/run_processor.py index 2eb676f57..782ccf156 100644 --- a/src/snowflake/cli/plugins/nativeapp/run_processor.py +++ b/src/snowflake/cli/plugins/nativeapp/run_processor.py @@ -299,6 +299,7 @@ def process( patch: Optional[int] = None, from_release_directive: bool = False, is_interactive: bool = False, + validate: bool = True, *args, **kwargs, ): @@ -328,5 +329,7 @@ def process( ) return - diff = self.deploy(bundle_map=bundle_map, prune=True, recursive=True) + diff = self.deploy( + bundle_map=bundle_map, prune=True, recursive=True, validate=validate + ) self._create_dev_app(diff) diff --git a/src/snowflake/cli/plugins/nativeapp/version/version_processor.py b/src/snowflake/cli/plugins/nativeapp/version/version_processor.py index a78982618..bb5ef7911 100644 --- a/src/snowflake/cli/plugins/nativeapp/version/version_processor.py +++ b/src/snowflake/cli/plugins/nativeapp/version/version_processor.py @@ -224,6 +224,7 @@ def process( role=self.package_role, prune=True, recursive=True, + stage_fqn=self.stage_fqn, ) # Warn if the version exists in a release directive(s) diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index f5a20ba5e..33f83b1f0 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -96,6 +96,10 @@ │ files in the current directory are │ │ deployed. │ │ [default: no-recursive] │ + │ --validate --no-validate When enabled, this option triggers │ + │ validation of a deployed Snowflake │ + │ Native App's setup script SQL │ + │ [default: validate] │ │ --project -p TEXT Path where the Snowflake Native App │ │ project resides. Defaults to │ │ current working directory. │ @@ -194,9 +198,9 @@ │ modes of authentication. │ │ [default: None] │ │ --template TEXT A specific template name within the template │ - │ repo to use as template for the Native Apps │ - │ project. Example: Default is basic if │ - │ `--template-repo` is │ + │ repo to use as template for the Snowflake │ + │ Native App project. Example: Default is basic │ + │ if `--template-repo` is │ │ https://github.com/snowflakedb/native-apps-t… │ │ and None if any other --template-repo is │ │ specified. │ @@ -386,6 +390,13 @@ │ perform potentially │ │ destructive actions. │ │ Defaults to unset. │ + │ --validate --no-validate When enabled, this │ + │ option triggers │ + │ validation of a │ + │ deployed Snowflake │ + │ Native App's setup │ + │ script SQL │ + │ [default: validate] │ │ --project -p TEXT Path where the │ │ Snowflake Native App │ │ project resides. │ @@ -536,6 +547,73 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ + ''' +# --- +# name: test_help_messages[app.validate] + ''' + + Usage: default app validate [OPTIONS] + + Validates a deployed Snowflake Native App's setup script. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --project -p TEXT Path where the Snowflake Native App project │ + │ resides. Defaults to current working directory. │ + │ --help -h Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Connection configuration ───────────────────────────────────────────────────╮ + │ --connection,--environment -c TEXT Name of the connection, as defined │ + │ in your `config.toml`. Default: │ + │ `default`. │ + │ --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-path TEXT Snowflake private key path. │ + │ Overrides the value specified for │ + │ the connection. │ + │ --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 contains additional │ + │ information. │ + │ --silent Turns off intermediate output to console. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + ''' # --- # name: test_help_messages[app.version.create] @@ -860,6 +938,7 @@ │ object from the application package. │ │ teardown Attempts to drop both the application object and application │ │ package as defined in the project definition file. │ + │ validate Validates a deployed Snowflake Native App's setup script. │ │ version Manages versions defined in an application package │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -7581,6 +7660,7 @@ │ object from the application package. │ │ teardown Attempts to drop both the application object and application │ │ package as defined in the project definition file. │ + │ validate Validates a deployed Snowflake Native App's setup script. │ │ version Manages versions defined in an application package │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/nativeapp/test_manager.py b/tests/nativeapp/test_manager.py index b8cc38bfd..b21120f27 100644 --- a/tests/nativeapp/test_manager.py +++ b/tests/nativeapp/test_manager.py @@ -12,10 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os from pathlib import Path from textwrap import dedent from unittest import mock +from unittest.mock import call import pytest from snowflake.cli.api.project.definition_manager import DefinitionManager @@ -28,6 +30,8 @@ ) from snowflake.cli.plugins.nativeapp.exceptions import ( ApplicationPackageAlreadyExistsError, + ApplicationPackageDoesNotExistError, + SetupScriptFailedValidation, UnexpectedOwnerError, ) from snowflake.cli.plugins.nativeapp.manager import ( @@ -48,6 +52,8 @@ mock_get_app_pkg_distribution_in_sf, ) from tests.nativeapp.utils import ( + NATIVEAPP_MANAGER_BUILD_BUNDLE, + NATIVEAPP_MANAGER_DEPLOY, NATIVEAPP_MANAGER_EXECUTE, NATIVEAPP_MANAGER_GET_EXISTING_APP_PKG_INFO, NATIVEAPP_MANAGER_IS_APP_PKG_DISTRIBUTION_SAME, @@ -105,7 +111,11 @@ def test_sync_deploy_root_with_stage( assert mock_diff_result.has_changes() mock_bundle_map = mock.Mock(spec=BundleMap) native_app_manager.sync_deploy_root_with_stage( - bundle_map=mock_bundle_map, role="new_role", prune=True, recursive=True + bundle_map=mock_bundle_map, + role="new_role", + prune=True, + recursive=True, + stage_fqn=native_app_manager.stage_fqn, ) expected = [ @@ -171,7 +181,11 @@ def test_sync_deploy_root_with_stage_prune( mock_bundle_map = mock.Mock(spec=BundleMap) native_app_manager.sync_deploy_root_with_stage( - bundle_map=mock_bundle_map, role="new_role", prune=prune, recursive=True + bundle_map=mock_bundle_map, + role="new_role", + prune=prune, + recursive=True, + stage_fqn=native_app_manager.stage_fqn, ) if expected_warn: @@ -826,3 +840,351 @@ def test_get_paths_to_sync( paths_to_sync = [Path(p) for p in paths_to_sync] result = _get_stage_paths_to_sync(paths_to_sync, Path("deploy/")) assert result.sort() == [StagePath(p) for p in expected_result].sort() + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_passing(mock_execute, temp_dir, mock_cursor): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + success_data = dict(status="SUCCESS") + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(success_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + native_app_manager.validate() + + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +@mock.patch(f"{NATIVEAPP_MODULE}.cc.warning") +def test_validate_passing_with_warnings( + mock_warning, mock_execute, temp_dir, mock_cursor +): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + warning_file = "@STAGE/setup_script.sql" + warning_cause = "APPLICATION ROLE should be created with IF NOT EXISTS." + warning = dict( + message=f"Warning in file {warning_file}: {warning_cause}", + cause=warning_cause, + errorCode="093352", + fileName=warning_file, + line=11, + column=35, + ) + failure_data = dict(status="SUCCESS", errors=[], warnings=[warning]) + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(failure_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + native_app_manager.validate() + + warn_message = f"{warning['message']} (error code {warning['errorCode']})" + mock_warning.assert_called_once_with(warn_message) + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +@mock.patch(f"{NATIVEAPP_MODULE}.cc.warning") +def test_validate_failing(mock_warning, mock_execute, temp_dir, mock_cursor): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + error_file = "@STAGE/empty.sql" + error_cause = "Empty SQL statement." + error = dict( + message=f"Error in file {error_file}: {error_cause}", + cause=error_cause, + errorCode="000900", + fileName=error_file, + line=-1, + column=-1, + ) + warning_file = "@STAGE/setup_script.sql" + warning_cause = "APPLICATION ROLE should be created with IF NOT EXISTS." + warning = dict( + message=f"Warning in file {warning_file}: {warning_cause}", + cause=warning_cause, + errorCode="093352", + fileName=warning_file, + line=11, + column=35, + ) + failure_data = dict(status="FAIL", errors=[error], warnings=[warning]) + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(failure_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + with pytest.raises( + SetupScriptFailedValidation, + match="Snowflake Native App setup script failed validation.", + ): + native_app_manager.validate() + + warn_message = f"{warning['message']} (error code {warning['errorCode']})" + error_message = f"{error['message']} (error code {error['errorCode']})" + mock_warning.assert_has_calls( + [call(warn_message), call(error_message)], any_order=False + ) + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_query_error(mock_execute, temp_dir, mock_cursor): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + with pytest.raises(SnowflakeSQLExecutionError): + native_app_manager.validate() + + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_not_deployed(mock_execute, temp_dir, mock_cursor): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + side_effects, expected = mock_execute_helper( + [ + ( + ProgrammingError( + msg="Application package app_pkg does not exist or not authorized.", + errno=2003, + ), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + with pytest.raises(ApplicationPackageDoesNotExistError, match="app_pkg"): + native_app_manager.validate() + + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_BUILD_BUNDLE) +@mock.patch(NATIVEAPP_MANAGER_DEPLOY) +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_use_scratch_stage( + mock_execute, mock_deploy, mock_build_bundle, temp_dir, mock_cursor +): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + success_data = dict(status="SUCCESS") + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(success_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage_snowflake_cli_scratch')" + ), + ), + ( + mock_cursor([{"CURRENT_ROLE()": "old_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role package_role")), + ( + mock_cursor([], []), + mock.call( + f"drop stage if exists app_pkg.app_src.stage_snowflake_cli_scratch" + ), + ), + (None, mock.call("use role old_role")), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + native_app_manager.validate(use_scratch_stage=True) + + mock_build_bundle.assert_called_once() + mock_deploy.assert_called_with( + bundle_map=mock_build_bundle.return_value, + prune=True, + recursive=True, + stage_fqn=native_app_manager.scratch_stage_fqn, + validate=False, + ) + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_BUILD_BUNDLE) +@mock.patch(NATIVEAPP_MANAGER_DEPLOY) +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_failing_drops_scratch_stage( + mock_execute, mock_deploy, mock_build_bundle, temp_dir, mock_cursor +): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + error_file = "@STAGE/empty.sql" + error_cause = "Empty SQL statement." + error = dict( + message=f"Error in file {error_file}: {error_cause}", + cause=error_cause, + errorCode="000900", + fileName=error_file, + line=-1, + column=-1, + ) + failure_data = dict(status="FAIL", errors=[error], warnings=[]) + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(failure_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage_snowflake_cli_scratch')" + ), + ), + ( + mock_cursor([{"CURRENT_ROLE()": "old_role"}], []), + mock.call("select current_role()", cursor_class=DictCursor), + ), + (None, mock.call("use role package_role")), + ( + mock_cursor([], []), + mock.call( + f"drop stage if exists app_pkg.app_src.stage_snowflake_cli_scratch" + ), + ), + (None, mock.call("use role old_role")), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + with pytest.raises( + SetupScriptFailedValidation, + match="Snowflake Native App setup script failed validation.", + ): + native_app_manager.validate(use_scratch_stage=True) + + mock_build_bundle.assert_called_once() + mock_deploy.assert_called_with( + bundle_map=mock_build_bundle.return_value, + prune=True, + recursive=True, + stage_fqn=native_app_manager.scratch_stage_fqn, + validate=False, + ) + assert mock_execute.mock_calls == expected + + +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_validate_raw_returns_data(mock_execute, temp_dir, mock_cursor): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + + error_file = "@STAGE/empty.sql" + error_cause = "Empty SQL statement." + error = dict( + message=f"Error in file {error_file}: {error_cause}", + cause=error_cause, + errorCode="000900", + fileName=error_file, + line=-1, + column=-1, + ) + warning_file = "@STAGE/setup_script.sql" + warning_cause = "APPLICATION ROLE should be created with IF NOT EXISTS." + warning = dict( + message=f"Warning in file {warning_file}: {warning_cause}", + cause=warning_cause, + errorCode="093352", + fileName=warning_file, + line=11, + column=35, + ) + failure_data = dict(status="FAIL", errors=[error], warnings=[warning]) + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([[json.dumps(failure_data)]], []), + mock.call( + "call system$validate_native_app_setup('@app_pkg.app_src.stage')" + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + assert ( + native_app_manager.get_validation_result(use_scratch_stage=False) + == failure_data + ) + assert mock_execute.mock_calls == expected diff --git a/tests/nativeapp/utils.py b/tests/nativeapp/utils.py index 14ffc5d3e..6c60ada4b 100644 --- a/tests/nativeapp/utils.py +++ b/tests/nativeapp/utils.py @@ -44,6 +44,9 @@ NATIVEAPP_MANAGER_GET_OBJECTS_OWNED_BY_APPLICATION = ( f"{NATIVEAPP_MANAGER}.get_objects_owned_by_application" ) +NATIVEAPP_MANAGER_BUILD_BUNDLE = f"{NATIVEAPP_MANAGER}.build_bundle" +NATIVEAPP_MANAGER_DEPLOY = f"{NATIVEAPP_MANAGER}.deploy" +NATIVEAPP_MANAGER_VALIDATE = f"{NATIVEAPP_MANAGER}.validate" TEARDOWN_PROCESSOR_GET_EXISTING_APP_INFO = f"{TEARDOWN_PROCESSOR}.get_existing_app_info" TEARDOWN_PROCESSOR_GET_EXISTING_APP_PKG_INFO = ( diff --git a/tests/project/__snapshots__/test_config.ambr b/tests/project/__snapshots__/test_config.ambr index d6ab0d46a..beb24badf 100644 --- a/tests/project/__snapshots__/test_config.ambr +++ b/tests/project/__snapshots__/test_config.ambr @@ -26,6 +26,7 @@ ]), 'warehouse': None, }), + 'scratch_stage': 'app_src.stage_snowflake_cli_scratch', 'source_stage': 'app_src.stage', }), 'snowpark': None, @@ -59,6 +60,7 @@ ]), 'warehouse': None, }), + 'scratch_stage': 'app_src.stage_snowflake_cli_scratch', 'source_stage': 'app_src.stage', }), 'snowpark': None, @@ -89,6 +91,7 @@ 'generated_root': '__generated/', 'name': 'minimal', 'package': None, + 'scratch_stage': 'app_src.stage_snowflake_cli_scratch', 'source_stage': 'app_src.stage', }), 'snowpark': None, @@ -139,6 +142,7 @@ ]), 'warehouse': None, }), + 'scratch_stage': 'app_src.stage_snowflake_cli_scratch', 'source_stage': '"MySourceSchema"."SRC_Stage"', }), 'snowpark': None, @@ -189,6 +193,7 @@ ]), 'warehouse': 'myapp_pkg_warehouse', }), + 'scratch_stage': 'app_src.stage_snowflake_cli_scratch', 'source_stage': '"MySourceSchema"."SRC_Stage"', }), 'snowpark': None, diff --git a/tests_integration/nativeapp/test_deploy.py b/tests_integration/nativeapp/test_deploy.py index 3f6942f77..2962d2bc4 100644 --- a/tests_integration/nativeapp/test_deploy.py +++ b/tests_integration/nativeapp/test_deploy.py @@ -116,9 +116,13 @@ def test_nativeapp_deploy( "command,contains,not_contains", [ # deploy --prune removes remote-only files - ["app deploy --prune", ["stage/manifest.yml"], ["stage/README.md"]], + [ + "app deploy --prune --no-validate", + ["stage/manifest.yml"], + ["stage/README.md"], + ], # deploy removes remote-only files (--prune is the default value) - ["app deploy", ["stage/manifest.yml"], ["stage/README.md"]], + ["app deploy --no-validate", ["stage/manifest.yml"], ["stage/README.md"]], # deploy --no-prune does not delete remote-only files ["app deploy --no-prune", ["stage/README.md"], []], ], @@ -200,7 +204,13 @@ def test_nativeapp_deploy_files( with pushd(Path(os.getcwd(), project_name)): # sync only two specific files to stage result = runner.invoke_with_connection_json( - ["app", "deploy", "app/manifest.yml", "app/setup_script.sql"], + [ + "app", + "deploy", + "app/manifest.yml", + "app/setup_script.sql", + "--no-validate", + ], env=TEST_ENV, ) assert result.exit_code == 0 @@ -254,7 +264,7 @@ def test_nativeapp_deploy_nested_directories( touch("app/nested/dir/file.txt") result = runner.invoke_with_connection_json( - ["app", "deploy", "app/nested/dir/file.txt"], + ["app", "deploy", "app/nested/dir/file.txt", "--no-validate"], env=TEST_ENV, ) assert result.exit_code == 0 @@ -303,7 +313,7 @@ def test_nativeapp_deploy_directory( with pushd(Path(os.getcwd(), project_dir)): touch("app/dir/file.txt") result = runner.invoke_with_connection( - ["app", "deploy", "app/dir", "--no-recursive"], + ["app", "deploy", "app/dir", "--no-recursive", "--no-validate"], env=TEST_ENV, ) assert_that_result_failed_with_message_containing( @@ -311,7 +321,7 @@ def test_nativeapp_deploy_directory( ) result = runner.invoke_with_connection_json( - ["app", "deploy", "app/dir", "-r"], + ["app", "deploy", "app/dir", "-r", "--no-validate"], env=TEST_ENV, ) assert result.exit_code == 0 @@ -359,7 +369,7 @@ def test_nativeapp_deploy_directory_no_recursive( try: touch("app/nested/dir/file.txt") result = runner.invoke_with_connection_json( - ["app", "deploy", "app/nested"], + ["app", "deploy", "app/nested", "--no-validate"], env=TEST_ENV, ) assert result.exit_code == 1, result.output @@ -390,7 +400,7 @@ def test_nativeapp_deploy_unknown_path( with pushd(Path(os.getcwd(), project_dir)): try: result = runner.invoke_with_connection_json( - ["app", "deploy", "does_not_exist"], + ["app", "deploy", "does_not_exist", "--no-validate"], env=TEST_ENV, ) assert result.exit_code == 1 @@ -422,7 +432,7 @@ def test_nativeapp_deploy_path_with_no_mapping( with pushd(Path(os.getcwd(), project_dir)): try: result = runner.invoke_with_connection_json( - ["app", "deploy", "snowflake.yml"], + ["app", "deploy", "snowflake.yml", "--no-validate"], env=TEST_ENV, ) assert result.exit_code == 1 diff --git a/tests_integration/nativeapp/test_validate.py b/tests_integration/nativeapp/test_validate.py new file mode 100644 index 000000000..aaabf68dd --- /dev/null +++ b/tests_integration/nativeapp/test_validate.py @@ -0,0 +1,84 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import uuid + +from snowflake.cli.api.project.util import generate_user_env +from tests.project.fixtures import * +from tests_integration.test_utils import ( + pushd, +) + +USER_NAME = f"user_{uuid.uuid4().hex}" +TEST_ENV = generate_user_env(USER_NAME) + + +@pytest.mark.integration +def test_nativeapp_validate(runner, temporary_working_directory): + project_name = "myapp" + result = runner.invoke_json( + ["app", "init", project_name], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + + with pushd(Path(os.getcwd(), project_name)): + try: + # validate the app's setup script + result = runner.invoke_with_connection( + ["app", "validate"], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + assert "Native App validation succeeded." in result.output + finally: + result = runner.invoke_with_connection( + ["app", "teardown", "--force"], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + + +@pytest.mark.integration +def test_nativeapp_validate_failing(runner, temporary_working_directory): + project_name = "myapp" + result = runner.invoke_json( + ["app", "init", project_name], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output + + with pushd(Path(os.getcwd(), project_name)): + # Create invalid SQL file + Path("app/setup_script.sql").write_text("Lorem ipsum dolor sit amet") + + try: + # validate the app's setup script, this will fail + # because we include an empty file + result = runner.invoke_with_connection( + ["app", "validate"], + env=TEST_ENV, + ) + assert result.exit_code == 1, result.output + assert ( + "Snowflake Native App setup script failed validation." in result.output + ) + assert "syntax error" in result.output + finally: + result = runner.invoke_with_connection( + ["app", "teardown", "--force"], + env=TEST_ENV, + ) + assert result.exit_code == 0, result.output