From 1c675549425a4f841f3a627607ea918d2532f094 Mon Sep 17 00:00:00 2001 From: Pratibha Shrivastav <164305667+PratibhaShrivastav18@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:28:01 +0530 Subject: [PATCH 1/9] Compute connect port 22 path fix (#9082) * port 22 compute connect fix * add changelog --- src/machinelearningservices/CHANGELOG.rst | 2 ++ .../azext_mlv2/manual/custom/_ssh_command.py | 6 ++++-- .../azext_mlv2/manual/custom/_ssh_connector.py | 7 ++++++- .../azext_mlv2/manual/custom/compute.py | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/machinelearningservices/CHANGELOG.rst b/src/machinelearningservices/CHANGELOG.rst index 1fb194c0002..6d786917f80 100644 --- a/src/machinelearningservices/CHANGELOG.rst +++ b/src/machinelearningservices/CHANGELOG.rst @@ -1,6 +1,8 @@ ## Azure Machine Learning CLI (v2) (unreleased) - `az ml compute update` - Fix a bug compute update which caused Enable SSO property to reset. +- `az ml compute connect-ssh` + - Fix proxy endpoint path ## 2025-05-15 diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_command.py b/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_command.py index 58498d22c84..177491ad548 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_command.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_command.py @@ -24,7 +24,8 @@ def get_ssh_command( services_dict: Dict[str, ServiceInstance], node_index: int, private_key_file_path: str, - ssh_args: Optional[Sequence[str]] = None + ssh_args: Optional[Sequence[str]] = None, + connector_args: Optional[Sequence[str]] = None ) -> Tuple[bool, str]: proxyEndpoint = _get_proxy_endpoint(services_dict, node_index).replace("", str(node_index)) connect_ssh_path = pathlib.Path(__file__).parent / "_ssh_connector.py" @@ -45,9 +46,10 @@ def get_ssh_command( identity_param = " -i {}".format(private_key_file_path) if private_key_file_path else "" # TODO: Find how to enable debug mode ssh_args_str = " ".join(ssh_args) if ssh_args else "" + connector_args_str = " ".join(connector_args) if connector_args else "" return ( connect_ssh_path_has_space, - f'{ssh_path} -v -o ProxyCommand="{sys.executable} {connect_ssh_path} {proxyEndpoint}" ' + f'{ssh_path} -v -o ProxyCommand="{sys.executable} {connect_ssh_path} {proxyEndpoint} {connector_args_str}" ' f"azureuser@{proxyEndpoint}{identity_param}{ssh_args_str}", ) diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_connector.py b/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_connector.py index 7d2f39f8961..20611054143 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_connector.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/_ssh_connector.py @@ -61,12 +61,17 @@ async def _connect_ssh(self): ) raise Exception(msg) # pylint: disable=broad-exception-raised proxy_endpoint = sys.argv[1] + + is_compute = len(sys.argv) > 2 and sys.argv[2] == "--is-compute" + uri = f"{proxy_endpoint}/nbip/v1.0/ws-tcp" + if is_compute: + uri += "/port/22" mgtScope = ["https://management.core.windows.net/.default"] aml_token = run_az_cli(["account", "get-access-token", "--scope", mgtScope[0]])["accessToken"] async with websockets.client.connect( - uri=f"{proxy_endpoint}/nbip/v1.0/ws-tcp", + uri=uri, extra_headers={"Authorization": f"Bearer {aml_token}"}, ) as websocket: diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/compute.py b/src/machinelearningservices/azext_mlv2/manual/custom/compute.py index a5944007288..4a16d9825cc 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/compute.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/compute.py @@ -274,12 +274,12 @@ def ml_compute_connect_ssh(cmd, resource_group_name, workspace_name, name, priva # create proxy endpoint for CI based on endpoint for jupyter # TODO: Improve with a call to get proxyendpoint from CI, requires API jupyter = [f["endpoint_uri"] for f in compute.services if f["display_name"] == "Jupyter"][0] - proxyEndpoint = jupyter.replace(name, f"{name}-22").replace("https://", "wss://").replace("/tree/", "") + proxyEndpoint = jupyter.replace("https://", "wss://").replace("/tree/", "") services_dict = { "ssh": ServiceInstance(type="SSH", status="Running", properties={"ProxyEndpoint": proxyEndpoint}) } - path_has_space, ssh_command = get_ssh_command(services_dict, 0, private_key_file_path) + path_has_space, ssh_command = get_ssh_command(services_dict, 0, private_key_file_path, connector_args=["--is-compute"]) print(f"ssh_command: {ssh_command}") if path_has_space: module_logger.error(ssh_connector_file_path_space_message()) From 166e63d8a808af46c109abf344d718fa15965ca3 Mon Sep 17 00:00:00 2001 From: Pratibha Shrivastav <164305667+PratibhaShrivastav18@users.noreply.github.com> Date: Tue, 26 Aug 2025 14:02:34 +0530 Subject: [PATCH 2/9] CLI v2 2.39.0 (Version/changelog changes) (#9089) --- src/machinelearningservices/CHANGELOG.rst | 4 +++- .../azext_mlv2/manual/requirements.txt | 2 +- src/machinelearningservices/setup.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/machinelearningservices/CHANGELOG.rst b/src/machinelearningservices/CHANGELOG.rst index 6d786917f80..a9d384b386a 100644 --- a/src/machinelearningservices/CHANGELOG.rst +++ b/src/machinelearningservices/CHANGELOG.rst @@ -1,4 +1,6 @@ -## Azure Machine Learning CLI (v2) (unreleased) +## 2025-08-27 + +### Azure Machine Learning CLI (v2) v 2.39.0 - `az ml compute update` - Fix a bug compute update which caused Enable SSO property to reset. - `az ml compute connect-ssh` diff --git a/src/machinelearningservices/azext_mlv2/manual/requirements.txt b/src/machinelearningservices/azext_mlv2/manual/requirements.txt index 1fd972ed1e9..83eef209ca5 100644 --- a/src/machinelearningservices/azext_mlv2/manual/requirements.txt +++ b/src/machinelearningservices/azext_mlv2/manual/requirements.txt @@ -2,4 +2,4 @@ cryptography docker azure-mgmt-resourcegraph<9.0.0,>=2.0.0 azure-identity==1.17.1 -azure-ai-ml==1.28.1 \ No newline at end of file +azure-ai-ml==1.29.0 \ No newline at end of file diff --git a/src/machinelearningservices/setup.py b/src/machinelearningservices/setup.py index b53c622be57..46e07d728a3 100644 --- a/src/machinelearningservices/setup.py +++ b/src/machinelearningservices/setup.py @@ -10,7 +10,7 @@ from setuptools import setup, find_packages # HISTORY.rst entry. -VERSION = '2.38.1' +VERSION = '2.39.0' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From a693b8826f94a4a6f3bf543af32c5ece455a82a0 Mon Sep 17 00:00:00 2001 From: Pratibha Shrivastav <164305667+PratibhaShrivastav18@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:16:06 +0530 Subject: [PATCH 3/9] Pylint fix + History update (#9143) * pylint fix * revert unwanted change --- src/machinelearningservices/HISTORY.rst | 5 +++++ .../azext_mlv2/manual/custom/compute.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/machinelearningservices/HISTORY.rst b/src/machinelearningservices/HISTORY.rst index 72821c13d93..be67d716394 100644 --- a/src/machinelearningservices/HISTORY.rst +++ b/src/machinelearningservices/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +2.39.0 +++++++ +* Fix a bug compute update which caused Enable SSO property to reset. +* Fix proxy endpoint path + 2.38.0 ++++++ * Fix a bug compute update which caused Enable SSO property to reset. \ No newline at end of file diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/compute.py b/src/machinelearningservices/azext_mlv2/manual/custom/compute.py index 4a16d9825cc..7d75b802412 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/compute.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/compute.py @@ -279,7 +279,9 @@ def ml_compute_connect_ssh(cmd, resource_group_name, workspace_name, name, priva services_dict = { "ssh": ServiceInstance(type="SSH", status="Running", properties={"ProxyEndpoint": proxyEndpoint}) } - path_has_space, ssh_command = get_ssh_command(services_dict, 0, private_key_file_path, connector_args=["--is-compute"]) + path_has_space, ssh_command = get_ssh_command( + services_dict, 0, private_key_file_path, connector_args=["--is-compute"] + ) print(f"ssh_command: {ssh_command}") if path_has_space: module_logger.error(ssh_connector_file_path_space_message()) From 5ee3862658294a5fda2c109825c5447a65c5089a Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Tue, 21 Oct 2025 14:34:27 +0530 Subject: [PATCH 4/9] deployment templates - CLI --- .../azext_mlv2/manual/_help/__init__.py | 2 + .../manual/_help/_deployment_template_help.py | 114 ++++ .../azext_mlv2/manual/_params/__init__.py | 2 + .../_params/_deployment_template_params.py | 77 +++ .../azext_mlv2/manual/commands.py | 16 + .../azext_mlv2/manual/custom/__init__.py | 1 + .../manual/custom/deployment_template.py | 255 +++++++++ .../test_deployment_template_scenarios.py | 506 ++++++++++++++++++ .../deployment_template_advanced.yaml | 42 ++ .../deployment_template_basic.yaml | 22 + .../deployment_template_minimal.yaml | 15 + 11 files changed, 1052 insertions(+) create mode 100644 src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py create mode 100644 src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py create mode 100644 src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py create mode 100644 src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py create mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml create mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml create mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml diff --git a/src/machinelearningservices/azext_mlv2/manual/_help/__init__.py b/src/machinelearningservices/azext_mlv2/manual/_help/__init__.py index d4fe7e35d2c..727f17f36ba 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_help/__init__.py +++ b/src/machinelearningservices/azext_mlv2/manual/_help/__init__.py @@ -19,6 +19,7 @@ from ._connection_help import get_connection_help from ._data_help import get_data_help from ._datastore_help import get_datastore_help +from ._deployment_template_help import get_deployment_template_help from ._environment_help import get_environment_help from ._feature_set_help import get_feature_set_help from ._feature_store_entity_help import get_feature_store_entity_help @@ -50,6 +51,7 @@ get_workspace_help() get_workspace_outbound_rule_help() get_datastore_help() +get_deployment_template_help() get_component_help() get_connection_help() get_schedule_help() diff --git a/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py new file mode 100644 index 00000000000..2cd9574aebf --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py @@ -0,0 +1,114 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +from knack.help_files import helps + + +def get_deployment_template_help(): + """Load deployment template help content.""" + pass # Help content is defined below using helps dictionary + +helps['ml deployment-template'] = """ +type: group +short-summary: Manage Azure ML deployment templates. +long-summary: | + Deployment templates are reusable templates that define deployment configurations for Azure ML. + They support registry-based operations only (not workspace-based) and provide a way to + standardize and share deployment configurations across teams and projects. +""" + +helps['ml deployment-template list'] = """ +type: command +short-summary: List deployment templates in a registry. +long-summary: | + List all deployment templates available in the specified registry. This command + returns all templates along with their metadata including name, version, description, and tags. +examples: + - name: List all deployment templates in a registry + text: az ml deployment-template list --registry-name myregistry + - name: List deployment templates with specific output format + text: az ml deployment-template list --registry-name myregistry --output table +""" + +helps['ml deployment-template get'] = """ +type: command +short-summary: Get a specific deployment template by name and version. +long-summary: | + Retrieve detailed information about a specific deployment template. If version is not + specified, the latest version will be returned. +examples: + - name: Get a specific version of a deployment template + text: az ml deployment-template get --name my-template --version 1 --registry-name myregistry + - name: Get the latest version of a deployment template + text: az ml deployment-template get --name my-template --registry-name myregistry +""" + +helps['ml deployment-template create'] = """ +type: command +short-summary: Create a new deployment template from a YAML file. +long-summary: | + Create a new deployment template using a YAML configuration file. The YAML file should + contain the complete deployment template definition including endpoints, parameters, and metadata. + You can override specific values using command-line parameters. +examples: + - name: Create a deployment template from a YAML file + text: az ml deployment-template create --file template.yml --registry-name myregistry + - name: Create with name and version overrides + text: az ml deployment-template create --file template.yml --name custom-template --version 2 --registry-name myregistry + - name: Create without waiting for completion + text: az ml deployment-template create --file template.yml --registry-name myregistry --no-wait +""" + +helps['ml deployment-template update'] = """ +type: command +short-summary: Update specific fields of an existing deployment template. +long-summary: | + Update metadata fields (description and tags) of an existing deployment template without + requiring a YAML file. This command follows Azure CLI conventions and only accepts specific + field updates. Tags are merged with existing tags rather than replaced. + + For structural changes to the deployment template (endpoints, deployment configuration, etc.), + use the 'create' command with a YAML file. +examples: + - name: Update deployment template description + text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --description "Updated description" + - name: Update deployment template tags + text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --tags environment=production owner=ml-team + - name: Update both description and tags + text: az ml deployment-template update --name my-template --registry-name myregistry --description "Production template" --tags status=active + - name: Update latest version without specifying version + text: az ml deployment-template update --name my-template --registry-name myregistry --description "Updated latest version" +""" + +helps['ml deployment-template archive'] = """ +type: command +short-summary: Archive a deployment template. +long-summary: | + Archive a deployment template to mark it as inactive. Archived templates are not + returned in list operations by default. You can archive a specific version or all + versions of a template. +examples: + - name: Archive a specific version + text: az ml deployment-template archive --name my-template --version 1 --registry-name myregistry + - name: Archive all versions of a template + text: az ml deployment-template archive --name my-template --registry-name myregistry + - name: Archive without waiting for completion + text: az ml deployment-template archive --name my-template --version 1 --registry-name myregistry --no-wait +""" + +helps['ml deployment-template restore'] = """ +type: command +short-summary: Restore an archived deployment template. +long-summary: | + Restore a previously archived deployment template to make it active again. Restored + templates will appear in list operations. You can restore a specific version or all + versions of a template. +examples: + - name: Restore a specific version + text: az ml deployment-template restore --name my-template --version 1 --registry-name myregistry + - name: Restore all versions of a template + text: az ml deployment-template restore --name my-template --registry-name myregistry + - name: Restore without waiting for completion + text: az ml deployment-template restore --name my-template --version 1 --registry-name myregistry --no-wait +""" diff --git a/src/machinelearningservices/azext_mlv2/manual/_params/__init__.py b/src/machinelearningservices/azext_mlv2/manual/_params/__init__.py index 302b8630c17..da9df47459f 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_params/__init__.py +++ b/src/machinelearningservices/azext_mlv2/manual/_params/__init__.py @@ -16,6 +16,7 @@ from ._connection_params import load_connection_params from ._data_params import load_data_params from ._datastore_params import load_datastore_params +from ._deployment_template_params import load_deployment_template_params from ._environment_params import load_environment_params from ._feature_set_params import load_feature_set_params from ._feature_store_entity_params import load_feature_store_entity_params @@ -44,6 +45,7 @@ def load_arguments(self, _): load_batch_endpoint_params(self) load_online_deployment_params(self) load_batch_deployment_params(self) + load_deployment_template_params(self) load_environment_params(self) load_compute_params(self) load_workspace_params(self) diff --git a/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py new file mode 100644 index 00000000000..2f229379816 --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py @@ -0,0 +1,77 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +from ._common_params import add_common_params, add_description_param, add_file_param, add_lro_param, add_override_param, add_tags_param + + +def add_deployment_template_common_param( + c, + name_help_message="Name of the deployment template.", + version_help_message="Version of the deployment template.", +): + c.argument("name", options_list=["--name", "-n"], help=name_help_message) + c.argument("version", options_list=["--version", "-v"], help=version_help_message) + + +def load_deployment_template_params(self): + with self.argument_context("ml deployment-template list") as c: + add_common_params(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) + + with self.argument_context("ml deployment-template get") as c: + add_common_params(c) + add_deployment_template_common_param(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) + + with self.argument_context("ml deployment-template create") as c: + add_common_params(c) + add_deployment_template_common_param(c) + add_lro_param(c) + add_file_param(c, "deployment-template", "https://aka.ms/ml-cli-v2-deployment-template-yaml") + add_override_param(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) + + with self.argument_context("ml deployment-template update") as c: + add_common_params(c) + add_deployment_template_common_param(c) + add_override_param(c) + add_description_param(c, help_message="Description of the deployment template.") + add_tags_param(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) + + with self.argument_context("ml deployment-template archive") as c: + add_common_params(c) + add_deployment_template_common_param(c) + add_lro_param(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) + + with self.argument_context("ml deployment-template restore") as c: + add_common_params(c) + add_deployment_template_common_param(c) + add_lro_param(c) + c.argument( + "registry_name", + options_list=["--registry-name", "-r"], + help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + ) diff --git a/src/machinelearningservices/azext_mlv2/manual/commands.py b/src/machinelearningservices/azext_mlv2/manual/commands.py index 1d8e6f6ea7a..91fce4f1861 100644 --- a/src/machinelearningservices/azext_mlv2/manual/commands.py +++ b/src/machinelearningservices/azext_mlv2/manual/commands.py @@ -226,6 +226,22 @@ def load_command_table(self, _): supports_no_wait=True, ) + with self.command_group("ml deployment-template", client_factory=cf_ml_cl) as g: + custom_tmpl = "azext_mlv2.manual.custom.deployment_template#{}" + custom_deployment_template = CliCommandType(operations_tmpl=custom_tmpl) + g.custom_command("list", "ml_deployment_template_list", command_type=custom_deployment_template) + g.custom_command("get", "ml_deployment_template_get", command_type=custom_deployment_template) + g.custom_command("create", "ml_deployment_template_create", supports_no_wait=True, command_type=custom_deployment_template) + g.generic_update_command( + "update", + getter_name="ml_deployment_template_get", + getter_type=custom_deployment_template, + setter_name="_ml_deployment_template_update", + setter_type=custom_deployment_template, + ) + g.custom_command("archive", "ml_deployment_template_archive", supports_no_wait=True, command_type=custom_deployment_template) + g.custom_command("restore", "ml_deployment_template_restore", supports_no_wait=True, command_type=custom_deployment_template) + with self.command_group("ml compute", client_factory=cf_ml_cl) as g: custom_tmpl = "azext_mlv2.manual.custom.compute#{}" custom_compute = CliCommandType(operations_tmpl=custom_tmpl) diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/__init__.py b/src/machinelearningservices/azext_mlv2/manual/custom/__init__.py index 1e8413a51f4..ee2ddfa3370 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/__init__.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/__init__.py @@ -36,6 +36,7 @@ from .connection import * # pylint: disable=wrong-import-position from .data import * # pylint: disable=wrong-import-position from .datastore import * # pylint: disable=wrong-import-position +from .deployment_template import * # pylint: disable=wrong-import-position from .environment import * # pylint: disable=wrong-import-position from .feature_set import * # pylint: disable=wrong-import-position from .feature_store import * # pylint: disable=wrong-import-position,unused-wildcard-import diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py new file mode 100644 index 00000000000..87e3e68c505 --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py @@ -0,0 +1,255 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +import logging +from typing import Dict + +try: + from azure.ai.ml.entities._load_functions import load_deployment_template +except ImportError: + load_deployment_template = None + +from azure.ai.ml.exceptions import ErrorCategory, ErrorTarget, UserErrorException, ValidationException + +from .raise_error import log_and_raise_error +from .utils import ( + _dump_entity_with_warnings, + get_ml_client, + is_not_found_error, + wrap_lro, +) + +module_logger = logging.getLogger(__name__) +module_logger.propagate = 0 +logger = logging.getLogger(__name__) + + +def ml_deployment_template_list(cmd, registry_name=None): + """List deployment templates in a registry.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + deployment_templates = ml_client.deployment_templates.list() + # Handle DeploymentTemplate serialization - try as_dict() first, then _to_dict() + result = [] + for template in deployment_templates: + try: + if hasattr(template, 'as_dict'): + result.append(template.as_dict()) + elif hasattr(template, '_to_dict'): + result.append(template._to_dict()) # pylint: disable=protected-access + else: + # Fallback to dict conversion + result.append(dict(template)) + except Exception as serialize_err: # pylint: disable=broad-except + module_logger.warning("Failed to serialize deployment template: %s", serialize_err) + result.append(str(template)) + return result + except Exception as err: # pylint: disable=broad-except + log_and_raise_error(err, debug) + + +def ml_deployment_template_get(cmd, name, version=None, registry_name=None): + """Get a specific deployment template by name and version.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + deployment_template = ml_client.deployment_templates.get(name=name, version=version) + # Handle DeploymentTemplate serialization + if hasattr(deployment_template, 'as_dict'): + return deployment_template.as_dict() + elif hasattr(deployment_template, '_to_dict'): + return deployment_template._to_dict() # pylint: disable=protected-access + else: + return dict(deployment_template) + except Exception as err: # pylint: disable=broad-except + if is_not_found_error(err): + raise ValueError(f"Deployment template '{name}' with version '{version}' does not exist.") from err + log_and_raise_error(err, debug) + + +def ml_deployment_template_create( + cmd, + file=None, + name=None, + version=None, + registry_name=None, + no_wait=False, + params_override=None, + **kwargs, # pylint: disable=unused-argument +): + """Create or update a deployment template.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + params_override = params_override or [] + + try: + if name: + params_override.append({"name": name}) + if version: + params_override.append({"version": version}) + + if load_deployment_template: + deployment_template = load_deployment_template(source=file, params_override=params_override) + else: + # Fallback: load YAML manually if load_deployment_template is not available + import yaml + if not file: + raise ValueError("A YAML file must be provided for deployment template creation.") + + with open(file, 'r', encoding='utf-8') as f: + yaml_content = yaml.safe_load(f) + + # Apply parameter overrides + for override in params_override: + if isinstance(override, dict): + yaml_content.update(override) + + deployment_template = yaml_content + + deployment_template_result = ml_client.deployment_templates.create_or_update(deployment_template) + + if no_wait: + module_logger.warning( + "Deployment template create/update request initiated. " + "Status can be checked using `az ml deployment-template get -n %s -v %s`", + deployment_template.name if hasattr(deployment_template, 'name') else name or "unknown", + deployment_template.version if hasattr(deployment_template, 'version') else version or "unknown" + ) + return None + else: + deployment_template_result = wrap_lro(cmd.cli_ctx, deployment_template_result) + + # Handle serialization + if hasattr(deployment_template_result, 'as_dict'): + return deployment_template_result.as_dict() + elif hasattr(deployment_template_result, '_to_dict'): + return deployment_template_result._to_dict() # pylint: disable=protected-access + else: + return dict(deployment_template_result) + except Exception as err: # pylint: disable=broad-except + yaml_operation = bool(file) + log_and_raise_error(err, debug, yaml_operation=yaml_operation) + + +def _ml_deployment_template_update( + cmd, registry_name=None, parameters: Dict = None +): + """Update function for generic_update_command pattern.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + # Filter out internal/computed fields that cause validation errors + # The generic_update_command passes all fields from the existing entity, + # but many of these are internal fields that shouldn't be in a YAML template + filtered_parameters = {} + + # Core fields that are typically in YAML templates + core_fields = ['name', 'version', 'description', 'tags', 'endpoints', 'schema'] + for field in core_fields: + if field in parameters: + filtered_parameters[field] = parameters[field] + + # Add other fields that don't cause validation errors + # Exclude fields that are typically computed/internal (both camelCase and snake_case variants) + excluded_fields = { + 'requestSettings', 'request_settings', 'allowedInstanceType', 'allowed_instance_type', + 'scoringPath', 'scoring_path', 'livenessProbe', 'liveness_probe', + 'environmentId', 'environment_id', 'scoringPort', 'scoring_port', + 'modelMountPath', 'model_mount_path', 'defaultInstanceType', 'default_instance_type', + 'instanceCount', 'instance_count', 'environmentVariables', 'environment_variables', + 'stage', 'deploymentTemplateType', 'deployment_template_type', + 'readinessProbe', 'readiness_probe', 'id', 'resourceGroup', 'resource_group', + 'subscriptionId', 'subscription_id', 'createdTime', 'created_time', + 'modifiedTime', 'modified_time', 'createdBy', 'created_by', 'modifiedBy', 'modified_by' + } + + for field, value in parameters.items(): + if field not in filtered_parameters: + filtered_parameters[field] = value + + deployment_template_result = ml_client.deployment_templates.create_or_update(parameters) + + # Handle serialization + if hasattr(deployment_template_result, 'as_dict'): + return deployment_template_result.as_dict() + elif hasattr(deployment_template_result, '_to_dict'): + return deployment_template_result._to_dict() # pylint: disable=protected-access + else: + return dict(deployment_template_result) + except Exception as err: # pylint: disable=broad-except + log_and_raise_error(err, debug) + + +def _ml_deployment_template_show(cmd, name, version=None, registry_name=None): + """Getter function for generic_update_command pattern.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + deployment_template = ml_client.deployment_templates.get(name=name, version=version) + + # Use to_rest_object to get proper field naming (snake_case instead of camelCase) + if hasattr(deployment_template, 'to_rest_object'): + return deployment_template.to_rest_object() + elif hasattr(deployment_template, 'as_dict'): + return deployment_template.as_dict() + elif hasattr(deployment_template, '_to_dict'): + return deployment_template._to_dict() # pylint: disable=protected-access + else: + return dict(deployment_template) + except Exception as err: # pylint: disable=broad-except + if is_not_found_error(err): + raise ValueError(f"Deployment template '{name}' with version '{version}' does not exist.") from err + log_and_raise_error(err, debug) + + +def ml_deployment_template_archive( + cmd, + name, + version=None, + registry_name=None, + no_wait=False, + **kwargs, # pylint: disable=unused-argument +): + """Archive a deployment template.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + ml_client.deployment_templates.archive(name=name, version=version) + except Exception as err: # pylint: disable=broad-except + log_and_raise_error(err, debug) + + +def ml_deployment_template_restore( + cmd, + name, + version=None, + registry_name=None, + no_wait=False, + **kwargs, # pylint: disable=unused-argument +): + """Restore an archived deployment template.""" + ml_client, debug = get_ml_client( + cli_ctx=cmd.cli_ctx, registry_name=registry_name + ) + + try: + ml_client.deployment_templates.restore(name=name, version=version) + except Exception as err: # pylint: disable=broad-except + log_and_raise_error(err, debug) + + + diff --git a/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py b/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py new file mode 100644 index 00000000000..b428cb0aff7 --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py @@ -0,0 +1,506 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +import os +import pytest +import yaml +from azext_mlv2.tests.scenario_test_helper import MLBaseScenarioTest + + +class DeploymentTemplateScenarioTest(MLBaseScenarioTest): + """Test cases for deployment template commands (list, get, create, archive, restore).""" + + def test_deployment_template_no_registry(self) -> None: + """Test that deployment template commands require registry parameter.""" + commands = [ + "az ml deployment-template list", + "az ml deployment-template show -n test-template", + "az ml deployment-template create -n test-template", + "az ml deployment-template archive -n test-template", + "az ml deployment-template restore -n test-template" + ] + + for base_command in commands: + with pytest.raises(Exception) as exp: + self.cmd(f'{base_command} --registry-name=""') + # Registry is required for deployment templates + assert "registry" in str(exp.value).lower() or "required" in str(exp.value).lower() + + def test_deployment_template_list_empty_registry(self) -> None: + """Test listing deployment templates from empty registry.""" + result = self.cmd("az ml deployment-template list --registry-name test-registry") + templates = yaml.safe_load(result.output) if result.output else [] + assert isinstance(templates, list) + # Empty registry should return empty list + assert len(templates) >= 0 + + def test_deployment_template_create_basic(self) -> None: + """Test creating a basic deployment template.""" + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + + # Create deployment template + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(result.output) + + # Verify basic properties + assert template["name"] == "test-deployment-template" + assert template["version"] == "1" + assert template["description"] == "Test deployment template for CLI testing" + assert "tags" in template + assert template["tags"]["purpose"] == "testing" + assert template["tags"]["framework"] == "azure-ml" + assert "endpoints" in template + assert len(template["endpoints"]) == 1 + assert template["endpoints"][0]["name"] == "default" + + def test_deployment_template_create_advanced(self) -> None: + """Test creating an advanced deployment template with multiple endpoints.""" + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml" + + # Create deployment template + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(result.output) + + # Verify advanced properties + assert template["name"] == "advanced-deployment-template" + assert template["version"] == "2" + assert template["description"] == "Advanced deployment template with multiple endpoints for testing" + assert "tags" in template + assert template["tags"]["environment"] == "development" + assert template["tags"]["team"] == "ml-platform" + assert "endpoints" in template + assert len(template["endpoints"]) == 2 + + # Verify endpoints + endpoint_names = [ep["name"] for ep in template["endpoints"]] + assert "primary" in endpoint_names + assert "canary" in endpoint_names + + # Verify traffic distribution + primary_endpoint = next(ep for ep in template["endpoints"] if ep["name"] == "primary") + canary_endpoint = next(ep for ep in template["endpoints"] if ep["name"] == "canary") + assert primary_endpoint["traffic"] == 80 + assert canary_endpoint["traffic"] == 20 + + def test_deployment_template_create_minimal(self) -> None: + """Test creating a minimal deployment template.""" + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml" + + # Create deployment template + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(result.output) + + # Verify minimal properties + assert template["name"] == "minimal-deployment-template" + assert template["version"] == "1" + assert template["description"] == "Minimal deployment template for basic testing" + assert "endpoints" in template + assert len(template["endpoints"]) == 1 + assert template["endpoints"][0]["name"] == "simple" + + def test_deployment_template_create_with_params_override(self) -> None: + """Test creating deployment template with parameter overrides.""" + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + + # Create with name and version override + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --name override-template --version 5") + template = yaml.safe_load(result.output) + + # Verify overridden values + assert template["name"] == "override-template" + assert template["version"] == "5" + # Original description should remain + assert template["description"] == "Test deployment template for CLI testing" + + def test_deployment_template_get_existing(self) -> None: + """Test getting an existing deployment template.""" + # First create a template + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + created_template = yaml.safe_load(create_result.output) + + # Then get it back + get_result = self.cmd(f"az ml deployment-template get --registry-name test-registry --name {created_template['name']} --version {created_template['version']}") + retrieved_template = yaml.safe_load(get_result.output) + + # Verify they match + assert retrieved_template["name"] == created_template["name"] + assert retrieved_template["version"] == created_template["version"] + assert retrieved_template["description"] == created_template["description"] + assert "endpoints" in retrieved_template + assert len(retrieved_template["endpoints"]) == len(created_template["endpoints"]) + + def test_deployment_template_get_nonexistent(self) -> None: + """Test getting a non-existent deployment template.""" + with pytest.raises(Exception) as exp: + self.cmd("az ml deployment-template get --registry-name test-registry --name nonexistent-template --version 999") + + # Should raise an error for non-existent template + error_msg = str(exp.value).lower() + assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent-template" in error_msg + + def test_deployment_template_get_latest_version(self) -> None: + """Test getting deployment template without specifying version (should get latest).""" + # First create a template + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + created_template = yaml.safe_load(create_result.output) + + # Get without version (should get latest) + get_result = self.cmd(f"az ml deployment-template get --registry-name test-registry --name {created_template['name']}") + retrieved_template = yaml.safe_load(get_result.output) + + # Verify it's the same template + assert retrieved_template["name"] == created_template["name"] + assert retrieved_template["version"] == created_template["version"] + + def test_deployment_template_list_after_create(self) -> None: + """Test listing deployment templates after creating some.""" + # Create multiple templates + configs = [ + ("./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml", "basic-template"), + ("./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml", "minimal-template") + ] + + created_names = [] + for config_path, override_name in configs: + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --name {override_name}") + template = yaml.safe_load(result.output) + created_names.append(template["name"]) + + # List all templates + list_result = self.cmd("az ml deployment-template list --registry-name test-registry") + templates = yaml.safe_load(list_result.output) + + # Verify our created templates are in the list + assert isinstance(templates, list) + template_names = [t["name"] for t in templates] + + for name in created_names: + assert name in template_names + + def test_deployment_template_archive_and_restore_version(self) -> None: + """Test archiving and restoring a specific version of deployment template.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Archive the specific version + archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template_name} --version {template_version}") + # Archive command typically returns empty output on success + assert archive_result.output == "" or "archived" in archive_result.output.lower() + + # Restore the specific version + restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template_name} --version {template_version}") + # Restore command typically returns empty output on success + assert restore_result.output == "" or "restored" in restore_result.output.lower() + + def test_deployment_template_archive_and_restore_all_versions(self) -> None: + """Test archiving and restoring all versions of a deployment template.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + + # Archive all versions (no version specified) + archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template_name}") + # Archive command typically returns empty output on success + assert archive_result.output == "" or "archived" in archive_result.output.lower() + + # Restore all versions (no version specified) + restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template_name}") + # Restore command typically returns empty output on success + assert restore_result.output == "" or "restored" in restore_result.output.lower() + + def test_deployment_template_archive_nonexistent(self) -> None: + """Test archiving a non-existent deployment template.""" + with pytest.raises(Exception) as exp: + self.cmd("az ml deployment-template archive --registry-name test-registry --name nonexistent-template --version 999") + + # Should raise an error for non-existent template + error_msg = str(exp.value).lower() + assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg + + def test_deployment_template_restore_nonexistent(self) -> None: + """Test restoring a non-existent deployment template.""" + with pytest.raises(Exception) as exp: + self.cmd("az ml deployment-template restore --registry-name test-registry --name nonexistent-template --version 999") + + # Should raise an error for non-existent template + error_msg = str(exp.value).lower() + assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg + + def test_deployment_template_create_no_wait(self) -> None: + """Test creating deployment template with --no-wait flag.""" + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + + # Create with no-wait flag + result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --no-wait") + + # With --no-wait, the command should return immediately with no output or a status message + assert result.output == "" or "initiated" in result.output.lower() or "status" in result.output.lower() + + def test_deployment_template_archive_no_wait(self) -> None: + """Test archiving deployment template with --no-wait flag.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + # Archive with no-wait flag + archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template['name']} --version {template['version']} --no-wait") + + # With --no-wait, should return immediately + assert archive_result.output == "" or "initiated" in archive_result.output.lower() + + def test_deployment_template_restore_no_wait(self) -> None: + """Test restoring deployment template with --no-wait flag.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + # Archive it first + self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template['name']} --version {template['version']}") + + # Restore with no-wait flag + restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template['name']} --version {template['version']} --no-wait") + + # With --no-wait, should return immediately + assert restore_result.output == "" or "initiated" in restore_result.output.lower() + + # ===== UPDATE COMMAND TESTS ===== + + def test_deployment_template_update_description(self) -> None: + """Test updating deployment template description.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + original_description = template["description"] + + # Update description + new_description = "Updated description for testing purposes" + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\"") + updated_template = yaml.safe_load(update_result.output) + + # Verify the update + assert updated_template["name"] == template_name + assert updated_template["version"] == template_version + assert updated_template["description"] == new_description + assert updated_template["description"] != original_description + # Other properties should remain unchanged + assert "endpoints" in updated_template + assert len(updated_template["endpoints"]) == len(template["endpoints"]) + + def test_deployment_template_update_tags(self) -> None: + """Test updating deployment template tags.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Update tags + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --tags environment=production owner=ml-team") + updated_template = yaml.safe_load(update_result.output) + + # Verify the update + assert updated_template["name"] == template_name + assert updated_template["version"] == template_version + assert "tags" in updated_template + # Should have new tags merged with existing ones + assert updated_template["tags"]["environment"] == "production" + assert updated_template["tags"]["owner"] == "ml-team" + # Original tags should still be present (merged, not replaced) + assert updated_template["tags"]["purpose"] == "testing" + assert updated_template["tags"]["framework"] == "azure-ml" + + def test_deployment_template_update_description_and_tags(self) -> None: + """Test updating both description and tags simultaneously.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Update both description and tags + new_description = "Updated minimal template with new tags" + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\" --tags updated=true version=v2") + updated_template = yaml.safe_load(update_result.output) + + # Verify both updates + assert updated_template["name"] == template_name + assert updated_template["version"] == template_version + assert updated_template["description"] == new_description + assert "tags" in updated_template + assert updated_template["tags"]["updated"] == "true" + assert updated_template["tags"]["version"] == "v2" + + def test_deployment_template_update_without_version(self) -> None: + """Test updating deployment template without specifying version (should update latest).""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + + # Update without specifying version (should update latest) + new_description = "Updated latest version without specifying version" + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --description \"{new_description}\"") + updated_template = yaml.safe_load(update_result.output) + + # Verify the update + assert updated_template["name"] == template_name + assert updated_template["description"] == new_description + + def test_deployment_template_update_nonexistent(self) -> None: + """Test updating a non-existent deployment template.""" + with pytest.raises(Exception) as exp: + self.cmd("az ml deployment-template update --registry-name test-registry --name nonexistent-template --version 999 --description \"This should fail\"") + + # Should raise an error for non-existent template + error_msg = str(exp.value).lower() + assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg + + def test_deployment_template_update_no_changes(self) -> None: + """Test updating deployment template with no actual changes.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + original_description = template["description"] + + # Update with same description (no real change) + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{original_description}\"") + updated_template = yaml.safe_load(update_result.output) + + # Should still work and return the template + assert updated_template["name"] == template_name + assert updated_template["version"] == template_version + assert updated_template["description"] == original_description + + def test_deployment_template_update_empty_tags(self) -> None: + """Test updating deployment template with empty tags.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Update with empty tags (should preserve existing tags) + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"Updated with empty tags\"") + updated_template = yaml.safe_load(update_result.output) + + # Verify description updated but tags preserved + assert updated_template["description"] == "Updated with empty tags" + assert "tags" in updated_template + assert updated_template["tags"]["purpose"] == "testing" + assert updated_template["tags"]["framework"] == "azure-ml" + + def test_deployment_template_update_no_wait(self) -> None: + """Test updating deployment template with --no-wait flag.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Update with no-wait flag + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"Updated with no-wait\" --no-wait") + + # With --no-wait, should return immediately with minimal output + assert update_result.output == "" or "initiated" in update_result.output.lower() or "status" in update_result.output.lower() + + def test_deployment_template_update_and_verify_unchanged_properties(self) -> None: + """Test that updating preserves all other properties of the deployment template.""" + # Create an advanced template with multiple properties + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # Store original properties + original_endpoints = template["endpoints"] + original_endpoint_count = len(original_endpoints) + + # Update only description + new_description = "Updated advanced template preserving all endpoints" + update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\"") + updated_template = yaml.safe_load(update_result.output) + + # Verify description updated + assert updated_template["description"] == new_description + + # Verify all other properties preserved + assert updated_template["name"] == template_name + assert updated_template["version"] == template_version + assert "endpoints" in updated_template + assert len(updated_template["endpoints"]) == original_endpoint_count + + # Verify endpoint details preserved + updated_endpoint_names = [ep["name"] for ep in updated_template["endpoints"]] + original_endpoint_names = [ep["name"] for ep in original_endpoints] + assert set(updated_endpoint_names) == set(original_endpoint_names) + + # Verify traffic distribution preserved + for updated_ep in updated_template["endpoints"]: + original_ep = next(ep for ep in original_endpoints if ep["name"] == updated_ep["name"]) + assert updated_ep["traffic"] == original_ep["traffic"] + + def test_deployment_template_update_chain_multiple_updates(self) -> None: + """Test chaining multiple updates to the same deployment template.""" + # Create a template first + config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" + create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") + template = yaml.safe_load(create_result.output) + + template_name = template["name"] + template_version = template["version"] + + # First update: description + first_description = "First update - description only" + first_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{first_description}\"") + first_result = yaml.safe_load(first_update.output) + assert first_result["description"] == first_description + + # Second update: tags + second_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --tags iteration=2 status=active") + second_result = yaml.safe_load(second_update.output) + assert second_result["description"] == first_description # Should preserve previous update + assert second_result["tags"]["iteration"] == "2" + assert second_result["tags"]["status"] == "active" + + # Third update: both description and tags + final_description = "Final update - both description and tags" + final_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{final_description}\" --tags final=true") + final_result = yaml.safe_load(final_update.output) + assert final_result["description"] == final_description + assert final_result["tags"]["final"] == "true" + # Previous tags should be merged + assert final_result["tags"]["iteration"] == "2" + assert final_result["tags"]["status"] == "active" diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml new file mode 100644 index 00000000000..1dc6e60b5cb --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml @@ -0,0 +1,42 @@ +name: advanced-deployment-template +version: "2" +description: Advanced deployment template with multiple endpoints for testing +tags: + environment: development + team: ml-platform + cost-center: "12345" +endpoints: + - name: primary + traffic: 80 + deployment: + model: + name: advanced-model + version: "2" + environment: + name: advanced-env + version: "1" + instance_count: 3 + instance_type: Standard_DS3_v2 + code_configuration: + scoring_script: score.py + code: src/ + scale_settings: + type: target_utilization + target_utilization_percentage: 70 + min_instances: 1 + max_instances: 10 + - name: canary + traffic: 20 + deployment: + model: + name: canary-model + version: "1" + environment: + name: canary-env + version: "1" + instance_count: 1 + instance_type: Standard_DS2_v2 + code_configuration: + scoring_script: score_canary.py + scale_settings: + type: default \ No newline at end of file diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml new file mode 100644 index 00000000000..f2ebf33b1c2 --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml @@ -0,0 +1,22 @@ +name: test-deployment-template +version: "1" +description: Test deployment template for CLI testing +tags: + purpose: testing + framework: azure-ml +endpoints: + - name: default + traffic: 100 + deployment: + model: + name: test-model + version: "1" + environment: + name: test-env + version: "1" + instance_count: 1 + instance_type: Standard_DS2_v2 + code_configuration: + scoring_script: score.py + scale_settings: + type: default \ No newline at end of file diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml new file mode 100644 index 00000000000..f09ef381110 --- /dev/null +++ b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml @@ -0,0 +1,15 @@ +name: minimal-deployment-template +version: "1" +description: Minimal deployment template for basic testing +endpoints: + - name: simple + traffic: 100 + deployment: + model: + name: simple-model + version: "1" + environment: + name: simple-env + version: "1" + instance_count: 1 + instance_type: Standard_DS1_v2 \ No newline at end of file From 29ca248cccfdce1e043e175d94f2665c4eb592a7 Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Fri, 31 Oct 2025 12:47:16 +0530 Subject: [PATCH 5/9] Update command modify --- .../manual/custom/deployment_template.py | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py index 87e3e68c505..115bdb8ceeb 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py @@ -148,36 +148,12 @@ def _ml_deployment_template_update( ) try: - # Filter out internal/computed fields that cause validation errors - # The generic_update_command passes all fields from the existing entity, - # but many of these are internal fields that shouldn't be in a YAML template - filtered_parameters = {} - - # Core fields that are typically in YAML templates - core_fields = ['name', 'version', 'description', 'tags', 'endpoints', 'schema'] - for field in core_fields: - if field in parameters: - filtered_parameters[field] = parameters[field] - - # Add other fields that don't cause validation errors - # Exclude fields that are typically computed/internal (both camelCase and snake_case variants) - excluded_fields = { - 'requestSettings', 'request_settings', 'allowedInstanceType', 'allowed_instance_type', - 'scoringPath', 'scoring_path', 'livenessProbe', 'liveness_probe', - 'environmentId', 'environment_id', 'scoringPort', 'scoring_port', - 'modelMountPath', 'model_mount_path', 'defaultInstanceType', 'default_instance_type', - 'instanceCount', 'instance_count', 'environmentVariables', 'environment_variables', - 'stage', 'deploymentTemplateType', 'deployment_template_type', - 'readinessProbe', 'readiness_probe', 'id', 'resourceGroup', 'resource_group', - 'subscriptionId', 'subscription_id', 'createdTime', 'created_time', - 'modifiedTime', 'modified_time', 'createdBy', 'created_by', 'modifiedBy', 'modified_by' - } - - for field, value in parameters.items(): - if field not in filtered_parameters: - filtered_parameters[field] = value - - deployment_template_result = ml_client.deployment_templates.create_or_update(parameters) + deployment_template = ml_client.deployment_templates.get(name=parameters["name"], version=parameters["version"]) + + deployment_template.description = parameters["description"] + deployment_template.tags = parameters["tags"] + + deployment_template_result = ml_client.deployment_templates.create_or_update(deployment_template) # Handle serialization if hasattr(deployment_template_result, 'as_dict'): From 663d1da57c38ef1368fc600b04b7571745136f1d Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Fri, 31 Oct 2025 13:23:01 +0530 Subject: [PATCH 6/9] make reg and version mandatory --- .../_params/_deployment_template_params.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py index 2f229379816..a5183983fea 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py +++ b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py @@ -9,9 +9,11 @@ def add_deployment_template_common_param( c, name_help_message="Name of the deployment template.", version_help_message="Version of the deployment template.", + name_required=True, + version_required=True, ): - c.argument("name", options_list=["--name", "-n"], help=name_help_message) - c.argument("version", options_list=["--version", "-v"], help=version_help_message) + c.argument("name", options_list=["--name", "-n"], help=name_help_message, required=name_required) + c.argument("version", options_list=["--version", "-v"], help=version_help_message, required=version_required) def load_deployment_template_params(self): @@ -20,58 +22,64 @@ def load_deployment_template_params(self): c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) with self.argument_context("ml deployment-template get") as c: add_common_params(c) - add_deployment_template_common_param(c) + add_deployment_template_common_param(c, name_required=True, version_required=True) c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) with self.argument_context("ml deployment-template create") as c: add_common_params(c) - add_deployment_template_common_param(c) + add_deployment_template_common_param(c, name_required=False, version_required=False) # Optional for create since they can come from file add_lro_param(c) add_file_param(c, "deployment-template", "https://aka.ms/ml-cli-v2-deployment-template-yaml") add_override_param(c) c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) with self.argument_context("ml deployment-template update") as c: add_common_params(c) - add_deployment_template_common_param(c) + add_deployment_template_common_param(c, name_required=True, version_required=True) add_override_param(c) add_description_param(c, help_message="Description of the deployment template.") add_tags_param(c) c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) with self.argument_context("ml deployment-template archive") as c: add_common_params(c) - add_deployment_template_common_param(c) + add_deployment_template_common_param(c, name_required=True, version_required=True) add_lro_param(c) c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) with self.argument_context("ml deployment-template restore") as c: add_common_params(c) - add_deployment_template_common_param(c) + add_deployment_template_common_param(c, name_required=True, version_required=True) add_lro_param(c) c.argument( "registry_name", options_list=["--registry-name", "-r"], + required=True, help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", ) From d9e8bce0fd9fbe29460371952ceea27b422b635e Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Fri, 31 Oct 2025 13:26:21 +0530 Subject: [PATCH 7/9] update documentation --- .../manual/_help/_deployment_template_help.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py index 2cd9574aebf..9a860ab2edf 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py +++ b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py @@ -40,8 +40,6 @@ def get_deployment_template_help(): examples: - name: Get a specific version of a deployment template text: az ml deployment-template get --name my-template --version 1 --registry-name myregistry - - name: Get the latest version of a deployment template - text: az ml deployment-template get --name my-template --registry-name myregistry """ helps['ml deployment-template create'] = """ @@ -72,13 +70,11 @@ def get_deployment_template_help(): use the 'create' command with a YAML file. examples: - name: Update deployment template description - text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --description "Updated description" + text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --set "description=Updated description" - name: Update deployment template tags - text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --tags environment=production owner=ml-team + text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --set "tags=environment=production owner=ml-team" - name: Update both description and tags - text: az ml deployment-template update --name my-template --registry-name myregistry --description "Production template" --tags status=active - - name: Update latest version without specifying version - text: az ml deployment-template update --name my-template --registry-name myregistry --description "Updated latest version" + text: az ml deployment-template update --name my-template --version 1 --registry-name myregistry --set "description=Production template" --set "tags=status=active" """ helps['ml deployment-template archive'] = """ @@ -91,8 +87,6 @@ def get_deployment_template_help(): examples: - name: Archive a specific version text: az ml deployment-template archive --name my-template --version 1 --registry-name myregistry - - name: Archive all versions of a template - text: az ml deployment-template archive --name my-template --registry-name myregistry - name: Archive without waiting for completion text: az ml deployment-template archive --name my-template --version 1 --registry-name myregistry --no-wait """ @@ -107,8 +101,6 @@ def get_deployment_template_help(): examples: - name: Restore a specific version text: az ml deployment-template restore --name my-template --version 1 --registry-name myregistry - - name: Restore all versions of a template - text: az ml deployment-template restore --name my-template --registry-name myregistry - name: Restore without waiting for completion text: az ml deployment-template restore --name my-template --version 1 --registry-name myregistry --no-wait """ From 118cbef828a19ff8ce386a2540bd84893c5c6287 Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Mon, 3 Nov 2025 10:45:51 +0530 Subject: [PATCH 8/9] adding changelog --- src/machinelearningservices/CHANGELOG.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/machinelearningservices/CHANGELOG.rst b/src/machinelearningservices/CHANGELOG.rst index a9d384b386a..567af97f4b9 100644 --- a/src/machinelearningservices/CHANGELOG.rst +++ b/src/machinelearningservices/CHANGELOG.rst @@ -1,3 +1,19 @@ +## 2025-11-04 + +### Azure Machine Learning CLI (v2) v 2.40.0 +- `az ml deployment-template create` + - Create a new deployment template from a YAML file. +- `az ml deployment-template list` + - List deployment templates in a registry +- `az ml deployment-template get` + - Get a specific deployment template by name and version. +- `az ml deployment-template update` + - Update specific fields of an existing deployment template. +- `az ml deployment-template archive` + - Archive a deployment template. +- `az ml deployment-template restore` + - Restore an archived deployment template. + ## 2025-08-27 ### Azure Machine Learning CLI (v2) v 2.39.0 From 308b156b0aa959fcfdf0678f69b329f98acd0a5d Mon Sep 17 00:00:00 2001 From: kshitij-microsoft Date: Mon, 3 Nov 2025 23:17:28 +0530 Subject: [PATCH 9/9] fixing style and removing tests --- .../manual/_help/_deployment_template_help.py | 28 +- .../_params/_deployment_template_params.py | 42 +- .../azext_mlv2/manual/commands.py | 9 +- .../manual/custom/deployment_template.py | 63 +-- .../test_deployment_template_scenarios.py | 506 ------------------ .../deployment_template_advanced.yaml | 42 -- .../deployment_template_basic.yaml | 22 - .../deployment_template_minimal.yaml | 15 - 8 files changed, 80 insertions(+), 647 deletions(-) delete mode 100644 src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py delete mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml delete mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml delete mode 100644 src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml diff --git a/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py index 9a860ab2edf..6cef35c9589 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py +++ b/src/machinelearningservices/azext_mlv2/manual/_help/_deployment_template_help.py @@ -7,14 +7,14 @@ def get_deployment_template_help(): """Load deployment template help content.""" - pass # Help content is defined below using helps dictionary + helps['ml deployment-template'] = """ type: group short-summary: Manage Azure ML deployment templates. long-summary: | - Deployment templates are reusable templates that define deployment configurations for Azure ML. - They support registry-based operations only (not workspace-based) and provide a way to + Deployment templates are reusable templates that define deployment configurations for Azure ML. + They support registry-based operations only (not workspace-based) and provide a way to standardize and share deployment configurations across teams and projects. """ @@ -22,7 +22,7 @@ def get_deployment_template_help(): type: command short-summary: List deployment templates in a registry. long-summary: | - List all deployment templates available in the specified registry. This command + List all deployment templates available in the specified registry. This command returns all templates along with their metadata including name, version, description, and tags. examples: - name: List all deployment templates in a registry @@ -35,7 +35,7 @@ def get_deployment_template_help(): type: command short-summary: Get a specific deployment template by name and version. long-summary: | - Retrieve detailed information about a specific deployment template. If version is not + Retrieve detailed information about a specific deployment template. If version is not specified, the latest version will be returned. examples: - name: Get a specific version of a deployment template @@ -46,7 +46,7 @@ def get_deployment_template_help(): type: command short-summary: Create a new deployment template from a YAML file. long-summary: | - Create a new deployment template using a YAML configuration file. The YAML file should + Create a new deployment template using a YAML configuration file. The YAML file should contain the complete deployment template definition including endpoints, parameters, and metadata. You can override specific values using command-line parameters. examples: @@ -62,11 +62,11 @@ def get_deployment_template_help(): type: command short-summary: Update specific fields of an existing deployment template. long-summary: | - Update metadata fields (description and tags) of an existing deployment template without - requiring a YAML file. This command follows Azure CLI conventions and only accepts specific + Update metadata fields (description and tags) of an existing deployment template without + requiring a YAML file. This command follows Azure CLI conventions and only accepts specific field updates. Tags are merged with existing tags rather than replaced. - - For structural changes to the deployment template (endpoints, deployment configuration, etc.), + + For structural changes to the deployment template (endpoints, deployment configuration, etc.), use the 'create' command with a YAML file. examples: - name: Update deployment template description @@ -81,8 +81,8 @@ def get_deployment_template_help(): type: command short-summary: Archive a deployment template. long-summary: | - Archive a deployment template to mark it as inactive. Archived templates are not - returned in list operations by default. You can archive a specific version or all + Archive a deployment template to mark it as inactive. Archived templates are not + returned in list operations by default. You can archive a specific version or all versions of a template. examples: - name: Archive a specific version @@ -95,8 +95,8 @@ def get_deployment_template_help(): type: command short-summary: Restore an archived deployment template. long-summary: | - Restore a previously archived deployment template to make it active again. Restored - templates will appear in list operations. You can restore a specific version or all + Restore a previously archived deployment template to make it active again. Restored + templates will appear in list operations. You can restore a specific version or all versions of a template. examples: - name: Restore a specific version diff --git a/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py index a5183983fea..8fe54f79345 100644 --- a/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py +++ b/src/machinelearningservices/azext_mlv2/manual/_params/_deployment_template_params.py @@ -2,7 +2,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -from ._common_params import add_common_params, add_description_param, add_file_param, add_lro_param, add_override_param, add_tags_param +from ._common_params import ( + add_common_params, + add_description_param, + add_file_param, + add_lro_param, + add_override_param, + add_tags_param, +) def add_deployment_template_common_param( @@ -23,7 +30,10 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) with self.argument_context("ml deployment-template get") as c: @@ -33,12 +43,16 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) with self.argument_context("ml deployment-template create") as c: add_common_params(c) - add_deployment_template_common_param(c, name_required=False, version_required=False) # Optional for create since they can come from file + # Optional for create since they can come from file + add_deployment_template_common_param(c, name_required=False, version_required=False) add_lro_param(c) add_file_param(c, "deployment-template", "https://aka.ms/ml-cli-v2-deployment-template-yaml") add_override_param(c) @@ -46,7 +60,10 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) with self.argument_context("ml deployment-template update") as c: @@ -59,7 +76,10 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) with self.argument_context("ml deployment-template archive") as c: @@ -70,7 +90,10 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) with self.argument_context("ml deployment-template restore") as c: @@ -81,5 +104,8 @@ def load_deployment_template_params(self): "registry_name", options_list=["--registry-name", "-r"], required=True, - help="Name of the registry. This is required since deployment templates only support registry-name and not workspace.", + help=( + "Name of the registry. This is required since deployment templates " + "only support registry-name and not workspace." + ), ) diff --git a/src/machinelearningservices/azext_mlv2/manual/commands.py b/src/machinelearningservices/azext_mlv2/manual/commands.py index 91fce4f1861..6a4bf0726a0 100644 --- a/src/machinelearningservices/azext_mlv2/manual/commands.py +++ b/src/machinelearningservices/azext_mlv2/manual/commands.py @@ -231,7 +231,8 @@ def load_command_table(self, _): custom_deployment_template = CliCommandType(operations_tmpl=custom_tmpl) g.custom_command("list", "ml_deployment_template_list", command_type=custom_deployment_template) g.custom_command("get", "ml_deployment_template_get", command_type=custom_deployment_template) - g.custom_command("create", "ml_deployment_template_create", supports_no_wait=True, command_type=custom_deployment_template) + g.custom_command("create", "ml_deployment_template_create", supports_no_wait=True, + command_type=custom_deployment_template) g.generic_update_command( "update", getter_name="ml_deployment_template_get", @@ -239,8 +240,10 @@ def load_command_table(self, _): setter_name="_ml_deployment_template_update", setter_type=custom_deployment_template, ) - g.custom_command("archive", "ml_deployment_template_archive", supports_no_wait=True, command_type=custom_deployment_template) - g.custom_command("restore", "ml_deployment_template_restore", supports_no_wait=True, command_type=custom_deployment_template) + g.custom_command("archive", "ml_deployment_template_archive", supports_no_wait=True, + command_type=custom_deployment_template) + g.custom_command("restore", "ml_deployment_template_restore", supports_no_wait=True, + command_type=custom_deployment_template) with self.command_group("ml compute", client_factory=cf_ml_cl) as g: custom_tmpl = "azext_mlv2.manual.custom.compute#{}" diff --git a/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py index 115bdb8ceeb..cd9ed839213 100644 --- a/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py +++ b/src/machinelearningservices/azext_mlv2/manual/custom/deployment_template.py @@ -1,4 +1,4 @@ -# --------------------------------------------------------- +# --------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- @@ -10,11 +10,8 @@ except ImportError: load_deployment_template = None -from azure.ai.ml.exceptions import ErrorCategory, ErrorTarget, UserErrorException, ValidationException - from .raise_error import log_and_raise_error from .utils import ( - _dump_entity_with_warnings, get_ml_client, is_not_found_error, wrap_lro, @@ -30,7 +27,7 @@ def ml_deployment_template_list(cmd, registry_name=None): ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + try: deployment_templates = ml_client.deployment_templates.list() # Handle DeploymentTemplate serialization - try as_dict() first, then _to_dict() @@ -57,16 +54,15 @@ def ml_deployment_template_get(cmd, name, version=None, registry_name=None): ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + try: deployment_template = ml_client.deployment_templates.get(name=name, version=version) # Handle DeploymentTemplate serialization if hasattr(deployment_template, 'as_dict'): return deployment_template.as_dict() - elif hasattr(deployment_template, '_to_dict'): + if hasattr(deployment_template, '_to_dict'): return deployment_template._to_dict() # pylint: disable=protected-access - else: - return dict(deployment_template) + return dict(deployment_template) except Exception as err: # pylint: disable=broad-except if is_not_found_error(err): raise ValueError(f"Deployment template '{name}' with version '{version}' does not exist.") from err @@ -87,15 +83,15 @@ def ml_deployment_template_create( ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + params_override = params_override or [] - + try: if name: params_override.append({"name": name}) if version: params_override.append({"version": version}) - + if load_deployment_template: deployment_template = load_deployment_template(source=file, params_override=params_override) else: @@ -103,19 +99,19 @@ def ml_deployment_template_create( import yaml if not file: raise ValueError("A YAML file must be provided for deployment template creation.") - + with open(file, 'r', encoding='utf-8') as f: yaml_content = yaml.safe_load(f) - + # Apply parameter overrides for override in params_override: if isinstance(override, dict): yaml_content.update(override) - + deployment_template = yaml_content - + deployment_template_result = ml_client.deployment_templates.create_or_update(deployment_template) - + if no_wait: module_logger.warning( "Deployment template create/update request initiated. " @@ -124,16 +120,14 @@ def ml_deployment_template_create( deployment_template.version if hasattr(deployment_template, 'version') else version or "unknown" ) return None - else: - deployment_template_result = wrap_lro(cmd.cli_ctx, deployment_template_result) - + deployment_template_result = wrap_lro(cmd.cli_ctx, deployment_template_result) + # Handle serialization if hasattr(deployment_template_result, 'as_dict'): return deployment_template_result.as_dict() - elif hasattr(deployment_template_result, '_to_dict'): + if hasattr(deployment_template_result, '_to_dict'): return deployment_template_result._to_dict() # pylint: disable=protected-access - else: - return dict(deployment_template_result) + return dict(deployment_template_result) except Exception as err: # pylint: disable=broad-except yaml_operation = bool(file) log_and_raise_error(err, debug, yaml_operation=yaml_operation) @@ -158,10 +152,9 @@ def _ml_deployment_template_update( # Handle serialization if hasattr(deployment_template_result, 'as_dict'): return deployment_template_result.as_dict() - elif hasattr(deployment_template_result, '_to_dict'): + if hasattr(deployment_template_result, '_to_dict'): return deployment_template_result._to_dict() # pylint: disable=protected-access - else: - return dict(deployment_template_result) + return dict(deployment_template_result) except Exception as err: # pylint: disable=broad-except log_and_raise_error(err, debug) @@ -171,19 +164,18 @@ def _ml_deployment_template_show(cmd, name, version=None, registry_name=None): ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + try: deployment_template = ml_client.deployment_templates.get(name=name, version=version) - + # Use to_rest_object to get proper field naming (snake_case instead of camelCase) if hasattr(deployment_template, 'to_rest_object'): return deployment_template.to_rest_object() - elif hasattr(deployment_template, 'as_dict'): + if hasattr(deployment_template, 'as_dict'): return deployment_template.as_dict() - elif hasattr(deployment_template, '_to_dict'): + if hasattr(deployment_template, '_to_dict'): return deployment_template._to_dict() # pylint: disable=protected-access - else: - return dict(deployment_template) + return dict(deployment_template) except Exception as err: # pylint: disable=broad-except if is_not_found_error(err): raise ValueError(f"Deployment template '{name}' with version '{version}' does not exist.") from err @@ -202,7 +194,7 @@ def ml_deployment_template_archive( ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + try: ml_client.deployment_templates.archive(name=name, version=version) except Exception as err: # pylint: disable=broad-except @@ -221,11 +213,8 @@ def ml_deployment_template_restore( ml_client, debug = get_ml_client( cli_ctx=cmd.cli_ctx, registry_name=registry_name ) - + try: ml_client.deployment_templates.restore(name=name, version=version) except Exception as err: # pylint: disable=broad-except log_and_raise_error(err, debug) - - - diff --git a/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py b/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py deleted file mode 100644 index b428cb0aff7..00000000000 --- a/src/machinelearningservices/azext_mlv2/tests/latest/test_deployment_template_scenarios.py +++ /dev/null @@ -1,506 +0,0 @@ -# --------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# --------------------------------------------------------- - -import os -import pytest -import yaml -from azext_mlv2.tests.scenario_test_helper import MLBaseScenarioTest - - -class DeploymentTemplateScenarioTest(MLBaseScenarioTest): - """Test cases for deployment template commands (list, get, create, archive, restore).""" - - def test_deployment_template_no_registry(self) -> None: - """Test that deployment template commands require registry parameter.""" - commands = [ - "az ml deployment-template list", - "az ml deployment-template show -n test-template", - "az ml deployment-template create -n test-template", - "az ml deployment-template archive -n test-template", - "az ml deployment-template restore -n test-template" - ] - - for base_command in commands: - with pytest.raises(Exception) as exp: - self.cmd(f'{base_command} --registry-name=""') - # Registry is required for deployment templates - assert "registry" in str(exp.value).lower() or "required" in str(exp.value).lower() - - def test_deployment_template_list_empty_registry(self) -> None: - """Test listing deployment templates from empty registry.""" - result = self.cmd("az ml deployment-template list --registry-name test-registry") - templates = yaml.safe_load(result.output) if result.output else [] - assert isinstance(templates, list) - # Empty registry should return empty list - assert len(templates) >= 0 - - def test_deployment_template_create_basic(self) -> None: - """Test creating a basic deployment template.""" - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - - # Create deployment template - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(result.output) - - # Verify basic properties - assert template["name"] == "test-deployment-template" - assert template["version"] == "1" - assert template["description"] == "Test deployment template for CLI testing" - assert "tags" in template - assert template["tags"]["purpose"] == "testing" - assert template["tags"]["framework"] == "azure-ml" - assert "endpoints" in template - assert len(template["endpoints"]) == 1 - assert template["endpoints"][0]["name"] == "default" - - def test_deployment_template_create_advanced(self) -> None: - """Test creating an advanced deployment template with multiple endpoints.""" - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml" - - # Create deployment template - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(result.output) - - # Verify advanced properties - assert template["name"] == "advanced-deployment-template" - assert template["version"] == "2" - assert template["description"] == "Advanced deployment template with multiple endpoints for testing" - assert "tags" in template - assert template["tags"]["environment"] == "development" - assert template["tags"]["team"] == "ml-platform" - assert "endpoints" in template - assert len(template["endpoints"]) == 2 - - # Verify endpoints - endpoint_names = [ep["name"] for ep in template["endpoints"]] - assert "primary" in endpoint_names - assert "canary" in endpoint_names - - # Verify traffic distribution - primary_endpoint = next(ep for ep in template["endpoints"] if ep["name"] == "primary") - canary_endpoint = next(ep for ep in template["endpoints"] if ep["name"] == "canary") - assert primary_endpoint["traffic"] == 80 - assert canary_endpoint["traffic"] == 20 - - def test_deployment_template_create_minimal(self) -> None: - """Test creating a minimal deployment template.""" - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml" - - # Create deployment template - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(result.output) - - # Verify minimal properties - assert template["name"] == "minimal-deployment-template" - assert template["version"] == "1" - assert template["description"] == "Minimal deployment template for basic testing" - assert "endpoints" in template - assert len(template["endpoints"]) == 1 - assert template["endpoints"][0]["name"] == "simple" - - def test_deployment_template_create_with_params_override(self) -> None: - """Test creating deployment template with parameter overrides.""" - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - - # Create with name and version override - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --name override-template --version 5") - template = yaml.safe_load(result.output) - - # Verify overridden values - assert template["name"] == "override-template" - assert template["version"] == "5" - # Original description should remain - assert template["description"] == "Test deployment template for CLI testing" - - def test_deployment_template_get_existing(self) -> None: - """Test getting an existing deployment template.""" - # First create a template - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - created_template = yaml.safe_load(create_result.output) - - # Then get it back - get_result = self.cmd(f"az ml deployment-template get --registry-name test-registry --name {created_template['name']} --version {created_template['version']}") - retrieved_template = yaml.safe_load(get_result.output) - - # Verify they match - assert retrieved_template["name"] == created_template["name"] - assert retrieved_template["version"] == created_template["version"] - assert retrieved_template["description"] == created_template["description"] - assert "endpoints" in retrieved_template - assert len(retrieved_template["endpoints"]) == len(created_template["endpoints"]) - - def test_deployment_template_get_nonexistent(self) -> None: - """Test getting a non-existent deployment template.""" - with pytest.raises(Exception) as exp: - self.cmd("az ml deployment-template get --registry-name test-registry --name nonexistent-template --version 999") - - # Should raise an error for non-existent template - error_msg = str(exp.value).lower() - assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent-template" in error_msg - - def test_deployment_template_get_latest_version(self) -> None: - """Test getting deployment template without specifying version (should get latest).""" - # First create a template - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - created_template = yaml.safe_load(create_result.output) - - # Get without version (should get latest) - get_result = self.cmd(f"az ml deployment-template get --registry-name test-registry --name {created_template['name']}") - retrieved_template = yaml.safe_load(get_result.output) - - # Verify it's the same template - assert retrieved_template["name"] == created_template["name"] - assert retrieved_template["version"] == created_template["version"] - - def test_deployment_template_list_after_create(self) -> None: - """Test listing deployment templates after creating some.""" - # Create multiple templates - configs = [ - ("./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml", "basic-template"), - ("./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml", "minimal-template") - ] - - created_names = [] - for config_path, override_name in configs: - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --name {override_name}") - template = yaml.safe_load(result.output) - created_names.append(template["name"]) - - # List all templates - list_result = self.cmd("az ml deployment-template list --registry-name test-registry") - templates = yaml.safe_load(list_result.output) - - # Verify our created templates are in the list - assert isinstance(templates, list) - template_names = [t["name"] for t in templates] - - for name in created_names: - assert name in template_names - - def test_deployment_template_archive_and_restore_version(self) -> None: - """Test archiving and restoring a specific version of deployment template.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Archive the specific version - archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template_name} --version {template_version}") - # Archive command typically returns empty output on success - assert archive_result.output == "" or "archived" in archive_result.output.lower() - - # Restore the specific version - restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template_name} --version {template_version}") - # Restore command typically returns empty output on success - assert restore_result.output == "" or "restored" in restore_result.output.lower() - - def test_deployment_template_archive_and_restore_all_versions(self) -> None: - """Test archiving and restoring all versions of a deployment template.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - - # Archive all versions (no version specified) - archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template_name}") - # Archive command typically returns empty output on success - assert archive_result.output == "" or "archived" in archive_result.output.lower() - - # Restore all versions (no version specified) - restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template_name}") - # Restore command typically returns empty output on success - assert restore_result.output == "" or "restored" in restore_result.output.lower() - - def test_deployment_template_archive_nonexistent(self) -> None: - """Test archiving a non-existent deployment template.""" - with pytest.raises(Exception) as exp: - self.cmd("az ml deployment-template archive --registry-name test-registry --name nonexistent-template --version 999") - - # Should raise an error for non-existent template - error_msg = str(exp.value).lower() - assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg - - def test_deployment_template_restore_nonexistent(self) -> None: - """Test restoring a non-existent deployment template.""" - with pytest.raises(Exception) as exp: - self.cmd("az ml deployment-template restore --registry-name test-registry --name nonexistent-template --version 999") - - # Should raise an error for non-existent template - error_msg = str(exp.value).lower() - assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg - - def test_deployment_template_create_no_wait(self) -> None: - """Test creating deployment template with --no-wait flag.""" - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - - # Create with no-wait flag - result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path} --no-wait") - - # With --no-wait, the command should return immediately with no output or a status message - assert result.output == "" or "initiated" in result.output.lower() or "status" in result.output.lower() - - def test_deployment_template_archive_no_wait(self) -> None: - """Test archiving deployment template with --no-wait flag.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - # Archive with no-wait flag - archive_result = self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template['name']} --version {template['version']} --no-wait") - - # With --no-wait, should return immediately - assert archive_result.output == "" or "initiated" in archive_result.output.lower() - - def test_deployment_template_restore_no_wait(self) -> None: - """Test restoring deployment template with --no-wait flag.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - # Archive it first - self.cmd(f"az ml deployment-template archive --registry-name test-registry --name {template['name']} --version {template['version']}") - - # Restore with no-wait flag - restore_result = self.cmd(f"az ml deployment-template restore --registry-name test-registry --name {template['name']} --version {template['version']} --no-wait") - - # With --no-wait, should return immediately - assert restore_result.output == "" or "initiated" in restore_result.output.lower() - - # ===== UPDATE COMMAND TESTS ===== - - def test_deployment_template_update_description(self) -> None: - """Test updating deployment template description.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - original_description = template["description"] - - # Update description - new_description = "Updated description for testing purposes" - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\"") - updated_template = yaml.safe_load(update_result.output) - - # Verify the update - assert updated_template["name"] == template_name - assert updated_template["version"] == template_version - assert updated_template["description"] == new_description - assert updated_template["description"] != original_description - # Other properties should remain unchanged - assert "endpoints" in updated_template - assert len(updated_template["endpoints"]) == len(template["endpoints"]) - - def test_deployment_template_update_tags(self) -> None: - """Test updating deployment template tags.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Update tags - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --tags environment=production owner=ml-team") - updated_template = yaml.safe_load(update_result.output) - - # Verify the update - assert updated_template["name"] == template_name - assert updated_template["version"] == template_version - assert "tags" in updated_template - # Should have new tags merged with existing ones - assert updated_template["tags"]["environment"] == "production" - assert updated_template["tags"]["owner"] == "ml-team" - # Original tags should still be present (merged, not replaced) - assert updated_template["tags"]["purpose"] == "testing" - assert updated_template["tags"]["framework"] == "azure-ml" - - def test_deployment_template_update_description_and_tags(self) -> None: - """Test updating both description and tags simultaneously.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Update both description and tags - new_description = "Updated minimal template with new tags" - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\" --tags updated=true version=v2") - updated_template = yaml.safe_load(update_result.output) - - # Verify both updates - assert updated_template["name"] == template_name - assert updated_template["version"] == template_version - assert updated_template["description"] == new_description - assert "tags" in updated_template - assert updated_template["tags"]["updated"] == "true" - assert updated_template["tags"]["version"] == "v2" - - def test_deployment_template_update_without_version(self) -> None: - """Test updating deployment template without specifying version (should update latest).""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - - # Update without specifying version (should update latest) - new_description = "Updated latest version without specifying version" - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --description \"{new_description}\"") - updated_template = yaml.safe_load(update_result.output) - - # Verify the update - assert updated_template["name"] == template_name - assert updated_template["description"] == new_description - - def test_deployment_template_update_nonexistent(self) -> None: - """Test updating a non-existent deployment template.""" - with pytest.raises(Exception) as exp: - self.cmd("az ml deployment-template update --registry-name test-registry --name nonexistent-template --version 999 --description \"This should fail\"") - - # Should raise an error for non-existent template - error_msg = str(exp.value).lower() - assert "not found" in error_msg or "does not exist" in error_msg or "nonexistent" in error_msg - - def test_deployment_template_update_no_changes(self) -> None: - """Test updating deployment template with no actual changes.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - original_description = template["description"] - - # Update with same description (no real change) - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{original_description}\"") - updated_template = yaml.safe_load(update_result.output) - - # Should still work and return the template - assert updated_template["name"] == template_name - assert updated_template["version"] == template_version - assert updated_template["description"] == original_description - - def test_deployment_template_update_empty_tags(self) -> None: - """Test updating deployment template with empty tags.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Update with empty tags (should preserve existing tags) - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"Updated with empty tags\"") - updated_template = yaml.safe_load(update_result.output) - - # Verify description updated but tags preserved - assert updated_template["description"] == "Updated with empty tags" - assert "tags" in updated_template - assert updated_template["tags"]["purpose"] == "testing" - assert updated_template["tags"]["framework"] == "azure-ml" - - def test_deployment_template_update_no_wait(self) -> None: - """Test updating deployment template with --no-wait flag.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Update with no-wait flag - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"Updated with no-wait\" --no-wait") - - # With --no-wait, should return immediately with minimal output - assert update_result.output == "" or "initiated" in update_result.output.lower() or "status" in update_result.output.lower() - - def test_deployment_template_update_and_verify_unchanged_properties(self) -> None: - """Test that updating preserves all other properties of the deployment template.""" - # Create an advanced template with multiple properties - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # Store original properties - original_endpoints = template["endpoints"] - original_endpoint_count = len(original_endpoints) - - # Update only description - new_description = "Updated advanced template preserving all endpoints" - update_result = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{new_description}\"") - updated_template = yaml.safe_load(update_result.output) - - # Verify description updated - assert updated_template["description"] == new_description - - # Verify all other properties preserved - assert updated_template["name"] == template_name - assert updated_template["version"] == template_version - assert "endpoints" in updated_template - assert len(updated_template["endpoints"]) == original_endpoint_count - - # Verify endpoint details preserved - updated_endpoint_names = [ep["name"] for ep in updated_template["endpoints"]] - original_endpoint_names = [ep["name"] for ep in original_endpoints] - assert set(updated_endpoint_names) == set(original_endpoint_names) - - # Verify traffic distribution preserved - for updated_ep in updated_template["endpoints"]: - original_ep = next(ep for ep in original_endpoints if ep["name"] == updated_ep["name"]) - assert updated_ep["traffic"] == original_ep["traffic"] - - def test_deployment_template_update_chain_multiple_updates(self) -> None: - """Test chaining multiple updates to the same deployment template.""" - # Create a template first - config_path = "./src/cli/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml" - create_result = self.cmd(f"az ml deployment-template create --registry-name test-registry --file {config_path}") - template = yaml.safe_load(create_result.output) - - template_name = template["name"] - template_version = template["version"] - - # First update: description - first_description = "First update - description only" - first_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{first_description}\"") - first_result = yaml.safe_load(first_update.output) - assert first_result["description"] == first_description - - # Second update: tags - second_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --tags iteration=2 status=active") - second_result = yaml.safe_load(second_update.output) - assert second_result["description"] == first_description # Should preserve previous update - assert second_result["tags"]["iteration"] == "2" - assert second_result["tags"]["status"] == "active" - - # Third update: both description and tags - final_description = "Final update - both description and tags" - final_update = self.cmd(f"az ml deployment-template update --registry-name test-registry --name {template_name} --version {template_version} --description \"{final_description}\" --tags final=true") - final_result = yaml.safe_load(final_update.output) - assert final_result["description"] == final_description - assert final_result["tags"]["final"] == "true" - # Previous tags should be merged - assert final_result["tags"]["iteration"] == "2" - assert final_result["tags"]["status"] == "active" diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml deleted file mode 100644 index 1dc6e60b5cb..00000000000 --- a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_advanced.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: advanced-deployment-template -version: "2" -description: Advanced deployment template with multiple endpoints for testing -tags: - environment: development - team: ml-platform - cost-center: "12345" -endpoints: - - name: primary - traffic: 80 - deployment: - model: - name: advanced-model - version: "2" - environment: - name: advanced-env - version: "1" - instance_count: 3 - instance_type: Standard_DS3_v2 - code_configuration: - scoring_script: score.py - code: src/ - scale_settings: - type: target_utilization - target_utilization_percentage: 70 - min_instances: 1 - max_instances: 10 - - name: canary - traffic: 20 - deployment: - model: - name: canary-model - version: "1" - environment: - name: canary-env - version: "1" - instance_count: 1 - instance_type: Standard_DS2_v2 - code_configuration: - scoring_script: score_canary.py - scale_settings: - type: default \ No newline at end of file diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml deleted file mode 100644 index f2ebf33b1c2..00000000000 --- a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_basic.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: test-deployment-template -version: "1" -description: Test deployment template for CLI testing -tags: - purpose: testing - framework: azure-ml -endpoints: - - name: default - traffic: 100 - deployment: - model: - name: test-model - version: "1" - environment: - name: test-env - version: "1" - instance_count: 1 - instance_type: Standard_DS2_v2 - code_configuration: - scoring_script: score.py - scale_settings: - type: default \ No newline at end of file diff --git a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml b/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml deleted file mode 100644 index f09ef381110..00000000000 --- a/src/machinelearningservices/azext_mlv2/tests/test_configs/deployment_template/deployment_template_minimal.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: minimal-deployment-template -version: "1" -description: Minimal deployment template for basic testing -endpoints: - - name: simple - traffic: 100 - deployment: - model: - name: simple-model - version: "1" - environment: - name: simple-env - version: "1" - instance_count: 1 - instance_type: Standard_DS1_v2 \ No newline at end of file