diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index d12d6e81d3..a778ed4486 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -31,6 +31,7 @@ * Currently only supports SQL scripts: `post_deploy: [{sql_script: script.sql}]` * Added `snow spcs service execute-job` command, which supports creating and executing a job service in the current schema. * Added `snow app events` command to fetch logs and traces from local and customer app installations +* Added support for `SNOWFLAKE_CLI_RESOURCE_SUFFIX` environment variable to be used as a suffix for Native App package and app names ## Fixes and improvements * Fixed problem with whitespaces in `snow connection add` command diff --git a/src/snowflake/cli/_plugins/nativeapp/project_model.py b/src/snowflake/cli/_plugins/nativeapp/project_model.py index e129ab46cc..f8155bac33 100644 --- a/src/snowflake/cli/_plugins/nativeapp/project_model.py +++ b/src/snowflake/cli/_plugins/nativeapp/project_model.py @@ -25,13 +25,18 @@ default_app_package, default_application, default_role, + resource_suffix, ) from snowflake.cli.api.project.schemas.native_app.application import ( PostDeployHook, ) 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 extract_schema, to_identifier +from snowflake.cli.api.project.util import ( + append_to_identifier, + extract_schema, + to_identifier, +) from snowflake.connector import DictCursor @@ -129,12 +134,15 @@ def project_identifier(self) -> str: # sometimes strip out double quotes, so we try to get them back here. return to_identifier(self.definition.name) - @cached_property + @property def package_name(self) -> str: + suffix = resource_suffix() if self.definition.package and self.definition.package.name: - return to_identifier(self.definition.package.name) + return append_to_identifier( + to_identifier(self.definition.package.name), suffix + ) else: - return to_identifier(default_app_package(self.project_identifier)) + return to_identifier(default_app_package(self.project_identifier, suffix)) @cached_property def package_role(self) -> str: @@ -150,12 +158,15 @@ def package_distribution(self) -> str: else: return "internal" - @cached_property + @property def app_name(self) -> str: + suffix = resource_suffix() if self.definition.application and self.definition.application.name: - return to_identifier(self.definition.application.name) + return append_to_identifier( + to_identifier(self.definition.application.name), suffix + ) else: - return to_identifier(default_application(self.project_identifier)) + return to_identifier(default_application(self.project_identifier, suffix)) @cached_property def app_role(self) -> str: diff --git a/src/snowflake/cli/api/project/definition.py b/src/snowflake/cli/api/project/definition.py index 318dec4760..2aaaa8c583 100644 --- a/src/snowflake/cli/api/project/definition.py +++ b/src/snowflake/cli/api/project/definition.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import List, Optional @@ -102,9 +103,14 @@ def generate_local_override_yml( return project.update_from_dict(local) -def default_app_package(project_name: str): - user = clean_identifier(get_env_username() or DEFAULT_USERNAME) - return append_to_identifier(to_identifier(project_name), f"_pkg_{user}") +def resource_suffix() -> str: + """A suffix that should be added to account-level resources.""" + return os.environ.get("SNOWFLAKE_CLI_RESOURCE_SUFFIX", "") + + +def default_app_package(project_name: str, suffix: str): + suffix = suffix or clean_identifier(get_env_username() or DEFAULT_USERNAME) + return append_to_identifier(to_identifier(project_name), f"_pkg_{suffix}") def default_role(): @@ -112,6 +118,6 @@ def default_role(): return conn.role -def default_application(project_name: str): - user = clean_identifier(get_env_username() or DEFAULT_USERNAME) - return append_to_identifier(to_identifier(project_name), f"_{user}") +def default_application(project_name: str, suffix: str): + suffix = suffix or clean_identifier(get_env_username() or DEFAULT_USERNAME) + return append_to_identifier(to_identifier(project_name), f"_{suffix}") diff --git a/tests_integration/nativeapp/test_deploy.py b/tests_integration/nativeapp/test_deploy.py index aa4cbcae29..af8bcc4fa9 100644 --- a/tests_integration/nativeapp/test_deploy.py +++ b/tests_integration/nativeapp/test_deploy.py @@ -111,6 +111,100 @@ def test_nativeapp_deploy( assert result.exit_code == 0 +@pytest.mark.integration +@enable_definition_v2_feature_flag +@pytest.mark.parametrize("test_project", ["napp_init_v1", "napp_init_v2"]) +def test_nativeapp_deploy_with_resource_suffix( + test_project, + project_directory, + runner, + snowflake_session, + print_paths_as_posix, +): + project_name = "myapp" + suffix = "_some_suffix" + test_env_with_suffix = TEST_ENV | dict(SNOWFLAKE_CLI_RESOURCE_SUFFIX=suffix) + with project_directory(test_project): + result = runner.invoke_with_connection( + ["app", "deploy"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + + try: + # package exist + package_name = f"{project_name}_pkg_{USER_NAME}{suffix}".upper() + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + + # make sure we always delete the app + result = runner.invoke_with_connection_json( + ["app", "teardown"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + finally: + # teardown is idempotent, so we can execute it again with no ill effects + result = runner.invoke_with_connection_json( + ["app", "teardown", "--force"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + + +@pytest.mark.integration +@enable_definition_v2_feature_flag +@pytest.mark.parametrize("test_project", ["napp_init_v1", "napp_init_v2"]) +def test_nativeapp_deploy_with_resource_suffix_quoted( + test_project, + project_directory, + runner, + snowflake_session, + print_paths_as_posix, +): + project_name = "myapp" + suffix = "_must.be.quoted!!!" + test_env_with_quoted_suffix = TEST_ENV | dict(SNOWFLAKE_CLI_RESOURCE_SUFFIX=suffix) + with project_directory(test_project): + result = runner.invoke_with_connection( + ["app", "deploy"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + + try: + # package exist + package_name = f"{project_name}_pkg_{USER_NAME}{suffix}" + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + + # make sure we always delete the app + result = runner.invoke_with_connection_json( + ["app", "teardown"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + finally: + # teardown is idempotent, so we can execute it again with no ill effects + result = runner.invoke_with_connection_json( + ["app", "teardown", "--force"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + + @pytest.mark.integration @enable_definition_v2_feature_flag @pytest.mark.parametrize( diff --git a/tests_integration/nativeapp/test_init_run.py b/tests_integration/nativeapp/test_init_run.py index 5f274ed0ed..9cacc7f20c 100644 --- a/tests_integration/nativeapp/test_init_run.py +++ b/tests_integration/nativeapp/test_init_run.py @@ -90,6 +90,118 @@ def test_nativeapp_init_run_without_modifications( assert result.exit_code == 0 +@pytest.mark.integration +@enable_definition_v2_feature_flag +@pytest.mark.parametrize("test_project", ["napp_init_v1", "napp_init_v2"]) +def test_nativeapp_init_run_with_resource_suffix( + test_project, + project_directory, + runner, + snowflake_session, +): + project_name = "myapp" + suffix = "_some_suffix" + test_env_with_suffix = TEST_ENV | dict(SNOWFLAKE_CLI_RESOURCE_SUFFIX=suffix) + with project_directory(test_project): + result = runner.invoke_with_connection_json( + ["app", "run"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + + try: + # app + package exist + package_name = f"{project_name}_pkg_{USER_NAME}{suffix}".upper() + app_name = f"{project_name}_{USER_NAME}{suffix}".upper() + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show applications like '{app_name}'", + ) + ), + dict(name=app_name), + ) + + # make sure we always delete the app + result = runner.invoke_with_connection_json( + ["app", "teardown"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + + finally: + # teardown is idempotent, so we can execute it again with no ill effects + result = runner.invoke_with_connection_json( + ["app", "teardown", "--force"], + env=test_env_with_suffix, + ) + assert result.exit_code == 0 + + +@pytest.mark.integration +@enable_definition_v2_feature_flag +@pytest.mark.parametrize("test_project", ["napp_init_v1", "napp_init_v2"]) +def test_nativeapp_init_run_with_resource_suffix_quoted( + test_project, + project_directory, + runner, + snowflake_session, +): + project_name = "myapp" + suffix = "_must.be.quoted!!!" + test_env_with_quoted_suffix = TEST_ENV | dict(SNOWFLAKE_CLI_RESOURCE_SUFFIX=suffix) + with project_directory(test_project): + result = runner.invoke_with_connection_json( + ["app", "run"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + + try: + # app + package exist + package_name = f"{project_name}_pkg_{USER_NAME}{suffix}" + app_name = f"{project_name}_{USER_NAME}{suffix}" + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show application packages like '{package_name}'", + ) + ), + dict(name=package_name), + ) + assert contains_row_with( + row_from_snowflake_session( + snowflake_session.execute_string( + f"show applications like '{app_name}'", + ) + ), + dict(name=app_name), + ) + + # make sure we always delete the app + result = runner.invoke_with_connection_json( + ["app", "teardown"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + + finally: + # teardown is idempotent, so we can execute it again with no ill effects + result = runner.invoke_with_connection_json( + ["app", "teardown", "--force"], + env=test_env_with_quoted_suffix, + ) + assert result.exit_code == 0 + + # Tests a simple flow of an existing project, but executing snow app run and teardown, all with distribution=internal @pytest.mark.integration @enable_definition_v2_feature_flag