Skip to content

Commit

Permalink
[SNOW-1462584] Implement setup script validation (#1161)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sfc-gh-fcampbell committed Jun 12, 2024
1 parent f7151db commit 5519f5a
Show file tree
Hide file tree
Showing 15 changed files with 700 additions and 25 deletions.
3 changes: 3 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
31 changes: 29 additions & 2 deletions src/snowflake/cli/plugins/nativeapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -175,6 +181,7 @@ def app_run(
),
interactive: bool = InteractiveOption,
force: Optional[bool] = ForceOption,
validate: bool = ValidateOption,
**options,
) -> CommandResult:
"""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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.")
6 changes: 6 additions & 0 deletions src/snowflake/cli/plugins/nativeapp/common_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
1 change: 1 addition & 0 deletions src/snowflake/cli/plugins/nativeapp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
11 changes: 9 additions & 2 deletions src/snowflake/cli/plugins/nativeapp/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)


Expand Down Expand Up @@ -88,3 +88,10 @@ def __init__(self):
"""
)
)


class SetupScriptFailedValidation(ClickException):
"""Snowflake Native App setup script failed validation."""

def __init__(self):
super().__init__(self.__doc__)
91 changes: 85 additions & 6 deletions src/snowflake/cli/plugins/nativeapp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import json
import os
from abc import ABC, abstractmethod
from functools import cached_property
Expand Down Expand Up @@ -51,6 +52,7 @@
ALLOWED_SPECIAL_COMMENTS,
COMMENT_COL,
ERROR_MESSAGE_606,
ERROR_MESSAGE_2003,
ERROR_MESSAGE_2043,
INTERNAL_DISTRIBUTION,
NAME_COL,
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -349,35 +358,38 @@ def sync_deploy_root_with_stage(
role: str,
prune: bool,
recursive: bool,
stage_fqn: str,
local_paths_to_sync: List[Path] | None = None,
) -> DiffResult:
"""
Ensures that the files on our remote stage match the artifacts we have in
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.
"""

# 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)"""
)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"""

Expand All @@ -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
5 changes: 4 additions & 1 deletion src/snowflake/cli/plugins/nativeapp/run_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ def process(
patch: Optional[int] = None,
from_release_directive: bool = False,
is_interactive: bool = False,
validate: bool = True,
*args,
**kwargs,
):
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 5519f5a

Please sign in to comment.