Skip to content

Commit

Permalink
Add release channel publish command (#1972)
Browse files Browse the repository at this point in the history
  • Loading branch information
sfc-gh-melnacouzi authored Jan 8, 2025
1 parent 549e8e9 commit 9433512
Show file tree
Hide file tree
Showing 12 changed files with 1,092 additions and 100 deletions.
2 changes: 2 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
* 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.
* Add ability to add/remove versions to/from release channels through `snow app release-channel add-version` and `snow app release-channel remove-version` commands.
* Add publish command to make it easier to manage publishing versions to release channels and updating release directives: `snow app publish`
* Add support for restricting Snowflake user authentication policy to Snowflake CLI-only.

## Fixes and improvements
* Fixed inability to add patches to lowercase quoted versions
* Fixes label being set to blank instead of None when not provided.
Expand Down
48 changes: 48 additions & 0 deletions src/snowflake/cli/_plugins/nativeapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,51 @@ def message(self):
@property
def result(self):
return self._element


@app.command("publish", requires_connection=True)
@with_project_definition()
@force_project_definition_v2()
def app_publish(
version: str = typer.Option(
show_default=False,
help="The version to publish to the release channel. The version must be created fist using 'snow app version create'.",
),
patch: int = typer.Option(
show_default=False,
help="The patch number under the given version. The patch number must be created first using 'snow app version create'.",
),
channel: Optional[str] = typer.Option(
"DEFAULT",
help="The name of the release channel to publish to. If not provided, the default release channel is used.",
),
directive: Optional[str] = typer.Option(
"DEFAULT",
help="The name of the release directive to update with the specified version and patch. If not provided, the default release directive is used.",
),
interactive: bool = InteractiveOption,
force: Optional[bool] = ForceOption,
**options,
) -> CommandResult:
"""
Adds the version to the release channel and updates the release directive with the new version and patch.
"""
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.PUBLISH,
version=version,
patch=patch,
release_channel=channel,
release_directive=directive,
interactive=interactive,
force=force,
)
return MessageResult(
f"Version {version} and patch {patch} published to release directive {directive} of release channel {channel}."
)
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 @@ -30,3 +30,4 @@

