diff --git a/src/azure-cli-core/azure/cli/core/profiles/_shared.py b/src/azure-cli-core/azure/cli/core/profiles/_shared.py index 1c72649519b..44e161f1b2d 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -10,7 +10,6 @@ from knack.log import get_logger - logger = get_logger(__name__) @@ -84,6 +83,8 @@ class ResourceType(Enum): # pylint: disable=too-few-public-methods MGMT_CUSTOMLOCATION = ('azure.mgmt.extendedlocation', 'CustomLocations') MGMT_CONTAINERSERVICE = ('azure.mgmt.containerservice', 'ContainerServiceClient') MGMT_APPCONTAINERS = ('azure.mgmt.appcontainers', 'ContainerAppsAPIClient') + MGMT_DISCONNECTEDOPERATIONS = ('azure.mgmt.disconnectedoperations', 'DisconnectedOperationsClient') + # the "None" below will stay till a command module fills in the type so "get_mgmt_service_client" # can be provided with "ResourceType.XXX" to initialize the client object. This usually happens @@ -155,6 +156,7 @@ def default_api_version(self): AZURE_API_PROFILES = { 'latest': { + ResourceType.MGMT_DISCONNECTEDOPERATIONS: '2024-12-01-preview', ResourceType.MGMT_STORAGE: '2024-01-01', ResourceType.MGMT_NETWORK: '2022-01-01', ResourceType.MGMT_COMPUTE: SDKProfile('2024-07-01', { diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py new file mode 100644 index 00000000000..59ae79d9124 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image +from azure.cli.command_modules.disconnectedoperations._help import ( + helps, # pylint: disable=unused-import +) +from azure.cli.core import AzCommandsLoader + + +class DisconnectedoperationsCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + from azure.cli.core.profiles import ResourceType + + disconnectedoperations_custom = CliCommandType( + operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}", + client_factory=cf_image, + ) + super().__init__( + cli_ctx=cli_ctx, + resource_type=ResourceType.MGMT_DISCONNECTEDOPERATIONS, + custom_command_type=disconnectedoperations_custom, + ) + + def load_command_table(self, args): + from azure.cli.command_modules.disconnectedoperations.commands import ( + load_command_table, + ) + + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azure.cli.command_modules.disconnectedoperations._params import ( + load_arguments, + ) + + load_arguments(self, command) + + +COMMAND_LOADER_CLS = DisconnectedoperationsCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py new file mode 100644 index 00000000000..ab3fbf60d18 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -0,0 +1,15 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +def get_disconnectedoperations_management_client(cli_ctx, *_): + from azure.cli.core.commands.client_factory import get_mgmt_service_client + from azure.mgmt.disconnectedoperations import DisconnectedOperationsClient + + return get_mgmt_service_client(cli_ctx, DisconnectedOperationsClient) + + +def cf_image(cli_ctx, *_): + return get_disconnectedoperations_management_client(cli_ctx).image diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py new file mode 100644 index 00000000000..021292824c0 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -0,0 +1,108 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps + +helps['edge'] = """ +type: group +short-summary: Manage edge operations. +""" + +helps['edge disconnected-operation'] = """ +type: group +short-summary: Manage edge disconnected operations. +""" + +helps['edge disconnected-operation edge-marketplace'] = """ +type: group +short-summary: Manage Edge Marketplace for disconnected operations. +""" + +helps['edge disconnected-operation edge-marketplace offer'] = """ +type: group +short-summary: Manage Edge Marketplace offers for disconnected operations. +""" + +helps['edge disconnected-operation edge-marketplace offer list'] = """ +type: command +short-summary: List all available marketplace offers. +examples: + - name: List all marketplace offers for a specific resource + text: > + az edge disconnected-operation edge-marketplace offer list --resource-group myResourceGroup --resource-name myResource + - name: List offers and format output as table + text: > + az edge disconnected-operation edge-marketplace offer list -g myResourceGroup --resource-name myResource --output table + - name: List offers and filter output using JMESPath query + text: > + az edge disconnected-operation edge-marketplace offer list -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" +parameters: + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name + type: string + short-summary: The resource name +""" + +helps['edge disconnected-operation edge-marketplace offer get'] = """ +type: command +short-summary: Get details of a specific marketplace offer. +examples: + - name: Get details of a specific marketplace offer + text: > + az edge disconnected-operation edge-marketplace offer get --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName + - name: Get offer details and output as JSON + text: > + az edge disconnected-operation edge-marketplace offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json + - name: Get offer details with custom query + text: > + az edge disconnected-operation edge-marketplace offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --query "[].{SKU:SKU,Version:Versions}" +parameters: + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name + type: string + short-summary: The resource name + - name: --publisher-name + type: string + short-summary: The publisher name of the offer + - name: --offer-name + type: string + short-summary: The name of the offer +""" + +helps['edge disconnected-operation edge-marketplace offer package'] = """ +type: command +short-summary: Download and package a marketplace offer with its metadata and icons. +long-summary: Downloads the marketplace offer metadata, icons, and creates a package in the specified output folder. +examples: + - name: Package a marketplace offer with specific version + text: > + az edge disconnected-operation edge-marketplace offer package --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber --output-folder "D:\\MarketplacePackages" +parameters: + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name + type: string + short-summary: The resource name + - name: --publisher-name + type: string + short-summary: The publisher name of the offer + - name: --offer-name + type: string + short-summary: The name of the offer + - name: --sku + type: string + short-summary: The SKU of the offer + - name: --version + type: string + short-summary: The version of the offer (optional, latest version will be used if not specified) + - name: --output-folder + type: string + short-summary: The folder path where the package will be downloaded +""" diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py new file mode 100644 index 00000000000..d5fa03dade1 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -0,0 +1,43 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands.parameters import resource_group_name_type + + +def load_arguments(self, _): + with self.argument_context( + "disconnectedoperations edgemarketplace listoffers" + ) as c: + c.argument("resource_group_name", arg_type=resource_group_name_type) + c.argument( + "resource_name", type=str, help="Name of the resource to list offers for" + ) + + with self.argument_context("disconnectedoperations edgemarketplace getoffer") as c: + c.argument("resource_group_name", arg_type=resource_group_name_type) + c.argument( + "resource_name", type=str, help="Name of the resource to list offers for" + ) + c.argument("offer_name", type=str, help="Name of the offer") + c.argument("publisher_name", type=str, help="Name of the publisher") + + with self.argument_context( + "disconnectedoperations edgemarketplace packageoffer" + ) as c: + c.argument("resource_group_name", arg_type=resource_group_name_type) + c.argument( + "resource_name", type=str, help="Name of the resource to list offers for" + ) + c.argument("publisher_name", type=str, help="Name of the publisher") + c.argument("offer_name", type=str, help="Name of the offer to package") + c.argument("sku", type=str, help="SKU of the product") + c.argument("version", type=str, help="Version of the product") + c.argument( + "output_folder", + type=str, + help="Drive and directory to save the package to. Example: E:\\ or D:\\packages\\", + ) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/__init__.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/__init__.py new file mode 100644 index 00000000000..f6acc11aa4e --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/__init__.py @@ -0,0 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__cmd_group.py new file mode 100644 index 00000000000..30f0e46625f --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "edge", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Edge disconnected operations CLI + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__init__.py new file mode 100644 index 00000000000..5a9d61963d6 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__init__.py @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__cmd_group.py new file mode 100644 index 00000000000..fce13eff9a7 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "edge disconnected-operation", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Disconnected operations cli + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__init__.py new file mode 100644 index 00000000000..d63ae5a6fc9 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__init__.py @@ -0,0 +1,12 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._list import * diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/_list.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/_list.py new file mode 100644 index 00000000000..45101687c88 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/_list.py @@ -0,0 +1,396 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "edge disconnected-operation list", + is_preview=True, +) +class List(AAZCommand): + """List DisconnectedOperation resources + + List DisconnectedOperation resources by subscription ID and resource group + """ + + _aaz_info = { + "version": "2024-12-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/providers/microsoft.edge/disconnectedoperations", "2024-12-01-preview"], + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.edge/disconnectedoperations", "2024-12-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + condition_0 = has_value(self.ctx.subscription_id) and has_value(self.ctx.args.resource_group) is not True + condition_1 = has_value(self.ctx.args.resource_group) and has_value(self.ctx.subscription_id) + if condition_0: + self.DisconnectedOperationsListBySubscription(ctx=self.ctx)() + if condition_1: + self.DisconnectedOperationsListByResourceGroup(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class DisconnectedOperationsListBySubscription(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/providers/Microsoft.Edge/disconnectedOperations", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2024-12-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.billing_model = AAZStrType( + serialized_name="billingModel", + flags={"read_only": True}, + ) + properties.connection_intent = AAZStrType( + serialized_name="connectionIntent", + flags={"required": True}, + ) + properties.connection_status = AAZStrType( + serialized_name="connectionStatus", + flags={"read_only": True}, + ) + properties.device_version = AAZStrType( + serialized_name="deviceVersion", + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.registration_status = AAZStrType( + serialized_name="registrationStatus", + ) + properties.stamp_id = AAZStrType( + serialized_name="stampId", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + class DisconnectedOperationsListByResourceGroup(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Edge/disconnectedOperations", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2024-12-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.billing_model = AAZStrType( + serialized_name="billingModel", + flags={"read_only": True}, + ) + properties.connection_intent = AAZStrType( + serialized_name="connectionIntent", + flags={"required": True}, + ) + properties.connection_status = AAZStrType( + serialized_name="connectionStatus", + flags={"read_only": True}, + ) + properties.device_version = AAZStrType( + serialized_name="deviceVersion", + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.registration_status = AAZStrType( + serialized_name="registrationStatus", + ) + properties.stamp_id = AAZStrType( + serialized_name="stampId", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + +__all__ = ["List"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__cmd_group.py new file mode 100644 index 00000000000..79a62e83275 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "edge disconnected-operation image", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Disconnected operations image CLI + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__init__.py new file mode 100644 index 00000000000..5e75ed17830 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__init__.py @@ -0,0 +1,12 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._list_download_uri import * diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/_list_download_uri.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/_list_download_uri.py new file mode 100644 index 00000000000..2629febe4e2 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/_list_download_uri.py @@ -0,0 +1,221 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "edge disconnected-operation image list-download-uri", + is_preview=True, +) +class ListDownloadUri(AAZCommand): + """Get deployment manifest. + """ + + _aaz_info = { + "version": "2024-12-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.edge/disconnectedoperations/{}/images/{}/listdownloaduri", "2024-12-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.image_name = AAZStrArg( + options=["--image-name"], + help="The name of the Image", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[a-zA-Z0-9-]{3,24}$", + ), + ) + _args_schema.name = AAZStrArg( + options=["--name"], + help="Name of the resource", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[a-zA-Z0-9][a-zA-Z0-9-_]{2,22}[a-zA-Z0-9]$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ImagesListDownloadUri(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ImagesListDownloadUri(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Edge/disconnectedOperations/{name}/images/{imageName}/listDownloadUri", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "imageName", self.ctx.args.image_name, + required=True, + ), + **self.serialize_url_param( + "name", self.ctx.args.name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2024-12-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.compatible_versions = AAZListType( + serialized_name="compatibleVersions", + flags={"read_only": True}, + ) + _schema_on_200.download_link = AAZStrType( + serialized_name="downloadLink", + flags={"read_only": True}, + ) + _schema_on_200.link_expiry = AAZStrType( + serialized_name="linkExpiry", + flags={"read_only": True}, + ) + _schema_on_200.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + _schema_on_200.release_date = AAZStrType( + serialized_name="releaseDate", + flags={"read_only": True}, + ) + _schema_on_200.release_display_name = AAZStrType( + serialized_name="releaseDisplayName", + flags={"read_only": True}, + ) + _schema_on_200.release_notes = AAZStrType( + serialized_name="releaseNotes", + flags={"read_only": True}, + ) + _schema_on_200.release_type = AAZStrType( + serialized_name="releaseType", + flags={"read_only": True}, + ) + _schema_on_200.release_version = AAZStrType( + serialized_name="releaseVersion", + flags={"read_only": True}, + ) + _schema_on_200.transaction_id = AAZStrType( + serialized_name="transactionId", + flags={"read_only": True}, + ) + + compatible_versions = cls._schema_on_200.compatible_versions + compatible_versions.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ListDownloadUriHelper: + """Helper class for ListDownloadUri""" + + +__all__ = ["ListDownloadUri"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py new file mode 100644 index 00000000000..e81396792b5 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -0,0 +1,94 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + + +from collections import OrderedDict + +from azure.cli.core.commands import CliCommandType + + +def transform_offers_table(result): + if not result: + return result + + # Transform each row while preserving order + transformed = [] + for item in result: + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version(s)", item["Versions"]), + ("OS_Type", item["OS_Type"]), + ] + ) + transformed.append(row) + + return transformed + + +def transform_offer_table(result): + if not result: + return result + + # Transform each row while preserving order + transformed = [] + for item in result: + # Format versions to be on separate lines if it's a list/array + versions = item["Versions"] + if isinstance(versions, str): + # Split by comma if it's a comma-separated string + versions = [v.strip() for v in versions.split(",")] + + if isinstance(versions, (list, tuple)): + # Format each version on a new line, preserving the full format + formatted_versions = "\n".join(str(v).strip() for v in versions) + else: + formatted_versions = str(versions) + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version(s)", formatted_versions), + ("OS_Type", item["OS_Type"]), + ] + ) + transformed.append(row) + + return transformed + + +def load_command_table(self, _): + custom_command_type = CliCommandType( + operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}" + ) + + # Register the parent command group + with self.command_group( + "edge disconnected-operation", + custom_command_type=custom_command_type, + is_preview=True, + ) as g: + pass # No commands directly at this level + + # Register the subgroup and its commands + with self.command_group( + "edge disconnected-operation edge-marketplace", + custom_command_type=custom_command_type, + is_preview=True, + ) as g: + g.custom_command( + "offer list", "list_offers", table_transformer=transform_offers_table + ) + g.custom_command( + "offer get", "get_offer", table_transformer=transform_offer_table + ) + g.custom_command("offer package", "package_offer") + + return self.command_table diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py new file mode 100644 index 00000000000..aff44b5f412 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -0,0 +1,600 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: disable=too-many-lines +# pylint: disable=too-many-statements + +provider_namespace = "Microsoft.DataBoxEdge" +sub_provider = "Microsoft.EdgeMarketPlace" +api_version = "2023-08-01-preview" + + +def _get_management_endpoint(cli_ctx): + """Helper function to determine management endpoint based on cloud configuration.""" + cloud = cli_ctx.cloud + return cloud.endpoints.resource_manager + # return "brazilus.management.azure.com" + + +def _handle_directory_cleanup(version_level_path, logger): + """Helper function to clean up existing directory.""" + import os + import shutil + + if os.path.exists(version_level_path): + try: + # Remove directory and all its contents + shutil.rmtree(version_level_path) + logger.info("Cleaned up existing version directory: %s", version_level_path) + except OSError as e: + error_message = f"Failed to clean up directory {version_level_path}: {str(e)}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "path": version_level_path, + } + return None + + +def _download_icons(icon_uris, icon_path, logger): + """Helper function to download icons.""" + import os + + import requests + + for size, uri in icon_uris.items(): + file_extension = "png" + file_path = os.path.join(icon_path, f"{size}.{file_extension}") + + # Skip if icon already exists + if os.path.exists(file_path): + logger.info("Icon %s already exists at %s, skipping download", size, file_path) + continue + + try: + logo_response = requests.get(uri) + if logo_response.status_code == 200: + with open(file_path, "wb") as f: + f.write(logo_response.content) + logger.info("Downloaded %s logo to %s", size, file_path) + else: + logger.error("Failed to download %s logo: %s", size, logo_response.status_code) + except requests.RequestException as e: + logger.error("Error downloading %s logo: %s", size, str(e)) + + +def _prepare_paths_and_metadata(output_folder, publisher_id, offer_id, sku, version_id, data, logger): + """Helper function to prepare directories and save metadata.""" + import json + import os + + # Create base path for this version + base_path = os.path.join(output_folder, "catalog_artifacts", publisher_id, offer_id, sku) + version_level_path = os.path.join(base_path, version_id) + icon_path = os.path.join(base_path, "icons") + + # Clean up existing directory if needed + cleanup_result = _handle_directory_cleanup(version_level_path, logger) + if cleanup_result: + return cleanup_result, None, None + + os.makedirs(icon_path, exist_ok=True) + os.makedirs(version_level_path, exist_ok=True) + + # Save metadata.json + metadata_path = os.path.join(version_level_path, "metadata.json") + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + logger.info("Saved metadata to %s", metadata_path) + + return None, version_level_path, icon_path + + +def _find_sku_and_version(skus, sku, version, logger): + """Helper function to find matching SKU and version.""" + for _sku in skus: + sku_id = _sku.get("marketplaceSkuId", "") + if sku_id != sku: + continue + + # Store the generation information + generation = _sku.get("generation") + # Get all versions for this SKU + versions = _sku.get("marketplaceSkuVersions", []) + versions = [v for v in versions if v.get("name") == version] + + if not versions: + logger.warning("No matching version found for SKU %s", sku_id) + return None, None + + # print if version and generation are found + print("Found VM version: %s" % versions[0].get('name')) + print("VM Generation: %s" % generation) + version_id = versions[0].get("name") + return version_id, generation + + # If we get here, no matching SKU was found + logger.warning("No matching SKU found: %s", sku) + return None, None + + +def package_offer(cmd, resource_group_name, resource_name, publisher_name, + offer_name, sku, version, output_folder): + """Get details of a specific marketplace offer and download its logos.""" + + import requests + from knack.log import get_logger + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + + logger = get_logger(__name__) + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL with parameters + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" + f"?api-version={api_version}" + ) + + try: + response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") + + if response.status_code != 200: + error_message = f"Request failed with status code: {response.status_code}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, + } + + data = response.json() + offer_content = data.get("properties", {}).get("offerContent", {}) + icon_uris = offer_content.get("iconFileUris", {}) + + # Download logos and metadata if output folder is specified + if output_folder: + publisher_id = offer_content.get("offerPublisher", {}).get("publisherId", "") + offer_id = offer_content.get("offerId", "") + skus = data.get("properties", {}).get("marketplaceSkus", []) + + # Find matching SKU and version + version_id, generation = _find_sku_and_version(skus, sku, version, logger) + + if not version_id: + return + + # Prepare directories and save metadata + result, version_level_path, icon_path = _prepare_paths_and_metadata( + output_folder, publisher_id, offer_id, sku, version_id, data, logger + ) + + if result: # Error occurred + return result + + # Download icons + if icon_uris: + _download_icons(icon_uris, icon_path, logger) + + print("Metadata and icons downloaded successfully") + print("Offer details retrieved successfully. Proceeding to download VHD.") + + # Downloading VM image + return download_vhd( + cmd, resource_group_name, resource_name, publisher_name, + offer_name, sku, version, generation, version_level_path + ) + + except requests.RequestException as e: + logger.error("Failed to retrieve offer: %s", str(e)) + return { + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } + + +def _check_azcopy_available(): + """Check if azcopy is available in the system path.""" + import shutil + import subprocess + + # First try using shutil.which which is the proper way to check for executables + if shutil.which("azcopy"): + return True + + # Fallback to trying the command directly + try: + result = subprocess.run(["azcopy", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return result.returncode == 0 + except FileNotFoundError: + return False + + +def _handle_token_response(token_response, output_folder, logger): + """Helper function to handle token response and download.""" + import os + import platform + + if token_response.status_code != 200: + logger.error("Failed to get access token: %s", token_response.status_code) + return { + "error": f"Failed to get access token: {token_response.status_code}", + "status": "failed", + } + + token_data = token_response.json() + download_url = token_data.get("accessToken") + + # Check if azcopy is available + if not _check_azcopy_available(): + # Determine OS-specific download link + system = platform.system().lower() + if system == 'windows': + azcopy_url = "https://aka.ms/downloadazcopy-v10-windows" + install_instructions = "Download, extract the ZIP file, and add the extracted folder to your PATH." + elif system == 'linux': + azcopy_url = "https://aka.ms/downloadazcopy-v10-linux" + install_instructions = "Download, extract the tar.gz file, and move the azcopy binary to a directory in your PATH." + elif system == 'darwin': # macOS + azcopy_url = "https://aka.ms/downloadazcopy-v10-mac" + install_instructions = "Download, extract the .zip file, and move the azcopy binary to a directory in your PATH." + else: + azcopy_url = "https://aka.ms/downloadazcopy" + install_instructions = "Download and install AzCopy for your platform." + + error_message = ( + f"AzCopy tool not found. Please install AzCopy for your {system} system and make sure it's available in your PATH.\n" + f"Download link: {azcopy_url}\n" + f"Installation: {install_instructions}" + ) + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "download_url": azcopy_url + } + + # Construct and execute azcopy command + command = f'azcopy copy "{download_url}" "{output_folder}" --check-md5 NoCheck' + print(command) + print("Executing command...") + os.system(command) + print("Download completed successfully.") + + return { + "status": "succeeded", + "message": "Download completed successfully.", + } + + +def _get_token_url(management_endpoint, subscription_id, resource_group_name, + resource_name, publisher_name, offer_name): + """Helper function to construct token URL.""" + return ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" + f"/getAccessToken?api-version={api_version}" + ) + + +def _process_async_operation(cmd, async_operation_url, logger, resource_group_name, + output_folder, subscription_id, resource_name, publisher_name, offer_name): + """Process async operation and monitor status.""" + import json + import time + from datetime import datetime + + import requests + + from azure.cli.core.util import send_raw_request + + max_retries = 10 + base_delay = 2 # seconds + timeout = 300 # 5 minutes timeout + start_time = datetime.now() + + # Package parameters needed for token handling + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + + print("Hitting async operation URL...") + for attempt in range(max_retries): + print("Attempt %s of %s..." % (attempt + 1, max_retries)) + try: + # Check timeout + if (datetime.now() - start_time).total_seconds() > timeout: + logger.error("Operation timed out after 5 minutes") + return {"error": "Operation timed out", "status": "failed"} + + # Get operation status + status_response = send_raw_request( + cmd.cli_ctx, "get", async_operation_url, + resource="https://management.azure.com" + ) + + if status_response.status_code not in (200, 202): + logger.error("Failed to get operation status: %s", status_response.status_code) + return { + "error": f"Status check failed: {status_response.status_code}", + "status": "failed", + } + + status_data = status_response.json() + status = status_data.get("status", "").lower() + print("Current status:", status) + + # Handle successful completion + if status == "succeeded": + logger.info("VHD download URL generation succeeded") + print(status_response) + requestId = status_data.get("properties", {}).get("requestId") + + if not requestId: + logger.error("Download URL not found in response") + return {"error": "Download URL not found", "status": "failed"} + + print(f"Fetched request Id for VHD Download: {requestId}") + + # Obtaining SAS token using request Id + token_url = _get_token_url( + management_endpoint, subscription_id, resource_group_name, + resource_name, publisher_name, offer_name + ) + token_body = {"requestId": requestId} + + token_response = send_raw_request( + cmd.cli_ctx, "post", token_url, + resource="https://management.azure.com", + body=json.dumps(token_body) + ) + + return _handle_token_response(token_response, output_folder, logger) + + # Handle failure + if status == "failed": + error_message = status_data.get("error", {}).get("message", "Unknown error") + logger.error("Operation failed: %s", error_message) + return {"error": error_message, "status": "failed"} + + # Still in progress, wait and retry + logger.info("Operation in progress... (attempt %s/%s)", attempt + 1, max_retries) + delay = base_delay * (2**attempt) # Exponential backoff + time.sleep(delay) + + except (requests.RequestException, ValueError) as e: + logger.error("Error checking operation status: %s", str(e)) + delay = base_delay * (2**attempt) + time.sleep(delay) + + # Exhausted all retries + logger.error("Maximum retry attempts reached") + return {"error": "Maximum retry attempts reached", "status": "failed"} + + +def download_vhd(cmd, resource_group_name, resource_name, publisher_name, + offer_name, sku, version, generation, output_folder): + """Generate access token for VHD download.""" + import json + + import requests + from knack.log import get_logger + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + + logger = get_logger(__name__) + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + + # API endpoint construction + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" + f"/generateAccessToken?api-version=2023-08-01-preview" + ) + + # Request body + body = { + "edgeMarketPlaceRegion": "westus", + "hypervGeneration": generation, + "marketPlaceSku": sku, + "marketPlaceSkuVersion": version, + } + + try: + print("Generating access token for VHD download...") + response = send_raw_request( + cmd.cli_ctx, "post", url, + resource="https://management.azure.com", + body=json.dumps(body) + ) + + print("Checking status of VHD download URL generation...") + + # Check if the request was successful + if response.status_code not in (200, 202): + error_message = f"Request failed with status code: {response.status_code}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, + } + + # Get async operation URL from headers + async_operation_url = response.headers.get("Azure-AsyncOperation") + + if not async_operation_url: + logger.error("Async operation URL not found in response") + return { + "error": "Async operation URL not found", + "status": "failed", + } + + # Process the async operation + return _process_async_operation( + cmd, async_operation_url, logger, resource_group_name, + output_folder, subscription_id, resource_name, + publisher_name, offer_name + ) + + except requests.RequestException as e: + logger.error("Failed to generate access token: %s", str(e)) + return { + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } + + +def list_offers(cmd, resource_group_name, resource_name): + """List all offers for disconnected operations.""" + import requests + from knack.log import get_logger + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + + logger = get_logger(__name__) + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL with parameters + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{sub_provider}/offers" + f"?api-version={api_version}" + ) + + try: + response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") + + if response.status_code == 200: + data = response.json() + result = [] + + for offer in data.get("value", []): + offer_content = offer.get("properties", {}).get("offerContent", {}) + skus = offer.get("properties", {}).get("marketplaceSkus", []) + + for sku in skus: + versions = sku.get("marketplaceSkuVersions", [])[:] + row = { + "Publisher": offer_content.get("offerPublisher", {}).get("publisherId"), + "Offer": offer_content.get("offerId"), + "SKU": sku.get("marketplaceSkuId"), + "Versions": f"{len(versions)} {'version' if len(versions) == 1 else 'versions'} available", + "OS_Type": sku.get("operatingSystem", {}).get("type"), + } + result.append(row) + + return result + + error_message = f"Request failed with status code: {response.status_code}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, + } + + except requests.RequestException as e: + logger.error("Failed to retrieve offers: %s", str(e)) + return { + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } + + +def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_name): + """List all offers for disconnected operations.""" + import requests + from knack.log import get_logger + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + + logger = get_logger(__name__) + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL with parameters + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" + f"?api-version={api_version}" + ) + + try: + response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") + + if response.status_code == 200: + data = response.json() + result = [] + + offer_content = data.get("properties", {}).get("offerContent", {}) + skus = data.get("properties", {}).get("marketplaceSkus", []) + + for sku in skus: + # Get all versions for this SKU + versions = sku.get("marketplaceSkuVersions", [])[:] + + # transform versions and size array into a multi-line string + version_str = ", ".join( + f"{v.get('name')}({v.get('minimumDownloadSizeInMb')}MB)" for v in versions + ) + + # Create a single row with flattened version info + row = { + "Publisher": offer_content.get("offerPublisher", {}).get("publisherId"), + "Offer": offer_content.get("offerId"), + "SKU": sku.get("marketplaceSkuId"), + "Versions": version_str, + "OS_Type": sku.get("operatingSystem", {}).get("type"), + } + result.append(row) + return result + + error_message = f"Request failed with status code: {response.status_code}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, + } + + except requests.RequestException as e: + logger.error("Failed to retrieve offers: %s", str(e)) + return { + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/__init__.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/__init__.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.py new file mode 100644 index 00000000000..f2455cb8df3 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.py @@ -0,0 +1,300 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from unittest import mock + +import requests + +from azure.cli.command_modules.disconnectedoperations import custom +from azure.cli.testsdk import ResourceGroupPreparer, ScenarioTest + + +class DisconnectedOperationsUnitTests(unittest.TestCase): + def setUp(self): + # Common mocks + self.mock_logger = mock.MagicMock() + self.mock_cmd = mock.MagicMock() + self.mock_cli_ctx = mock.MagicMock() + self.mock_cmd.cli_ctx = self.mock_cli_ctx + self.mock_cloud = mock.MagicMock() + self.mock_cli_ctx.cloud = self.mock_cloud + self.mock_cloud.endpoints.resource_manager = "management.azure.com" + + def test_get_management_endpoint(self): + """Test _get_management_endpoint returns the resource manager endpoint""" + endpoint = custom._get_management_endpoint(self.mock_cli_ctx) + self.assertEqual(endpoint, self.mock_cloud.endpoints.resource_manager) + + @mock.patch('os.path.exists') + @mock.patch('shutil.rmtree') + def test_handle_directory_cleanup_success(self, mock_rmtree, mock_exists): + """Test directory cleanup when directory exists""" + mock_exists.return_value = True + + result = custom._handle_directory_cleanup('/test/path', self.mock_logger) + + mock_exists.assert_called_once_with('/test/path') + mock_rmtree.assert_called_once_with('/test/path') + self.mock_logger.info.assert_called_once() + self.assertIsNone(result) + + @mock.patch('os.path.exists') + @mock.patch('shutil.rmtree') + def test_handle_directory_cleanup_error(self, mock_rmtree, mock_exists): + """Test directory cleanup when error occurs""" + mock_exists.return_value = True + mock_rmtree.side_effect = OSError("Test error") + + result = custom._handle_directory_cleanup('/test/path', self.mock_logger) + + mock_exists.assert_called_once_with('/test/path') + mock_rmtree.assert_called_once_with('/test/path') + self.mock_logger.error.assert_called_once() + self.assertIsNotNone(result) + self.assertEqual(result["status"], "failed") + self.assertIn("error", result) + + @mock.patch('os.path.exists') + @mock.patch('requests.get') + def test_download_icons_success(self, mock_get, mock_exists): + """Test icon download success path""" + # Setup mocks + mock_exists.return_value = False + + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.content = b"fake_image_content" + mock_get.return_value = mock_response + + # Setup test data + icons = {"small": "http://example.com/small.png"} + + # Mock open to avoid actual file operations + m = mock.mock_open() + with mock.patch('builtins.open', m): + custom._download_icons(icons, '/test/icons', self.mock_logger) + + # Verify - using platform-independent path comparison + mock_get.assert_called_once_with("http://example.com/small.png") + + # Get the actual file path from the mock call + actual_call = m.call_args + actual_path = actual_call[0][0] + actual_mode = actual_call[0][1] + + # Verify the mode is correct + self.assertEqual(actual_mode, 'wb') + + # Verify path ends with the expected path (using platform-independent comparison) + expected_end = 'small.png' + print(actual_path) + self.assertTrue(actual_path.endswith(expected_end)) + + # Verify file write was called with correct content + handle = m() + handle.write.assert_called_once_with(b"fake_image_content") + self.mock_logger.info.assert_called_once() + + @mock.patch('os.path.exists') + @mock.patch('requests.get') + def test_download_icons_request_error(self, mock_get, mock_exists): + """Test icon download with request error""" + # Setup mocks + mock_exists.return_value = False + mock_get.side_effect = requests.RequestException("Connection error") # Use the imported requests module + + # Setup test data + icons = {"small": "http://example.com/small.png"} + + # Test + custom._download_icons(icons, '/test/icons', self.mock_logger) + + # Verify + mock_get.assert_called_once_with("http://example.com/small.png") + self.mock_logger.error.assert_called_once() + + @mock.patch('azure.cli.core.commands.client_factory.get_subscription_id') + @mock.patch('azure.cli.core.util.send_raw_request') + def test_list_offers_success(self, mock_send_raw_request, mock_get_subscription_id): + """Test list_offers success path""" + # Setup mocks + mock_get_subscription_id.return_value = "test-subscription" + + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "properties": { + "offerContent": { + "offerPublisher": {"publisherId": "test-publisher"}, + "offerId": "test-offer" + }, + "marketplaceSkus": [ + { + "marketplaceSkuId": "test-sku", + "marketplaceSkuVersions": ["1.0", "2.0"], + "operatingSystem": {"type": "Windows"} + } + ] + } + } + ] + } + mock_send_raw_request.return_value = mock_response + + # Test + result = custom.list_offers(self.mock_cmd, "test-rg", "test-resource") + + # Verify + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["Publisher"], "test-publisher") + self.assertEqual(result[0]["Offer"], "test-offer") + self.assertEqual(result[0]["SKU"], "test-sku") + self.assertEqual(result[0]["Versions"], "2 versions available") + + @mock.patch('azure.cli.core.commands.client_factory.get_subscription_id') + @mock.patch('azure.cli.core.util.send_raw_request') + def test_get_offer_success(self, mock_send_raw_request, mock_get_subscription_id): + """Test get_offer success path""" + # Setup mocks + mock_get_subscription_id.return_value = "test-subscription" + + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "properties": { + "offerContent": { + "offerPublisher": {"publisherId": "test-publisher"}, + "offerId": "test-offer" + }, + "marketplaceSkus": [ + { + "marketplaceSkuId": "test-sku", + "marketplaceSkuVersions": [ + {"name": "1.0", "minimumDownloadSizeInMb": 100}, + {"name": "2.0", "minimumDownloadSizeInMb": 200} + ], + "operatingSystem": {"type": "Windows"} + } + ] + } + } + mock_send_raw_request.return_value = mock_response + + # Test + result = custom.get_offer(self.mock_cmd, "test-rg", "test-resource", "test-publisher", "test-offer") + + # Verify + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["Publisher"], "test-publisher") + self.assertEqual(result[0]["Offer"], "test-offer") + self.assertEqual(result[0]["SKU"], "test-sku") + self.assertIn("1.0(100MB)", result[0]["Versions"]) + + @mock.patch('azure.cli.core.commands.client_factory.get_subscription_id') + @mock.patch('azure.cli.core.util.send_raw_request') + def test_package_offer_not_found(self, mock_send_raw_request, mock_get_subscription_id): + """Test package_offer when offer is not found""" + # Setup mocks + mock_get_subscription_id.return_value = "test-subscription" + + mock_response = mock.MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_send_raw_request.return_value = mock_response + + # Test + result = custom.package_offer( + self.mock_cmd, "test-rg", "test-resource", + "test-publisher", "test-offer", "test-sku", "1.0", "/tmp" + ) + + # Verify + self.assertEqual(result["status"], "failed") + self.assertIn("error", result) + self.assertEqual(result["resource_group_name"], "test-rg") + + +class DisconnectedOperationsScenarioTests(ScenarioTest): + @ResourceGroupPreparer(name_prefix='cli_test_disconnectedops') + def test_list_offers(self, resource_group): + """Integration test for list_offers command""" + self.kwargs.update({ + 'resource_group': resource_group, + 'resource': self.create_random_name('edgedevice', 20) + }) + + # Skip if recording as this requires an actual Edge device + if self.is_live: + # Create Edge device first (requires additional setup) + # This would need an actual Edge device setup in the resource group + # For recording purposes, we're just showing the structure + self.cmd('az databoxedge device create --resource-group-name {resource_group} -n {resource}') + + offers = self.cmd('az disconnectedoperations edgemarketplace listoffer --resource-group-name {resource_group} --resource-name {resource}').get_output_in_json() + self.assertIsNotNone(offers) + # In a real test, we'd validate specific values in the output + @ResourceGroupPreparer(name_prefix='cli_test_disconnectedops') + def test_get_offer(self, resource_group): + """Integration test for get_offer command""" + self.kwargs.update({ + 'resource_group': resource_group, + 'resource': self.create_random_name('edgedevice', 20), + 'publisher': 'microsoftwindowsserver', + 'offer': 'windowsserver' + }) + + # Skip if recording as this requires an actual Edge device + if self.is_live: + # This test would need to be updated with actual device creation + # and valid offer details that exist in your test environment + + result = self.cmd('az disconnectedoperations edgemarketplace getoffer --resource-group-name {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-name {offer}').get_output_in_json() + self.assertIsNotNone(result) + # Verify specific values in output for a real test + + @ResourceGroupPreparer(name_prefix='cli_test_disconnectedops_params') + def test_get_offer_with_resource_group_name_parameter(self, resource_group): + """Test get_offer with explicit resource-group-name parameter""" + self.kwargs.update({ + 'resource_group': resource_group, + 'resource': self.create_random_name('edgedevice', 20), + 'publisher': 'microsoftwindowsserver', + 'offer': 'windowsserver' + }) + + # Skip if recording as this requires an actual Edge device + if self.is_live: + # Create Edge device first (requires additional setup) + self.cmd('az databoxedge device create --resource-group-name {resource_group} --name {resource}') + + # Test with the full --resource-group-name parameter + result = self.cmd('az disconnectedoperations edgemarketplace getoffer --resource-group-name {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-name {offer}').get_output_in_json() + self.assertIsNotNone(result) + # In a real test with actual data, we would add more specific assertions + + @ResourceGroupPreparer(name_prefix='cli_test_disconnectedops_pkg') + def test_package_offer_with_resource_group_name_parameter(self, resource_group): + """Test package_offer with explicit resource-group-name parameter""" + self.kwargs.update({ + 'resource_group': resource_group, + 'resource': self.create_random_name('edgedevice', 20), + 'publisher': 'microsoftwindowsserver', + 'offer': 'windowsserver', + 'sku': 'datacenter-core-1903-with-containers-smalldisk', + 'version': '18362.720.2003120536', + 'output_folder': self.create_temp_dir() + }) + + if self.is_live: + # Skip actual device creation in recorded tests + # Test with the full --resource-group-name parameter + result = self.cmd('az disconnectedoperations edgemarketplace packageoffer --resource-group-name {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-name {offer} --sku {sku} --version {version} --output-folder {output_folder}').get_output_in_json() + self.assertIsNotNone(result) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file