Skip to content

Commit

Permalink
App post-deploy hook: sql scripts (#1244)
Browse files Browse the repository at this point in the history
* add app post deploy sql script hook

* default warehouse and database

* clarify release notes

* add template support

* release notes

* add custom schema validation

* simplify schema validation
  • Loading branch information
sfc-gh-gbloom authored Jun 25, 2024
1 parent b0aec1f commit 2444bd5
Show file tree
Hide file tree
Showing 18 changed files with 375 additions and 1 deletion.
2 changes: 2 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
## New additions
* Added support for `title` field in Streamlit definition in `snowflake.yml` project file.
* Added `--auto-compress` flag to `snow stage copy` command enabling use of gzip to compress files during upload.
* Added new `native_app.application.post_deploy` section to `snowflake.yml` schema to execute actions after the application has been deployed via `snow app run`.
* Added the `sql_script` hook type to run SQL scripts with template support.

## Fixes and improvements
* Passing a directory to `snow app deploy` will now deploy any contained file or subfolder specified in the application's artifact rules
Expand Down
14 changes: 13 additions & 1 deletion src/snowflake/cli/api/project/schemas/native_app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from __future__ import annotations

from typing import Optional
from typing import List, Optional

from pydantic import Field
from snowflake.cli.api.project.schemas.updatable_model import (
Expand All @@ -23,6 +23,14 @@
)


class SqlScriptHookType(UpdatableModel):
sql_script: str = Field(title="SQL file path relative to the project root")


# Currently sql_script is the only supported hook type. Change to a Union once other hook types are added
ApplicationPostDeployHook = SqlScriptHookType


class Application(UpdatableModel):
role: Optional[str] = Field(
title="Role to use when creating the application object and consumer-side objects",
Expand All @@ -40,3 +48,7 @@ class Application(UpdatableModel):
title="Whether to enable debug mode when using a named stage to create an application object",
default=True,
)
post_deploy: Optional[List[ApplicationPostDeployHook]] = Field(
title="Actions that will be executed after the application object is created/upgraded",
default=None,
)
13 changes: 13 additions & 0 deletions src/snowflake/cli/plugins/nativeapp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
default_application,
default_role,
)
from snowflake.cli.api.project.schemas.native_app.application import (
ApplicationPostDeployHook,
)
from snowflake.cli.api.project.schemas.native_app.native_app import NativeApp
from snowflake.cli.api.project.schemas.native_app.path_mapping import PathMapping
from snowflake.cli.api.project.util import (
Expand Down Expand Up @@ -265,6 +268,16 @@ def app_role(self) -> str:
else:
return self._default_role

@cached_property
def app_post_deploy_hooks(self) -> Optional[List[ApplicationPostDeployHook]]:
"""
List of application post deploy hooks.
"""
if self.definition.application and self.definition.application.post_deploy:
return self.definition.application.post_deploy
else:
return None

@cached_property
def _default_role(self) -> str:
role = default_role()
Expand Down
34 changes: 34 additions & 0 deletions src/snowflake/cli/plugins/nativeapp/run_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
unquote_identifier,
)
from snowflake.cli.api.utils.cursor import find_all_rows
from snowflake.cli.api.utils.rendering import snowflake_sql_jinja_render
from snowflake.cli.plugins.nativeapp.artifacts import BundleMap
from snowflake.cli.plugins.nativeapp.constants import (
ALLOWED_SPECIAL_COMMENTS,
Expand Down Expand Up @@ -104,6 +105,7 @@ def _create_dev_app(self, diff: DiffResult) -> None:
f"alter application {self.app_name} set debug_mode = {self.debug_mode}"
)

self._execute_post_deploy_hooks()
return

except ProgrammingError as err:
Expand Down Expand Up @@ -141,6 +143,38 @@ def _create_dev_app(self, diff: DiffResult) -> None:
except ProgrammingError as err:
generic_sql_error_handler(err)

self._execute_post_deploy_hooks()

def _execute_sql_script(self, sql_script_path):
"""
Executing the SQL script in the provided file path after expanding template variables.
"use warehouse" and "use database" will be executed first if they are set in definition file or in the current connection.
"""
with open(sql_script_path) as f:
sql_script = f.read()
try:
if self.application_warehouse:
self._execute_query(f"use warehouse {self.application_warehouse}")
if self._conn.database:
self._execute_query(f"use database {self._conn.database}")
sql_script = snowflake_sql_jinja_render(content=sql_script)
self._execute_queries(sql_script)
except ProgrammingError as err:
generic_sql_error_handler(err)

