diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 5005fd5e13..7353c250ac 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -19,6 +19,7 @@ ## Deprecations ## New additions +* Added support for `auto_suspend_secs` parameter in SPCS service commands (`deploy`, `set`, `unset`) to configure automatic service suspension after inactivity period. ## Fixes and improvements * Fixed DBT deploy command to properly handle fully qualified names diff --git a/src/snowflake/cli/_plugins/spcs/services/commands.py b/src/snowflake/cli/_plugins/spcs/services/commands.py index 8b9545b234..2cbe8d1e77 100644 --- a/src/snowflake/cli/_plugins/spcs/services/commands.py +++ b/src/snowflake/cli/_plugins/spcs/services/commands.py @@ -151,6 +151,14 @@ def _service_name_callback(name: FQN) -> FQN: help=_AUTO_RESUME_HELP, ) +_AUTO_SUSPEND_SECS_HELP = "Number of seconds of inactivity after which the service will be automatically suspended." +AutoSuspendSecsOption = OverrideableOption( + None, + "--auto-suspend-secs", + help=_AUTO_SUSPEND_SECS_HELP, + min=0, +) + _COMMENT_HELP = "Comment for the service." add_object_command_aliases( @@ -217,7 +225,7 @@ def deploy( upgrade: bool = typer.Option( False, "--upgrade", - help="Updates the existing service. Can update min_instances, max_instances, query_warehouse, auto_resume, external_access_integrations and comment.", + help="Updates the existing service. Can update min_instances, max_instances, query_warehouse, auto_resume, auto_suspend_secs, external_access_integrations and comment.", ), **options, ) -> CommandResult: @@ -241,6 +249,7 @@ def deploy( min_instances=service.min_instances, max_instances=max_instances, auto_resume=service.auto_resume, + auto_suspend_secs=service.auto_suspend_secs, external_access_integrations=service.external_access_integrations, query_warehouse=service.query_warehouse, tags=service.tags, @@ -529,6 +538,7 @@ def set_property( max_instances: Optional[int] = MaxInstancesOption(show_default=False), query_warehouse: Optional[str] = QueryWarehouseOption(show_default=False), auto_resume: Optional[bool] = AutoResumeOption(default=None, show_default=False), + auto_suspend_secs: Optional[int] = AutoSuspendSecsOption(show_default=False), external_access_integrations: Optional[List[str]] = typer.Option( None, "--eai-name", @@ -546,6 +556,7 @@ def set_property( max_instances=max_instances, query_warehouse=query_warehouse, auto_resume=auto_resume, + auto_suspend_secs=auto_suspend_secs, external_access_integrations=external_access_integrations, comment=comment, ) @@ -576,6 +587,12 @@ def unset_property( help=f"Reset the AUTO_RESUME property - {_AUTO_RESUME_HELP}", show_default=False, ), + auto_suspend_secs: bool = AutoSuspendSecsOption( + default=False, + param_decls=["--auto-suspend-secs"], + help=f"Reset the AUTO_SUSPEND_SECS property - {_AUTO_SUSPEND_SECS_HELP}", + show_default=False, + ), comment: bool = CommentOption( default=False, help=f"Reset the COMMENT property - {_COMMENT_HELP}", @@ -593,6 +610,7 @@ def unset_property( max_instances=max_instances, query_warehouse=query_warehouse, auto_resume=auto_resume, + auto_suspend_secs=auto_suspend_secs, comment=comment, ) return SingleQueryResult(cursor) diff --git a/src/snowflake/cli/_plugins/spcs/services/manager.py b/src/snowflake/cli/_plugins/spcs/services/manager.py index 1ec098c6c3..2585f130fe 100644 --- a/src/snowflake/cli/_plugins/spcs/services/manager.py +++ b/src/snowflake/cli/_plugins/spcs/services/manager.py @@ -114,6 +114,7 @@ def deploy( min_instances: int, max_instances: int, auto_resume: bool, + auto_suspend_secs: Optional[int], external_access_integrations: Optional[List[str]], query_warehouse: Optional[str], tags: Optional[List[Tag]], @@ -139,6 +140,7 @@ def deploy( max_instances=max_instances, query_warehouse=query_warehouse, auto_resume=auto_resume, + auto_suspend_secs=auto_suspend_secs, external_access_integrations=external_access_integrations, comment=comment, ) @@ -163,6 +165,9 @@ def deploy( if max_instances: query.append(f"MAX_INSTANCES = {max_instances}") + if auto_suspend_secs is not None: + query.append(f"AUTO_SUSPEND_SECS = {auto_suspend_secs}") + if query_warehouse: query.append(f"QUERY_WAREHOUSE = {query_warehouse}") @@ -531,6 +536,7 @@ def set_property( max_instances: Optional[int], query_warehouse: Optional[str], auto_resume: Optional[bool], + auto_suspend_secs: Optional[int], external_access_integrations: Optional[List[str]], comment: Optional[str], ): @@ -539,6 +545,7 @@ def set_property( ("max_instances", max_instances), ("query_warehouse", query_warehouse), ("auto_resume", auto_resume), + ("auto_suspend_secs", auto_suspend_secs), ("external_access_integrations", external_access_integrations), ("comment", comment), ] @@ -562,6 +569,9 @@ def set_property( if auto_resume is not None: query.append(f" auto_resume = {auto_resume}") + if auto_suspend_secs is not None: + query.append(f" auto_suspend_secs = {auto_suspend_secs}") + if external_access_integrations is not None: external_access_integration_list = ",".join( f"{e}" for e in external_access_integrations @@ -582,6 +592,7 @@ def unset_property( max_instances: bool, query_warehouse: bool, auto_resume: bool, + auto_suspend_secs: bool, comment: bool, ): property_pairs = [ @@ -589,6 +600,7 @@ def unset_property( ("max_instances", max_instances), ("query_warehouse", query_warehouse), ("auto_resume", auto_resume), + ("auto_suspend_secs", auto_suspend_secs), ("comment", comment), ] diff --git a/src/snowflake/cli/_plugins/spcs/services/service_entity_model.py b/src/snowflake/cli/_plugins/spcs/services/service_entity_model.py index 7d80301478..ce4ff872fb 100644 --- a/src/snowflake/cli/_plugins/spcs/services/service_entity_model.py +++ b/src/snowflake/cli/_plugins/spcs/services/service_entity_model.py @@ -30,6 +30,11 @@ class ServiceEntityModel(EntityModelBaseWithArtifacts, ExternalAccessBaseModel): title="The service will automatically resume when a service function or ingress is called.", default=True, ) + auto_suspend_secs: Optional[int] = Field( + title="Number of seconds of inactivity after which the service is automatically suspended.", + default=None, + ge=0, + ) query_warehouse: Optional[str] = Field( title="Warehouse to use if a service container connects to Snowflake to execute a query without explicitly specifying a warehouse to use", default=None, diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index db42509909..3f62e8a932 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -15604,8 +15604,8 @@ +- Options --------------------------------------------------------------------+ | --upgrade Updates the existing service. Can update | | min_instances, max_instances, query_warehouse, | - | auto_resume, external_access_integrations and | - | comment. | + | auto_resume, auto_suspend_secs, | + | external_access_integrations and comment. | | --project -p TEXT Path where the Snowflake project is stored. | | Defaults to the current working directory. | | --env TEXT String in the format key=value. Overrides variables | @@ -17227,6 +17227,13 @@ | service function | | or ingress is | | called. | + | --auto-suspend-s… INTEGER RANGE Number of | + | [x>=0] seconds of | + | inactivity after | + | which the | + | service will be | + | automatically | + | suspended. | | --eai-name TEXT Identifies | | external access | | integrations | @@ -17634,20 +17641,24 @@ | [required] | +------------------------------------------------------------------------------+ +- Options --------------------------------------------------------------------+ - | --min-instances Reset the MIN_INSTANCES property - Minimum | - | number of service instances to run. | - | --max-instances Reset the MAX_INSTANCES property - Maximum | - | number of service instances to run. | - | --query-warehouse Reset the QUERY_WAREHOUSE property - Warehouse | - | to use if a service container connects to | - | Snowflake to execute a query without explicitly | - | specifying a warehouse to use. | - | --auto-resume Reset the AUTO_RESUME property - The service | - | will automatically resume when a service | - | function or ingress is called. | - | --comment Reset the COMMENT property - Comment for the | - | service. | - | --help -h Show this message and exit. | + | --min-instances Reset the MIN_INSTANCES property - Minimum | + | number of service instances to run. | + | --max-instances Reset the MAX_INSTANCES property - Maximum | + | number of service instances to run. | + | --query-warehouse Reset the QUERY_WAREHOUSE property - | + | Warehouse to use if a service container | + | connects to Snowflake to execute a query | + | without explicitly specifying a warehouse to | + | use. | + | --auto-resume Reset the AUTO_RESUME property - The service | + | will automatically resume when a service | + | function or ingress is called. | + | --auto-suspend-secs Reset the AUTO_SUSPEND_SECS property - Number | + | of seconds of inactivity after which the | + | service will be automatically suspended. | + | --comment Reset the COMMENT property - Comment for the | + | service. | + | --help -h Show this message and exit. | +------------------------------------------------------------------------------+ +- Connection configuration ---------------------------------------------------+ | --connection,--environment -c TEXT Name of the connection, as | diff --git a/tests/spcs/test_services.py b/tests/spcs/test_services.py index 81d06564c1..3d6e42e605 100644 --- a/tests/spcs/test_services.py +++ b/tests/spcs/test_services.py @@ -1503,6 +1503,7 @@ def test_set_property(mock_execute_query): max_instances = 3 query_warehouse = "test_warehouse" auto_resume = False + auto_suspend_secs = 600 external_access_integrations = [ "google_apis_access_integration", "salesforce_api_access_integration", @@ -1516,6 +1517,7 @@ def test_set_property(mock_execute_query): max_instances=max_instances, query_warehouse=query_warehouse, auto_resume=auto_resume, + auto_suspend_secs=auto_suspend_secs, external_access_integrations=external_access_integrations, comment=comment, ) @@ -1527,6 +1529,7 @@ def test_set_property(mock_execute_query): f"max_instances = {max_instances}", f"query_warehouse = {query_warehouse}", f"auto_resume = {auto_resume}", + f"auto_suspend_secs = {auto_suspend_secs}", f"external_access_integrations = ({eai_list})", f"comment = {comment}", ] @@ -1538,7 +1541,9 @@ def test_set_property(mock_execute_query): def test_set_property_no_properties(): service_name = "test_service" with pytest.raises(NoPropertiesProvidedError) as e: - ServiceManager().set_property(service_name, None, None, None, None, None, None) + ServiceManager().set_property( + service_name, None, None, None, None, None, None, None + ) assert ( e.value.message == f"No properties specified for service '{service_name}'. Please provide at least one property to set." @@ -1554,6 +1559,7 @@ def test_set_property_cli(mock_set, mock_statement_success, runner): max_instances = 3 query_warehouse = "test_warehouse" auto_resume = False + auto_suspend_secs = 600 external_access_integrations = [ "google_apis_access_integration", "salesforce_api_access_integration", @@ -1572,6 +1578,8 @@ def test_set_property_cli(mock_set, mock_statement_success, runner): "--query-warehouse", query_warehouse, "--no-auto-resume", + "--auto-suspend-secs", + str(auto_suspend_secs), "--eai-name", "google_apis_access_integration", "--eai-name", @@ -1586,6 +1594,7 @@ def test_set_property_cli(mock_set, mock_statement_success, runner): max_instances=max_instances, query_warehouse=query_warehouse, auto_resume=auto_resume, + auto_suspend_secs=auto_suspend_secs, external_access_integrations=external_access_integrations, comment=to_string_literal(comment), ) @@ -1608,6 +1617,7 @@ def test_set_property_no_properties_cli(mock_set, runner): max_instances=None, query_warehouse=None, auto_resume=None, + auto_suspend_secs=None, external_access_integrations=None, comment=None, ) @@ -1618,8 +1628,10 @@ def test_unset_property(mock_execute_query): service_name = "test_service" cursor = Mock(spec=SnowflakeCursor) mock_execute_query.return_value = cursor - result = ServiceManager().unset_property(service_name, True, True, True, True, True) - expected_query = "alter service test_service unset min_instances,max_instances,query_warehouse,auto_resume,comment" + result = ServiceManager().unset_property( + service_name, True, True, True, True, True, True + ) + expected_query = "alter service test_service unset min_instances,max_instances,query_warehouse,auto_resume,auto_suspend_secs,comment" mock_execute_query.assert_called_once_with(expected_query) assert result == cursor @@ -1627,7 +1639,9 @@ def test_unset_property(mock_execute_query): def test_unset_property_no_properties(): service_name = "test_service" with pytest.raises(NoPropertiesProvidedError) as e: - ServiceManager().unset_property(service_name, False, False, False, False, False) + ServiceManager().unset_property( + service_name, False, False, False, False, False, False + ) assert ( e.value.message == f"No properties specified for service '{service_name}'. Please provide at least one property to reset to its default value." @@ -1649,6 +1663,7 @@ def test_unset_property_cli(mock_unset, mock_statement_success, runner): "--max-instances", "--query-warehouse", "--auto-resume", + "--auto-suspend-secs", "--comment", ] ) @@ -1658,6 +1673,7 @@ def test_unset_property_cli(mock_unset, mock_statement_success, runner): max_instances=True, query_warehouse=True, auto_resume=True, + auto_suspend_secs=True, comment=True, ) assert result.exit_code == 0, result.output @@ -1679,10 +1695,47 @@ def test_unset_property_no_properties_cli(mock_unset, runner): max_instances=False, query_warehouse=False, auto_resume=False, + auto_suspend_secs=False, comment=False, ) +@patch(EXECUTE_QUERY) +def test_set_property_auto_suspend_secs_only(mock_execute_query): + service_name = "test_service" + auto_suspend_secs = 300 + cursor = Mock(spec=SnowflakeCursor) + mock_execute_query.return_value = cursor + result = ServiceManager().set_property( + service_name=service_name, + min_instances=None, + max_instances=None, + query_warehouse=None, + auto_resume=None, + auto_suspend_secs=auto_suspend_secs, + external_access_integrations=None, + comment=None, + ) + expected_query = ( + f"alter service {service_name} set\nauto_suspend_secs = {auto_suspend_secs}" + ) + mock_execute_query.assert_called_once_with(expected_query) + assert result == cursor + + +@patch(EXECUTE_QUERY) +def test_unset_property_auto_suspend_secs_only(mock_execute_query): + service_name = "test_service" + cursor = Mock(spec=SnowflakeCursor) + mock_execute_query.return_value = cursor + result = ServiceManager().unset_property( + service_name, False, False, False, False, True, False + ) + expected_query = f"alter service {service_name} unset auto_suspend_secs" + mock_execute_query.assert_called_once_with(expected_query) + assert result == cursor + + def test_unset_property_with_args(runner): service_name = "test_service" result = runner.invoke( diff --git a/tests_integration/test_data/projects/spcs_service/snowflake.yml b/tests_integration/test_data/projects/spcs_service/snowflake.yml index 6e628dad5c..288f0720b5 100644 --- a/tests_integration/test_data/projects/spcs_service/snowflake.yml +++ b/tests_integration/test_data/projects/spcs_service/snowflake.yml @@ -12,5 +12,6 @@ entities: max_instances: 1 query_warehouse: xsmall comment: "This is a test service" + auto_suspend_secs: 1000 artifacts: - spec.yml diff --git a/tests_integration/tests_using_container_services/spcs/test_services.py b/tests_integration/tests_using_container_services/spcs/test_services.py index 9fd04d95de..93f43abf7e 100644 --- a/tests_integration/tests_using_container_services/spcs/test_services.py +++ b/tests_integration/tests_using_container_services/spcs/test_services.py @@ -105,6 +105,7 @@ def test_service_create_from_project_definition( "spec_file": "spec_upgrade.yml", "min_instances": 1, "max_instances": 2, + "auto_suspend_secs": 1000, "query_warehouse": "xsmall", "comment": "Upgraded service", "artifacts": ["spec_upgrade.yml"],