DEFAULT_CHANNEL = "DEFAULT"
DEFAULT_DIRECTIVE = "DEFAULT"
MAX_VERSIONS_IN_RELEASE_CHANNEL = 2
201 changes: 172 additions & 29 deletions src/snowflake/cli/_plugins/nativeapp/entities/application_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
import re
from datetime import datetime
from pathlib import Path
from textwrap import dedent
from typing import Any, List, Literal, Optional, Set, Union
Expand All @@ -27,6 +28,7 @@
DEFAULT_DIRECTIVE,
EXTERNAL_DISTRIBUTION,
INTERNAL_DISTRIBUTION,
MAX_VERSIONS_IN_RELEASE_CHANNEL,
NAME_COL,
OWNER_COL,
PATCH_COL,
Expand All @@ -53,7 +55,7 @@
from snowflake.cli._plugins.nativeapp.sf_facade_exceptions import (
InsufficientPrivilegesError,
)
from snowflake.cli._plugins.nativeapp.sf_sql_facade import ReleaseChannel
from snowflake.cli._plugins.nativeapp.sf_sql_facade import ReleaseChannel, Version
from snowflake.cli._plugins.nativeapp.utils import needs_confirmation, sanitize_dir_name
from snowflake.cli._plugins.snowpark.snowpark_entity_model import (
FunctionEntityModel,
Expand Down Expand Up @@ -333,21 +335,14 @@ def action_drop(self, action_ctx: ActionContext, force_drop: bool, *args, **kwar
)
return

with sql_executor.use_role(self.role):
# 2. Check for versions in the application package
show_versions_query = f"show versions in application package {self.name}"
show_versions_cursor = sql_executor.execute_query(
show_versions_query, cursor_class=DictCursor
)
if show_versions_cursor.rowcount is None:
raise SnowflakeSQLExecutionError(show_versions_query)

if show_versions_cursor.rowcount > 0:
# allow dropping a package with versions when --force is set
if not force_drop:
raise CouldNotDropApplicationPackageWithVersions(
"Drop versions first, or use --force to override."
)
# 2. Check for versions in the application package
versions_in_pkg = get_snowflake_facade().show_versions(self.name, self.role)
if len(versions_in_pkg) > 0:
# allow dropping a package with versions when --force is set
if not force_drop:
raise CouldNotDropApplicationPackageWithVersions(
"Drop versions first, or use --force to override."
)

# 3. Check distribution of the existing application package
actual_distribution = self.get_app_pkg_distribution_in_snowflake()
Expand Down Expand Up @@ -422,15 +417,7 @@ def action_version_list(
Get all existing versions, if defined, for an application package.
It executes a 'show versions in application package' query and returns all the results.
"""
sql_executor = get_sql_executor()
with sql_executor.use_role(self.role):
show_obj_query = f"show versions in application package {self.name}"
show_obj_cursor = sql_executor.execute_query(show_obj_query)

if show_obj_cursor.rowcount is None:
raise SnowflakeSQLExecutionError(show_obj_query)

return show_obj_cursor
return get_snowflake_facade().show_versions(self.name, self.role)

def action_version_create(
self,
Expand Down Expand Up @@ -634,7 +621,9 @@ def _validate_target_accounts(self, accounts: list[str]) -> None:
)

def get_sanitized_release_channel(
self, release_channel: Optional[str]
self,
release_channel: Optional[str],
available_release_channels: Optional[list[ReleaseChannel]] = None,
) -> Optional[str]:
"""
Sanitize the release channel name provided by the user and validate it against the available release channels.
Expand All @@ -646,9 +635,10 @@ def get_sanitized_release_channel(
if not release_channel:
return None

available_release_channels = get_snowflake_facade().show_release_channels(
self.name, self.role
)
if available_release_channels is 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
Expand Down Expand Up @@ -970,6 +960,159 @@ def action_release_channel_remove_version(
role=self.role,
)

def _find_version_with_no_recent_update(
self, versions_info: list[Version], free_versions: set[str]
) -> Optional[str]:
"""
Finds the version with the oldest created_on date from the free versions.
"""

if not free_versions:
return None

# map of versionId to last Updated Date. Last Updated Date is based on patch creation date.
last_updated_map: dict[str, datetime] = {}
for version_info in versions_info:
last_updated_value = last_updated_map.get(version_info["version"], None)
if (
not last_updated_value
or version_info["created_on"] > last_updated_value
):
last_updated_map[version_info["version"]] = version_info["created_on"]

oldest_version = None
oldest_version_last_updated_on = None

for version in free_versions:
last_updated = last_updated_map[version]
if not oldest_version or last_updated < oldest_version_last_updated_on:
oldest_version = version
oldest_version_last_updated_on = last_updated

return oldest_version

def action_publish(
self,
action_ctx: ActionContext,
version: str,
patch: int,
release_channel: Optional[str],
release_directive: str,
interactive: bool,
force: bool,
*args,
**kwargs,
) -> None:
"""
Publishes a version and a patch to a release directive of a release channel.
The version is first added to the release channel,
and then the release directive is set to the version and patch provided.
If the number of versions in a release channel exceeds the maximum allowable versions,
the user is prompted to remove an existing version to make space for the new version.
"""
if force:
policy = AllowAlwaysPolicy()
elif interactive:
policy = AskAlwaysPolicy()
else:
policy = DenyAlwaysPolicy()

console = self._workspace_ctx.console
versions_info = get_snowflake_facade().show_versions(self.name, self.role)

available_patches = [
version_info["patch"]
for version_info in versions_info
if version_info["version"] == unquote_identifier(version)
]

if not available_patches:
raise ClickException(
f"Version {version} does not exist in application package {self.name}."
)

if patch not in available_patches:
raise ClickException(
f"Patch {patch} does not exist for version {version} in application package {self.name}."
)

available_release_channels = get_snowflake_facade().show_release_channels(
self.name, self.role
)

release_channel = self.get_sanitized_release_channel(
release_channel, available_release_channels
)

if release_channel:
release_channel_info = {}
for channel_info in available_release_channels:
if channel_info["name"] == unquote_identifier(release_channel):
release_channel_info = channel_info
break

versions_in_channel = release_channel_info["versions"]
if unquote_identifier(version) not in release_channel_info["versions"]:
if len(versions_in_channel) >= MAX_VERSIONS_IN_RELEASE_CHANNEL:
# If we hit the maximum allowable versions in a release channel, we need to remove one version to make space for the new version
all_release_directives = (
get_snowflake_facade().show_release_directives(
package_name=self.name,
role=self.role,
release_channel=release_channel,
)
)

# check which versions are attached to any release directive
targeted_versions = {d["version"] for d in all_release_directives}

free_versions = {
v for v in versions_in_channel if v not in targeted_versions
}

if not free_versions:
raise ClickException(
f"Maximum number of versions in release channel {release_channel} reached. Cannot add more versions."
)

version_to_remove = self._find_version_with_no_recent_update(
versions_info, free_versions
)
user_prompt = f"Maximum number of versions in release channel reached. Would you like to remove version {version_to_remove} to make space for version {version}?"
if not policy.should_proceed(user_prompt):
raise ClickException(
"Cannot proceed with publishing the new version. Please remove an existing version from the release channel to make space for the new version, or use --force to automatically clean up unused versions."
)

console.warning(
f"Maximum number of versions in release channel reached. Removing version {version_to_remove} from release_channel {release_channel} to make space for version {version}."
)
get_snowflake_facade().remove_version_from_release_channel(
package_name=self.name,
release_channel=release_channel,
version=version_to_remove,
role=self.role,
)

get_snowflake_facade().add_version_to_release_channel(
package_name=self.name,
release_channel=release_channel,
version=version,
role=self.role,
)

get_snowflake_facade().set_release_directive(
package_name=self.name,
release_directive=release_directive,
release_channel=release_channel,
target_accounts=None,
version=version,
patch=patch,
role=self.role,
)

def _bundle_children(self, action_ctx: ActionContext) -> List[str]:
# Create _children directory
children_artifacts_dir = self.children_artifacts_deploy_root
Expand Down
43 changes: 43 additions & 0 deletions src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@
},
)

Version = TypedDict(
"Version",
{
"version": str,
"patch": int,
"label": str | None,
"created_on": datetime,
"review_status": str,
},
)


class SnowflakeSQLFacade:
def __init__(self, sql_executor: BaseSqlExecutor | None = None):
Expand Down Expand Up @@ -1413,6 +1424,38 @@ def remove_version_from_release_channel(
f"Failed to remove version {version} from release channel {release_channel} in application package {package_name}.",
)

def show_versions(
self,
package_name: str,
role: str | None = None,
) -> list[Version]:
"""
Show all versions in an application 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.
"""
package_name = to_identifier(package_name)

with self._use_role_optional(role):
try:
cursor = self._sql_executor.execute_query(
f"show versions in application package {package_name}",
cursor_class=DictCursor,
)
except Exception as err:
if isinstance(err, ProgrammingError):
if err.errno == DOES_NOT_EXIST_OR_NOT_AUTHORIZED:
raise UserInputError(
f"Application package {package_name} does not exist or you are not authorized to access it."
) from err
handle_unclassified_error(
err,
f"Failed to show versions for application package {package_name}.",
)

return cursor.fetchall()


def _strip_empty_lines(text: str) -> str:
"""
Expand Down
Loading

0 comments on commit 9433512

Please sign in to comment.