def _execute_post_deploy_hooks(self):
post_deploy_script_hooks = self.app_post_deploy_hooks
if post_deploy_script_hooks:
with cc.phase("Executing application post-deploy actions"):
for hook in post_deploy_script_hooks:
if hook.sql_script:
cc.step(f"Executing SQL script: {hook.sql_script}")
self._execute_sql_script(hook.sql_script)
else:
raise ValueError(
f"Unsupported application post-deploy hook type: {hook}"
)

def get_all_existing_versions(self) -> SnowflakeCursor:
"""
Get all existing versions, if defined, for an application package.
Expand Down
180 changes: 180 additions & 0 deletions tests/nativeapp/test_post_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# 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.

from textwrap import dedent
from unittest import mock

import pytest
from snowflake.cli.api.project.definition_manager import DefinitionManager
from snowflake.cli.api.project.errors import SchemaValidationError
from snowflake.cli.api.project.schemas.native_app.application import (
ApplicationPostDeployHook,
)
from snowflake.cli.plugins.nativeapp.run_processor import NativeAppRunProcessor

from tests.nativeapp.patch_utils import mock_connection
from tests.nativeapp.utils import (
NATIVEAPP_MANAGER_EXECUTE,
NATIVEAPP_MANAGER_EXECUTE_QUERIES,
RUN_PROCESSOR_APP_POST_DEPLOY_HOOKS,
)
from tests.testing_utils.fixtures import MockConnectionCtx

CLI_GLOBAL_TEMPLATE_CONTEXT = (
"snowflake.cli.api.cli_global_context._CliGlobalContextAccess.template_context"
)
MOCK_CONNECTION_DB = "tests.testing_utils.fixtures.MockConnectionCtx.database"
MOCK_CONNECTION_WH = "tests.testing_utils.fixtures.MockConnectionCtx.warehouse"


def _get_run_processor(working_dir):
dm = DefinitionManager(working_dir)
return NativeAppRunProcessor(
project_definition=dm.project_definition.native_app,
project_root=dm.project_root,
)


@mock.patch(NATIVEAPP_MANAGER_EXECUTE)
@mock.patch(NATIVEAPP_MANAGER_EXECUTE_QUERIES)
@mock.patch(CLI_GLOBAL_TEMPLATE_CONTEXT, new_callable=mock.PropertyMock)
@mock_connection()
def test_sql_scripts(
mock_conn,
mock_cli_ctx,
mock_execute_queries,
mock_execute_query,
project_directory,
):
mock_conn.return_value = MockConnectionCtx()
mock_cli_ctx.return_value = {
"ctx": {"native_app": {"name": "myapp"}, "env": {"foo": "bar"}}
}
with project_directory("napp_post_deploy") as project_dir:
processor = _get_run_processor(str(project_dir))

processor._execute_post_deploy_hooks() # noqa SLF001

assert mock_execute_query.mock_calls == [
mock.call("use warehouse MockWarehouse"),
mock.call("use database MockDatabase"),
mock.call("use warehouse MockWarehouse"),
mock.call("use database MockDatabase"),
]
assert mock_execute_queries.mock_calls == [
# Verify template variables were expanded correctly
mock.call(
dedent(
"""\
-- app post-deploy script (1/2)
select myapp;
select bar;
"""
)
),
mock.call("-- app post-deploy script (2/2)\n"),
]


@mock.patch(NATIVEAPP_MANAGER_EXECUTE)
@mock.patch(NATIVEAPP_MANAGER_EXECUTE_QUERIES)
@mock.patch(CLI_GLOBAL_TEMPLATE_CONTEXT, new_callable=mock.PropertyMock)
@mock_connection()
@mock.patch(MOCK_CONNECTION_DB, new_callable=mock.PropertyMock)
@mock.patch(MOCK_CONNECTION_WH, new_callable=mock.PropertyMock)
def test_sql_scripts_with_no_warehouse_no_database(
mock_conn_wh,
mock_conn_db,
mock_conn,
mock_cli_ctx,
mock_execute_queries,
mock_execute_query,
project_directory,
):
mock_conn_wh.return_value = None
mock_conn_db.return_value = None
mock_conn.return_value = MockConnectionCtx(None)
mock_cli_ctx.return_value = {
"ctx": {"native_app": {"name": "myapp"}, "env": {"foo": "bar"}}
}
with project_directory("napp_post_deploy") as project_dir:
processor = _get_run_processor(str(project_dir))

processor._execute_post_deploy_hooks() # noqa SLF001

# Verify no "use warehouse" and no "use database" were called
assert mock_execute_query.mock_calls == []
assert mock_execute_queries.mock_calls == [
mock.call(
dedent(
"""\
-- app post-deploy script (1/2)
select myapp;
select bar;
"""
)
),
mock.call("-- app post-deploy script (2/2)\n"),
]


@mock_connection()
def test_missing_sql_script(
mock_conn,
project_directory,
):
mock_conn.return_value = MockConnectionCtx()
with project_directory("napp_post_deploy_missing_file") as project_dir:
processor = _get_run_processor(str(project_dir))

with pytest.raises(FileNotFoundError) as err:
processor._execute_post_deploy_hooks() # noqa SLF001


@mock.patch(RUN_PROCESSOR_APP_POST_DEPLOY_HOOKS, new_callable=mock.PropertyMock)
@mock_connection()
def test_invalid_hook_type(
mock_conn,
mock_deploy_hooks,
project_directory,
):
mock_hook = mock.Mock()
mock_hook.invalid_type = "invalid_type"
mock_hook.sql_script = None
mock_deploy_hooks.return_value = [mock_hook]
mock_conn.return_value = MockConnectionCtx()
with project_directory("napp_post_deploy") as project_dir:
processor = _get_run_processor(str(project_dir))

with pytest.raises(ValueError) as err:
processor._execute_post_deploy_hooks() # noqa SLF001
assert "Unsupported application post-deploy hook type" in str(err)


@pytest.mark.parametrize(
"args,expected_error",
[
({"sql_script": "/path"}, None),
({}, "missing following fields: ('sql_script',)"),
],
)
def test_post_deploy_hook_schema(args, expected_error):
if expected_error:
with pytest.raises(SchemaValidationError) as err:
ApplicationPostDeployHook(**args)
assert expected_error in str(err)
else:
ApplicationPostDeployHook(**args)
1 change: 1 addition & 0 deletions tests/nativeapp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
TEARDOWN_PROCESSOR_DROP_GENERIC_OBJECT = f"{TEARDOWN_PROCESSOR}.drop_generic_object"

RUN_PROCESSOR_GET_EXISTING_APP_INFO = f"{RUN_PROCESSOR}.get_existing_app_info"
RUN_PROCESSOR_APP_POST_DEPLOY_HOOKS = f"{RUN_PROCESSOR}.app_post_deploy_hooks"

FIND_VERSION_FROM_MANIFEST = f"{VERSION_MODULE}.find_version_info_in_manifest_file"

Expand Down
2 changes: 2 additions & 0 deletions tests/project/__snapshots__/test_config.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
'application': dict({
'debug': True,
'name': 'myapp_polly',
'post_deploy': None,
'role': 'myapp_consumer',
'warehouse': None,
}),
Expand Down Expand Up @@ -157,6 +158,7 @@
'application': dict({
'debug': True,
'name': 'myapp_polly',
'post_deploy': None,
'role': 'myapp_consumer',
'warehouse': None,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- app post-deploy script (1/2)

select &{ ctx.native_app.name };
select &{ ctx.env.foo };
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- app post-deploy script (2/2)
15 changes: 15 additions & 0 deletions tests/test_data/projects/napp_post_deploy/snowflake.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
definition_version: 1.1
native_app:
name: myapp

artifacts:
- src: app/*
dest: ./

application:
post_deploy:
- sql_script: scripts/post_deploy1.sql
- sql_script: scripts/post_deploy2.sql

env:
foo: bar
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
definition_version: 1
native_app:
name: myapp

artifacts:
- src: app/*
dest: ./

application:
post_deploy:
- sql_script: scripts/missing.sql
Loading

0 comments on commit 2444bd5

Please sign in to comment.