From fdb30b00a11e5abf59015d74d8670984825d1de0 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 12 Feb 2025 08:59:12 +0530 Subject: [PATCH 01/32] initial commit --- .../azure/cli/core/profiles/_shared.py | 4 +- .../disconnectedoperations/__init__.py | 35 ++ .../disconnectedoperations/_client_factory.py | 21 + .../disconnectedoperations/_help.py | 73 ++++ .../disconnectedoperations/_params.py | 30 ++ .../disconnectedoperations/aaz/__init__.py | 6 + .../aaz/latest/__init__.py | 10 + .../aaz/latest/edge/__cmd_group.py | 24 ++ .../aaz/latest/edge/__init__.py | 11 + .../disconnected_operation/__cmd_group.py | 24 ++ .../edge/disconnected_operation/__init__.py | 12 + .../edge/disconnected_operation/_list.py | 396 ++++++++++++++++++ .../image/__cmd_group.py | 24 ++ .../disconnected_operation/image/__init__.py | 12 + .../image/_list_download_uri.py | 221 ++++++++++ .../disconnectedoperations/commands.py | 27 ++ .../disconnectedoperations/custom.py | 345 +++++++++++++++ .../disconnectedoperations/tests/__init__.py | 6 + .../tests/latest/__init__.py | 6 + .../latest/test_disconnectedoperations.py | 24 ++ 20 files changed, 1310 insertions(+), 1 deletion(-) create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/_list.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/_list_download_uri.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.py 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..1aaa566929a --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -0,0 +1,35 @@ +# -------------------------------------------------------------------------------------------- +# 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 import AzCommandsLoader +from azure.cli.command_modules.disconnectedoperations._help import helps # pylint: disable=unused-import +from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image + + +class DisconnectedoperationsCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + from azure.cli.core.profiles import ResourceType # required when using python sdk + disconnectedoperations_custom = CliCommandType( + operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}', + client_factory=cf_image) + super(DisconnectedoperationsCommandsLoader, self).__init__(cli_ctx=cli_ctx, + resource_type=ResourceType.MGMT_DISCONNECTEDOPERATIONS, # required when using python sdk + 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..20df0404d3b --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +def cf_logos(cli_ctx, *_): + return get_disconnectedoperations_management_client(cli_ctx).logos + +def cf_metadata(cli_ctx, *_): + return get_disconnectedoperations_management_client(cli_ctx).metadata + 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..492066f4645 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -0,0 +1,73 @@ +from knack.help_files import helps # pylint: disable=unused-import + +helps['disconnectedoperations'] = """ + type: group + short-summary: Commands to manage disconnected operations. + long-summary: Manage Azure Edge marketplace operations in disconnected environments. +""" + +helps['disconnectedoperations edgemarketplace'] = """ + type: group + short-summary: Manage Edge Marketplace operations. + long-summary: Commands to manage Edge Marketplace images and offers. +""" + +helps['disconnectedoperations edgemarketplace listoffers'] = """ + type: command + short-summary: List available marketplace offers. + long-summary: List all available marketplace offers with their SKUs and versions. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + default: brazilus.management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + default: Private.EdgeInternal + - name: --api-version + type: string + short-summary: API version to use. + default: 2023-08-01-preview + examples: + - name: List offers in default format + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List offers in table format + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table + - name: List offers with custom endpoint + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --management-endpoint customendpoint.azure.com +""" + +helps['disconnectedoperations edgemarketplace packageimage'] = """ + type: command + short-summary: Package a marketplace image. + long-summary: Download and package a marketplace image for use in disconnected environments. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --publisher + type: string + required: true + short-summary: Publisher of the marketplace image. + - name: --offer + type: string + required: true + short-summary: Offer name of the marketplace image. + - name: --sku + type: string + required: true + short-summary: SKU of the marketplace image. + - name: --location -l + type: string + required: true + short-summary: Location for the packaged image. + examples: + - name: Package a Windows Server image + text: az disconnectedoperations edgemarketplace packageimage -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --location eastus +""" \ No newline at end of file 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..ec6554388c5 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -0,0 +1,30 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +from azure.cli.core.commands.parameters import resource_group_name_type + + +def load_arguments(self, _): # pylint: disable=unused-argument + with self.argument_context('disconnectedoperations edgemarketplace packageimage') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('publisher', options_list=['--publisher']) + c.argument('offer', options_list=['--offer']) + c.argument('sku', options_list=['--skus']) + c.argument('location', options_list=['--location']) + + with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: + c.argument('management_endpoint', type=str, + help='Management endpoint URL') + c.argument('provider_namespace', type=str, + help='Provider namespace') + c.argument('sub_provider', type=str, + help='Sub-provider namespace') + c.argument('api_version', type=str, + help='API version') 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..cc842ac3e1b --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -0,0 +1,27 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +# from azure.cli.core.commands import CliCommandType +# from azure.cli.core.profiles import ResourceType + +from azure.cli.core.commands import CliCommandType + +def load_command_table(self, _): + custom_command_type = CliCommandType( + operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}' + ) + + with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: + g.custom_command('packageimage', 'package_image') + g.custom_command('listoffers', 'list_offers') + g.custom_command('get-download-url', 'get_image_download_url') + g.custom_command('getoffer', 'get_offer') + + return self.command_table \ No newline at end of file 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..77dd8b3b112 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -0,0 +1,345 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +from azure.cli.core import AzCommandsLoader +from knack.log import get_logger + + +logger = get_logger(__name__) + + + +def get_offer(cmd, + resource_group_name, + offer_name, + output_folder=None, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + sub_provider="Microsoft.EdgeMarketPlace", + api_version="2023-08-01-preview"): + """ + Get details of a specific marketplace offer and download its logos. + + Args: + cmd: The command context object + resource_group_name: Name of resource group + offer_name: Name of the offer to retrieve + output_folder: Folder path to save logos (optional) + management_endpoint: Management endpoint URL + provider_namespace: Provider namespace + sub_provider: Sub-provider namespace + api_version: API version + """ + import os + import json + import requests + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID from current context + 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}/disconnectedOperations/demo-winfield1" + f"/providers/{sub_provider}/offers/{offer_name}" + f"?api-version={api_version}" + ) + + resource = "https://management.azure.com" + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + if response.status_code == 200: + 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', []) + + for sku in skus: + sku_id = sku.get('marketplaceSkuId', '') + versions = sku.get('marketplaceSkuVersions', []) + + for version in versions: + version_id = version.get('name') + + # Create base path for this version + base_path = os.path.join(output_folder, 'catalog_artifacts', + publisher_id, offer_id, sku_id, version_id) + icon_path = os.path.join(base_path, 'icons') + os.makedirs(icon_path, exist_ok=True) + + # Save metadata.json + metadata_path = os.path.join(base_path, 'metadata.json') + metadata = { + 'name': data.get('name'), + 'publisher': offer_content.get('offerPublisher'), + 'offer_id': offer_content.get('offerId'), + 'summary': offer_content.get('summary'), + 'description': offer_content.get('description'), + 'sku': { + 'name': sku.get('displayName'), + 'id': sku.get('marketplaceSkuId'), + 'os_type': sku.get('operatingSystem'), + 'version': version + } + } + + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2) + logger.info(f"Saved metadata to {metadata_path}") + + # Download icons + if icon_uris: + for size, uri in icon_uris.items(): + try: + logo_response = requests.get(uri) + if logo_response.status_code == 200: + file_extension = 'png' + file_path = os.path.join(icon_path, f"{size}.{file_extension}") + + with open(file_path, 'wb') as f: + f.write(logo_response.content) + logger.info(f"Downloaded {size} logo to {file_path}") + else: + logger.error(f"Failed to download {size} logo: {logo_response.status_code}") + except Exception as e: + logger.error(f"Error downloading {size} logo: {str(e)}") + + + # Format offer details + result = { + 'name': data.get('name'), + 'publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), + 'offer_id': offer_content.get('offerId'), + 'summary': offer_content.get('summary'), + 'description': offer_content.get('description'), + 'skus': [] + } + + # Add SKU information + skus = data.get('properties', {}).get('marketplaceSkus', []) + for sku in skus: + sku_info = { + 'name': sku.get('displayName'), + 'id': sku.get('marketplaceSkuId'), + 'os_type': sku.get('operatingSystem', {}).get('type'), + 'versions': [ + { + 'version': v.get('name'), + 'size_mb': v.get('minimumDownloadSizeInMb') + } for v in sku.get('marketplaceSkuVersions', [])[:3] # Latest 3 versions + ] + } + result['skus'].append(sku_info) + + return result + + else: + 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 Exception as e: + logger.error(f"Failed to retrieve offer: {str(e)}") + return { + 'error': str(e), + 'status': 'failed', + 'resource_group_name': resource_group_name + } + +def get_image_download_url(cmd, + resource_group_name, + publisher, + offer, + sku, + version, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + api_version="2024-11-01-preview"): + """ + Get download URL for a specific marketplace image version. + """ + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL for the listDownloadUri API + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/images/{publisher}.{offer}.{sku}.{version}/listDownloadUri" + f"?api-version={api_version}" + ) + + try: + # Make POST request to get download URL + response = send_raw_request(cmd.cli_ctx, 'post', url, + resource="https://management.azure.com") + + if response.status_code == 200: + download_info = response.json() + return { + 'download_url': download_info.get('downloadUri'), + 'expiry': download_info.get('expiryTime'), + 'publisher': publisher, + 'offer': offer, + 'sku': sku, + 'version': version + } + else: + error_message = f"Failed to get download URL. Status code: {response.status_code}" + logger.error(error_message) + return { + 'error': error_message, + 'status': 'failed', + 'response': response.text + } + + except Exception as e: + logger.error(f"Error getting download URL: {str(e)}") + return { + 'error': str(e), + 'status': 'failed' + } + +def package_image(cmd, + resource_group_name, + publisher, + offer, + sku, + location): + self.kwargs.update({ + 'resource_group_name': resource_group_name, + 'publisher': publisher, + 'offer': offer, + 'sku': sku + }) + + # download metadata + + + # download the icons + + # download the image + + return { + 'resource_group_name': resource_group_name, + 'publisher': publisher, + 'offer': offer, + 'sku': sku, + 'location': location, + 'status': 'success' + } + +def list_offers(cmd, + resource_group_name, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + sub_provider="Microsoft.EdgeMarketPlace", + api_version="2023-08-01-preview"): + """ + List all offers for disconnected operations. + """ + from azure.cli.core.profiles import ResourceType + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID from current context + 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}/disconnectedOperations/demo-winfield1" + f"/providers/{sub_provider}/offers" + f"?api-version={api_version}" + ) + + # Define headers with resource for authentication + headers = { + 'Content-Type': 'application/json', + } + + # Define the resource for authentication + resource = "https://management.azure.com" # Using standard Azure management endpoint + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + if response.status_code == 200: + data = response.json() + + # Format data for output + 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', []) + for version in versions[:3]: # Show only latest 3 versions + row = { + 'Publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), + 'Offer': offer_content.get('offerId'), + 'SKU': sku.get('marketplaceSkuId'), + 'Version': version.get('name'), + 'OS_Type': sku.get('operatingSystem', {}).get('type'), + 'Size_MB': version.get('minimumDownloadSizeInMb') + } + result.append(row) + + return result + else: + 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 Exception as e: + logger.error(f"Failed to retrieve offers: {str(e)}") + return { + 'error': str(e), + 'status': 'failed', + 'resource_group_name': resource_group_name + } \ No newline at end of file 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..35610802cc7 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.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 +# -------------------------------------------------------------------------------------------- + +from azure.cli.testsdk import * + + +class DisconnectedoperationsScenario(ScenarioTest): + @ResourceGroupPreparer(name_prefix='cli_test_mycommand') + def test_my_command(self, resource_group): + + self.kwargs.update({ + 'resource_group_name': resource_group, + 'publisher': 'publisher', + 'offer': 'offer', + 'sku': 'sku' + }) + # Run the command and check the output + result = self.cmd('az disconnectedoperations package') + self.assertEqual(result, 'hello') + \ No newline at end of file From 20ccfa2ae80ba86788382dca2a0e67eb6b2873d4 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 12 Feb 2025 09:24:58 +0530 Subject: [PATCH 02/32] fixed linter issues --- .../disconnectedoperations/_help.py | 89 ++++++++++++++++--- .../disconnectedoperations/_params.py | 54 +++++++---- .../disconnectedoperations/commands.py | 16 ++-- .../disconnectedoperations/custom.py | 80 ++++++----------- 4 files changed, 143 insertions(+), 96 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 492066f4645..3692f1572b1 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -24,28 +24,75 @@ - name: --management-endpoint type: string short-summary: Management endpoint URL. - default: brazilus.management.azure.com + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com - name: --provider-namespace type: string short-summary: Provider namespace. - default: Private.EdgeInternal + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --sub-provider + type: string + short-summary: Sub-provider namespace. + default: Microsoft.EdgeMarketPlace - name: --api-version type: string short-summary: API version to use. default: 2023-08-01-preview examples: - - name: List offers in default format + - name: List offers using production environment text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List offers using test environment + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --provider-namespace Private.EdgeInternal - name: List offers in table format text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table - - name: List offers with custom endpoint - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --management-endpoint customendpoint.azure.com """ -helps['disconnectedoperations edgemarketplace packageimage'] = """ +helps['disconnectedoperations edgemarketplace get-offer'] = """ type: command - short-summary: Package a marketplace image. - long-summary: Download and package a marketplace image for use in disconnected environments. + short-summary: Get details of a specific marketplace offer. + long-summary: Retrieve detailed information about a marketplace offer and optionally download its logos. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --offer-name + type: string + required: true + short-summary: Name of the offer to retrieve. + - name: --output-folder + type: string + short-summary: Local folder path to save logos and metadata. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --sub-provider + type: string + short-summary: Sub-provider namespace. + default: Microsoft.EdgeMarketPlace + - name: --api-version + type: string + short-summary: API version to use. + default: 2023-08-01-preview + examples: + - name: Get offer details using production environment + text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer + - name: Get offer details and save logos using test environment + text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer --output-folder ./artifacts --provider-namespace Private.EdgeInternal +""" + +helps['disconnectedoperations edgemarketplace get-image-download-url'] = """ + type: command + short-summary: Get download URL for a marketplace image. + long-summary: Get the download URL for a specific marketplace image version. parameters: - name: --resource-group -g type: string @@ -62,12 +109,28 @@ - name: --sku type: string required: true - short-summary: SKU of the marketplace image. - - name: --location -l + short-summary: SKU identifier. + - name: --version type: string required: true - short-summary: Location for the packaged image. + short-summary: Version of the marketplace image. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --api-version + type: string + short-summary: API version to use. + default: 2024-11-01-preview examples: - - name: Package a Windows Server image - text: az disconnectedoperations edgemarketplace packageimage -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --location eastus + - name: Get image download URL using production environment + text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest + - name: Get image download URL using test environment + text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest --provider-namespace Private.EdgeInternal """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index ec6554388c5..23a429126ed 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -5,26 +5,44 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -# pylint: disable=too-many-lines -# pylint: disable=too-many-statements - from azure.cli.core.commands.parameters import resource_group_name_type - +from knack.arguments import CLIArgumentType def load_arguments(self, _): # pylint: disable=unused-argument - with self.argument_context('disconnectedoperations edgemarketplace packageimage') as c: - c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('publisher', options_list=['--publisher']) - c.argument('offer', options_list=['--offer']) - c.argument('sku', options_list=['--skus']) - c.argument('location', options_list=['--location']) + provider_namespace_type = CLIArgumentType( + type=str, + help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production', + default="Microsoft.Edge" + ) + management_endpoint_type = CLIArgumentType( + type=str, + help='Management endpoint URL. Uses brazilus.management.azure.com for test environment, management.azure.com for production', + default="management.azure.com" + ) + with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: - c.argument('management_endpoint', type=str, - help='Management endpoint URL') - c.argument('provider_namespace', type=str, - help='Provider namespace') - c.argument('sub_provider', type=str, - help='Sub-provider namespace') - c.argument('api_version', type=str, - help='API version') + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") + c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + + with self.argument_context('disconnectedoperations edgemarketplace get-offer') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('offer_name', type=str, help='Name of the offer to retrieve') + c.argument('output_folder', type=str, help='Local folder path to save logos and metadata') + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") + c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + + with self.argument_context('disconnectedoperations edgemarketplace get-image-download-url') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('publisher', type=str, help='Publisher of the marketplace image') + c.argument('offer', type=str, help='Offer name') + c.argument('sku', type=str, help='SKU identifier') + c.argument('version', type=str, help='Version of the marketplace image') + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('api_version', type=str, help='API version', default="2024-11-01-preview") \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index cc842ac3e1b..3aac4f08719 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -5,12 +5,6 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -# pylint: disable=too-many-lines -# pylint: disable=too-many-statements - -# from azure.cli.core.commands import CliCommandType -# from azure.cli.core.profiles import ResourceType - from azure.cli.core.commands import CliCommandType def load_command_table(self, _): @@ -19,9 +13,11 @@ def load_command_table(self, _): ) with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('packageimage', 'package_image') - g.custom_command('listoffers', 'list_offers') - g.custom_command('get-download-url', 'get_image_download_url') - g.custom_command('getoffer', 'get_offer') + g.custom_command('listoffers', 'list_offers', + help='List all marketplace offers for disconnected operations') + g.custom_command('get-image-download-url', 'get_image_download_url', + help='Get download URL for a specific marketplace image version') + g.custom_command('get-offer', 'get_offer', + help='Get details of a specific marketplace offer and download its logos') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 77dd8b3b112..b8c3f01c09e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -14,29 +14,21 @@ logger = get_logger(__name__) +def _get_management_endpoint(provider_namespace): + """Helper function to determine management endpoint based on provider namespace.""" + return "brazilus.management.azure.com" if provider_namespace == "Private.EdgeInternal" else "management.azure.com" def get_offer(cmd, resource_group_name, offer_name, output_folder=None, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", sub_provider="Microsoft.EdgeMarketPlace", api_version="2023-08-01-preview"): - """ - Get details of a specific marketplace offer and download its logos. - - Args: - cmd: The command context object - resource_group_name: Name of resource group - offer_name: Name of the offer to retrieve - output_folder: Folder path to save logos (optional) - management_endpoint: Management endpoint URL - provider_namespace: Provider namespace - sub_provider: Sub-provider namespace - api_version: API version - """ + """Get details of a specific marketplace offer and download its logos.""" + import os import json import requests @@ -44,6 +36,9 @@ def get_offer(cmd, from azure.cli.core.util import send_raw_request from knack.log import get_logger + # Use helper function if management_endpoint not explicitly provided + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) logger = get_logger(__name__) # Get subscription ID from current context @@ -178,16 +173,18 @@ def get_image_download_url(cmd, offer, sku, version, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", api_version="2024-11-01-preview"): - """ - Get download URL for a specific marketplace image version. - """ + """Get download URL for a specific marketplace image version.""" + from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request from knack.log import get_logger - + + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) + logger = get_logger(__name__) # Get subscription ID @@ -234,44 +231,14 @@ def get_image_download_url(cmd, 'status': 'failed' } -def package_image(cmd, - resource_group_name, - publisher, - offer, - sku, - location): - self.kwargs.update({ - 'resource_group_name': resource_group_name, - 'publisher': publisher, - 'offer': offer, - 'sku': sku - }) - - # download metadata - - - # download the icons - - # download the image - - return { - 'resource_group_name': resource_group_name, - 'publisher': publisher, - 'offer': offer, - 'sku': sku, - 'location': location, - 'status': 'success' - } - def list_offers(cmd, resource_group_name, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", sub_provider="Microsoft.EdgeMarketPlace", api_version="2023-08-01-preview"): - """ - List all offers for disconnected operations. - """ + """List all offers for disconnected operations.""" + from azure.cli.core.profiles import ResourceType from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request @@ -279,6 +246,9 @@ def list_offers(cmd, logger = get_logger(__name__) + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) From fb715369fc2e94242c89fe32a1fc83fdaa0dda72 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 18 Feb 2025 15:37:49 +0530 Subject: [PATCH 03/32] Added enhanced list and download logic --- .../disconnectedoperations/_params.py | 34 ++- .../disconnectedoperations/commands.py | 41 +++- .../disconnectedoperations/custom.py | 203 ++++++------------ 3 files changed, 118 insertions(+), 160 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index 23a429126ed..166d6ed135d 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -11,38 +11,28 @@ def load_arguments(self, _): # pylint: disable=unused-argument provider_namespace_type = CLIArgumentType( type=str, - help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production', - default="Microsoft.Edge" + help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.EdgeMarketplace" for production', + default="Private.EdgeInternal" ) management_endpoint_type = CLIArgumentType( type=str, - help='Management endpoint URL. Uses brazilus.management.azure.com for test environment, management.azure.com for production', - default="management.azure.com" + help='Management endpoint URL. Use brazilus.management.azure.com for test environment, management.azure.com for production', + default="brazilus.management.azure.com" ) with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") - c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") - with self.argument_context('disconnectedoperations edgemarketplace get-offer') as c: + with self.argument_context('disconnectedoperations edgemarketplace getoffer') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) c.argument('offer_name', type=str, help='Name of the offer to retrieve') - c.argument('output_folder', type=str, help='Local folder path to save logos and metadata') - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") - c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + c.argument('product_name', type=str, help='Name of the product to retrieve') - with self.argument_context('disconnectedoperations edgemarketplace get-image-download-url') as c: + with self.argument_context('disconnectedoperations edgemarketplace packageoffer') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('publisher', type=str, help='Publisher of the marketplace image') - c.argument('offer', type=str, help='Offer name') - c.argument('sku', type=str, help='SKU identifier') - c.argument('version', type=str, help='Version of the marketplace image') - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('api_version', type=str, help='API version', default="2024-11-01-preview") \ No newline at end of file + 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 to retrieve') + c.argument('version', type=str, help='Version of the product to retrieve') + 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/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 3aac4f08719..57bd67d294e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -5,7 +5,39 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- + from azure.cli.core.commands import CliCommandType +from collections import OrderedDict + +def transform_offers_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', formatted_versions), + ('OS_Type', item['OS_Type']) + ]) + transformed.append(row) + + return transformed def load_command_table(self, _): custom_command_type = CliCommandType( @@ -13,11 +45,8 @@ def load_command_table(self, _): ) with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('listoffers', 'list_offers', - help='List all marketplace offers for disconnected operations') - g.custom_command('get-image-download-url', 'get_image_download_url', - help='Get download URL for a specific marketplace image version') - g.custom_command('get-offer', 'get_offer', - help='Get details of a specific marketplace offer and download its logos') + g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) + g.custom_command('getoffer', 'get_offer') + g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index b8c3f01c09e..408fe8b0978 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,25 +8,22 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -from azure.cli.core import AzCommandsLoader from knack.log import get_logger - logger = get_logger(__name__) -def _get_management_endpoint(provider_namespace): +def _get_management_endpoint(): """Helper function to determine management endpoint based on provider namespace.""" - return "brazilus.management.azure.com" if provider_namespace == "Private.EdgeInternal" else "management.azure.com" + return "brazilus.management.azure.com" # if provider_namespace == "Private.EdgeInternal" else "management.azure.com" -def get_offer(cmd, +def package_offer(cmd, resource_group_name, + publisher_name, offer_name, - output_folder=None, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - sub_provider="Microsoft.EdgeMarketPlace", - api_version="2023-08-01-preview"): + sku, + version, + output_folder): """Get details of a specific marketplace offer and download its logos.""" import os @@ -37,20 +34,23 @@ def get_offer(cmd, from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) + management_endpoint = _get_management_endpoint() logger = get_logger(__name__) # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace = "Private.EdgeInternal" + sub_provider = "Microsoft.EdgeMarketPlace" + api_version = "2023-08-01-preview" + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" - f"/providers/{sub_provider}/offers/{offer_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) @@ -73,18 +73,44 @@ def get_offer(cmd, for sku in skus: sku_id = sku.get('marketplaceSkuId', '') versions = sku.get('marketplaceSkuVersions', []) + + # If version is specified, filter for that version, else take the latest + if version: + versions = [v for v in versions if v.get('name') == version] + else: + versions = versions[:1] # Take only the latest version + + if not versions: + logger.warning(f"No matching version found for SKU {sku_id}") + continue for version in versions: version_id = version.get('name') # Create base path for this version base_path = os.path.join(output_folder, 'catalog_artifacts', - publisher_id, offer_id, sku_id, version_id) + publisher_id, offer_id, sku_id) + version_level_path = os.path.join(base_path, version_id) icon_path = os.path.join(base_path, 'icons') + + # Check if version directory exists and has content + if os.path.exists(version_level_path): + # Check if directory has any files + if os.path.exists(os.path.join(version_level_path, 'metadata.json')) or \ + any(os.scandir(version_level_path)): + error_message = f"Version directory already exists and contains files: {version_level_path}. Please delete the version folder in case you want to re-download the package." + logger.error(error_message) + return { + 'error': error_message, + 'status': 'failed', + 'path': version_level_path + } + os.makedirs(icon_path, exist_ok=True) + os.makedirs(version_level_path, exist_ok=True) # Save metadata.json - metadata_path = os.path.join(base_path, 'metadata.json') + metadata_path = os.path.join(version_level_path, 'metadata.json') metadata = { 'name': data.get('name'), 'publisher': offer_content.get('offerPublisher'), @@ -106,12 +132,17 @@ def get_offer(cmd, # Download icons if icon_uris: 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(f"Icon {size} already exists at {file_path}, skipping download") + continue + try: logo_response = requests.get(uri) if logo_response.status_code == 200: - file_extension = 'png' - file_path = os.path.join(icon_path, f"{size}.{file_extension}") - with open(file_path, 'wb') as f: f.write(logo_response.content) logger.info(f"Downloaded {size} logo to {file_path}") @@ -120,34 +151,7 @@ def get_offer(cmd, except Exception as e: logger.error(f"Error downloading {size} logo: {str(e)}") - - # Format offer details - result = { - 'name': data.get('name'), - 'publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), - 'offer_id': offer_content.get('offerId'), - 'summary': offer_content.get('summary'), - 'description': offer_content.get('description'), - 'skus': [] - } - - # Add SKU information - skus = data.get('properties', {}).get('marketplaceSkus', []) - for sku in skus: - sku_info = { - 'name': sku.get('displayName'), - 'id': sku.get('marketplaceSkuId'), - 'os_type': sku.get('operatingSystem', {}).get('type'), - 'versions': [ - { - 'version': v.get('name'), - 'size_mb': v.get('minimumDownloadSizeInMb') - } for v in sku.get('marketplaceSkuVersions', [])[:3] # Latest 3 versions - ] - } - result['skus'].append(sku_info) - - return result + print ("Metadata and icons downloaded successfully") else: error_message = f"Request failed with status code: {response.status_code}" @@ -166,91 +170,23 @@ def get_offer(cmd, 'status': 'failed', 'resource_group_name': resource_group_name } - -def get_image_download_url(cmd, - resource_group_name, - publisher, - offer, - sku, - version, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - api_version="2024-11-01-preview"): - """Get download URL for a specific marketplace image version.""" - - from azure.cli.core.commands.client_factory import get_subscription_id - from azure.cli.core.util import send_raw_request - from knack.log import get_logger - - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) - - logger = get_logger(__name__) - - # Get subscription ID - subscription_id = get_subscription_id(cmd.cli_ctx) - - # Construct URL for the listDownloadUri API - url = ( - f"https://{management_endpoint}" - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" - f"/images/{publisher}.{offer}.{sku}.{version}/listDownloadUri" - f"?api-version={api_version}" - ) - - try: - # Make POST request to get download URL - response = send_raw_request(cmd.cli_ctx, 'post', url, - resource="https://management.azure.com") - - if response.status_code == 200: - download_info = response.json() - return { - 'download_url': download_info.get('downloadUri'), - 'expiry': download_info.get('expiryTime'), - 'publisher': publisher, - 'offer': offer, - 'sku': sku, - 'version': version - } - else: - error_message = f"Failed to get download URL. Status code: {response.status_code}" - logger.error(error_message) - return { - 'error': error_message, - 'status': 'failed', - 'response': response.text - } - - except Exception as e: - logger.error(f"Error getting download URL: {str(e)}") - return { - 'error': str(e), - 'status': 'failed' - } -def list_offers(cmd, - resource_group_name, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - sub_provider="Microsoft.EdgeMarketPlace", - api_version="2023-08-01-preview"): +def list_offers(cmd, resource_group_name): """List all offers for disconnected operations.""" - from azure.cli.core.profiles import ResourceType from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request from knack.log import get_logger logger = get_logger(__name__) - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) + management_endpoint = _get_management_endpoint() # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace="Private.EdgeInternal" + sub_provider="Microsoft.EdgeMarketPlace" + api_version="2023-08-01-preview" # Construct URL with parameters url = ( @@ -275,27 +211,30 @@ def list_offers(cmd, if response.status_code == 200: data = response.json() - - # Format data for output 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', []) - for version in versions[:3]: # Show only latest 3 versions - row = { - 'Publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), - 'Offer': offer_content.get('offerId'), - 'SKU': sku.get('marketplaceSkuId'), - 'Version': version.get('name'), - 'OS_Type': sku.get('operatingSystem', {}).get('type'), - 'Size_MB': version.get('minimumDownloadSizeInMb') - } - result.append(row) + versions = sku.get('marketplaceSkuVersions', [])[:] + # Format versions as comma-separated string with size + 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 + else: error_message = f"Request failed with status code: {response.status_code}" logger.error(error_message) From bdd0065bb7c3b1907b3089ddce0fa5d726009854 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 18 Feb 2025 15:49:28 +0530 Subject: [PATCH 04/32] fixed linter comments --- .../disconnectedoperations/_help.py | 128 ++++-------------- .../disconnectedoperations/commands.py | 1 - 2 files changed, 24 insertions(+), 105 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 3692f1572b1..18a3361d09e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -1,136 +1,56 @@ -from knack.help_files import helps # pylint: disable=unused-import +from knack.help_files import helps helps['disconnectedoperations'] = """ type: group - short-summary: Commands to manage disconnected operations. - long-summary: Manage Azure Edge marketplace operations in disconnected environments. + short-summary: Commands to manage Azure Disconnected Operations. + long-summary: Manage Azure Disconnected Operations for Edge marketplace offers. """ helps['disconnectedoperations edgemarketplace'] = """ type: group - short-summary: Manage Edge Marketplace operations. - long-summary: Commands to manage Edge Marketplace images and offers. + short-summary: Manage Edge marketplace offers for disconnected operations. + long-summary: Commands to list, get details, and package Edge marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ type: command - short-summary: List available marketplace offers. - long-summary: List all available marketplace offers with their SKUs and versions. - parameters: - - name: --resource-group -g - type: string - required: true - short-summary: Name of resource group. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --sub-provider - type: string - short-summary: Sub-provider namespace. - default: Microsoft.EdgeMarketPlace - - name: --api-version - type: string - short-summary: API version to use. - default: 2023-08-01-preview + short-summary: List all available Edge marketplace offers. + long-summary: List all available Edge marketplace offers with their publishers, SKUs, and versions. examples: - - name: List offers using production environment + - name: List all offers in a resource group text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup - - name: List offers using test environment - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --provider-namespace Private.EdgeInternal - - name: List offers in table format - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table """ -helps['disconnectedoperations edgemarketplace get-offer'] = """ +helps['disconnectedoperations edgemarketplace packageoffer'] = """ type: command - short-summary: Get details of a specific marketplace offer. - long-summary: Retrieve detailed information about a marketplace offer and optionally download its logos. + short-summary: Package an Edge marketplace offer for disconnected operations. + long-summary: Download and package an Edge marketplace offer including its metadata, logos, and other artifacts. parameters: - name: --resource-group -g type: string - required: true short-summary: Name of resource group. - - name: --offer-name - type: string - required: true - short-summary: Name of the offer to retrieve. - - name: --output-folder - type: string - short-summary: Local folder path to save logos and metadata. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --sub-provider - type: string - short-summary: Sub-provider namespace. - default: Microsoft.EdgeMarketPlace - - name: --api-version - type: string - short-summary: API version to use. - default: 2023-08-01-preview - examples: - - name: Get offer details using production environment - text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer - - name: Get offer details and save logos using test environment - text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer --output-folder ./artifacts --provider-namespace Private.EdgeInternal -""" - -helps['disconnectedoperations edgemarketplace get-image-download-url'] = """ - type: command - short-summary: Get download URL for a marketplace image. - long-summary: Get the download URL for a specific marketplace image version. - parameters: - - name: --resource-group -g - type: string required: true - short-summary: Name of resource group. - - name: --publisher + - name: --publisher-name type: string + short-summary: Name of the publisher. required: true - short-summary: Publisher of the marketplace image. - - name: --offer + - name: --offer-name type: string + short-summary: Name of the offer. required: true - short-summary: Offer name of the marketplace image. - name: --sku type: string - required: true - short-summary: SKU identifier. + short-summary: SKU of the offer. - name: --version type: string - required: true - short-summary: Version of the marketplace image. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --api-version + short-summary: Version of the offer. If not specified, latest version will be used. + - name: --output-folder type: string - short-summary: API version to use. - default: 2024-11-01-preview + short-summary: Output folder path for downloaded artifacts. + required: true examples: - - name: Get image download URL using production environment - text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest - - name: Get image download URL using test environment - text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest --provider-namespace Private.EdgeInternal + - name: Package latest version of an offer + text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --output-folder ./output + - name: Package specific version of an offer + text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --version 1.0.0 --output-folder ./output """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 57bd67d294e..609de09ab4e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -46,7 +46,6 @@ def load_command_table(self, _): with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) - g.custom_command('getoffer', 'get_offer') g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file From 073d0d375d5e27afa980c9b4e79ba38aafc0cfcb Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 19 Feb 2025 16:49:38 +0530 Subject: [PATCH 05/32] added get-offer --- .../disconnectedoperations/_help.py | 111 +++++++++++++----- .../disconnectedoperations/commands.py | 20 +++- .../disconnectedoperations/custom.py | 110 ++++++++++++++--- 3 files changed, 196 insertions(+), 45 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 18a3361d09e..102459b07e4 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -1,56 +1,109 @@ -from knack.help_files import helps +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- -helps['disconnectedoperations'] = """ - type: group - short-summary: Commands to manage Azure Disconnected Operations. - long-summary: Manage Azure Disconnected Operations for Edge marketplace offers. -""" +from knack.help_files import helps helps['disconnectedoperations edgemarketplace'] = """ type: group - short-summary: Manage Edge marketplace offers for disconnected operations. - long-summary: Commands to list, get details, and package Edge marketplace offers for disconnected operations. + short-summary: Manage Edge Marketplace offers for disconnected operations. + long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ type: command - short-summary: List all available Edge marketplace offers. - long-summary: List all available Edge marketplace offers with their publishers, SKUs, and versions. + short-summary: List all available marketplace offers. examples: - - name: List all offers in a resource group - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List all marketplace offers for a specific resource + text: > + az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource + - name: List offers and format output as table + text: > + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --output table + - name: List offers and filter output using JMESPath query + text: > + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --query "[?OS_Type=='Linux']" + parameters: + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name -n + type: string + short-summary: The resource name +""" + +helps['disconnectedoperations edgemarketplace getoffer'] = """ + type: command + short-summary: Get details of a specific marketplace offer. + examples: + - name: Get details of a specific marketplace offer + text: > + az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource + --publisher-name publisherName --offer-name offerName + - name: Get offer details and output as JSON + text: > + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --output json + - name: Get offer details with custom query + text: > + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n 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 -n + 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['disconnectedoperations edgemarketplace packageoffer'] = """ type: command - short-summary: Package an Edge marketplace offer for disconnected operations. - long-summary: Download and package an Edge marketplace offer including its metadata, logos, and other artifacts. + 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 disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource + --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber + --output-folder ./output + - name: Package latest version of an offer + text: > + az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --sku skuName + --output-folder ./latest-package + - name: Package an offer and save to a specific directory + text: > + az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --sku skuName + --output-folder "D:\\MarketplacePackages" parameters: - name: --resource-group -g type: string - short-summary: Name of resource group. - required: true + short-summary: Name of resource group + - name: --resource-name -n + type: string + short-summary: The resource name - name: --publisher-name type: string - short-summary: Name of the publisher. - required: true + short-summary: The publisher name of the offer - name: --offer-name type: string - short-summary: Name of the offer. - required: true + short-summary: The name of the offer - name: --sku type: string - short-summary: SKU of the offer. + short-summary: The SKU of the offer - name: --version type: string - short-summary: Version of the offer. If not specified, latest version will be used. + short-summary: The version of the offer (optional, latest version will be used if not specified) - name: --output-folder type: string - short-summary: Output folder path for downloaded artifacts. - required: true - examples: - - name: Package latest version of an offer - text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --output-folder ./output - - name: Package specific version of an offer - text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --version 1.0.0 --output-folder ./output + short-summary: The folder path where the package will be downloaded """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 609de09ab4e..2bb27f47f1c 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -13,6 +13,24 @@ 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', 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: @@ -27,7 +45,6 @@ def transform_offers_table(result): formatted_versions = '\n'.join(str(v).strip() for v in versions) else: formatted_versions = str(versions) - row = OrderedDict([ ('Publisher', item['Publisher']), ('Offer', item['Offer']), @@ -46,6 +63,7 @@ def load_command_table(self, _): with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) + g.custom_command('getoffer', 'get_offer', table_transformer=transform_offer_table) g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 408fe8b0978..1f855182ef5 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -12,13 +12,15 @@ logger = get_logger(__name__) -def _get_management_endpoint(): - """Helper function to determine management endpoint based on provider namespace.""" - return "brazilus.management.azure.com" # if provider_namespace == "Private.EdgeInternal" else "management.azure.com" - +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" # For testing purposes def package_offer(cmd, resource_group_name, + resource_name, publisher_name, offer_name, sku, @@ -34,7 +36,7 @@ def package_offer(cmd, from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided - management_endpoint = _get_management_endpoint() + management_endpoint = _get_management_endpoint(cmd.cli_ctx) logger = get_logger(__name__) # Get subscription ID from current context @@ -49,7 +51,7 @@ def package_offer(cmd, f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) @@ -171,7 +173,7 @@ def package_offer(cmd, 'resource_group_name': resource_group_name } -def list_offers(cmd, resource_group_name): +def list_offers(cmd, resource_group_name, resource_name): """List all offers for disconnected operations.""" from azure.cli.core.commands.client_factory import get_subscription_id @@ -180,7 +182,7 @@ def list_offers(cmd, resource_group_name): logger = get_logger(__name__) - management_endpoint = _get_management_endpoint() + management_endpoint = _get_management_endpoint(cmd.cli_ctx) # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) @@ -193,7 +195,7 @@ def list_offers(cmd, resource_group_name): f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers" f"?api-version={api_version}" ) @@ -219,16 +221,11 @@ def list_offers(cmd, resource_group_name): for sku in skus: versions = sku.get('marketplaceSkuVersions', [])[:] - # Format versions as comma-separated string with size - 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, + 'Versions': f"{len(versions)} {'version' if len(versions) == 1 else 'versions'} available", 'OS_Type': sku.get('operatingSystem', {}).get('type') } result.append(row) @@ -245,6 +242,89 @@ def list_offers(cmd, resource_group_name): 'response': response.text } + except Exception as e: + logger.error(f"Failed to retrieve offers: {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.""" + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + + # Get subscription ID from current context + subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace="Private.EdgeInternal" + sub_provider="Microsoft.EdgeMarketPlace" + api_version="2023-08-01-preview" + + # Construct URL with parameters + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" + f"?api-version={api_version}" + ) + + # Define headers with resource for authentication + headers = { + 'Content-Type': 'application/json', + } + + # Define the resource for authentication + resource = "https://management.azure.com" # Using standard Azure management endpoint + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + 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 + + else: + 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 Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { From ded88198658f9be481aee7e69a7116b831d4a074 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 19 Feb 2025 17:17:55 +0530 Subject: [PATCH 06/32] updated help file --- .../disconnectedoperations/_help.py | 29 ++++++++----------- .../disconnectedoperations/_params.py | 17 +++-------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 102459b07e4..910fa2075e3 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -5,6 +5,11 @@ from knack.help_files import helps +helps['disconnectedoperations'] = """ + type: group + short-summary: Manage disconnected operations. + long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +""" helps['disconnectedoperations edgemarketplace'] = """ type: group short-summary: Manage Edge Marketplace offers for disconnected operations. @@ -20,15 +25,15 @@ az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource - name: List offers and format output as table text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --output table + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table - name: List offers and filter output using JMESPath query text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --query "[?OS_Type=='Linux']" + az disconnectedoperations edgemarketplace listoffers -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 -n + - name: --resource-name type: string short-summary: The resource name """ @@ -43,17 +48,17 @@ --publisher-name publisherName --offer-name offerName - name: Get offer details and output as JSON text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json - name: Get offer details with custom query text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + az disconnectedoperations edgemarketplace getoffer -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 -n + - name: --resource-name type: string short-summary: The resource name - name: --publisher-name @@ -73,22 +78,12 @@ text: > az disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber - --output-folder ./output - - name: Package latest version of an offer - text: > - az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource - --publisher-name publisherName --offer-name offerName --sku skuName - --output-folder ./latest-package - - name: Package an offer and save to a specific directory - text: > - az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource - --publisher-name publisherName --offer-name offerName --sku skuName --output-folder "D:\\MarketplacePackages" parameters: - name: --resource-group -g type: string short-summary: Name of resource group - - name: --resource-name -n + - name: --resource-name type: string short-summary: The resource name - name: --publisher-name diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index 166d6ed135d..b259919fe82 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -6,31 +6,22 @@ # -------------------------------------------------------------------------------------------- from azure.cli.core.commands.parameters import resource_group_name_type -from knack.arguments import CLIArgumentType -def load_arguments(self, _): # pylint: disable=unused-argument - provider_namespace_type = CLIArgumentType( - type=str, - help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.EdgeMarketplace" for production', - default="Private.EdgeInternal" - ) - - management_endpoint_type = CLIArgumentType( - type=str, - help='Management endpoint URL. Use brazilus.management.azure.com for test environment, management.azure.com for production', - default="brazilus.management.azure.com" - ) +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 to retrieve') c.argument('product_name', type=str, help='Name of the product to retrieve') 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 to retrieve') From f084e8ca759d9dbc7de725ac716c718e4b9bbf25 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:12:28 +0530 Subject: [PATCH 07/32] Added image download logic --- .../disconnectedoperations/__init__.py | 32 +- .../disconnectedoperations/_client_factory.py | 8 +- .../disconnectedoperations/_help.py | 168 +++-- .../disconnectedoperations/_params.py | 48 +- .../disconnectedoperations/commands.py | 76 ++- .../disconnectedoperations/custom.py | 633 +++++++++++++----- 6 files changed, 631 insertions(+), 334 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index 1aaa566929a..07ea9258bcb 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -5,30 +5,40 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -from azure.cli.core import AzCommandsLoader -from azure.cli.command_modules.disconnectedoperations._help import helps # pylint: disable=unused-import from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image +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 # required when using python sdk + from azure.cli.core.profiles import ( + ResourceType, # required when using python sdk + ) + disconnectedoperations_custom = CliCommandType( - operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}', - client_factory=cf_image) - super(DisconnectedoperationsCommandsLoader, self).__init__(cli_ctx=cli_ctx, - resource_type=ResourceType.MGMT_DISCONNECTEDOPERATIONS, # required when using python sdk - custom_command_type=disconnectedoperations_custom) + 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 + 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 + from azure.cli.command_modules.disconnectedoperations._params import ( + load_arguments, + ) + load_arguments(self, command) 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 index 20df0404d3b..ab3fbf60d18 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -7,15 +7,9 @@ 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 - -def cf_logos(cli_ctx, *_): - return get_disconnectedoperations_management_client(cli_ctx).logos - -def cf_metadata(cli_ctx, *_): - return get_disconnectedoperations_management_client(cli_ctx).metadata - diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 910fa2075e3..644668227e5 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -6,99 +6,97 @@ from knack.help_files import helps helps['disconnectedoperations'] = """ - type: group - short-summary: Manage disconnected operations. - long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +type: group +short-summary: Manage disconnected operations. """ helps['disconnectedoperations edgemarketplace'] = """ - type: group - short-summary: Manage Edge Marketplace offers for disconnected operations. - long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +type: group +short-summary: Manage Edge Marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ - type: command - short-summary: List all available marketplace offers. - examples: - - name: List all marketplace offers for a specific resource - text: > - az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource - - name: List offers and format output as table - text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table - - name: List offers and filter output using JMESPath query - text: > - az disconnectedoperations edgemarketplace listoffers -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 +type: command +short-summary: List all available marketplace offers. +examples: +- name: List all marketplace offers for a specific resource + text: > +az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource +- name: List offers and format output as table + text: > +az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table +- name: List offers and filter output using JMESPath query + text: > +az disconnectedoperations edgemarketplace listoffers -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['disconnectedoperations edgemarketplace getoffer'] = """ - type: command - short-summary: Get details of a specific marketplace offer. - examples: - - name: Get details of a specific marketplace offer - text: > - az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource - --publisher-name publisherName --offer-name offerName - - name: Get offer details and output as JSON - text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource - --publisher-name publisherName --offer-name offerName --output json - - name: Get offer details with custom query - text: > - az disconnectedoperations edgemarketplace getoffer -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 +type: command +short-summary: Get details of a specific marketplace offer. +examples: +- name: Get details of a specific marketplace offer + text: > +az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource +--publisher-name publisherName --offer-name offerName +- name: Get offer details and output as JSON + text: > +az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource +--publisher-name publisherName --offer-name offerName --output json +- name: Get offer details with custom query + text: > +az disconnectedoperations edgemarketplace getoffer -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['disconnectedoperations edgemarketplace packageoffer'] = """ - 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 disconnectedoperations edgemarketplace packageoffer --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 -""" \ No newline at end of file +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 disconnectedoperations edgemarketplace packageoffer --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 index b259919fe82..d5fa03dade1 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -7,23 +7,37 @@ 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 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 to retrieve') - c.argument('product_name', type=str, help='Name of the product to retrieve') + 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 to retrieve') - c.argument('version', type=str, help='Version of the product to retrieve') - c.argument('output_folder', type=str, help='Drive and directory to save the package to. Example: E:\\ or D:\\packages\\') + 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/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 2bb27f47f1c..813a9943e62 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -6,64 +6,80 @@ # -------------------------------------------------------------------------------------------- -from azure.cli.core.commands import CliCommandType 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', item['Versions']), - ('OS_Type', item['OS_Type']) - ]) + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version", 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'] + 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(',')] - + 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) + 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', formatted_versions), - ('OS_Type', item['OS_Type']) - ]) + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version", 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#{}' + operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}" ) - with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) - g.custom_command('getoffer', 'get_offer', table_transformer=transform_offer_table) - g.custom_command('packageoffer', 'package_offer') - - return self.command_table \ No newline at end of file + with self.command_group( + "disconnectedoperations edgemarketplace", + custom_command_type=custom_command_type, + is_preview=True, + ) as g: + g.custom_command( + "listoffers", "list_offers", table_transformer=transform_offers_table + ) + g.custom_command( + "getoffer", "get_offer", table_transformer=transform_offer_table + ) + g.custom_command("packageoffer", "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 index 1f855182ef5..e6ee98baa1f 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,32 +8,39 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -from knack.log import get_logger +provider_namespace = "Microsoft.DataBoxEdge" +sub_provider = "Microsoft.EdgeMarketPlace" +api_version = "2023-08-01-preview" -logger = get_logger(__name__) 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" # For testing purposes - -def package_offer(cmd, - resource_group_name, - resource_name, - publisher_name, - offer_name, - sku, - version, - output_folder): + return "brazilus.management.azure.com" # For testing purposes + + +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 os import json + import os + import shutil + 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 - from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided management_endpoint = _get_management_endpoint(cmd.cli_ctx) @@ -41,294 +48,552 @@ def package_offer(cmd, # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - - provider_namespace = "Private.EdgeInternal" - sub_provider = "Microsoft.EdgeMarketPlace" - api_version = "2023-08-01-preview" # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) resource = "https://management.azure.com" - + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + if response.status_code == 200: data = response.json() - offer_content = data.get('properties', {}).get('offerContent', {}) - icon_uris = offer_content.get('iconFileUris', {}) - + 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', []) - - for sku in skus: - sku_id = sku.get('marketplaceSkuId', '') - versions = sku.get('marketplaceSkuVersions', []) - - # If version is specified, filter for that version, else take the latest - if version: - versions = [v for v in versions if v.get('name') == version] - else: - versions = versions[:1] # Take only the latest version - - if not versions: - logger.warning(f"No matching version found for SKU {sku_id}") + publisher_id = offer_content.get("offerPublisher", {}).get( + "publisherId", "" + ) + offer_id = offer_content.get("offerId", "") + skus = data.get("properties", {}).get("marketplaceSkus", []) + + for _sku in skus: + sku_id = _sku.get("marketplaceSkuId", "") + + if sku_id != sku: continue + else: + # Store the generation information + generation = _sku.get("generation") - for version in versions: - version_id = version.get('name') - - # Create base path for this version - base_path = os.path.join(output_folder, 'catalog_artifacts', - publisher_id, offer_id, sku_id) - version_level_path = os.path.join(base_path, version_id) - icon_path = os.path.join(base_path, 'icons') - - # Check if version directory exists and has content - if os.path.exists(version_level_path): - # Check if directory has any files - if os.path.exists(os.path.join(version_level_path, 'metadata.json')) or \ - any(os.scandir(version_level_path)): - error_message = f"Version directory already exists and contains files: {version_level_path}. Please delete the version folder in case you want to re-download the package." - logger.error(error_message) - return { - 'error': error_message, - 'status': 'failed', - 'path': version_level_path - } + # 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( + f"No matching version found for SKU {sku_id}" + ) + return + + # print if version and generation are found + print(f"Found VM version: {versions[0].get('name')}") + print(f"VM Generation: {generation}") - 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') - metadata = { - 'name': data.get('name'), - 'publisher': offer_content.get('offerPublisher'), - 'offer_id': offer_content.get('offerId'), - 'summary': offer_content.get('summary'), - 'description': offer_content.get('description'), - 'sku': { - 'name': sku.get('displayName'), - 'id': sku.get('marketplaceSkuId'), - 'os_type': sku.get('operatingSystem'), - 'version': version - } + version_id = versions[0].get("name") + + # check if sku is not found + if not version_id: + logger.warning(f"No matching SKU found: {sku}") + return + + # Create base path for this version + base_path = os.path.join( + output_folder, + "catalog_artifacts", + publisher_id, + offer_id, + sku_id, + ) + version_level_path = os.path.join(base_path, version_id) + icon_path = os.path.join(base_path, "icons") + + # Check if version directory exists and has content + if os.path.exists(version_level_path): + try: + # Remove directory and all its contents + shutil.rmtree(version_level_path) + logger.info( + f"Cleaned up existing version directory: {version_level_path}" + ) + except Exception as e: + error_message = f"Failed to clean up version directory {version_level_path}: {str(e)}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "path": version_level_path, } - - with open(metadata_path, 'w', encoding='utf-8') as f: - json.dump(metadata, f, indent=2) - logger.info(f"Saved metadata to {metadata_path}") - - # Download icons - if icon_uris: - 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(f"Icon {size} already exists at {file_path}, skipping download") - 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(f"Downloaded {size} logo to {file_path}") - else: - logger.error(f"Failed to download {size} logo: {logo_response.status_code}") - except Exception as e: - logger.error(f"Error downloading {size} logo: {str(e)}") - - print ("Metadata and icons downloaded successfully") - + + 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") + # Save Api response as it is on metadata.json + metadata = data + + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2) + logger.info(f"Saved metadata to {metadata_path}") + + # Download icons + if icon_uris: + 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( + f"Icon {size} already exists at {file_path}, skipping download" + ) + 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(f"Downloaded {size} logo to {file_path}") + else: + logger.error( + f"Failed to download {size} logo: {logo_response.status_code}" + ) + except Exception as e: + logger.error(f"Error downloading {size} logo: {str(e)}") + + print("Metadata and icons downloaded successfully") + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offer: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } + + 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, + ) + + +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 os + import time + from datetime import datetime + + 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...") + print(response) + + # 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, + } + + # parse headers + headers = response.headers + + # get async operation URL from headers + async_operation_url = headers.get("Azure-AsyncOperation") + + # hit async operation URL until "status" in response is "Succeeded" with exponential backoff + if async_operation_url: + max_retries = 10 + base_delay = 2 # seconds + timeout = 300 # 5 minutes timeout + start_time = datetime.now() + + print("Hitting async operation URL...") + for attempt in range(max_retries): + print(f"Attempt {attempt + 1} of {max_retries}...") + try: + # Calculate exponential backoff delay + delay = base_delay * (2**attempt) + + # Check if we've exceeded timeout + if (datetime.now() - start_time).total_seconds() > timeout: + logger.error("Operation timed out after 5 minutes") + return { + "error": "Operation timed out", + "status": "failed", + "resource_group_name": resource_group_name, + } + + # 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 in (200, 202): + status_data = status_response.json() + status = status_data.get("status", "").lower() + + print("Current status:", status) + + if status == "succeeded": + logger.info("VHD download URL generation succeeded") + print(status_response) + # Get the download URL from the response + requestId = status_data.get("properties", {}).get( + "requestId" + ) + + # Obtaining SAS token using request Id + if requestId: + print( + f"Fetched request Id for VHD Download: {requestId}" + ) + + # Obtaining SAS token using request Id + token_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"/getAccessToken?api-version={api_version}" + ) + + 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), + ) + + if token_response.status_code == 200: + token_data = token_response.json() + + # Generate azcopy command + download_url = token_data.get("accessToken") + # diskId = token_data.get("diskId") + + # Construct the azcopy command + command = f'azcopy copy "{download_url}" "{output_folder}" --check-md5 NoCheck' + + print(command) + print("Executing command...") + + # Execute the command + os.system(command) + print("Download completed successfully.") + return { + "status": "succeeded", + "message": "Download completed successfully.", + } + else: + logger.error( + f"Failed to get access token: {token_response.status_code}" + ) + return { + "error": f"Failed to get access token: {token_response.status_code}", + "status": "failed", + } + + else: + logger.error("Download URL not found in response") + return { + "error": "Download URL not found", + "status": "failed", + } + + elif status == "failed": + error_message = status_data.get("error", {}).get( + "message", "Unknown error" + ) + logger.error(f"Operation failed: {error_message}") + return {"error": error_message, "status": "failed"} + + else: # In progress + logger.info( + f"Operation in progress... (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(delay) + continue + + else: + logger.error( + f"Failed to get operation status: {status_response.status_code}" + ) + return { + "error": f"Status check failed: {status_response.status_code}", + "status": "failed", + } + + except Exception as e: + logger.error(f"Error checking operation status: {str(e)}") + time.sleep(delay) + continue + + # If we've exhausted all retries + logger.error("Maximum retry attempts reached") + return {"error": "Maximum retry attempts reached", "status": "failed"} + + except Exception as e: + logger.error(f"Failed to generate access token: {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.""" + 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 - from knack.log import get_logger logger = get_logger(__name__) management_endpoint = _get_management_endpoint(cmd.cli_ctx) - + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - provider_namespace="Private.EdgeInternal" - sub_provider="Microsoft.EdgeMarketPlace" - api_version="2023-08-01-preview" - + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers" f"?api-version={api_version}" ) # Define headers with resource for authentication headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } # Define the resource for authentication - resource = "https://management.azure.com" # Using standard Azure management endpoint - + resource = ( + "https://management.azure.com" # Using standard Azure management endpoint + ) + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + 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 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', [])[:] + 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') + "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 - + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name + "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.""" + 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 - from knack.log import get_logger logger = get_logger(__name__) management_endpoint = _get_management_endpoint(cmd.cli_ctx) - + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - provider_namespace="Private.EdgeInternal" - sub_provider="Microsoft.EdgeMarketPlace" - api_version="2023-08-01-preview" - + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) # Define headers with resource for authentication headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } # Define the resource for authentication - resource = "https://management.azure.com" # Using standard Azure management endpoint - + resource = ( + "https://management.azure.com" # Using standard Azure management endpoint + ) + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + if response.status_code == 200: data = response.json() result = [] - - offer_content = data.get('properties', {}).get('offerContent', {}) - skus = data.get('properties', {}).get('marketplaceSkus', []) + 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', [])[:] + 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]) - + 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') + "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 - + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name - } \ No newline at end of file + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } From 4e310f15cfec882b2e8e6cc165e668821639c44c Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:21:37 +0530 Subject: [PATCH 08/32] removing mgmt storage latest --- src/azure-cli-core/azure/cli/core/profiles/_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 44e161f1b2d..c42d311d068 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -157,7 +157,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_STORAGE: '2024-01-01', ResourceType.MGMT_NETWORK: '2022-01-01', ResourceType.MGMT_COMPUTE: SDKProfile('2024-07-01', { 'resource_skus': '2019-04-01', From f888c184a0e6ab2cb553680f402ddb93c5d42bcb Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:27:41 +0530 Subject: [PATCH 09/32] Added storage mgmt version back --- src/azure-cli-core/azure/cli/core/profiles/_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c42d311d068..44e161f1b2d 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -157,7 +157,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_STORAGE: '2024-01-01', ResourceType.MGMT_NETWORK: '2022-01-01', ResourceType.MGMT_COMPUTE: SDKProfile('2024-07-01', { 'resource_skus': '2019-04-01', From abe306cf4cb059c9e1c496a064075a2c55e03eb7 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 12 Feb 2025 08:59:12 +0530 Subject: [PATCH 10/32] initial commit --- .../azure/cli/core/profiles/_shared.py | 4 +- .../disconnectedoperations/__init__.py | 35 ++ .../disconnectedoperations/_client_factory.py | 21 + .../disconnectedoperations/_help.py | 73 ++++ .../disconnectedoperations/_params.py | 30 ++ .../disconnectedoperations/aaz/__init__.py | 6 + .../aaz/latest/__init__.py | 10 + .../aaz/latest/edge/__cmd_group.py | 24 ++ .../aaz/latest/edge/__init__.py | 11 + .../disconnected_operation/__cmd_group.py | 24 ++ .../edge/disconnected_operation/__init__.py | 12 + .../edge/disconnected_operation/_list.py | 396 ++++++++++++++++++ .../image/__cmd_group.py | 24 ++ .../disconnected_operation/image/__init__.py | 12 + .../image/_list_download_uri.py | 221 ++++++++++ .../disconnectedoperations/commands.py | 27 ++ .../disconnectedoperations/custom.py | 345 +++++++++++++++ .../disconnectedoperations/tests/__init__.py | 6 + .../tests/latest/__init__.py | 6 + .../latest/test_disconnectedoperations.py | 24 ++ 20 files changed, 1310 insertions(+), 1 deletion(-) create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/_list.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge/disconnected_operation/image/_list_download_uri.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.py 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..1aaa566929a --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -0,0 +1,35 @@ +# -------------------------------------------------------------------------------------------- +# 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 import AzCommandsLoader +from azure.cli.command_modules.disconnectedoperations._help import helps # pylint: disable=unused-import +from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image + + +class DisconnectedoperationsCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + from azure.cli.core.profiles import ResourceType # required when using python sdk + disconnectedoperations_custom = CliCommandType( + operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}', + client_factory=cf_image) + super(DisconnectedoperationsCommandsLoader, self).__init__(cli_ctx=cli_ctx, + resource_type=ResourceType.MGMT_DISCONNECTEDOPERATIONS, # required when using python sdk + 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..20df0404d3b --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +def cf_logos(cli_ctx, *_): + return get_disconnectedoperations_management_client(cli_ctx).logos + +def cf_metadata(cli_ctx, *_): + return get_disconnectedoperations_management_client(cli_ctx).metadata + 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..492066f4645 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -0,0 +1,73 @@ +from knack.help_files import helps # pylint: disable=unused-import + +helps['disconnectedoperations'] = """ + type: group + short-summary: Commands to manage disconnected operations. + long-summary: Manage Azure Edge marketplace operations in disconnected environments. +""" + +helps['disconnectedoperations edgemarketplace'] = """ + type: group + short-summary: Manage Edge Marketplace operations. + long-summary: Commands to manage Edge Marketplace images and offers. +""" + +helps['disconnectedoperations edgemarketplace listoffers'] = """ + type: command + short-summary: List available marketplace offers. + long-summary: List all available marketplace offers with their SKUs and versions. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + default: brazilus.management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + default: Private.EdgeInternal + - name: --api-version + type: string + short-summary: API version to use. + default: 2023-08-01-preview + examples: + - name: List offers in default format + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List offers in table format + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table + - name: List offers with custom endpoint + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --management-endpoint customendpoint.azure.com +""" + +helps['disconnectedoperations edgemarketplace packageimage'] = """ + type: command + short-summary: Package a marketplace image. + long-summary: Download and package a marketplace image for use in disconnected environments. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --publisher + type: string + required: true + short-summary: Publisher of the marketplace image. + - name: --offer + type: string + required: true + short-summary: Offer name of the marketplace image. + - name: --sku + type: string + required: true + short-summary: SKU of the marketplace image. + - name: --location -l + type: string + required: true + short-summary: Location for the packaged image. + examples: + - name: Package a Windows Server image + text: az disconnectedoperations edgemarketplace packageimage -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --location eastus +""" \ No newline at end of file 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..ec6554388c5 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -0,0 +1,30 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +from azure.cli.core.commands.parameters import resource_group_name_type + + +def load_arguments(self, _): # pylint: disable=unused-argument + with self.argument_context('disconnectedoperations edgemarketplace packageimage') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('publisher', options_list=['--publisher']) + c.argument('offer', options_list=['--offer']) + c.argument('sku', options_list=['--skus']) + c.argument('location', options_list=['--location']) + + with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: + c.argument('management_endpoint', type=str, + help='Management endpoint URL') + c.argument('provider_namespace', type=str, + help='Provider namespace') + c.argument('sub_provider', type=str, + help='Sub-provider namespace') + c.argument('api_version', type=str, + help='API version') 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..cc842ac3e1b --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -0,0 +1,27 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +# from azure.cli.core.commands import CliCommandType +# from azure.cli.core.profiles import ResourceType + +from azure.cli.core.commands import CliCommandType + +def load_command_table(self, _): + custom_command_type = CliCommandType( + operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}' + ) + + with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: + g.custom_command('packageimage', 'package_image') + g.custom_command('listoffers', 'list_offers') + g.custom_command('get-download-url', 'get_image_download_url') + g.custom_command('getoffer', 'get_offer') + + return self.command_table \ No newline at end of file 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..77dd8b3b112 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -0,0 +1,345 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +from azure.cli.core import AzCommandsLoader +from knack.log import get_logger + + +logger = get_logger(__name__) + + + +def get_offer(cmd, + resource_group_name, + offer_name, + output_folder=None, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + sub_provider="Microsoft.EdgeMarketPlace", + api_version="2023-08-01-preview"): + """ + Get details of a specific marketplace offer and download its logos. + + Args: + cmd: The command context object + resource_group_name: Name of resource group + offer_name: Name of the offer to retrieve + output_folder: Folder path to save logos (optional) + management_endpoint: Management endpoint URL + provider_namespace: Provider namespace + sub_provider: Sub-provider namespace + api_version: API version + """ + import os + import json + import requests + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID from current context + 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}/disconnectedOperations/demo-winfield1" + f"/providers/{sub_provider}/offers/{offer_name}" + f"?api-version={api_version}" + ) + + resource = "https://management.azure.com" + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + if response.status_code == 200: + 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', []) + + for sku in skus: + sku_id = sku.get('marketplaceSkuId', '') + versions = sku.get('marketplaceSkuVersions', []) + + for version in versions: + version_id = version.get('name') + + # Create base path for this version + base_path = os.path.join(output_folder, 'catalog_artifacts', + publisher_id, offer_id, sku_id, version_id) + icon_path = os.path.join(base_path, 'icons') + os.makedirs(icon_path, exist_ok=True) + + # Save metadata.json + metadata_path = os.path.join(base_path, 'metadata.json') + metadata = { + 'name': data.get('name'), + 'publisher': offer_content.get('offerPublisher'), + 'offer_id': offer_content.get('offerId'), + 'summary': offer_content.get('summary'), + 'description': offer_content.get('description'), + 'sku': { + 'name': sku.get('displayName'), + 'id': sku.get('marketplaceSkuId'), + 'os_type': sku.get('operatingSystem'), + 'version': version + } + } + + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2) + logger.info(f"Saved metadata to {metadata_path}") + + # Download icons + if icon_uris: + for size, uri in icon_uris.items(): + try: + logo_response = requests.get(uri) + if logo_response.status_code == 200: + file_extension = 'png' + file_path = os.path.join(icon_path, f"{size}.{file_extension}") + + with open(file_path, 'wb') as f: + f.write(logo_response.content) + logger.info(f"Downloaded {size} logo to {file_path}") + else: + logger.error(f"Failed to download {size} logo: {logo_response.status_code}") + except Exception as e: + logger.error(f"Error downloading {size} logo: {str(e)}") + + + # Format offer details + result = { + 'name': data.get('name'), + 'publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), + 'offer_id': offer_content.get('offerId'), + 'summary': offer_content.get('summary'), + 'description': offer_content.get('description'), + 'skus': [] + } + + # Add SKU information + skus = data.get('properties', {}).get('marketplaceSkus', []) + for sku in skus: + sku_info = { + 'name': sku.get('displayName'), + 'id': sku.get('marketplaceSkuId'), + 'os_type': sku.get('operatingSystem', {}).get('type'), + 'versions': [ + { + 'version': v.get('name'), + 'size_mb': v.get('minimumDownloadSizeInMb') + } for v in sku.get('marketplaceSkuVersions', [])[:3] # Latest 3 versions + ] + } + result['skus'].append(sku_info) + + return result + + else: + 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 Exception as e: + logger.error(f"Failed to retrieve offer: {str(e)}") + return { + 'error': str(e), + 'status': 'failed', + 'resource_group_name': resource_group_name + } + +def get_image_download_url(cmd, + resource_group_name, + publisher, + offer, + sku, + version, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + api_version="2024-11-01-preview"): + """ + Get download URL for a specific marketplace image version. + """ + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL for the listDownloadUri API + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/images/{publisher}.{offer}.{sku}.{version}/listDownloadUri" + f"?api-version={api_version}" + ) + + try: + # Make POST request to get download URL + response = send_raw_request(cmd.cli_ctx, 'post', url, + resource="https://management.azure.com") + + if response.status_code == 200: + download_info = response.json() + return { + 'download_url': download_info.get('downloadUri'), + 'expiry': download_info.get('expiryTime'), + 'publisher': publisher, + 'offer': offer, + 'sku': sku, + 'version': version + } + else: + error_message = f"Failed to get download URL. Status code: {response.status_code}" + logger.error(error_message) + return { + 'error': error_message, + 'status': 'failed', + 'response': response.text + } + + except Exception as e: + logger.error(f"Error getting download URL: {str(e)}") + return { + 'error': str(e), + 'status': 'failed' + } + +def package_image(cmd, + resource_group_name, + publisher, + offer, + sku, + location): + self.kwargs.update({ + 'resource_group_name': resource_group_name, + 'publisher': publisher, + 'offer': offer, + 'sku': sku + }) + + # download metadata + + + # download the icons + + # download the image + + return { + 'resource_group_name': resource_group_name, + 'publisher': publisher, + 'offer': offer, + 'sku': sku, + 'location': location, + 'status': 'success' + } + +def list_offers(cmd, + resource_group_name, + management_endpoint="brazilus.management.azure.com", + provider_namespace="Private.EdgeInternal", + sub_provider="Microsoft.EdgeMarketPlace", + api_version="2023-08-01-preview"): + """ + List all offers for disconnected operations. + """ + from azure.cli.core.profiles import ResourceType + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + # Get subscription ID from current context + 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}/disconnectedOperations/demo-winfield1" + f"/providers/{sub_provider}/offers" + f"?api-version={api_version}" + ) + + # Define headers with resource for authentication + headers = { + 'Content-Type': 'application/json', + } + + # Define the resource for authentication + resource = "https://management.azure.com" # Using standard Azure management endpoint + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + if response.status_code == 200: + data = response.json() + + # Format data for output + 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', []) + for version in versions[:3]: # Show only latest 3 versions + row = { + 'Publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), + 'Offer': offer_content.get('offerId'), + 'SKU': sku.get('marketplaceSkuId'), + 'Version': version.get('name'), + 'OS_Type': sku.get('operatingSystem', {}).get('type'), + 'Size_MB': version.get('minimumDownloadSizeInMb') + } + result.append(row) + + return result + else: + 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 Exception as e: + logger.error(f"Failed to retrieve offers: {str(e)}") + return { + 'error': str(e), + 'status': 'failed', + 'resource_group_name': resource_group_name + } \ No newline at end of file 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..35610802cc7 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/tests/latest/test_disconnectedoperations.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 +# -------------------------------------------------------------------------------------------- + +from azure.cli.testsdk import * + + +class DisconnectedoperationsScenario(ScenarioTest): + @ResourceGroupPreparer(name_prefix='cli_test_mycommand') + def test_my_command(self, resource_group): + + self.kwargs.update({ + 'resource_group_name': resource_group, + 'publisher': 'publisher', + 'offer': 'offer', + 'sku': 'sku' + }) + # Run the command and check the output + result = self.cmd('az disconnectedoperations package') + self.assertEqual(result, 'hello') + \ No newline at end of file From b3a27b79227d7ff48323f7a70aa28eade3503120 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 12 Feb 2025 09:24:58 +0530 Subject: [PATCH 11/32] fixed linter issues --- .../disconnectedoperations/_help.py | 89 ++++++++++++++++--- .../disconnectedoperations/_params.py | 54 +++++++---- .../disconnectedoperations/commands.py | 16 ++-- .../disconnectedoperations/custom.py | 80 ++++++----------- 4 files changed, 143 insertions(+), 96 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 492066f4645..3692f1572b1 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -24,28 +24,75 @@ - name: --management-endpoint type: string short-summary: Management endpoint URL. - default: brazilus.management.azure.com + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com - name: --provider-namespace type: string short-summary: Provider namespace. - default: Private.EdgeInternal + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --sub-provider + type: string + short-summary: Sub-provider namespace. + default: Microsoft.EdgeMarketPlace - name: --api-version type: string short-summary: API version to use. default: 2023-08-01-preview examples: - - name: List offers in default format + - name: List offers using production environment text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List offers using test environment + text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --provider-namespace Private.EdgeInternal - name: List offers in table format text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table - - name: List offers with custom endpoint - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --management-endpoint customendpoint.azure.com """ -helps['disconnectedoperations edgemarketplace packageimage'] = """ +helps['disconnectedoperations edgemarketplace get-offer'] = """ type: command - short-summary: Package a marketplace image. - long-summary: Download and package a marketplace image for use in disconnected environments. + short-summary: Get details of a specific marketplace offer. + long-summary: Retrieve detailed information about a marketplace offer and optionally download its logos. + parameters: + - name: --resource-group -g + type: string + required: true + short-summary: Name of resource group. + - name: --offer-name + type: string + required: true + short-summary: Name of the offer to retrieve. + - name: --output-folder + type: string + short-summary: Local folder path to save logos and metadata. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --sub-provider + type: string + short-summary: Sub-provider namespace. + default: Microsoft.EdgeMarketPlace + - name: --api-version + type: string + short-summary: API version to use. + default: 2023-08-01-preview + examples: + - name: Get offer details using production environment + text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer + - name: Get offer details and save logos using test environment + text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer --output-folder ./artifacts --provider-namespace Private.EdgeInternal +""" + +helps['disconnectedoperations edgemarketplace get-image-download-url'] = """ + type: command + short-summary: Get download URL for a marketplace image. + long-summary: Get the download URL for a specific marketplace image version. parameters: - name: --resource-group -g type: string @@ -62,12 +109,28 @@ - name: --sku type: string required: true - short-summary: SKU of the marketplace image. - - name: --location -l + short-summary: SKU identifier. + - name: --version type: string required: true - short-summary: Location for the packaged image. + short-summary: Version of the marketplace image. + - name: --management-endpoint + type: string + short-summary: Management endpoint URL. + long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. + default: management.azure.com + - name: --provider-namespace + type: string + short-summary: Provider namespace. + long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. + default: Microsoft.Edge + - name: --api-version + type: string + short-summary: API version to use. + default: 2024-11-01-preview examples: - - name: Package a Windows Server image - text: az disconnectedoperations edgemarketplace packageimage -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --location eastus + - name: Get image download URL using production environment + text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest + - name: Get image download URL using test environment + text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest --provider-namespace Private.EdgeInternal """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index ec6554388c5..23a429126ed 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -5,26 +5,44 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -# pylint: disable=too-many-lines -# pylint: disable=too-many-statements - from azure.cli.core.commands.parameters import resource_group_name_type - +from knack.arguments import CLIArgumentType def load_arguments(self, _): # pylint: disable=unused-argument - with self.argument_context('disconnectedoperations edgemarketplace packageimage') as c: - c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('publisher', options_list=['--publisher']) - c.argument('offer', options_list=['--offer']) - c.argument('sku', options_list=['--skus']) - c.argument('location', options_list=['--location']) + provider_namespace_type = CLIArgumentType( + type=str, + help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production', + default="Microsoft.Edge" + ) + management_endpoint_type = CLIArgumentType( + type=str, + help='Management endpoint URL. Uses brazilus.management.azure.com for test environment, management.azure.com for production', + default="management.azure.com" + ) + with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: - c.argument('management_endpoint', type=str, - help='Management endpoint URL') - c.argument('provider_namespace', type=str, - help='Provider namespace') - c.argument('sub_provider', type=str, - help='Sub-provider namespace') - c.argument('api_version', type=str, - help='API version') + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") + c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + + with self.argument_context('disconnectedoperations edgemarketplace get-offer') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('offer_name', type=str, help='Name of the offer to retrieve') + c.argument('output_folder', type=str, help='Local folder path to save logos and metadata') + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") + c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + + with self.argument_context('disconnectedoperations edgemarketplace get-image-download-url') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('publisher', type=str, help='Publisher of the marketplace image') + c.argument('offer', type=str, help='Offer name') + c.argument('sku', type=str, help='SKU identifier') + c.argument('version', type=str, help='Version of the marketplace image') + c.argument('management_endpoint', arg_type=management_endpoint_type) + c.argument('provider_namespace', arg_type=provider_namespace_type) + c.argument('api_version', type=str, help='API version', default="2024-11-01-preview") \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index cc842ac3e1b..3aac4f08719 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -5,12 +5,6 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -# pylint: disable=too-many-lines -# pylint: disable=too-many-statements - -# from azure.cli.core.commands import CliCommandType -# from azure.cli.core.profiles import ResourceType - from azure.cli.core.commands import CliCommandType def load_command_table(self, _): @@ -19,9 +13,11 @@ def load_command_table(self, _): ) with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('packageimage', 'package_image') - g.custom_command('listoffers', 'list_offers') - g.custom_command('get-download-url', 'get_image_download_url') - g.custom_command('getoffer', 'get_offer') + g.custom_command('listoffers', 'list_offers', + help='List all marketplace offers for disconnected operations') + g.custom_command('get-image-download-url', 'get_image_download_url', + help='Get download URL for a specific marketplace image version') + g.custom_command('get-offer', 'get_offer', + help='Get details of a specific marketplace offer and download its logos') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 77dd8b3b112..b8c3f01c09e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -14,29 +14,21 @@ logger = get_logger(__name__) +def _get_management_endpoint(provider_namespace): + """Helper function to determine management endpoint based on provider namespace.""" + return "brazilus.management.azure.com" if provider_namespace == "Private.EdgeInternal" else "management.azure.com" def get_offer(cmd, resource_group_name, offer_name, output_folder=None, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", sub_provider="Microsoft.EdgeMarketPlace", api_version="2023-08-01-preview"): - """ - Get details of a specific marketplace offer and download its logos. - - Args: - cmd: The command context object - resource_group_name: Name of resource group - offer_name: Name of the offer to retrieve - output_folder: Folder path to save logos (optional) - management_endpoint: Management endpoint URL - provider_namespace: Provider namespace - sub_provider: Sub-provider namespace - api_version: API version - """ + """Get details of a specific marketplace offer and download its logos.""" + import os import json import requests @@ -44,6 +36,9 @@ def get_offer(cmd, from azure.cli.core.util import send_raw_request from knack.log import get_logger + # Use helper function if management_endpoint not explicitly provided + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) logger = get_logger(__name__) # Get subscription ID from current context @@ -178,16 +173,18 @@ def get_image_download_url(cmd, offer, sku, version, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", api_version="2024-11-01-preview"): - """ - Get download URL for a specific marketplace image version. - """ + """Get download URL for a specific marketplace image version.""" + from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request from knack.log import get_logger - + + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) + logger = get_logger(__name__) # Get subscription ID @@ -234,44 +231,14 @@ def get_image_download_url(cmd, 'status': 'failed' } -def package_image(cmd, - resource_group_name, - publisher, - offer, - sku, - location): - self.kwargs.update({ - 'resource_group_name': resource_group_name, - 'publisher': publisher, - 'offer': offer, - 'sku': sku - }) - - # download metadata - - - # download the icons - - # download the image - - return { - 'resource_group_name': resource_group_name, - 'publisher': publisher, - 'offer': offer, - 'sku': sku, - 'location': location, - 'status': 'success' - } - def list_offers(cmd, resource_group_name, - management_endpoint="brazilus.management.azure.com", - provider_namespace="Private.EdgeInternal", + management_endpoint=None, + provider_namespace="Microsoft.Edge", sub_provider="Microsoft.EdgeMarketPlace", api_version="2023-08-01-preview"): - """ - List all offers for disconnected operations. - """ + """List all offers for disconnected operations.""" + from azure.cli.core.profiles import ResourceType from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request @@ -279,6 +246,9 @@ def list_offers(cmd, logger = get_logger(__name__) + if management_endpoint is None: + management_endpoint = _get_management_endpoint(provider_namespace) + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) From d2159a27afef5a5c0829603f45d75f2dce89bb0d Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 18 Feb 2025 15:37:49 +0530 Subject: [PATCH 12/32] Added enhanced list and download logic --- .../disconnectedoperations/_params.py | 34 ++- .../disconnectedoperations/commands.py | 41 +++- .../disconnectedoperations/custom.py | 203 ++++++------------ 3 files changed, 118 insertions(+), 160 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index 23a429126ed..166d6ed135d 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -11,38 +11,28 @@ def load_arguments(self, _): # pylint: disable=unused-argument provider_namespace_type = CLIArgumentType( type=str, - help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production', - default="Microsoft.Edge" + help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.EdgeMarketplace" for production', + default="Private.EdgeInternal" ) management_endpoint_type = CLIArgumentType( type=str, - help='Management endpoint URL. Uses brazilus.management.azure.com for test environment, management.azure.com for production', - default="management.azure.com" + help='Management endpoint URL. Use brazilus.management.azure.com for test environment, management.azure.com for production', + default="brazilus.management.azure.com" ) with self.argument_context('disconnectedoperations edgemarketplace listoffers') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") - c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") - with self.argument_context('disconnectedoperations edgemarketplace get-offer') as c: + with self.argument_context('disconnectedoperations edgemarketplace getoffer') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) c.argument('offer_name', type=str, help='Name of the offer to retrieve') - c.argument('output_folder', type=str, help='Local folder path to save logos and metadata') - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('sub_provider', type=str, help='Sub-provider namespace', default="Microsoft.EdgeMarketPlace") - c.argument('api_version', type=str, help='API version', default="2023-08-01-preview") + c.argument('product_name', type=str, help='Name of the product to retrieve') - with self.argument_context('disconnectedoperations edgemarketplace get-image-download-url') as c: + with self.argument_context('disconnectedoperations edgemarketplace packageoffer') as c: c.argument('resource_group_name', arg_type=resource_group_name_type) - c.argument('publisher', type=str, help='Publisher of the marketplace image') - c.argument('offer', type=str, help='Offer name') - c.argument('sku', type=str, help='SKU identifier') - c.argument('version', type=str, help='Version of the marketplace image') - c.argument('management_endpoint', arg_type=management_endpoint_type) - c.argument('provider_namespace', arg_type=provider_namespace_type) - c.argument('api_version', type=str, help='API version', default="2024-11-01-preview") \ No newline at end of file + 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 to retrieve') + c.argument('version', type=str, help='Version of the product to retrieve') + 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/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 3aac4f08719..57bd67d294e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -5,7 +5,39 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- + from azure.cli.core.commands import CliCommandType +from collections import OrderedDict + +def transform_offers_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', formatted_versions), + ('OS_Type', item['OS_Type']) + ]) + transformed.append(row) + + return transformed def load_command_table(self, _): custom_command_type = CliCommandType( @@ -13,11 +45,8 @@ def load_command_table(self, _): ) with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('listoffers', 'list_offers', - help='List all marketplace offers for disconnected operations') - g.custom_command('get-image-download-url', 'get_image_download_url', - help='Get download URL for a specific marketplace image version') - g.custom_command('get-offer', 'get_offer', - help='Get details of a specific marketplace offer and download its logos') + g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) + g.custom_command('getoffer', 'get_offer') + g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index b8c3f01c09e..408fe8b0978 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,25 +8,22 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -from azure.cli.core import AzCommandsLoader from knack.log import get_logger - logger = get_logger(__name__) -def _get_management_endpoint(provider_namespace): +def _get_management_endpoint(): """Helper function to determine management endpoint based on provider namespace.""" - return "brazilus.management.azure.com" if provider_namespace == "Private.EdgeInternal" else "management.azure.com" + return "brazilus.management.azure.com" # if provider_namespace == "Private.EdgeInternal" else "management.azure.com" -def get_offer(cmd, +def package_offer(cmd, resource_group_name, + publisher_name, offer_name, - output_folder=None, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - sub_provider="Microsoft.EdgeMarketPlace", - api_version="2023-08-01-preview"): + sku, + version, + output_folder): """Get details of a specific marketplace offer and download its logos.""" import os @@ -37,20 +34,23 @@ def get_offer(cmd, from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) + management_endpoint = _get_management_endpoint() logger = get_logger(__name__) # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace = "Private.EdgeInternal" + sub_provider = "Microsoft.EdgeMarketPlace" + api_version = "2023-08-01-preview" + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" - f"/providers/{sub_provider}/offers/{offer_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) @@ -73,18 +73,44 @@ def get_offer(cmd, for sku in skus: sku_id = sku.get('marketplaceSkuId', '') versions = sku.get('marketplaceSkuVersions', []) + + # If version is specified, filter for that version, else take the latest + if version: + versions = [v for v in versions if v.get('name') == version] + else: + versions = versions[:1] # Take only the latest version + + if not versions: + logger.warning(f"No matching version found for SKU {sku_id}") + continue for version in versions: version_id = version.get('name') # Create base path for this version base_path = os.path.join(output_folder, 'catalog_artifacts', - publisher_id, offer_id, sku_id, version_id) + publisher_id, offer_id, sku_id) + version_level_path = os.path.join(base_path, version_id) icon_path = os.path.join(base_path, 'icons') + + # Check if version directory exists and has content + if os.path.exists(version_level_path): + # Check if directory has any files + if os.path.exists(os.path.join(version_level_path, 'metadata.json')) or \ + any(os.scandir(version_level_path)): + error_message = f"Version directory already exists and contains files: {version_level_path}. Please delete the version folder in case you want to re-download the package." + logger.error(error_message) + return { + 'error': error_message, + 'status': 'failed', + 'path': version_level_path + } + os.makedirs(icon_path, exist_ok=True) + os.makedirs(version_level_path, exist_ok=True) # Save metadata.json - metadata_path = os.path.join(base_path, 'metadata.json') + metadata_path = os.path.join(version_level_path, 'metadata.json') metadata = { 'name': data.get('name'), 'publisher': offer_content.get('offerPublisher'), @@ -106,12 +132,17 @@ def get_offer(cmd, # Download icons if icon_uris: 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(f"Icon {size} already exists at {file_path}, skipping download") + continue + try: logo_response = requests.get(uri) if logo_response.status_code == 200: - file_extension = 'png' - file_path = os.path.join(icon_path, f"{size}.{file_extension}") - with open(file_path, 'wb') as f: f.write(logo_response.content) logger.info(f"Downloaded {size} logo to {file_path}") @@ -120,34 +151,7 @@ def get_offer(cmd, except Exception as e: logger.error(f"Error downloading {size} logo: {str(e)}") - - # Format offer details - result = { - 'name': data.get('name'), - 'publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), - 'offer_id': offer_content.get('offerId'), - 'summary': offer_content.get('summary'), - 'description': offer_content.get('description'), - 'skus': [] - } - - # Add SKU information - skus = data.get('properties', {}).get('marketplaceSkus', []) - for sku in skus: - sku_info = { - 'name': sku.get('displayName'), - 'id': sku.get('marketplaceSkuId'), - 'os_type': sku.get('operatingSystem', {}).get('type'), - 'versions': [ - { - 'version': v.get('name'), - 'size_mb': v.get('minimumDownloadSizeInMb') - } for v in sku.get('marketplaceSkuVersions', [])[:3] # Latest 3 versions - ] - } - result['skus'].append(sku_info) - - return result + print ("Metadata and icons downloaded successfully") else: error_message = f"Request failed with status code: {response.status_code}" @@ -166,91 +170,23 @@ def get_offer(cmd, 'status': 'failed', 'resource_group_name': resource_group_name } - -def get_image_download_url(cmd, - resource_group_name, - publisher, - offer, - sku, - version, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - api_version="2024-11-01-preview"): - """Get download URL for a specific marketplace image version.""" - - from azure.cli.core.commands.client_factory import get_subscription_id - from azure.cli.core.util import send_raw_request - from knack.log import get_logger - - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) - - logger = get_logger(__name__) - - # Get subscription ID - subscription_id = get_subscription_id(cmd.cli_ctx) - - # Construct URL for the listDownloadUri API - url = ( - f"https://{management_endpoint}" - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" - f"/images/{publisher}.{offer}.{sku}.{version}/listDownloadUri" - f"?api-version={api_version}" - ) - - try: - # Make POST request to get download URL - response = send_raw_request(cmd.cli_ctx, 'post', url, - resource="https://management.azure.com") - - if response.status_code == 200: - download_info = response.json() - return { - 'download_url': download_info.get('downloadUri'), - 'expiry': download_info.get('expiryTime'), - 'publisher': publisher, - 'offer': offer, - 'sku': sku, - 'version': version - } - else: - error_message = f"Failed to get download URL. Status code: {response.status_code}" - logger.error(error_message) - return { - 'error': error_message, - 'status': 'failed', - 'response': response.text - } - - except Exception as e: - logger.error(f"Error getting download URL: {str(e)}") - return { - 'error': str(e), - 'status': 'failed' - } -def list_offers(cmd, - resource_group_name, - management_endpoint=None, - provider_namespace="Microsoft.Edge", - sub_provider="Microsoft.EdgeMarketPlace", - api_version="2023-08-01-preview"): +def list_offers(cmd, resource_group_name): """List all offers for disconnected operations.""" - from azure.cli.core.profiles import ResourceType from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request from knack.log import get_logger logger = get_logger(__name__) - if management_endpoint is None: - management_endpoint = _get_management_endpoint(provider_namespace) + management_endpoint = _get_management_endpoint() # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace="Private.EdgeInternal" + sub_provider="Microsoft.EdgeMarketPlace" + api_version="2023-08-01-preview" # Construct URL with parameters url = ( @@ -275,27 +211,30 @@ def list_offers(cmd, if response.status_code == 200: data = response.json() - - # Format data for output 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', []) - for version in versions[:3]: # Show only latest 3 versions - row = { - 'Publisher': offer_content.get('offerPublisher', {}).get('publisherDisplayName'), - 'Offer': offer_content.get('offerId'), - 'SKU': sku.get('marketplaceSkuId'), - 'Version': version.get('name'), - 'OS_Type': sku.get('operatingSystem', {}).get('type'), - 'Size_MB': version.get('minimumDownloadSizeInMb') - } - result.append(row) + versions = sku.get('marketplaceSkuVersions', [])[:] + # Format versions as comma-separated string with size + 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 + else: error_message = f"Request failed with status code: {response.status_code}" logger.error(error_message) From a6544bbf590b842701d693d4952acb890ca2bbcb Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 18 Feb 2025 15:49:28 +0530 Subject: [PATCH 13/32] fixed linter comments --- .../disconnectedoperations/_help.py | 128 ++++-------------- .../disconnectedoperations/commands.py | 1 - 2 files changed, 24 insertions(+), 105 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 3692f1572b1..18a3361d09e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -1,136 +1,56 @@ -from knack.help_files import helps # pylint: disable=unused-import +from knack.help_files import helps helps['disconnectedoperations'] = """ type: group - short-summary: Commands to manage disconnected operations. - long-summary: Manage Azure Edge marketplace operations in disconnected environments. + short-summary: Commands to manage Azure Disconnected Operations. + long-summary: Manage Azure Disconnected Operations for Edge marketplace offers. """ helps['disconnectedoperations edgemarketplace'] = """ type: group - short-summary: Manage Edge Marketplace operations. - long-summary: Commands to manage Edge Marketplace images and offers. + short-summary: Manage Edge marketplace offers for disconnected operations. + long-summary: Commands to list, get details, and package Edge marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ type: command - short-summary: List available marketplace offers. - long-summary: List all available marketplace offers with their SKUs and versions. - parameters: - - name: --resource-group -g - type: string - required: true - short-summary: Name of resource group. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --sub-provider - type: string - short-summary: Sub-provider namespace. - default: Microsoft.EdgeMarketPlace - - name: --api-version - type: string - short-summary: API version to use. - default: 2023-08-01-preview + short-summary: List all available Edge marketplace offers. + long-summary: List all available Edge marketplace offers with their publishers, SKUs, and versions. examples: - - name: List offers using production environment + - name: List all offers in a resource group text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup - - name: List offers using test environment - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --provider-namespace Private.EdgeInternal - - name: List offers in table format - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --output table """ -helps['disconnectedoperations edgemarketplace get-offer'] = """ +helps['disconnectedoperations edgemarketplace packageoffer'] = """ type: command - short-summary: Get details of a specific marketplace offer. - long-summary: Retrieve detailed information about a marketplace offer and optionally download its logos. + short-summary: Package an Edge marketplace offer for disconnected operations. + long-summary: Download and package an Edge marketplace offer including its metadata, logos, and other artifacts. parameters: - name: --resource-group -g type: string - required: true short-summary: Name of resource group. - - name: --offer-name - type: string - required: true - short-summary: Name of the offer to retrieve. - - name: --output-folder - type: string - short-summary: Local folder path to save logos and metadata. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --sub-provider - type: string - short-summary: Sub-provider namespace. - default: Microsoft.EdgeMarketPlace - - name: --api-version - type: string - short-summary: API version to use. - default: 2023-08-01-preview - examples: - - name: Get offer details using production environment - text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer - - name: Get offer details and save logos using test environment - text: az disconnectedoperations edgemarketplace get-offer -g myResourceGroup --offer-name myOffer --output-folder ./artifacts --provider-namespace Private.EdgeInternal -""" - -helps['disconnectedoperations edgemarketplace get-image-download-url'] = """ - type: command - short-summary: Get download URL for a marketplace image. - long-summary: Get the download URL for a specific marketplace image version. - parameters: - - name: --resource-group -g - type: string required: true - short-summary: Name of resource group. - - name: --publisher + - name: --publisher-name type: string + short-summary: Name of the publisher. required: true - short-summary: Publisher of the marketplace image. - - name: --offer + - name: --offer-name type: string + short-summary: Name of the offer. required: true - short-summary: Offer name of the marketplace image. - name: --sku type: string - required: true - short-summary: SKU identifier. + short-summary: SKU of the offer. - name: --version type: string - required: true - short-summary: Version of the marketplace image. - - name: --management-endpoint - type: string - short-summary: Management endpoint URL. - long-summary: Uses brazilus.management.azure.com for test environment, management.azure.com for production. - default: management.azure.com - - name: --provider-namespace - type: string - short-summary: Provider namespace. - long-summary: Use "Private.EdgeInternal" for test environment or "Microsoft.Edge" for production. - default: Microsoft.Edge - - name: --api-version + short-summary: Version of the offer. If not specified, latest version will be used. + - name: --output-folder type: string - short-summary: API version to use. - default: 2024-11-01-preview + short-summary: Output folder path for downloaded artifacts. + required: true examples: - - name: Get image download URL using production environment - text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest - - name: Get image download URL using test environment - text: az disconnectedoperations edgemarketplace get-image-download-url -g myResourceGroup --publisher MicrosoftWindowsServer --offer WindowsServer --sku 2019-Datacenter --version latest --provider-namespace Private.EdgeInternal + - name: Package latest version of an offer + text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --output-folder ./output + - name: Package specific version of an offer + text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --version 1.0.0 --output-folder ./output """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 57bd67d294e..609de09ab4e 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -46,7 +46,6 @@ def load_command_table(self, _): with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) - g.custom_command('getoffer', 'get_offer') g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file From 82077ab4adee7463bbda1515cfaf54b2e92d9185 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 19 Feb 2025 16:49:38 +0530 Subject: [PATCH 14/32] added get-offer --- .../disconnectedoperations/_help.py | 111 +++++++++++++----- .../disconnectedoperations/commands.py | 20 +++- .../disconnectedoperations/custom.py | 110 ++++++++++++++--- 3 files changed, 196 insertions(+), 45 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 18a3361d09e..102459b07e4 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -1,56 +1,109 @@ -from knack.help_files import helps +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- -helps['disconnectedoperations'] = """ - type: group - short-summary: Commands to manage Azure Disconnected Operations. - long-summary: Manage Azure Disconnected Operations for Edge marketplace offers. -""" +from knack.help_files import helps helps['disconnectedoperations edgemarketplace'] = """ type: group - short-summary: Manage Edge marketplace offers for disconnected operations. - long-summary: Commands to list, get details, and package Edge marketplace offers for disconnected operations. + short-summary: Manage Edge Marketplace offers for disconnected operations. + long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ type: command - short-summary: List all available Edge marketplace offers. - long-summary: List all available Edge marketplace offers with their publishers, SKUs, and versions. + short-summary: List all available marketplace offers. examples: - - name: List all offers in a resource group - text: az disconnectedoperations edgemarketplace listoffers -g myResourceGroup + - name: List all marketplace offers for a specific resource + text: > + az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource + - name: List offers and format output as table + text: > + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --output table + - name: List offers and filter output using JMESPath query + text: > + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --query "[?OS_Type=='Linux']" + parameters: + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name -n + type: string + short-summary: The resource name +""" + +helps['disconnectedoperations edgemarketplace getoffer'] = """ + type: command + short-summary: Get details of a specific marketplace offer. + examples: + - name: Get details of a specific marketplace offer + text: > + az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource + --publisher-name publisherName --offer-name offerName + - name: Get offer details and output as JSON + text: > + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --output json + - name: Get offer details with custom query + text: > + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n 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 -n + 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['disconnectedoperations edgemarketplace packageoffer'] = """ type: command - short-summary: Package an Edge marketplace offer for disconnected operations. - long-summary: Download and package an Edge marketplace offer including its metadata, logos, and other artifacts. + 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 disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource + --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber + --output-folder ./output + - name: Package latest version of an offer + text: > + az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --sku skuName + --output-folder ./latest-package + - name: Package an offer and save to a specific directory + text: > + az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource + --publisher-name publisherName --offer-name offerName --sku skuName + --output-folder "D:\\MarketplacePackages" parameters: - name: --resource-group -g type: string - short-summary: Name of resource group. - required: true + short-summary: Name of resource group + - name: --resource-name -n + type: string + short-summary: The resource name - name: --publisher-name type: string - short-summary: Name of the publisher. - required: true + short-summary: The publisher name of the offer - name: --offer-name type: string - short-summary: Name of the offer. - required: true + short-summary: The name of the offer - name: --sku type: string - short-summary: SKU of the offer. + short-summary: The SKU of the offer - name: --version type: string - short-summary: Version of the offer. If not specified, latest version will be used. + short-summary: The version of the offer (optional, latest version will be used if not specified) - name: --output-folder type: string - short-summary: Output folder path for downloaded artifacts. - required: true - examples: - - name: Package latest version of an offer - text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --output-folder ./output - - name: Package specific version of an offer - text: az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup --publisher-name publisherName --offer-name offerName --version 1.0.0 --output-folder ./output + short-summary: The folder path where the package will be downloaded """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 609de09ab4e..2bb27f47f1c 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -13,6 +13,24 @@ 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', 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: @@ -27,7 +45,6 @@ def transform_offers_table(result): formatted_versions = '\n'.join(str(v).strip() for v in versions) else: formatted_versions = str(versions) - row = OrderedDict([ ('Publisher', item['Publisher']), ('Offer', item['Offer']), @@ -46,6 +63,7 @@ def load_command_table(self, _): with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) + g.custom_command('getoffer', 'get_offer', table_transformer=transform_offer_table) g.custom_command('packageoffer', 'package_offer') return self.command_table \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 408fe8b0978..1f855182ef5 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -12,13 +12,15 @@ logger = get_logger(__name__) -def _get_management_endpoint(): - """Helper function to determine management endpoint based on provider namespace.""" - return "brazilus.management.azure.com" # if provider_namespace == "Private.EdgeInternal" else "management.azure.com" - +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" # For testing purposes def package_offer(cmd, resource_group_name, + resource_name, publisher_name, offer_name, sku, @@ -34,7 +36,7 @@ def package_offer(cmd, from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided - management_endpoint = _get_management_endpoint() + management_endpoint = _get_management_endpoint(cmd.cli_ctx) logger = get_logger(__name__) # Get subscription ID from current context @@ -49,7 +51,7 @@ def package_offer(cmd, f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) @@ -171,7 +173,7 @@ def package_offer(cmd, 'resource_group_name': resource_group_name } -def list_offers(cmd, resource_group_name): +def list_offers(cmd, resource_group_name, resource_name): """List all offers for disconnected operations.""" from azure.cli.core.commands.client_factory import get_subscription_id @@ -180,7 +182,7 @@ def list_offers(cmd, resource_group_name): logger = get_logger(__name__) - management_endpoint = _get_management_endpoint() + management_endpoint = _get_management_endpoint(cmd.cli_ctx) # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) @@ -193,7 +195,7 @@ def list_offers(cmd, resource_group_name): f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/demo-winfield1" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers" f"?api-version={api_version}" ) @@ -219,16 +221,11 @@ def list_offers(cmd, resource_group_name): for sku in skus: versions = sku.get('marketplaceSkuVersions', [])[:] - # Format versions as comma-separated string with size - 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, + 'Versions': f"{len(versions)} {'version' if len(versions) == 1 else 'versions'} available", 'OS_Type': sku.get('operatingSystem', {}).get('type') } result.append(row) @@ -245,6 +242,89 @@ def list_offers(cmd, resource_group_name): 'response': response.text } + except Exception as e: + logger.error(f"Failed to retrieve offers: {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.""" + + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + from knack.log import get_logger + + logger = get_logger(__name__) + + management_endpoint = _get_management_endpoint(cmd.cli_ctx) + + # Get subscription ID from current context + subscription_id = get_subscription_id(cmd.cli_ctx) + provider_namespace="Private.EdgeInternal" + sub_provider="Microsoft.EdgeMarketPlace" + api_version="2023-08-01-preview" + + # Construct URL with parameters + url = ( + f"https://{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" + f"?api-version={api_version}" + ) + + # Define headers with resource for authentication + headers = { + 'Content-Type': 'application/json', + } + + # Define the resource for authentication + resource = "https://management.azure.com" # Using standard Azure management endpoint + + try: + response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) + + 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 + + else: + 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 Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { From 3a6d5090d2f8d638e8e5882d88e73ad7e9d51b91 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Wed, 19 Feb 2025 17:17:55 +0530 Subject: [PATCH 15/32] updated help file --- .../disconnectedoperations/_help.py | 29 ++++++++----------- .../disconnectedoperations/_params.py | 17 +++-------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 102459b07e4..910fa2075e3 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -5,6 +5,11 @@ from knack.help_files import helps +helps['disconnectedoperations'] = """ + type: group + short-summary: Manage disconnected operations. + long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +""" helps['disconnectedoperations edgemarketplace'] = """ type: group short-summary: Manage Edge Marketplace offers for disconnected operations. @@ -20,15 +25,15 @@ az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource - name: List offers and format output as table text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --output table + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table - name: List offers and filter output using JMESPath query text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup -n myResource --query "[?OS_Type=='Linux']" + az disconnectedoperations edgemarketplace listoffers -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 -n + - name: --resource-name type: string short-summary: The resource name """ @@ -43,17 +48,17 @@ --publisher-name publisherName --offer-name offerName - name: Get offer details and output as JSON text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json - name: Get offer details with custom query text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup -n myResource + az disconnectedoperations edgemarketplace getoffer -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 -n + - name: --resource-name type: string short-summary: The resource name - name: --publisher-name @@ -73,22 +78,12 @@ text: > az disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber - --output-folder ./output - - name: Package latest version of an offer - text: > - az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource - --publisher-name publisherName --offer-name offerName --sku skuName - --output-folder ./latest-package - - name: Package an offer and save to a specific directory - text: > - az disconnectedoperations edgemarketplace packageoffer -g myResourceGroup -n myResource - --publisher-name publisherName --offer-name offerName --sku skuName --output-folder "D:\\MarketplacePackages" parameters: - name: --resource-group -g type: string short-summary: Name of resource group - - name: --resource-name -n + - name: --resource-name type: string short-summary: The resource name - name: --publisher-name diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index 166d6ed135d..b259919fe82 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -6,31 +6,22 @@ # -------------------------------------------------------------------------------------------- from azure.cli.core.commands.parameters import resource_group_name_type -from knack.arguments import CLIArgumentType -def load_arguments(self, _): # pylint: disable=unused-argument - provider_namespace_type = CLIArgumentType( - type=str, - help='Provider namespace. Use "Private.EdgeInternal" for test environment or "Microsoft.EdgeMarketplace" for production', - default="Private.EdgeInternal" - ) - - management_endpoint_type = CLIArgumentType( - type=str, - help='Management endpoint URL. Use brazilus.management.azure.com for test environment, management.azure.com for production', - default="brazilus.management.azure.com" - ) +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 to retrieve') c.argument('product_name', type=str, help='Name of the product to retrieve') 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 to retrieve') From 5f2053d090833e4afef4ebfb5e2ee08780fc52ee Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:12:28 +0530 Subject: [PATCH 16/32] Added image download logic --- .../disconnectedoperations/__init__.py | 32 +- .../disconnectedoperations/_client_factory.py | 8 +- .../disconnectedoperations/_help.py | 168 +++-- .../disconnectedoperations/_params.py | 48 +- .../disconnectedoperations/commands.py | 76 ++- .../disconnectedoperations/custom.py | 633 +++++++++++++----- 6 files changed, 631 insertions(+), 334 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index 1aaa566929a..07ea9258bcb 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -5,30 +5,40 @@ # Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- -from azure.cli.core import AzCommandsLoader -from azure.cli.command_modules.disconnectedoperations._help import helps # pylint: disable=unused-import from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image +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 # required when using python sdk + from azure.cli.core.profiles import ( + ResourceType, # required when using python sdk + ) + disconnectedoperations_custom = CliCommandType( - operations_tmpl='azure.cli.command_modules.disconnectedoperations.custom#{}', - client_factory=cf_image) - super(DisconnectedoperationsCommandsLoader, self).__init__(cli_ctx=cli_ctx, - resource_type=ResourceType.MGMT_DISCONNECTEDOPERATIONS, # required when using python sdk - custom_command_type=disconnectedoperations_custom) + 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 + 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 + from azure.cli.command_modules.disconnectedoperations._params import ( + load_arguments, + ) + load_arguments(self, command) 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 index 20df0404d3b..ab3fbf60d18 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -7,15 +7,9 @@ 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 - -def cf_logos(cli_ctx, *_): - return get_disconnectedoperations_management_client(cli_ctx).logos - -def cf_metadata(cli_ctx, *_): - return get_disconnectedoperations_management_client(cli_ctx).metadata - diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 910fa2075e3..644668227e5 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -6,99 +6,97 @@ from knack.help_files import helps helps['disconnectedoperations'] = """ - type: group - short-summary: Manage disconnected operations. - long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +type: group +short-summary: Manage disconnected operations. """ helps['disconnectedoperations edgemarketplace'] = """ - type: group - short-summary: Manage Edge Marketplace offers for disconnected operations. - long-summary: Commands to list, get details, and package marketplace offers for disconnected operations. +type: group +short-summary: Manage Edge Marketplace offers for disconnected operations. """ helps['disconnectedoperations edgemarketplace listoffers'] = """ - type: command - short-summary: List all available marketplace offers. - examples: - - name: List all marketplace offers for a specific resource - text: > - az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource - - name: List offers and format output as table - text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table - - name: List offers and filter output using JMESPath query - text: > - az disconnectedoperations edgemarketplace listoffers -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 +type: command +short-summary: List all available marketplace offers. +examples: +- name: List all marketplace offers for a specific resource + text: > +az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource +- name: List offers and format output as table + text: > +az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table +- name: List offers and filter output using JMESPath query + text: > +az disconnectedoperations edgemarketplace listoffers -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['disconnectedoperations edgemarketplace getoffer'] = """ - type: command - short-summary: Get details of a specific marketplace offer. - examples: - - name: Get details of a specific marketplace offer - text: > - az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource - --publisher-name publisherName --offer-name offerName - - name: Get offer details and output as JSON - text: > - az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource - --publisher-name publisherName --offer-name offerName --output json - - name: Get offer details with custom query - text: > - az disconnectedoperations edgemarketplace getoffer -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 +type: command +short-summary: Get details of a specific marketplace offer. +examples: +- name: Get details of a specific marketplace offer + text: > +az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource +--publisher-name publisherName --offer-name offerName +- name: Get offer details and output as JSON + text: > +az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource +--publisher-name publisherName --offer-name offerName --output json +- name: Get offer details with custom query + text: > +az disconnectedoperations edgemarketplace getoffer -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['disconnectedoperations edgemarketplace packageoffer'] = """ - 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 disconnectedoperations edgemarketplace packageoffer --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 -""" \ No newline at end of file +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 disconnectedoperations edgemarketplace packageoffer --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 index b259919fe82..d5fa03dade1 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -7,23 +7,37 @@ 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 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 to retrieve') - c.argument('product_name', type=str, help='Name of the product to retrieve') + 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 to retrieve') - c.argument('version', type=str, help='Version of the product to retrieve') - c.argument('output_folder', type=str, help='Drive and directory to save the package to. Example: E:\\ or D:\\packages\\') + 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/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 2bb27f47f1c..813a9943e62 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -6,64 +6,80 @@ # -------------------------------------------------------------------------------------------- -from azure.cli.core.commands import CliCommandType 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', item['Versions']), - ('OS_Type', item['OS_Type']) - ]) + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version", 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'] + 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(',')] - + 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) + 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', formatted_versions), - ('OS_Type', item['OS_Type']) - ]) + row = OrderedDict( + [ + ("Publisher", item["Publisher"]), + ("Offer", item["Offer"]), + ("SKU", item["SKU"]), + ("Version", 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#{}' + operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}" ) - with self.command_group('disconnectedoperations edgemarketplace', custom_command_type=custom_command_type) as g: - g.custom_command('listoffers', 'list_offers', table_transformer=transform_offers_table) - g.custom_command('getoffer', 'get_offer', table_transformer=transform_offer_table) - g.custom_command('packageoffer', 'package_offer') - - return self.command_table \ No newline at end of file + with self.command_group( + "disconnectedoperations edgemarketplace", + custom_command_type=custom_command_type, + is_preview=True, + ) as g: + g.custom_command( + "listoffers", "list_offers", table_transformer=transform_offers_table + ) + g.custom_command( + "getoffer", "get_offer", table_transformer=transform_offer_table + ) + g.custom_command("packageoffer", "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 index 1f855182ef5..e6ee98baa1f 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,32 +8,39 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -from knack.log import get_logger +provider_namespace = "Microsoft.DataBoxEdge" +sub_provider = "Microsoft.EdgeMarketPlace" +api_version = "2023-08-01-preview" -logger = get_logger(__name__) 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" # For testing purposes - -def package_offer(cmd, - resource_group_name, - resource_name, - publisher_name, - offer_name, - sku, - version, - output_folder): + return "brazilus.management.azure.com" # For testing purposes + + +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 os import json + import os + import shutil + 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 - from knack.log import get_logger # Use helper function if management_endpoint not explicitly provided management_endpoint = _get_management_endpoint(cmd.cli_ctx) @@ -41,294 +48,552 @@ def package_offer(cmd, # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - - provider_namespace = "Private.EdgeInternal" - sub_provider = "Microsoft.EdgeMarketPlace" - api_version = "2023-08-01-preview" # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) resource = "https://management.azure.com" - + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + if response.status_code == 200: data = response.json() - offer_content = data.get('properties', {}).get('offerContent', {}) - icon_uris = offer_content.get('iconFileUris', {}) - + 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', []) - - for sku in skus: - sku_id = sku.get('marketplaceSkuId', '') - versions = sku.get('marketplaceSkuVersions', []) - - # If version is specified, filter for that version, else take the latest - if version: - versions = [v for v in versions if v.get('name') == version] - else: - versions = versions[:1] # Take only the latest version - - if not versions: - logger.warning(f"No matching version found for SKU {sku_id}") + publisher_id = offer_content.get("offerPublisher", {}).get( + "publisherId", "" + ) + offer_id = offer_content.get("offerId", "") + skus = data.get("properties", {}).get("marketplaceSkus", []) + + for _sku in skus: + sku_id = _sku.get("marketplaceSkuId", "") + + if sku_id != sku: continue + else: + # Store the generation information + generation = _sku.get("generation") - for version in versions: - version_id = version.get('name') - - # Create base path for this version - base_path = os.path.join(output_folder, 'catalog_artifacts', - publisher_id, offer_id, sku_id) - version_level_path = os.path.join(base_path, version_id) - icon_path = os.path.join(base_path, 'icons') - - # Check if version directory exists and has content - if os.path.exists(version_level_path): - # Check if directory has any files - if os.path.exists(os.path.join(version_level_path, 'metadata.json')) or \ - any(os.scandir(version_level_path)): - error_message = f"Version directory already exists and contains files: {version_level_path}. Please delete the version folder in case you want to re-download the package." - logger.error(error_message) - return { - 'error': error_message, - 'status': 'failed', - 'path': version_level_path - } + # 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( + f"No matching version found for SKU {sku_id}" + ) + return + + # print if version and generation are found + print(f"Found VM version: {versions[0].get('name')}") + print(f"VM Generation: {generation}") - 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') - metadata = { - 'name': data.get('name'), - 'publisher': offer_content.get('offerPublisher'), - 'offer_id': offer_content.get('offerId'), - 'summary': offer_content.get('summary'), - 'description': offer_content.get('description'), - 'sku': { - 'name': sku.get('displayName'), - 'id': sku.get('marketplaceSkuId'), - 'os_type': sku.get('operatingSystem'), - 'version': version - } + version_id = versions[0].get("name") + + # check if sku is not found + if not version_id: + logger.warning(f"No matching SKU found: {sku}") + return + + # Create base path for this version + base_path = os.path.join( + output_folder, + "catalog_artifacts", + publisher_id, + offer_id, + sku_id, + ) + version_level_path = os.path.join(base_path, version_id) + icon_path = os.path.join(base_path, "icons") + + # Check if version directory exists and has content + if os.path.exists(version_level_path): + try: + # Remove directory and all its contents + shutil.rmtree(version_level_path) + logger.info( + f"Cleaned up existing version directory: {version_level_path}" + ) + except Exception as e: + error_message = f"Failed to clean up version directory {version_level_path}: {str(e)}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "path": version_level_path, } - - with open(metadata_path, 'w', encoding='utf-8') as f: - json.dump(metadata, f, indent=2) - logger.info(f"Saved metadata to {metadata_path}") - - # Download icons - if icon_uris: - 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(f"Icon {size} already exists at {file_path}, skipping download") - 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(f"Downloaded {size} logo to {file_path}") - else: - logger.error(f"Failed to download {size} logo: {logo_response.status_code}") - except Exception as e: - logger.error(f"Error downloading {size} logo: {str(e)}") - - print ("Metadata and icons downloaded successfully") - + + 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") + # Save Api response as it is on metadata.json + metadata = data + + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2) + logger.info(f"Saved metadata to {metadata_path}") + + # Download icons + if icon_uris: + 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( + f"Icon {size} already exists at {file_path}, skipping download" + ) + 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(f"Downloaded {size} logo to {file_path}") + else: + logger.error( + f"Failed to download {size} logo: {logo_response.status_code}" + ) + except Exception as e: + logger.error(f"Error downloading {size} logo: {str(e)}") + + print("Metadata and icons downloaded successfully") + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offer: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } + + 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, + ) + + +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 os + import time + from datetime import datetime + + 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...") + print(response) + + # 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, + } + + # parse headers + headers = response.headers + + # get async operation URL from headers + async_operation_url = headers.get("Azure-AsyncOperation") + + # hit async operation URL until "status" in response is "Succeeded" with exponential backoff + if async_operation_url: + max_retries = 10 + base_delay = 2 # seconds + timeout = 300 # 5 minutes timeout + start_time = datetime.now() + + print("Hitting async operation URL...") + for attempt in range(max_retries): + print(f"Attempt {attempt + 1} of {max_retries}...") + try: + # Calculate exponential backoff delay + delay = base_delay * (2**attempt) + + # Check if we've exceeded timeout + if (datetime.now() - start_time).total_seconds() > timeout: + logger.error("Operation timed out after 5 minutes") + return { + "error": "Operation timed out", + "status": "failed", + "resource_group_name": resource_group_name, + } + + # 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 in (200, 202): + status_data = status_response.json() + status = status_data.get("status", "").lower() + + print("Current status:", status) + + if status == "succeeded": + logger.info("VHD download URL generation succeeded") + print(status_response) + # Get the download URL from the response + requestId = status_data.get("properties", {}).get( + "requestId" + ) + + # Obtaining SAS token using request Id + if requestId: + print( + f"Fetched request Id for VHD Download: {requestId}" + ) + + # Obtaining SAS token using request Id + token_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"/getAccessToken?api-version={api_version}" + ) + + 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), + ) + + if token_response.status_code == 200: + token_data = token_response.json() + + # Generate azcopy command + download_url = token_data.get("accessToken") + # diskId = token_data.get("diskId") + + # Construct the azcopy command + command = f'azcopy copy "{download_url}" "{output_folder}" --check-md5 NoCheck' + + print(command) + print("Executing command...") + + # Execute the command + os.system(command) + print("Download completed successfully.") + return { + "status": "succeeded", + "message": "Download completed successfully.", + } + else: + logger.error( + f"Failed to get access token: {token_response.status_code}" + ) + return { + "error": f"Failed to get access token: {token_response.status_code}", + "status": "failed", + } + + else: + logger.error("Download URL not found in response") + return { + "error": "Download URL not found", + "status": "failed", + } + + elif status == "failed": + error_message = status_data.get("error", {}).get( + "message", "Unknown error" + ) + logger.error(f"Operation failed: {error_message}") + return {"error": error_message, "status": "failed"} + + else: # In progress + logger.info( + f"Operation in progress... (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(delay) + continue + + else: + logger.error( + f"Failed to get operation status: {status_response.status_code}" + ) + return { + "error": f"Status check failed: {status_response.status_code}", + "status": "failed", + } + + except Exception as e: + logger.error(f"Error checking operation status: {str(e)}") + time.sleep(delay) + continue + + # If we've exhausted all retries + logger.error("Maximum retry attempts reached") + return {"error": "Maximum retry attempts reached", "status": "failed"} + + except Exception as e: + logger.error(f"Failed to generate access token: {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.""" + 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 - from knack.log import get_logger logger = get_logger(__name__) management_endpoint = _get_management_endpoint(cmd.cli_ctx) - + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - provider_namespace="Private.EdgeInternal" - sub_provider="Microsoft.EdgeMarketPlace" - api_version="2023-08-01-preview" - + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers" f"?api-version={api_version}" ) # Define headers with resource for authentication headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } # Define the resource for authentication - resource = "https://management.azure.com" # Using standard Azure management endpoint - + resource = ( + "https://management.azure.com" # Using standard Azure management endpoint + ) + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + 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 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', [])[:] + 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') + "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 - + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name + "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.""" + 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 - from knack.log import get_logger logger = get_logger(__name__) management_endpoint = _get_management_endpoint(cmd.cli_ctx) - + # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) - provider_namespace="Private.EdgeInternal" - sub_provider="Microsoft.EdgeMarketPlace" - api_version="2023-08-01-preview" - + # Construct URL with parameters url = ( f"https://{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.DataBoxEdge/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) # Define headers with resource for authentication headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } # Define the resource for authentication - resource = "https://management.azure.com" # Using standard Azure management endpoint - + resource = ( + "https://management.azure.com" # Using standard Azure management endpoint + ) + try: - response = send_raw_request(cmd.cli_ctx, 'get', url, resource=resource) - + response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + if response.status_code == 200: data = response.json() result = [] - - offer_content = data.get('properties', {}).get('offerContent', {}) - skus = data.get('properties', {}).get('marketplaceSkus', []) + 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', [])[:] + 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]) - + 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') + "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 - + else: 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 + "error": error_message, + "status": "failed", + "resource_group_name": resource_group_name, + "response": response.text, } - + except Exception as e: logger.error(f"Failed to retrieve offers: {str(e)}") return { - 'error': str(e), - 'status': 'failed', - 'resource_group_name': resource_group_name - } \ No newline at end of file + "error": str(e), + "status": "failed", + "resource_group_name": resource_group_name, + } From c0b969d76f3d5941b831f195aa65092b4d75543d Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:21:37 +0530 Subject: [PATCH 17/32] removing mgmt storage latest --- src/azure-cli-core/azure/cli/core/profiles/_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 44e161f1b2d..c42d311d068 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -157,7 +157,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_STORAGE: '2024-01-01', ResourceType.MGMT_NETWORK: '2022-01-01', ResourceType.MGMT_COMPUTE: SDKProfile('2024-07-01', { 'resource_skus': '2019-04-01', From 65dd221e83f4d9949bc8edf8468ad53fac90e1ee Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 25 Feb 2025 18:27:41 +0530 Subject: [PATCH 18/32] Added storage mgmt version back --- src/azure-cli-core/azure/cli/core/profiles/_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c42d311d068..44e161f1b2d 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -157,7 +157,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_STORAGE: '2024-01-01', ResourceType.MGMT_NETWORK: '2022-01-01', ResourceType.MGMT_COMPUTE: SDKProfile('2024-07-01', { 'resource_skus': '2019-04-01', From d67dab0f93e63ef3ece2c7f145b20b10cdbe1dff Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Mon, 3 Mar 2025 11:10:36 +0530 Subject: [PATCH 19/32] fixed styling issues --- .../disconnectedoperations/_help.py | 10 +- .../disconnectedoperations/commands.py | 4 +- .../disconnectedoperations/custom.py | 710 ++++++++---------- 3 files changed, 339 insertions(+), 385 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 644668227e5..3fbb58d6a8b 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -42,15 +42,15 @@ examples: - name: Get details of a specific marketplace offer text: > -az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource +az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName - name: Get offer details and output as JSON text: > -az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource +az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json - name: Get offer details with custom query text: > -az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource +az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --query "[].{SKU:SKU,Version:Versions}" parameters: - name: --resource-group -g @@ -74,8 +74,8 @@ examples: - name: Package a marketplace offer with specific version text: > -az disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource ---publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber +az disconnectedoperations edgemarketplace packageoffer --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 diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 813a9943e62..8fa7da6606c 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -23,7 +23,7 @@ def transform_offers_table(result): ("Publisher", item["Publisher"]), ("Offer", item["Offer"]), ("SKU", item["SKU"]), - ("Version", item["Versions"]), + ("Version(s)", item["Versions"]), ("OS_Type", item["OS_Type"]), ] ) @@ -55,7 +55,7 @@ def transform_offer_table(result): ("Publisher", item["Publisher"]), ("Offer", item["Offer"]), ("SKU", item["SKU"]), - ("Version", formatted_versions), + ("Version(s)", formatted_versions), ("OS_Type", item["OS_Type"]), ] ) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index e6ee98baa1f..a7697fe94b6 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -15,38 +15,126 @@ 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" # For testing purposes - - -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.""" + cloud = cli_ctx.cloud + return cloud.endpoints.resource_manager + # return "brazilus.management.azure.com" - import json + +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 - # Use helper function if management_endpoint not explicitly provided - management_endpoint = _get_management_endpoint(cmd.cli_ctx) logger = get_logger(__name__) - - # Get subscription ID from current context + management_endpoint = _get_management_endpoint(cmd.cli_ctx) subscription_id = get_subscription_id(cmd.cli_ctx) # Construct URL with parameters @@ -59,123 +147,10 @@ def package_offer( f"?api-version={api_version}" ) - resource = "https://management.azure.com" - try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) - - if response.status_code == 200: - 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", []) - - for _sku in skus: - sku_id = _sku.get("marketplaceSkuId", "") - - if sku_id != sku: - continue - else: - # 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( - f"No matching version found for SKU {sku_id}" - ) - return - - # print if version and generation are found - print(f"Found VM version: {versions[0].get('name')}") - print(f"VM Generation: {generation}") - - version_id = versions[0].get("name") - - # check if sku is not found - if not version_id: - logger.warning(f"No matching SKU found: {sku}") - return - - # Create base path for this version - base_path = os.path.join( - output_folder, - "catalog_artifacts", - publisher_id, - offer_id, - sku_id, - ) - version_level_path = os.path.join(base_path, version_id) - icon_path = os.path.join(base_path, "icons") - - # Check if version directory exists and has content - if os.path.exists(version_level_path): - try: - # Remove directory and all its contents - shutil.rmtree(version_level_path) - logger.info( - f"Cleaned up existing version directory: {version_level_path}" - ) - except Exception as e: - error_message = f"Failed to clean up version directory {version_level_path}: {str(e)}" - logger.error(error_message) - return { - "error": error_message, - "status": "failed", - "path": version_level_path, - } - - 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") - # Save Api response as it is on metadata.json - metadata = data - - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=2) - logger.info(f"Saved metadata to {metadata_path}") - - # Download icons - if icon_uris: - 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( - f"Icon {size} already exists at {file_path}, skipping download" - ) - 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(f"Downloaded {size} logo to {file_path}") - else: - logger.error( - f"Failed to download {size} logo: {logo_response.status_code}" - ) - except Exception as e: - logger.error(f"Error downloading {size} logo: {str(e)}") - - print("Metadata and icons downloaded successfully") + response = send_raw_request(cmd.cli_ctx, "get", url, resource=management_endpoint) - else: + if response.status_code != 200: error_message = f"Request failed with status code: {response.status_code}" logger.error(error_message) return { @@ -185,46 +160,191 @@ def package_offer( "response": response.text, } - except Exception as e: - logger.error(f"Failed to retrieve offer: {str(e)}") + 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, } - 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, + +def _handle_token_response(token_response, output_folder, logger): + """Helper function to handle token response and download.""" + import os + + 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") + + # 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 download_vhd( - cmd, - resource_group_name, - resource_name, - publisher_name, - offer_name, - sku, - version, - generation, - output_folder, -): - """Generate access token for VHD download.""" +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 datetime import json - import os 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 @@ -255,15 +375,12 @@ def download_vhd( try: print("Generating access token for VHD download...") response = send_raw_request( - cmd.cli_ctx, - "post", - url, + cmd.cli_ctx, "post", url, resource="https://management.azure.com", - body=json.dumps(body), + body=json.dumps(body) ) print("Checking status of VHD download URL generation...") - print(response) # Check if the request was successful if response.status_code not in (200, 202): @@ -276,153 +393,25 @@ def download_vhd( "response": response.text, } - # parse headers - headers = response.headers - - # get async operation URL from headers - async_operation_url = headers.get("Azure-AsyncOperation") - - # hit async operation URL until "status" in response is "Succeeded" with exponential backoff - if async_operation_url: - max_retries = 10 - base_delay = 2 # seconds - timeout = 300 # 5 minutes timeout - start_time = datetime.now() - - print("Hitting async operation URL...") - for attempt in range(max_retries): - print(f"Attempt {attempt + 1} of {max_retries}...") - try: - # Calculate exponential backoff delay - delay = base_delay * (2**attempt) - - # Check if we've exceeded timeout - if (datetime.now() - start_time).total_seconds() > timeout: - logger.error("Operation timed out after 5 minutes") - return { - "error": "Operation timed out", - "status": "failed", - "resource_group_name": resource_group_name, - } - - # 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 in (200, 202): - status_data = status_response.json() - status = status_data.get("status", "").lower() - - print("Current status:", status) - - if status == "succeeded": - logger.info("VHD download URL generation succeeded") - print(status_response) - # Get the download URL from the response - requestId = status_data.get("properties", {}).get( - "requestId" - ) - - # Obtaining SAS token using request Id - if requestId: - print( - f"Fetched request Id for VHD Download: {requestId}" - ) - - # Obtaining SAS token using request Id - token_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"/getAccessToken?api-version={api_version}" - ) - - 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), - ) - - if token_response.status_code == 200: - token_data = token_response.json() - - # Generate azcopy command - download_url = token_data.get("accessToken") - # diskId = token_data.get("diskId") - - # Construct the azcopy command - command = f'azcopy copy "{download_url}" "{output_folder}" --check-md5 NoCheck' - - print(command) - print("Executing command...") - - # Execute the command - os.system(command) - print("Download completed successfully.") - return { - "status": "succeeded", - "message": "Download completed successfully.", - } - else: - logger.error( - f"Failed to get access token: {token_response.status_code}" - ) - return { - "error": f"Failed to get access token: {token_response.status_code}", - "status": "failed", - } - - else: - logger.error("Download URL not found in response") - return { - "error": "Download URL not found", - "status": "failed", - } - - elif status == "failed": - error_message = status_data.get("error", {}).get( - "message", "Unknown error" - ) - logger.error(f"Operation failed: {error_message}") - return {"error": error_message, "status": "failed"} - - else: # In progress - logger.info( - f"Operation in progress... (attempt {attempt + 1}/{max_retries})" - ) - time.sleep(delay) - continue - - else: - logger.error( - f"Failed to get operation status: {status_response.status_code}" - ) - return { - "error": f"Status check failed: {status_response.status_code}", - "status": "failed", - } - - except Exception as e: - logger.error(f"Error checking operation status: {str(e)}") - time.sleep(delay) - continue - - # If we've exhausted all retries - logger.error("Maximum retry attempts reached") - return {"error": "Maximum retry attempts reached", "status": "failed"} - - except Exception as e: - logger.error(f"Failed to generate access token: {str(e)}") + # 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", @@ -432,17 +421,14 @@ def download_vhd( 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) - - # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) # Construct URL with parameters @@ -455,18 +441,8 @@ def list_offers(cmd, resource_group_name, resource_name): f"?api-version={api_version}" ) - # Define headers with resource for authentication - headers = { - "Content-Type": "application/json", - } - - # Define the resource for authentication - resource = ( - "https://management.azure.com" # Using standard Azure management endpoint - ) - try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") if response.status_code == 200: data = response.json() @@ -479,9 +455,7 @@ def list_offers(cmd, resource_group_name, resource_name): for sku in skus: versions = sku.get("marketplaceSkuVersions", [])[:] row = { - "Publisher": offer_content.get("offerPublisher", {}).get( - "publisherId" - ), + "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", @@ -491,18 +465,17 @@ def list_offers(cmd, resource_group_name, resource_name): return result - else: - 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, - } + 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 Exception as e: - logger.error(f"Failed to retrieve offers: {str(e)}") + except requests.RequestException as e: + logger.error("Failed to retrieve offers: %s", str(e)) return { "error": str(e), "status": "failed", @@ -512,17 +485,14 @@ def list_offers(cmd, resource_group_name, resource_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) - - # Get subscription ID from current context subscription_id = get_subscription_id(cmd.cli_ctx) # Construct URL with parameters @@ -535,18 +505,8 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_nam f"?api-version={api_version}" ) - # Define headers with resource for authentication - headers = { - "Content-Type": "application/json", - } - - # Define the resource for authentication - resource = ( - "https://management.azure.com" # Using standard Azure management endpoint - ) - try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource=resource) + response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") if response.status_code == 200: data = response.json() @@ -561,17 +521,12 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_nam # 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 - ] + 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" - ), + "Publisher": offer_content.get("offerPublisher", {}).get("publisherId"), "Offer": offer_content.get("offerId"), "SKU": sku.get("marketplaceSkuId"), "Versions": version_str, @@ -580,18 +535,17 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_nam result.append(row) return result - else: - 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, - } + 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 Exception as e: - logger.error(f"Failed to retrieve offers: {str(e)}") + except requests.RequestException as e: + logger.error("Failed to retrieve offers: %s", str(e)) return { "error": str(e), "status": "failed", From 5f9ceff89b9acfd9d89814443145eb744c1280e2 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Mon, 3 Mar 2025 13:41:12 +0530 Subject: [PATCH 20/32] Added unit tests --- .../disconnectedoperations/custom.py | 51 +++- .../latest/test_disconnectedoperations.py | 268 +++++++++++++++++- 2 files changed, 303 insertions(+), 16 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index a7697fe94b6..aff44b5f412 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -148,7 +148,7 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, ) try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource=management_endpoint) + 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}" @@ -206,9 +206,27 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_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) @@ -220,6 +238,35 @@ def _handle_token_response(token_response, output_folder, logger): 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) @@ -249,9 +296,9 @@ def _get_token_url(management_endpoint, subscription_id, resource_group_name, 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 datetime import json import time + from datetime import datetime import requests 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 index 35610802cc7..282f1203c55 100644 --- 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 @@ -1,24 +1,264 @@ # -------------------------------------------------------------------------------------------- # 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.testsdk import * +import os +import unittest +from unittest import mock +import requests -class DisconnectedoperationsScenario(ScenarioTest): - @ResourceGroupPreparer(name_prefix='cli_test_mycommand') - def test_my_command(self, resource_group): +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 -g {resource_group} -n {resource}') + + offers = self.cmd('az disconnectedoperations edgemarketplace listoffer -g {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_name': resource_group, - 'publisher': 'publisher', - 'offer': 'offer', - 'sku': 'sku' + 'resource_group': resource_group, + 'resource': self.create_random_name('edgedevice', 20), + 'publisher': 'microsoftwindowsserver', + 'offer': 'windowsserver' }) - # Run the command and check the output - result = self.cmd('az disconnectedoperations package') - self.assertEqual(result, 'hello') - \ No newline at end of file + + # 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 -g {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 + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 977bb3f37ab4b0f6f6d445e89523973d8e6be2fb Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Mon, 3 Mar 2025 14:12:02 +0530 Subject: [PATCH 21/32] added more tests and fixed linting issues --- .../disconnectedoperations/__init__.py | 4 +- .../disconnectedoperations/_help.py | 126 +++++++++--------- .../disconnectedoperations/commands.py | 9 ++ .../latest/test_disconnectedoperations.py | 40 +++++- 4 files changed, 109 insertions(+), 70 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index 07ea9258bcb..a1b6ee6947b 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -12,9 +12,7 @@ class DisconnectedoperationsCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): from azure.cli.core.commands import CliCommandType - from azure.cli.core.profiles import ( - ResourceType, # required when using python sdk - ) + from azure.cli.core.profiles import ResourceType disconnectedoperations_custom = CliCommandType( operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}", diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 3fbb58d6a8b..baae0a9ad4c 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -9,6 +9,7 @@ type: group short-summary: Manage disconnected operations. """ + helps['disconnectedoperations edgemarketplace'] = """ type: group short-summary: Manage Edge Marketplace offers for disconnected operations. @@ -18,53 +19,50 @@ type: command short-summary: List all available marketplace offers. examples: -- name: List all marketplace offers for a specific resource - text: > -az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource -- name: List offers and format output as table - text: > -az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table -- name: List offers and filter output using JMESPath query - text: > -az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" + - name: List all marketplace offers for a specific resource + text: > + az disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource + - name: List offers and format output as table + text: > + az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table + - name: List offers and filter output using JMESPath query + text: > + az disconnectedoperations edgemarketplace listoffers -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 + - name: --resource-group -g + type: string + short-summary: Name of resource group + - name: --resource-name + type: string + short-summary: The resource name """ helps['disconnectedoperations edgemarketplace getoffer'] = """ type: command short-summary: Get details of a specific marketplace offer. examples: -- name: Get details of a specific marketplace offer - text: > -az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource ---publisher-name publisherName --offer-name offerName -- name: Get offer details and output as JSON - text: > -az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource ---publisher-name publisherName --offer-name offerName --output json -- name: Get offer details with custom query - text: > -az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource ---publisher-name publisherName --offer-name offerName --query "[].{SKU:SKU,Version:Versions}" + - name: Get details of a specific marketplace offer + text: > + az disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName + - name: Get offer details and output as JSON + text: > + az disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json + - name: Get offer details with custom query + text: > + az disconnectedoperations edgemarketplace getoffer -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 + - 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['disconnectedoperations edgemarketplace packageoffer'] = """ @@ -72,31 +70,29 @@ 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 disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource ---publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber ---output-folder "D:\\MarketplacePackages" + - name: Package a marketplace offer with specific version + text: > + az disconnectedoperations edgemarketplace packageoffer --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 + - 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/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index 8fa7da6606c..cd43e8996f3 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -69,6 +69,15 @@ def load_command_table(self, _): operations_tmpl="azure.cli.command_modules.disconnectedoperations.custom#{}" ) + # Register the parent command group + with self.command_group( + "disconnectedoperations", + 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( "disconnectedoperations edgemarketplace", custom_command_type=custom_command_type, 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 index 282f1203c55..5d633bc2b1e 100644 --- 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 @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import os import unittest from unittest import mock @@ -239,7 +238,6 @@ def test_list_offers(self, resource_group): offers = self.cmd('az disconnectedoperations edgemarketplace listoffer -g {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""" @@ -258,7 +256,45 @@ def test_get_offer(self, resource_group): result = self.cmd('az disconnectedoperations edgemarketplace getoffer -g {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 From 7e11fbc1606e3bc7aa507d2b261ae768d2eb81d6 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Mon, 3 Mar 2025 14:19:33 +0530 Subject: [PATCH 22/32] Updated test case --- .../tests/latest/test_disconnectedoperations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 5d633bc2b1e..f2455cb8df3 100644 --- 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 @@ -233,9 +233,9 @@ def test_list_offers(self, resource_group): # 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 -g {resource_group} -n {resource}') + self.cmd('az databoxedge device create --resource-group-name {resource_group} -n {resource}') - offers = self.cmd('az disconnectedoperations edgemarketplace listoffer -g {resource_group} --resource-name {resource}').get_output_in_json() + 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') @@ -253,7 +253,7 @@ def test_get_offer(self, resource_group): # 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 -g {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-name {offer}').get_output_in_json() + 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 From caa8d91e50351de663e472eb18bf532aef42b435 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 11 Mar 2025 15:40:50 +0530 Subject: [PATCH 23/32] Addressed review comments for command and help --- .../disconnectedoperations/__init__.py | 3 +++ .../disconnectedoperations/_help.py | 27 +++++++++++-------- .../disconnectedoperations/commands.py | 8 +++--- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index a1b6ee6947b..59ae79d9124 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -6,6 +6,9 @@ # -------------------------------------------------------------------------------------------- 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 diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index baae0a9ad4c..17f63c72364 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -10,24 +10,29 @@ short-summary: Manage disconnected operations. """ -helps['disconnectedoperations edgemarketplace'] = """ +helps['disconnectedoperations edge-marketplace'] = """ +type: group +short-summary: Manage Edge Marketplace for disconnected operations. +""" + +helps['disconnectedoperations edge-marketplace offer'] = """ type: group short-summary: Manage Edge Marketplace offers for disconnected operations. """ -helps['disconnectedoperations edgemarketplace listoffers'] = """ +helps['disconnectedoperations 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 disconnectedoperations edgemarketplace listoffers --resource-group myResourceGroup --resource-name myResource + az disconnectedoperations edge-marketplace offer list --resource-group myResourceGroup --resource-name myResource - name: List offers and format output as table text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --output table + az disconnectedoperations edge-marketplace offer list -g myResourceGroup --resource-name myResource --output table - name: List offers and filter output using JMESPath query text: > - az disconnectedoperations edgemarketplace listoffers -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" + az disconnectedoperations edge-marketplace offer list -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" parameters: - name: --resource-group -g type: string @@ -37,19 +42,19 @@ short-summary: The resource name """ -helps['disconnectedoperations edgemarketplace getoffer'] = """ +helps['disconnectedoperations 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 disconnectedoperations edgemarketplace getoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName + az disconnectedoperations 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 disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json + az disconnectedoperations 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 disconnectedoperations edgemarketplace getoffer -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --query "[].{SKU:SKU,Version:Versions}" + az disconnectedoperations 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 @@ -65,14 +70,14 @@ short-summary: The name of the offer """ -helps['disconnectedoperations edgemarketplace packageoffer'] = """ +helps['disconnectedoperations 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 disconnectedoperations edgemarketplace packageoffer --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber --output-folder "D:\\MarketplacePackages" + az disconnectedoperations 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 diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index cd43e8996f3..f3ad496f601 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -79,16 +79,16 @@ def load_command_table(self, _): # Register the subgroup and its commands with self.command_group( - "disconnectedoperations edgemarketplace", + "disconnectedoperations edge-marketplace", custom_command_type=custom_command_type, is_preview=True, ) as g: g.custom_command( - "listoffers", "list_offers", table_transformer=transform_offers_table + "offer list", "list_offers", table_transformer=transform_offers_table ) g.custom_command( - "getoffer", "get_offer", table_transformer=transform_offer_table + "offer get", "get_offer", table_transformer=transform_offer_table ) - g.custom_command("packageoffer", "package_offer") + g.custom_command("offer package", "package_offer") return self.command_table From b73d8525be58a48d3f60dfe997159d4067283255 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 11 Mar 2025 18:40:52 +0530 Subject: [PATCH 24/32] Addressed review comments for edge --- .../disconnectedoperations/_help.py | 33 +++++++++++-------- .../disconnectedoperations/commands.py | 4 +-- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 17f63c72364..021292824c0 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -5,34 +5,39 @@ from knack.help_files import helps -helps['disconnectedoperations'] = """ +helps['edge'] = """ type: group -short-summary: Manage disconnected operations. +short-summary: Manage edge operations. """ -helps['disconnectedoperations edge-marketplace'] = """ +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['disconnectedoperations edge-marketplace offer'] = """ +helps['edge disconnected-operation edge-marketplace offer'] = """ type: group short-summary: Manage Edge Marketplace offers for disconnected operations. """ -helps['disconnectedoperations edge-marketplace offer list'] = """ +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 disconnectedoperations edge-marketplace offer list --resource-group myResourceGroup --resource-name myResource + az edge disconnected-operation edge-marketplace offer list --resource-group myResourceGroup --resource-name myResource - name: List offers and format output as table text: > - az disconnectedoperations edge-marketplace offer list -g myResourceGroup --resource-name myResource --output table + 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 disconnectedoperations edge-marketplace offer list -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" + az edge disconnected-operation edge-marketplace offer list -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" parameters: - name: --resource-group -g type: string @@ -42,19 +47,19 @@ short-summary: The resource name """ -helps['disconnectedoperations edge-marketplace offer get'] = """ +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 disconnectedoperations edge-marketplace offer get --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName + 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 disconnectedoperations edge-marketplace offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --output json + 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 disconnectedoperations edge-marketplace offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --query "[].{SKU:SKU,Version:Versions}" + 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 @@ -70,14 +75,14 @@ short-summary: The name of the offer """ -helps['disconnectedoperations edge-marketplace offer package'] = """ +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 disconnectedoperations edge-marketplace offer package --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-name offerName --sku skuName --version versionNumber --output-folder "D:\\MarketplacePackages" + 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 diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index f3ad496f601..e81396792b5 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -71,7 +71,7 @@ def load_command_table(self, _): # Register the parent command group with self.command_group( - "disconnectedoperations", + "edge disconnected-operation", custom_command_type=custom_command_type, is_preview=True, ) as g: @@ -79,7 +79,7 @@ def load_command_table(self, _): # Register the subgroup and its commands with self.command_group( - "disconnectedoperations edge-marketplace", + "edge disconnected-operation edge-marketplace", custom_command_type=custom_command_type, is_preview=True, ) as g: From 59da40e59cb4d24b525f3ab5d12105696edc0862 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Thu, 13 Mar 2025 14:48:27 +0530 Subject: [PATCH 25/32] Fixed endpoints --- .../disconnectedoperations/custom.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index aff44b5f412..8c152482b97 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,7 +8,7 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -provider_namespace = "Microsoft.DataBoxEdge" +provider_namespace = "Microsoft.Edge" sub_provider = "Microsoft.EdgeMarketPlace" api_version = "2023-08-01-preview" @@ -16,9 +16,15 @@ 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" + # Remove ending slash if exists + if cloud.endpoints.resource_manager.endswith("/"): + cloud.endpoints.resource_manager = cloud.endpoints.resource_manager[:-1] + + # Append https:// if not exists + if not cloud.endpoints.resource_manager.startswith("https://"): + cloud.endpoints.resource_manager = "https://" + cloud.endpoints.resource_manager + return cloud.endpoints.resource_manager def _handle_directory_cleanup(version_level_path, logger): """Helper function to clean up existing directory.""" @@ -87,7 +93,7 @@ def _prepare_paths_and_metadata(output_folder, publisher_id, offer_id, sku, vers os.makedirs(version_level_path, exist_ok=True) # Save metadata.json - metadata_path = os.path.join(version_level_path, "metadata.json") + metadata_path = os.path.join(base_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) @@ -139,10 +145,10 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, # Construct URL with parameters url = ( - f"https://{management_endpoint}" + f"{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) @@ -284,10 +290,10 @@ 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"{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" f"/getAccessToken?api-version={api_version}" ) @@ -403,10 +409,10 @@ def download_vhd(cmd, resource_group_name, resource_name, publisher_name, # API endpoint construction url = ( - f"https://{management_endpoint}" + f"{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" f"/generateAccessToken?api-version=2023-08-01-preview" ) @@ -480,14 +486,13 @@ def list_offers(cmd, resource_group_name, resource_name): # Construct URL with parameters url = ( - f"https://{management_endpoint}" + f"{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/disconnectedOperations/{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") @@ -544,10 +549,10 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_nam # Construct URL with parameters url = ( - f"https://{management_endpoint}" + f"{management_endpoint}" f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/dataBoxEdgeDevices/{resource_name}" + f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" f"?api-version={api_version}" ) From 7f3eccb1ae4625cb73188b63c58b0ed0f0ea6b66 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Fri, 28 Mar 2025 14:30:16 +0530 Subject: [PATCH 26/32] Added catalog content in metadata file --- .../disconnectedoperations/commands.py | 4 +-- .../disconnectedoperations/custom.py | 31 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py index e81396792b5..43ab51b66aa 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/commands.py @@ -71,7 +71,7 @@ def load_command_table(self, _): # Register the parent command group with self.command_group( - "edge disconnected-operation", + "edge", custom_command_type=custom_command_type, is_preview=True, ) as g: @@ -79,7 +79,7 @@ def load_command_table(self, _): # Register the subgroup and its commands with self.command_group( - "edge disconnected-operation edge-marketplace", + "edge disconnected-operation", custom_command_type=custom_command_type, is_preview=True, ) as g: diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 8c152482b97..002a2b3d632 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -26,6 +26,7 @@ def _get_management_endpoint(cli_ctx): return cloud.endpoints.resource_manager + def _handle_directory_cleanup(version_level_path, logger): """Helper function to clean up existing directory.""" import os @@ -74,7 +75,7 @@ def _download_icons(icon_uris, icon_path, logger): 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): +def _prepare_paths_and_metadata(output_folder, publisher_id, offer_id, sku, version_id, data, catalog_content, logger): """Helper function to prepare directories and save metadata.""" import json import os @@ -95,7 +96,7 @@ def _prepare_paths_and_metadata(output_folder, publisher_id, offer_id, sku, vers # Save metadata.json metadata_path = os.path.join(base_path, "metadata.json") with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) + json.dump(catalog_content, f, indent=2) logger.info("Saved metadata to %s", metadata_path) return None, version_level_path, icon_path @@ -142,7 +143,7 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, logger = get_logger(__name__) management_endpoint = _get_management_endpoint(cmd.cli_ctx) subscription_id = get_subscription_id(cmd.cli_ctx) - + catalog_api_version = "2021-06-01" # Construct URL with parameters url = ( f"{management_endpoint}" @@ -153,6 +154,12 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, f"?api-version={api_version}" ) + catalog_url = ( + f"https://catalogapi.azure.com" + f"/offers/{publisher_name}.{offer_name}" + f"?api-version={catalog_api_version}" + ) + try: response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") @@ -165,8 +172,20 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, "resource_group_name": resource_group_name, "response": response.text, } + + catalog_content = requests.get(catalog_url) + + if catalog_content.status_code != 200: + error_message = f"Catalog request failed with status code: {catalog_content.status_code}" + logger.error(error_message) + return { + "error": error_message, + "status": "failed", + "response": catalog_content.text, + } data = response.json() + catalog_data = catalog_content.json() offer_content = data.get("properties", {}).get("offerContent", {}) icon_uris = offer_content.get("iconFileUris", {}) @@ -184,7 +203,7 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, # 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 + output_folder, publisher_id, offer_id, sku, version_id, data, catalog_data, logger ) if result: # Error occurred @@ -419,7 +438,7 @@ def download_vhd(cmd, resource_group_name, resource_name, publisher_name, # Request body body = { - "edgeMarketPlaceRegion": "westus", + "edgeMarketPlaceRegion": "eastus", "hypervGeneration": generation, "marketPlaceSku": sku, "marketPlaceSkuVersion": version, @@ -536,7 +555,7 @@ def list_offers(cmd, resource_group_name, resource_name): def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_name): - """List all offers for disconnected operations.""" + """Get a specific for disconnected operations given its publisher and offer name""" import requests from knack.log import get_logger From d616d8691a951ec59a59747f62ae88d9dd4b1e1f Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Mon, 31 Mar 2025 17:42:45 +0530 Subject: [PATCH 27/32] Replacing endpoints with unpublished edge marketplace sdk functions --- .../disconnectedoperations/__init__.py | 11 + .../disconnectedoperations/_params.py | 4 +- .../latest/edge_marketplace/__cmd_group.py | 20 + .../aaz/latest/edge_marketplace/__init__.py | 11 + .../edge_marketplace/offer/__cmd_group.py | 20 + .../latest/edge_marketplace/offer/__init__.py | 15 + .../offer/_generate_access_token.py | 247 +++++++++ .../offer/_get_access_token.py | 188 +++++++ .../latest/edge_marketplace/offer/_list.py | 380 ++++++++++++++ .../latest/edge_marketplace/offer/_show.py | 340 ++++++++++++ .../disconnectedoperations/custom.py | 493 +++++++++--------- 11 files changed, 1480 insertions(+), 249 deletions(-) create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__cmd_group.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__init__.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_generate_access_token.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_get_access_token.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_list.py create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_show.py diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index 59ae79d9124..139e8d9b3ab 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -31,6 +31,17 @@ def load_command_table(self, args): from azure.cli.command_modules.disconnectedoperations.commands import ( load_command_table, ) + from azure.cli.core.aaz import load_aaz_command_table + try: + from . import aaz + except ImportError: + aaz = None + if aaz: + load_aaz_command_table( + loader=self, + aaz_pkg_name=aaz.__name__, + args=args + ) load_command_table(self, args) return self.command_table diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index d5fa03dade1..95380d65d10 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -22,7 +22,7 @@ def load_arguments(self, _): 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("offer_id", type=str, help="Name of the offer") c.argument("publisher_name", type=str, help="Name of the publisher") with self.argument_context( @@ -33,7 +33,7 @@ def load_arguments(self, _): "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("offer_id", 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( diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__cmd_group.py new file mode 100644 index 00000000000..727e9377e23 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__cmd_group.py @@ -0,0 +1,20 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class __CMDGroup(AAZCommandGroup): + """Manage Edge Marketplace + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__init__.py new file mode 100644 index 00000000000..5a9d61963d6 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/__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_marketplace/offer/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__cmd_group.py new file mode 100644 index 00000000000..42606e38b61 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__cmd_group.py @@ -0,0 +1,20 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class __CMDGroup(AAZCommandGroup): + """Manage Offer + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__init__.py new file mode 100644 index 00000000000..040c7a7d7a7 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/__init__.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. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._generate_access_token import * +from ._get_access_token import * +from ._list import * +from ._show import * diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_generate_access_token.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_generate_access_token.py new file mode 100644 index 00000000000..8f9c1a58d24 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_generate_access_token.py @@ -0,0 +1,247 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class GenerateAccessToken(AAZCommand): + """A long-running resource action. + """ + + _aaz_info = { + "version": "2023-08-01-preview", + "resources": [ + ["mgmt-plane", "/{resourceuri}/providers/microsoft.edgemarketplace/offers/{}/generateaccesstoken", "2023-08-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(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.offer_id = AAZStrArg( + options=["--offer-id"], + help="Id of the offer", + required=True, + ) + _args_schema.resource_uri = AAZStrArg( + options=["--resource-uri"], + help="The fully qualified Azure Resource manager identifier of the resource.", + required=True, + ) + + # define Arg Group "Body" + + _args_schema = cls._args_schema + _args_schema.device_sku = AAZStrArg( + options=["--device-sku"], + arg_group="Body", + help="The device sku.", + ) + _args_schema.device_version = AAZStrArg( + options=["--device-version"], + arg_group="Body", + help="The device sku version.", + ) + _args_schema.edge_market_place_region = AAZStrArg( + options=["--edge-market-place-region"], + arg_group="Body", + help="The region where the disk will be created.", + required=True, + ) + _args_schema.ege_market_place_resource_id = AAZStrArg( + options=["--ege-market-place-resource-id"], + arg_group="Body", + help="The region where the disk will be created.", + ) + _args_schema.hyperv_generation = AAZStrArg( + options=["--hyperv-generation"], + arg_group="Body", + help="The hyperv version.", + ) + _args_schema.market_place_sku = AAZStrArg( + options=["--market-place-sku"], + arg_group="Body", + help="The marketplace sku.", + ) + _args_schema.market_place_sku_version = AAZStrArg( + options=["--market-place-sku-version"], + arg_group="Body", + help="The marketplace sku version.", + ) + _args_schema.publisher_name = AAZStrArg( + options=["--publisher-name"], + arg_group="Body", + help="The name of the publisher.", + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.OffersGenerateAccessToken(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 OffersGenerateAccessToken(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 [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/{resourceUri}/providers/Microsoft.EdgeMarketplace/offers/{offerId}/generateAccessToken", + **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( + "offerId", self.ctx.args.offer_id, + required=True, + ), + **self.serialize_url_param( + "resourceUri", self.ctx.args.resource_uri, + skip_quote=True, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2023-08-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}} + ) + _builder.set_prop("deviceSku", AAZStrType, ".device_sku") + _builder.set_prop("deviceVersion", AAZStrType, ".device_version") + _builder.set_prop("edgeMarketPlaceRegion", AAZStrType, ".edge_market_place_region", typ_kwargs={"flags": {"required": True}}) + _builder.set_prop("egeMarketPlaceResourceId", AAZStrType, ".ege_market_place_resource_id") + _builder.set_prop("hypervGeneration", AAZStrType, ".hyperv_generation") + _builder.set_prop("marketPlaceSku", AAZStrType, ".market_place_sku") + _builder.set_prop("marketPlaceSkuVersion", AAZStrType, ".market_place_sku_version") + _builder.set_prop("publisherName", AAZStrType, ".publisher_name") + + return self.serialize_content(_content_value) + + 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.access_token = AAZStrType( + serialized_name="accessToken", + flags={"required": True}, + ) + _schema_on_200.disk_id = AAZStrType( + serialized_name="diskId", + ) + _schema_on_200.status = AAZStrType() + + return cls._schema_on_200 + + +class _GenerateAccessTokenHelper: + """Helper class for GenerateAccessToken""" + + +__all__ = ["GenerateAccessToken"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_get_access_token.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_get_access_token.py new file mode 100644 index 00000000000..45da835ab22 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_get_access_token.py @@ -0,0 +1,188 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class GetAccessToken(AAZCommand): + """get access token. + """ + + _aaz_info = { + "version": "2023-08-01-preview", + "resources": [ + ["mgmt-plane", "/{resourceuri}/providers/microsoft.edgemarketplace/offers/{}/getaccesstoken", "2023-08-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.offer_id = AAZStrArg( + options=["--offer-id"], + help="Id of the offer", + required=True, + ) + _args_schema.resource_uri = AAZStrArg( + options=["--resource-uri"], + help="The fully qualified Azure Resource manager identifier of the resource.", + required=True, + ) + + # define Arg Group "Body" + + _args_schema = cls._args_schema + _args_schema.request_id = AAZStrArg( + options=["--request-id"], + arg_group="Body", + help="The name of the publisher.", + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.OffersGetAccessToken(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 OffersGetAccessToken(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( + "/{resourceUri}/providers/Microsoft.EdgeMarketplace/offers/{offerId}/getAccessToken", + **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( + "offerId", self.ctx.args.offer_id, + required=True, + ), + **self.serialize_url_param( + "resourceUri", self.ctx.args.resource_uri, + skip_quote=True, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2023-08-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}} + ) + _builder.set_prop("requestId", AAZStrType, ".request_id", typ_kwargs={"flags": {"required": True}}) + + return self.serialize_content(_content_value) + + 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.access_token = AAZStrType( + serialized_name="accessToken", + flags={"required": True}, + ) + _schema_on_200.disk_id = AAZStrType( + serialized_name="diskId", + ) + _schema_on_200.status = AAZStrType() + + return cls._schema_on_200 + + +class _GetAccessTokenHelper: + """Helper class for GetAccessToken""" + + +__all__ = ["GetAccessToken"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_list.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_list.py new file mode 100644 index 00000000000..14cd016262c --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_list.py @@ -0,0 +1,380 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class List(AAZCommand): + """List Offer resources by parent + """ + + _aaz_info = { + "version": "2023-08-01-preview", + "resources": [ + ["mgmt-plane", "/{resourceuri}/providers/microsoft.edgemarketplace/offers", "2023-08-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_uri = AAZStrArg( + options=["--resource-uri"], + help="The fully qualified Azure Resource manager identifier of the resource.", + required=True, + ) + _args_schema.filter = AAZStrArg( + options=["--filter"], + help="Filter the result list using the given expression.", + ) + _args_schema.maxpagesize = AAZIntArg( + options=["--maxpagesize"], + help="The maximum number of result items per page.", + ) + _args_schema.skip = AAZIntArg( + options=["--skip"], + help="The number of result items to skip.", + default=0, + ) + _args_schema.skip_token = AAZStrArg( + options=["--skip-token"], + help="Skip over when retrieving results.", + ) + _args_schema.top = AAZIntArg( + options=["--top"], + help="The number of result items to return.", + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.OffersList(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 OffersList(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( + "/{resourceUri}/providers/Microsoft.EdgeMarketplace/offers", + **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( + "resourceUri", self.ctx.args.resource_uri, + skip_quote=True, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "$filter", self.ctx.args.filter, + ), + **self.serialize_query_param( + "$skipToken", self.ctx.args.skip_token, + ), + **self.serialize_query_param( + "$top", self.ctx.args.top, + ), + **self.serialize_query_param( + "maxpagesize", self.ctx.args.maxpagesize, + ), + **self.serialize_query_param( + "skip", self.ctx.args.skip, + ), + **self.serialize_query_param( + "api-version", "2023-08-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.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType( + flags={"client_flatten": True}, + ) + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.content_url = AAZStrType( + serialized_name="contentUrl", + ) + properties.content_version = AAZStrType( + serialized_name="contentVersion", + ) + properties.marketplace_skus = AAZListType( + serialized_name="marketplaceSkus", + ) + properties.offer_content = AAZObjectType( + serialized_name="offerContent", + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + ) + + marketplace_skus = cls._schema_on_200.value.Element.properties.marketplace_skus + marketplace_skus.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.marketplace_skus.Element + _element.catalog_plan_id = AAZStrType( + serialized_name="catalogPlanId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.display_name = AAZStrType( + serialized_name="displayName", + ) + _element.display_rank = AAZIntType( + serialized_name="displayRank", + ) + _element.generation = AAZStrType() + _element.long_summary = AAZStrType( + serialized_name="longSummary", + ) + _element.marketplace_sku_id = AAZStrType( + serialized_name="marketplaceSkuId", + flags={"required": True}, + ) + _element.marketplace_sku_versions = AAZListType( + serialized_name="marketplaceSkuVersions", + ) + _element.operating_system = AAZObjectType( + serialized_name="operatingSystem", + ) + _element.summary = AAZStrType() + _element.type = AAZStrType() + + marketplace_sku_versions = cls._schema_on_200.value.Element.properties.marketplace_skus.Element.marketplace_sku_versions + marketplace_sku_versions.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.marketplace_skus.Element.marketplace_sku_versions.Element + _element.minimum_download_size_in_mb = AAZIntType( + serialized_name="minimumDownloadSizeInMb", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.size_on_disk_in_mb = AAZIntType( + serialized_name="sizeOnDiskInMb", + ) + _element.stage_name = AAZStrType( + serialized_name="stageName", + ) + + operating_system = cls._schema_on_200.value.Element.properties.marketplace_skus.Element.operating_system + operating_system.family = AAZStrType() + operating_system.name = AAZStrType( + flags={"required": True}, + ) + operating_system.type = AAZStrType() + + offer_content = cls._schema_on_200.value.Element.properties.offer_content + offer_content.availability = AAZStrType() + offer_content.category_ids = AAZListType( + serialized_name="categoryIds", + ) + offer_content.description = AAZStrType() + offer_content.display_name = AAZStrType( + serialized_name="displayName", + flags={"required": True}, + ) + offer_content.icon_file_uris = AAZObjectType( + serialized_name="iconFileUris", + ) + offer_content.long_summary = AAZStrType( + serialized_name="longSummary", + ) + offer_content.offer_id = AAZStrType( + serialized_name="offerId", + flags={"required": True}, + ) + offer_content.offer_publisher = AAZObjectType( + serialized_name="offerPublisher", + ) + offer_content.offer_type = AAZStrType( + serialized_name="offerType", + ) + offer_content.operating_systems = AAZListType( + serialized_name="operatingSystems", + ) + offer_content.popularity = AAZIntType() + offer_content.release_type = AAZStrType( + serialized_name="releaseType", + ) + offer_content.summary = AAZStrType() + offer_content.support_uri = AAZStrType( + serialized_name="supportUri", + ) + offer_content.terms_and_conditions = AAZObjectType( + serialized_name="termsAndConditions", + ) + + category_ids = cls._schema_on_200.value.Element.properties.offer_content.category_ids + category_ids.Element = AAZStrType() + + icon_file_uris = cls._schema_on_200.value.Element.properties.offer_content.icon_file_uris + icon_file_uris.large = AAZStrType() + icon_file_uris.medium = AAZStrType() + icon_file_uris.small = AAZStrType() + icon_file_uris.wide = AAZStrType() + + offer_publisher = cls._schema_on_200.value.Element.properties.offer_content.offer_publisher + offer_publisher.publisher_display_name = AAZStrType( + serialized_name="publisherDisplayName", + flags={"required": True}, + ) + offer_publisher.publisher_id = AAZStrType( + serialized_name="publisherId", + flags={"required": True}, + ) + + operating_systems = cls._schema_on_200.value.Element.properties.offer_content.operating_systems + operating_systems.Element = AAZStrType() + + terms_and_conditions = cls._schema_on_200.value.Element.properties.offer_content.terms_and_conditions + terms_and_conditions.legal_terms_type = AAZStrType( + serialized_name="legalTermsType", + ) + terms_and_conditions.legal_terms_uri = AAZStrType( + serialized_name="legalTermsUri", + ) + terms_and_conditions.privacy_policy_uri = AAZStrType( + serialized_name="privacyPolicyUri", + ) + + 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", + ) + + 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_marketplace/offer/_show.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_show.py new file mode 100644 index 00000000000..8e08fd91896 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/aaz/latest/edge_marketplace/offer/_show.py @@ -0,0 +1,340 @@ +# -------------------------------------------------------------------------------------------- +# 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 * + + +class Show(AAZCommand): + """Get a Offer + """ + + _aaz_info = { + "version": "2023-08-01-preview", + "resources": [ + ["mgmt-plane", "/{resourceuri}/providers/microsoft.edgemarketplace/offers/{}", "2023-08-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.offer_id = AAZStrArg( + options=["--offer-id"], + help="Id of the offer", + required=True, + ) + _args_schema.resource_uri = AAZStrArg( + options=["--resource-uri"], + help="The fully qualified Azure Resource manager identifier of the resource.", + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.OffersGet(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 OffersGet(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( + "/{resourceUri}/providers/Microsoft.EdgeMarketplace/offers/{offerId}", + **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( + "offerId", self.ctx.args.offer_id, + required=True, + ), + **self.serialize_url_param( + "resourceUri", self.ctx.args.resource_uri, + skip_quote=True, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2023-08-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.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType( + flags={"client_flatten": True}, + ) + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.content_url = AAZStrType( + serialized_name="contentUrl", + ) + properties.content_version = AAZStrType( + serialized_name="contentVersion", + ) + properties.marketplace_skus = AAZListType( + serialized_name="marketplaceSkus", + ) + properties.offer_content = AAZObjectType( + serialized_name="offerContent", + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + ) + + marketplace_skus = cls._schema_on_200.properties.marketplace_skus + marketplace_skus.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.marketplace_skus.Element + _element.catalog_plan_id = AAZStrType( + serialized_name="catalogPlanId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.display_name = AAZStrType( + serialized_name="displayName", + ) + _element.display_rank = AAZIntType( + serialized_name="displayRank", + ) + _element.generation = AAZStrType() + _element.long_summary = AAZStrType( + serialized_name="longSummary", + ) + _element.marketplace_sku_id = AAZStrType( + serialized_name="marketplaceSkuId", + flags={"required": True}, + ) + _element.marketplace_sku_versions = AAZListType( + serialized_name="marketplaceSkuVersions", + ) + _element.operating_system = AAZObjectType( + serialized_name="operatingSystem", + ) + _element.summary = AAZStrType() + _element.type = AAZStrType() + + marketplace_sku_versions = cls._schema_on_200.properties.marketplace_skus.Element.marketplace_sku_versions + marketplace_sku_versions.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.marketplace_skus.Element.marketplace_sku_versions.Element + _element.minimum_download_size_in_mb = AAZIntType( + serialized_name="minimumDownloadSizeInMb", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.size_on_disk_in_mb = AAZIntType( + serialized_name="sizeOnDiskInMb", + ) + _element.stage_name = AAZStrType( + serialized_name="stageName", + ) + + operating_system = cls._schema_on_200.properties.marketplace_skus.Element.operating_system + operating_system.family = AAZStrType() + operating_system.name = AAZStrType( + flags={"required": True}, + ) + operating_system.type = AAZStrType() + + offer_content = cls._schema_on_200.properties.offer_content + offer_content.availability = AAZStrType() + offer_content.category_ids = AAZListType( + serialized_name="categoryIds", + ) + offer_content.description = AAZStrType() + offer_content.display_name = AAZStrType( + serialized_name="displayName", + flags={"required": True}, + ) + offer_content.icon_file_uris = AAZObjectType( + serialized_name="iconFileUris", + ) + offer_content.long_summary = AAZStrType( + serialized_name="longSummary", + ) + offer_content.offer_id = AAZStrType( + serialized_name="offerId", + flags={"required": True}, + ) + offer_content.offer_publisher = AAZObjectType( + serialized_name="offerPublisher", + ) + offer_content.offer_type = AAZStrType( + serialized_name="offerType", + ) + offer_content.operating_systems = AAZListType( + serialized_name="operatingSystems", + ) + offer_content.popularity = AAZIntType() + offer_content.release_type = AAZStrType( + serialized_name="releaseType", + ) + offer_content.summary = AAZStrType() + offer_content.support_uri = AAZStrType( + serialized_name="supportUri", + ) + offer_content.terms_and_conditions = AAZObjectType( + serialized_name="termsAndConditions", + ) + + category_ids = cls._schema_on_200.properties.offer_content.category_ids + category_ids.Element = AAZStrType() + + icon_file_uris = cls._schema_on_200.properties.offer_content.icon_file_uris + icon_file_uris.large = AAZStrType() + icon_file_uris.medium = AAZStrType() + icon_file_uris.small = AAZStrType() + icon_file_uris.wide = AAZStrType() + + offer_publisher = cls._schema_on_200.properties.offer_content.offer_publisher + offer_publisher.publisher_display_name = AAZStrType( + serialized_name="publisherDisplayName", + flags={"required": True}, + ) + offer_publisher.publisher_id = AAZStrType( + serialized_name="publisherId", + flags={"required": True}, + ) + + operating_systems = cls._schema_on_200.properties.offer_content.operating_systems + operating_systems.Element = AAZStrType() + + terms_and_conditions = cls._schema_on_200.properties.offer_content.terms_and_conditions + terms_and_conditions.legal_terms_type = AAZStrType( + serialized_name="legalTermsType", + ) + terms_and_conditions.legal_terms_uri = AAZStrType( + serialized_name="legalTermsUri", + ) + terms_and_conditions.privacy_policy_uri = AAZStrType( + serialized_name="privacyPolicyUri", + ) + + system_data = cls._schema_on_200.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", + ) + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + +__all__ = ["Show"] diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 002a2b3d632..372b58756df 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -9,7 +9,7 @@ # pylint: disable=too-many-statements provider_namespace = "Microsoft.Edge" -sub_provider = "Microsoft.EdgeMarketPlace" +sub_provider = "Microsoft.EdgeMarketplace" api_version = "2023-08-01-preview" @@ -131,7 +131,7 @@ def _find_sku_and_version(skus, sku, version, logger): def package_offer(cmd, resource_group_name, resource_name, publisher_name, - offer_name, sku, version, output_folder): + offer_id, sku, version, output_folder): """Get details of a specific marketplace offer and download its logos.""" import requests @@ -150,13 +150,13 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" + f"/providers/{sub_provider}/offers/{publisher_name}:{offer_id}" f"?api-version={api_version}" ) catalog_url = ( f"https://catalogapi.azure.com" - f"/offers/{publisher_name}.{offer_name}" + f"/offers/{publisher_name}.{offer_id}" f"?api-version={catalog_api_version}" ) @@ -219,7 +219,7 @@ def package_offer(cmd, resource_group_name, resource_name, publisher_name, # Downloading VM image return download_vhd( cmd, resource_group_name, resource_name, publisher_name, - offer_name, sku, version, generation, version_level_path + offer_id, sku, version, generation, version_level_path ) except requests.RequestException as e: @@ -250,18 +250,10 @@ def _check_azcopy_available(): def _handle_token_response(token_response, output_folder, logger): """Helper function to handle token response and download.""" - import os import platform + import subprocess - 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") + download_url = token_response.get("accessToken") # Check if azcopy is available if not _check_azcopy_available(): @@ -293,194 +285,217 @@ def _handle_token_response(token_response, output_folder, logger): } # Construct and execute azcopy command - command = f'azcopy copy "{download_url}" "{output_folder}" --check-md5 NoCheck' - print(command) + + print(f"Executing: azcopy copy [URL] {output_folder} --check-md5 NoCheck") 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"{management_endpoint}" - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" - f"/getAccessToken?api-version={api_version}" + # This will display output in real-time (just like os.system) + result = subprocess.run( + ["azcopy", "copy", download_url, output_folder, "--check-md5", "NoCheck"], + check=False # Don't raise exception on non-zero exit ) + if result.returncode == 0: + print("Download completed successfully.") + return { + "status": "succeeded", + "message": "Download completed successfully.", + } + else: + error_msg = f"AzCopy failed with return code: {result.returncode}" + logger.error(error_msg) + return { + "error": error_msg, + "status": "failed", + } + -def _process_async_operation(cmd, async_operation_url, logger, resource_group_name, - output_folder, subscription_id, resource_name, publisher_name, offer_name): +def _process_download_operation(cmd, async_operation_url, logger, resource_group_name, + output_folder, subscription_id, resource_name, publisher_name, offer_id): """Process async operation and monitor status.""" - import json - import time - from datetime import datetime - import requests + from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( + GetAccessToken, + ) 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"} + try: + # Get operation status - has to be raw request because this is an async operation - no swagger listing for this + status_response = send_raw_request( + cmd.cli_ctx, "get", async_operation_url, + resource="https://management.azure.com" + ) - print(f"Fetched request Id for VHD Download: {requestId}") + 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", + } - # 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} + status_data = status_response.json() + status = status_data.get("status", "").lower() + print("Current status:", status) - token_response = send_raw_request( - cmd.cli_ctx, "post", token_url, - resource="https://management.azure.com", - body=json.dumps(token_body) - ) + # Handle successful completion + if status == "succeeded": + logger.info("VHD download URL generation succeeded") + print(status_response) + requestId = status_data.get("properties", {}).get("requestId") - return _handle_token_response(token_response, output_folder, logger) + if not requestId: + logger.error("Download URL not found in response") + return {"error": "Download URL not found", "status": "failed"} - # 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"} + print(f"Fetched request Id for VHD Download: {requestId}") - # 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) + # Obtaining SAS token using request Id + resource_uri = ( + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" + ) + + # Create command arguments dictionary + command_args = { + "resource_uri": resource_uri, + "offer_id": offer_id, + "request_id": requestId + } - except (requests.RequestException, ValueError) as e: - logger.error("Error checking operation status: %s", str(e)) - delay = base_delay * (2**attempt) - time.sleep(delay) + token_command = GetAccessToken(cmd) + result = token_command(command_args=command_args) + return _handle_token_response(result, output_folder, logger) - # Exhausted all retries - logger.error("Maximum retry attempts reached") - return {"error": "Maximum retry attempts reached", "status": "failed"} + # 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"} + + except requests.RequestException as e: + logger.error("Failed to process async operation: %s", str(e)) + return { + "error": str(e), + "status": "failed", + } def download_vhd(cmd, resource_group_name, resource_name, publisher_name, - offer_name, sku, version, generation, output_folder): + offer_id, sku, version, generation, output_folder): """Generate access token for VHD download.""" - import json import requests from knack.log import get_logger + from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( + GenerateAccessToken, + ) from azure.cli.core.commands.client_factory import get_subscription_id - from azure.cli.core.util import send_raw_request + + class CustomGenerateAccessToken(GenerateAccessToken): + """Extended version of GenerateAccessToken that captures headers properly""" + + def _output(self, *args, **kwargs): + # Get the original result + result = super()._output(*args, **kwargs) + # Convert to dict if not already + if not isinstance(result, dict): + result = {} + # Add headers if they were captured in the ctx + if hasattr(self.ctx, 'captured_headers'): + result['_headers'] = self.ctx.captured_headers + return result + + class OffersGenerateAccessToken(GenerateAccessToken.OffersGenerateAccessToken): + def __init__(self, ctx): + super().__init__(ctx) + # Initialize headers on context + if not hasattr(ctx, 'captured_headers'): + ctx.captured_headers = {} + + def __call__(self, *args, **kwargs): + # Override the send_request method to capture headers + original_send_request = self.client.send_request + + def intercepted_send_request(request, **kwargs): + # Call the original method + response = original_send_request(request, **kwargs) + # Capture headers from the response + if hasattr(response, 'http_response') and hasattr(response.http_response, 'headers'): + headers = dict(response.http_response.headers) + # Store headers on the context object + self.ctx.captured_headers.update(headers) + + # Check for the specific header + if 'Azure-AsyncOperation' in headers: + print("✅ Captured Azure-AsyncOperation header") + return response + + # Replace the send_request method + self.client.send_request = intercepted_send_request + + try: + # Call the original method to get the poller + return super().__call__(*args, **kwargs) + finally: + # Restore the original send_request method + self.client.send_request = original_send_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"{management_endpoint}" + # Construct URL with parameters + # Create resource URI + resource_uri = ( f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/Microsoft.EdgeMarketPlace/offers/{publisher_name}:{offer_name}" - f"/generateAccessToken?api-version=2023-08-01-preview" + f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" ) - # Request body - body = { - "edgeMarketPlaceRegion": "eastus", - "hypervGeneration": generation, - "marketPlaceSku": sku, - "marketPlaceSkuVersion": version, - } + command_args = { + # Required URL parameters + "resource_uri": resource_uri, + "offer_id": publisher_name + ":" + offer_id, # Format required by the API + + # Required body parameters + "edge_market_place_region": "eastus", # Required in the body + + # Optional body parameters as needed + "hyperv_generation": generation, + "market_place_sku": sku, + "market_place_sku_version": version, + "publisher_name": publisher_name, + + # For long-running operations, you can set no_wait + "no_wait": False # Set to True if you don't want to wait for completion + } + 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 - ) + # Create and call the command + generate_token_command = CustomGenerateAccessToken(cmd) + poller = generate_token_command(command_args=command_args) + + print("Generating VHD download SAS token...") + # Wait for completion and get the result + result = poller.result() + + # Try to get headers from either the result or command object + headers = result.get('_headers') if isinstance(result, dict) else None + if not headers and hasattr(generate_token_command, '_headers'): + headers = generate_token_command._headers + + if headers: + async_op_url = headers.get('Azure-AsyncOperation') + if async_op_url: + # Process the async operation + return _process_download_operation( + cmd, async_op_url, logger, resource_group_name, + output_folder, subscription_id, resource_name, + publisher_name, offer_id + ) except requests.RequestException as e: logger.error("Failed to generate access token: %s", str(e)) @@ -496,54 +511,46 @@ def list_offers(cmd, resource_group_name, resource_name): import requests from knack.log import get_logger + from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( + List, + ) 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"{management_endpoint}" + resource_uri = ( f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/{sub_provider}/offers" - f"?api-version={api_version}" ) + command_args = { + "resource_uri": resource_uri, + } try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") + list_command = List(cmd) + result_items_iterator = list_command(command_args=command_args) - 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) + result_items = list(result_items_iterator) - return result + result = [] + for offer in result_items: + offer_content = offer.get("offerContent", {}) + skus = offer.get("marketplaceSkus", []) - 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, - } + 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 except requests.RequestException as e: logger.error("Failed to retrieve offers: %s", str(e)) @@ -554,71 +561,63 @@ def list_offers(cmd, resource_group_name, resource_name): } -def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_name): +def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_id): """Get a specific for disconnected operations given its publisher and offer name""" import requests from knack.log import get_logger + from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( + Show, + ) 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"{management_endpoint}" + # Create resource URI + resource_uri = ( f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/{sub_provider}/offers/{publisher_name}:{offer_name}" - f"?api-version={api_version}" + f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" ) + # Set up command arguments + command_args = { + "resource_uri": resource_uri, + "offer_id": publisher_name + ":" + offer_id # Format required by the API + } 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, - } + # Create and call the Show command + show_command = Show(cmd) # Pass the cmd object directly + show_result = show_command(command_args=command_args) # Returns the deserialized result + result = [] + offer_content = show_result.get("offerContent", {}) + skus = show_result.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 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, - } + } \ No newline at end of file From 2d26a6fb59e651b3bb69f785c051ff655c57af03 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 1 Apr 2025 13:05:43 +0530 Subject: [PATCH 28/32] Refactored packaging tool --- .../disconnectedoperations/_utils.py | 179 ++++ .../disconnectedoperations/custom.py | 791 ++++++++++-------- 2 files changed, 637 insertions(+), 333 deletions(-) create mode 100644 src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py new file mode 100644 index 00000000000..2ac546db726 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py @@ -0,0 +1,179 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Utility functions for disconnected operations module.""" + +import os +import platform +import shutil +import subprocess +from typing import Any, Dict, Optional + +import requests +from knack.log import get_logger + +# Constants +PROVIDER_NAMESPACE = "Microsoft.Edge" +SUB_PROVIDER = "Microsoft.EdgeMarketplace" +API_VERSION = "2023-08-01-preview" +CATALOG_API_VERSION = "2021-06-01" + +logger = get_logger(__name__) + +class OperationResult: + """Standard result object for operations.""" + + def __init__(self, success: bool, message: str = "", data: Any = None, error: str = ""): + self.success = success + self.message = message + self.data = data or {} + self.error = error + + def to_dict(self) -> Dict[str, Any]: + """Convert result to dictionary format.""" + result = { + "status": "succeeded" if self.success else "failed" + } + + if self.message: + result["message"] = self.message + + if self.error: + result["error"] = self.error + + if self.data: + result.update(self.data) + + return result + + +def get_management_endpoint(cli_ctx) -> str: + """Get management endpoint based on cloud configuration.""" + cloud = cli_ctx.cloud + + # Remove ending slash if exists + endpoint = cloud.endpoints.resource_manager + if endpoint.endswith("/"): + endpoint = endpoint[:-1] + + # Append https:// if not exists + if not endpoint.startswith("https://"): + endpoint = "https://" + endpoint + + return endpoint + + +def handle_directory_cleanup(path: str) -> Optional[OperationResult]: + """Clean up existing directory. + + Args: + path: Directory path to clean up + + Returns: + None if successful, OperationResult with error details if failed + """ + if os.path.exists(path): + try: + # Remove directory and all its contents + shutil.rmtree(path) + logger.info("Cleaned up existing directory: %s", path) + return None + except OSError as e: + error_message = f"Failed to clean up directory {path}: {str(e)}" + logger.error(error_message) + return OperationResult( + success=False, + error=error_message, + data={"path": path} + ) + return None + + +def download_file(url: str, file_path: str) -> bool: + """Download a file from URL to specified path. + + Args: + url: Source URL + file_path: Destination file path + + Returns: + True if successful, False otherwise + """ + try: + response = requests.get(url) + if response.status_code == 200: + with open(file_path, "wb") as f: + f.write(response.content) + logger.info("Downloaded file to %s", file_path) + return True + else: + logger.error("Failed to download file: %s", response.status_code) + return False + except requests.RequestException as e: + logger.error("Error downloading file: %s", str(e)) + return False + + +def is_azcopy_available() -> bool: + """Check if azcopy is available in the system path.""" + # 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 get_azcopy_install_info() -> Dict[str, str]: + """Get OS-specific AzCopy installation information.""" + system = platform.system().lower() + + if system == 'windows': + return { + "url": "https://aka.ms/downloadazcopy-v10-windows", + "instructions": "Download, extract the ZIP file, and add the extracted folder to your PATH." + } + elif system == 'linux': + return { + "url": "https://aka.ms/downloadazcopy-v10-linux", + "instructions": "Download, extract the tar.gz file, and move the azcopy binary to a directory in your PATH." + } + elif system == 'darwin': # macOS + return { + "url": "https://aka.ms/downloadazcopy-v10-mac", + "instructions": "Download, extract the .zip file, and move the azcopy binary to a directory in your PATH." + } + else: + return { + "url": "https://aka.ms/downloadazcopy", + "instructions": "Download and install AzCopy for your platform." + } + + +def construct_resource_uri(subscription_id: str, resource_group_name: str, resource_name: str) -> str: + """Construct a resource URI for disconnected operations. + + Args: + subscription_id: Azure subscription ID + resource_group_name: Resource group name + resource_name: Resource name + + Returns: + Resource URI string + """ + return ( + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{PROVIDER_NAMESPACE}/disconnectedOperations/{resource_name}" + ) \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index 372b58756df..c7c46d3140a 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -8,52 +8,40 @@ # pylint: disable=too-many-lines # pylint: disable=too-many-statements -provider_namespace = "Microsoft.Edge" -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 - # Remove ending slash if exists - if cloud.endpoints.resource_manager.endswith("/"): - cloud.endpoints.resource_manager = cloud.endpoints.resource_manager[:-1] - - # Append https:// if not exists - if not cloud.endpoints.resource_manager.startswith("https://"): - cloud.endpoints.resource_manager = "https://" + cloud.endpoints.resource_manager - - return cloud.endpoints.resource_manager - - -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 - +import json +import os +import subprocess +from typing import Any, Dict, List, Optional, Tuple + +import requests +from knack.log import get_logger + +from azure.cli.command_modules.disconnectedoperations._utils import ( + API_VERSION, + CATALOG_API_VERSION, + PROVIDER_NAMESPACE, + SUB_PROVIDER, + OperationResult, + construct_resource_uri, + download_file, + get_azcopy_install_info, + get_management_endpoint, + handle_directory_cleanup, + is_azcopy_available, +) +from azure.cli.core.commands.client_factory import get_subscription_id +from azure.cli.core.util import send_raw_request + +logger = get_logger(__name__) + + +def _download_icons(icon_uris: Dict[str, str], icon_path: str) -> None: + """Download icons from URIs to specified directory. + + Args: + icon_uris: Dictionary mapping size to icon URI + icon_path: Path to save icons + """ for size, uri in icon_uris.items(): file_extension = "png" file_path = os.path.join(icon_path, f"{size}.{file_extension}") @@ -63,30 +51,39 @@ def _download_icons(icon_uris, icon_path, logger): 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, catalog_content, logger): - """Helper function to prepare directories and save metadata.""" - import json - import os + download_file(uri, file_path) + +def _prepare_paths_and_metadata( + output_folder: str, + publisher_id: str, + offer_id: str, + sku: str, + version_id: str, + data: Dict[str, Any], + catalog_content: Dict[str, Any] +) -> Tuple[Optional[OperationResult], Optional[str], Optional[str]]: + """Prepare directories and save metadata. + + Args: + output_folder: Base output folder + publisher_id: Publisher ID + offer_id: Offer ID + sku: SKU ID + version_id: Version ID + data: Offer data + catalog_content: Catalog content data + + Returns: + Tuple of (error_result, version_path, icon_path) + """ # 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) + cleanup_result = handle_directory_cleanup(version_level_path) if cleanup_result: return cleanup_result, None, None @@ -102,8 +99,21 @@ def _prepare_paths_and_metadata(output_folder, publisher_id, offer_id, sku, vers return None, version_level_path, icon_path -def _find_sku_and_version(skus, sku, version, logger): - """Helper function to find matching SKU and version.""" +def _find_sku_and_version( + skus: List[Dict[str, Any]], + sku: str, + version: str +) -> Tuple[Optional[str], Optional[str]]: + """Find matching SKU and version. + + Args: + skus: List of SKUs + sku: SKU ID to find + version: Version to find + + Returns: + Tuple of (version_id, generation) + """ for _sku in skus: sku_id = _sku.get("marketplaceSkuId", "") if sku_id != sku: @@ -120,8 +130,8 @@ def _find_sku_and_version(skus, sku, version, logger): return None, None # print if version and generation are found - print("Found VM version: %s" % versions[0].get('name')) - print("VM Generation: %s" % generation) + print(f"Found VM version: {versions[0].get('name')}") + print(f"VM Generation: {generation}") version_id = versions[0].get("name") return version_id, generation @@ -130,166 +140,38 @@ def _find_sku_and_version(skus, sku, version, logger): return None, None -def package_offer(cmd, resource_group_name, resource_name, publisher_name, - offer_id, 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) - catalog_api_version = "2021-06-01" - # Construct URL with parameters - url = ( - f"{management_endpoint}" - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" - f"/providers/{sub_provider}/offers/{publisher_name}:{offer_id}" - f"?api-version={api_version}" - ) - - catalog_url = ( - f"https://catalogapi.azure.com" - f"/offers/{publisher_name}.{offer_id}" - f"?api-version={catalog_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, - } +def _handle_token_response(token_response: Dict[str, Any], output_folder: str) -> Dict[str, Any]: + """Handle token response and download content. + + Args: + token_response: Token response containing access token + output_folder: Folder to save downloaded content - catalog_content = requests.get(catalog_url) - - if catalog_content.status_code != 200: - error_message = f"Catalog request failed with status code: {catalog_content.status_code}" - logger.error(error_message) - return { - "error": error_message, - "status": "failed", - "response": catalog_content.text, - } - - data = response.json() - catalog_data = catalog_content.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, catalog_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_id, 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 platform - import subprocess - + Returns: + Operation result dictionary + """ download_url = token_response.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." - + if not is_azcopy_available(): + azcopy_info = get_azcopy_install_info() + 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}" + f"AzCopy tool not found. Please install AzCopy and make sure it's available in your PATH.\n" + f"Download link: {azcopy_info['url']}\n" + f"Installation: {azcopy_info['instructions']}" ) logger.error(error_message) - return { - "error": error_message, - "status": "failed", - "download_url": azcopy_url - } + return OperationResult( + success=False, + error=error_message, + data={"download_url": azcopy_info['url']} + ).to_dict() # Construct and execute azcopy command - print(f"Executing: azcopy copy [URL] {output_folder} --check-md5 NoCheck") - print("Executing command...") - # This will display output in real-time (just like os.system) + # This will display output in real-time result = subprocess.run( ["azcopy", "copy", download_url, output_folder, "--check-md5", "NoCheck"], check=False # Don't raise exception on non-zero exit @@ -297,64 +179,90 @@ def _handle_token_response(token_response, output_folder, logger): if result.returncode == 0: print("Download completed successfully.") - return { - "status": "succeeded", - "message": "Download completed successfully.", - } + return OperationResult( + success=True, + message="Download completed successfully." + ).to_dict() else: error_msg = f"AzCopy failed with return code: {result.returncode}" logger.error(error_msg) - return { - "error": error_msg, - "status": "failed", - } - - -def _process_download_operation(cmd, async_operation_url, logger, resource_group_name, - output_folder, subscription_id, resource_name, publisher_name, offer_id): - """Process async operation and monitor status.""" - import requests - + return OperationResult( + success=False, + error=error_msg + ).to_dict() + + +def _process_download_operation( + cmd, + async_operation_url: str, + resource_group_name: str, + output_folder: str, + subscription_id: str, + resource_name: str, + publisher_name: str, + offer_id: str +) -> Dict[str, Any]: + """Process async operation and monitor status. + + Args: + cmd: Command context object + async_operation_url: URL to check operation status + resource_group_name: Name of the resource group + output_folder: Folder to save downloaded content + subscription_id: Azure subscription ID + resource_name: Name of the disconnected operations resource + publisher_name: Marketplace publisher name + offer_id: Marketplace offer ID + + Returns: + Operation result dictionary + """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( GetAccessToken, ) - from azure.cli.core.util import send_raw_request try: - # Get operation status - has to be raw request because this is an async operation - no swagger listing for this + # Get operation status - has to be raw request because this is an async operation 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", - } + error_message = f"Status check failed: {status_response.status_code}" + logger.error(error_message) + return OperationResult( + success=False, + error=error_message, + data={ + "operation_url": async_operation_url, + "resource_group_name": resource_group_name + } + ).to_dict() status_data = status_response.json() status = status_data.get("status", "").lower() - print("Current status:", status) + logger.info("Current status: %s", 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"} + error_message = "Download URL not found in response" + logger.error(error_message) + return OperationResult( + success=False, + error=error_message, + data={"resource_group_name": resource_group_name} + ).to_dict() - print(f"Fetched request Id for VHD Download: {requestId}") + logger.info("Fetched request Id for VHD Download: %s", requestId) # Obtaining SAS token using request Id - resource_uri = ( - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" + resource_uri = construct_resource_uri( + subscription_id, resource_group_name, resource_name ) # Create command arguments dictionary @@ -366,34 +274,197 @@ def _process_download_operation(cmd, async_operation_url, logger, resource_group token_command = GetAccessToken(cmd) result = token_command(command_args=command_args) - return _handle_token_response(result, output_folder, logger) + return _handle_token_response(result, output_folder) # 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"} + return OperationResult( + success=False, + error=error_message, + data={"resource_group_name": resource_group_name} + ).to_dict() + + # If we get here, the operation is still in progress + return OperationResult( + success=True, + message=f"Operation status: {status}", + data={ + "status": "in_progress", + "operation_url": async_operation_url + } + ).to_dict() except requests.RequestException as e: - logger.error("Failed to process async operation: %s", str(e)) - return { - "error": str(e), - "status": "failed", - } + error_message = f"Failed to process async operation: {str(e)}" + logger.error(error_message) + return OperationResult( + success=False, + error=error_message, + data={ + "resource_group_name": resource_group_name, + "operation_url": async_operation_url + } + ).to_dict() + + +# Main command functions + +def package_offer( + cmd, + resource_group_name: str, + resource_name: str, + publisher_name: str, + offer_id: str, + sku: str, + version: str, + output_folder: str, + region: Optional[str] = None +) -> Dict[str, Any]: + """Get details of a specific marketplace offer and download its logos. + + Args: + cmd: Command context object + resource_group_name: Name of the resource group + resource_name: Name of the disconnected operations resource + publisher_name: Marketplace publisher name + offer_id: Marketplace offer ID + sku: Marketplace SKU + version: SKU version + output_folder: Folder to save downloaded content + region: Optional. Azure region to use for marketplace access + + Returns: + Operation result dictionary + """ + management_endpoint = get_management_endpoint(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + + # Construct URL with parameters + url = ( + f"{management_endpoint}" + f"/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group_name}" + f"/providers/{PROVIDER_NAMESPACE}/disconnectedOperations/{resource_name}" + f"/providers/{SUB_PROVIDER}/offers/{publisher_name}:{offer_id}" + f"?api-version={API_VERSION}" + ) + catalog_url = ( + f"https://catalogapi.azure.com" + f"/offers/{publisher_name}.{offer_id}" + f"?api-version={CATALOG_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 OperationResult( + success=False, + error=error_message, + data={ + "resource_group_name": resource_group_name, + "response": response.text + } + ).to_dict() + + catalog_content = requests.get(catalog_url) + + if catalog_content.status_code != 200: + error_message = f"Catalog request failed with status code: {catalog_content.status_code}" + logger.error(error_message) + return OperationResult( + success=False, + error=error_message, + data={"response": catalog_content.text} + ).to_dict() + + data = response.json() + catalog_data = catalog_content.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) + + if not version_id: + return OperationResult( + success=False, + error=f"Could not find version {version} for SKU {sku}" + ).to_dict() + + # 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, catalog_data + ) + + if result: # Error occurred + return result.to_dict() + + # Download icons + if icon_uris: + _download_icons(icon_uris, icon_path) -def download_vhd(cmd, resource_group_name, resource_name, publisher_name, - offer_id, sku, version, generation, output_folder): - """Generate access token for VHD download.""" + print("Metadata and icons downloaded successfully") + print("Offer details retrieved successfully. Proceeding to download VHD.") - import requests - from knack.log import get_logger + # Downloading VM image + return _download_vhd( + cmd, resource_group_name, resource_name, publisher_name, + offer_id, sku, version, generation, version_level_path, region + ) + except requests.RequestException as e: + logger.error("Failed to retrieve offer: %s", str(e)) + return OperationResult( + success=False, + error=str(e), + data={"resource_group_name": resource_group_name} + ).to_dict() + + +def _download_vhd( + cmd, + resource_group_name: str, + resource_name: str, + publisher_name: str, + offer_id: str, + sku: str, + version: str, + generation: str, + output_folder: str, + region: Optional[str] = None +) -> Dict[str, Any]: + """Generate access token for VHD download. + + Args: + cmd: Command context object + resource_group_name: Name of the resource group + resource_name: Name of the disconnected operations resource + publisher_name: Marketplace publisher name + offer_id: Marketplace offer ID + sku: Marketplace SKU + version: SKU version + generation: HyperV generation + output_folder: Folder to save downloaded content + region: Optional. Azure region to use for marketplace access + + Returns: + Operation result dictionary + """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( GenerateAccessToken, ) - from azure.cli.core.commands.client_factory import get_subscription_id - class CustomGenerateAccessToken(GenerateAccessToken): """Extended version of GenerateAccessToken that captures headers properly""" @@ -430,7 +501,7 @@ def intercepted_send_request(request, **kwargs): # Check for the specific header if 'Azure-AsyncOperation' in headers: - print("✅ Captured Azure-AsyncOperation header") + logger.info("✅ Captured Azure-AsyncOperation header") return response # Replace the send_request method @@ -442,35 +513,34 @@ def intercepted_send_request(request, **kwargs): finally: # Restore the original send_request method self.client.send_request = original_send_request - - logger = get_logger(__name__) + subscription_id = get_subscription_id(cmd.cli_ctx) - # Construct URL with parameters + # Determine region to use + region = _determine_region(cmd, region) + print(f"Using region {region} for marketplace access") + # Create resource URI - resource_uri = ( - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" + resource_uri = construct_resource_uri( + subscription_id, resource_group_name, resource_name ) - command_args = { - # Required URL parameters - "resource_uri": resource_uri, - "offer_id": publisher_name + ":" + offer_id, # Format required by the API - - # Required body parameters - "edge_market_place_region": "eastus", # Required in the body - - # Optional body parameters as needed - "hyperv_generation": generation, - "market_place_sku": sku, - "market_place_sku_version": version, - "publisher_name": publisher_name, - - # For long-running operations, you can set no_wait - "no_wait": False # Set to True if you don't want to wait for completion + # Required URL parameters + "resource_uri": resource_uri, + "offer_id": publisher_name + ":" + offer_id, # Format required by the API + + # Required body parameters + "edge_market_place_region": region, + + # Optional body parameters as needed + "hyperv_generation": generation, + "market_place_sku": sku, + "market_place_sku_version": version, + "publisher_name": publisher_name, + + # For long-running operations, you can set no_wait + "no_wait": False } try: @@ -478,7 +548,7 @@ def intercepted_send_request(request, **kwargs): generate_token_command = CustomGenerateAccessToken(cmd) poller = generate_token_command(command_args=command_args) - print("Generating VHD download SAS token...") + print("Generating VHD download SAS token... (This might take some time)") # Wait for completion and get the result result = poller.result() @@ -492,46 +562,88 @@ def intercepted_send_request(request, **kwargs): if async_op_url: # Process the async operation return _process_download_operation( - cmd, async_op_url, logger, resource_group_name, + cmd, async_op_url, resource_group_name, output_folder, subscription_id, resource_name, publisher_name, offer_id ) + # If we get here, couldn't find the async operation URL + return OperationResult( + success=False, + error="Could not find Azure-AsyncOperation header in response", + data={"resource_group_name": resource_group_name} + ).to_dict() + 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, - } + return OperationResult( + success=False, + error=str(e), + data={"resource_group_name": resource_group_name} + ).to_dict() -def list_offers(cmd, resource_group_name, resource_name): - """List all offers for disconnected operations.""" - import requests - from knack.log import get_logger +def _determine_region(cmd, region: Optional[str] = None) -> str: + """Determine region to use based on priorities. + + Args: + cmd: Command context object + region: Explicitly provided region + + Returns: + Region to use + """ + # Priority order: + # 1. Explicitly provided region parameter + # 2. Configuration setting + # 3. Current cloud's primary region + # 4. Fallback to eastus + if not region: + # Try to get from configuration + try: + region = cmd.cli_ctx.config.get('disconnectedoperations', 'default_region', None) + except (AttributeError, KeyError): + pass + + # If still not set, try to determine from cloud configuration + if not region: + # Get the current cloud configuration + cloud = cmd.cli_ctx.cloud + # Use the cloud's default region if available, or fall back to eastus + region = getattr(cloud, 'primary_endpoint_region', 'eastus') + + return region + +def list_offers(cmd, resource_group_name: str, resource_name: str) -> List[Dict[str, str]]: + """List all offers for disconnected operations. + + Args: + cmd: Command context object + resource_group_name: Name of the resource group + resource_name: Name of the disconnected operations resource + + Returns: + List of offer dictionaries + """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( - List, + List as OfferList, ) - from azure.cli.core.commands.client_factory import get_subscription_id - logger = get_logger(__name__) subscription_id = get_subscription_id(cmd.cli_ctx) # Construct URL with parameters - resource_uri = ( - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/{provider_namespace}/disconnectedOperations/{resource_name}" + resource_uri = construct_resource_uri( + subscription_id, resource_group_name, resource_name ) + command_args = { - "resource_uri": resource_uri, + "resource_uri": resource_uri, } + try: - list_command = List(cmd) + list_command = OfferList(cmd) result_items_iterator = list_command(command_args=command_args) - result_items = list(result_items_iterator) result = [] @@ -554,32 +666,41 @@ def list_offers(cmd, resource_group_name, resource_name): 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_id): - """Get a specific for disconnected operations given its publisher and offer name""" - import requests - from knack.log import get_logger - + return OperationResult( + success=False, + error=str(e), + data={"resource_group_name": resource_group_name} + ).to_dict() + + +def get_offer( + cmd, + resource_group_name: str, + resource_name: str, + publisher_name: str, + offer_id: str +) -> List[Dict[str, str]]: + """Get a specific offer for disconnected operations. + + Args: + cmd: Command context object + resource_group_name: Name of the resource group + resource_name: Name of the disconnected operations resource + publisher_name: Marketplace publisher name + offer_id: Marketplace offer ID + + Returns: + List of offer dictionaries with SKU details + """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( Show, ) - from azure.cli.core.commands.client_factory import get_subscription_id - logger = get_logger(__name__) subscription_id = get_subscription_id(cmd.cli_ctx) - # Construct URL with parameters # Create resource URI - resource_uri = ( - f"/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group_name}" - f"/providers/Microsoft.Edge/disconnectedOperations/{resource_name}" + resource_uri = construct_resource_uri( + subscription_id, resource_group_name, resource_name ) # Set up command arguments @@ -587,10 +708,12 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_id) "resource_uri": resource_uri, "offer_id": publisher_name + ":" + offer_id # Format required by the API } + try: # Create and call the Show command - show_command = Show(cmd) # Pass the cmd object directly - show_result = show_command(command_args=command_args) # Returns the deserialized result + show_command = Show(cmd) + show_result = show_command(command_args=command_args) + result = [] offer_content = show_result.get("offerContent", {}) skus = show_result.get("marketplaceSkus", []) @@ -613,11 +736,13 @@ def get_offer(cmd, resource_group_name, resource_name, publisher_name, offer_id) "OS_Type": sku.get("operatingSystem", {}).get("type"), } result.append(row) + return result + 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, - } \ No newline at end of file + logger.error("Failed to retrieve offer: %s", str(e)) + return OperationResult( + success=False, + error=str(e), + data={"resource_group_name": resource_group_name} + ).to_dict() \ No newline at end of file From ccf7ded2a824f1beefe521a6f734f8ec232b0e05 Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 1 Apr 2025 15:28:20 +0530 Subject: [PATCH 29/32] Fixed helpfile with recent changes --- .../disconnectedoperations/_help.py | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 021292824c0..22ce0ce0e7f 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -7,102 +7,108 @@ helps['edge'] = """ type: group -short-summary: Manage edge operations. +short-summary: Manage Azure Edge services and operations for edge computing scenarios. """ +# Remove the duplicate entry and improve the remaining one helps['edge disconnected-operation'] = """ type: group -short-summary: Manage edge disconnected operations. +short-summary: Manage Azure Edge Marketplace services for disconnected (offline) environments. +long-summary: Enables downloading and packaging of marketplace offerings for deployment in environments with limited or no internet connectivity. """ -helps['edge disconnected-operation edge-marketplace'] = """ +helps['edge disconnected-operation offer'] = """ type: group -short-summary: Manage Edge Marketplace for disconnected operations. +short-summary: Manage marketplace offers for disconnected Edge environments. +long-summary: View, download, and package marketplace VM images and solutions for deployment to disconnected edge environments. """ -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'] = """ +helps['edge disconnected-operation offer list'] = """ type: command -short-summary: List all available marketplace offers. +short-summary: List all available marketplace offers for disconnected operations. +long-summary: Retrieves a list of all VM images and solutions available in the marketplace that can be packaged for disconnected environments, including publisher, offer, SKU, and version information. 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 + az edge disconnected-operation 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 + az edge disconnected-operation 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']" + az edge disconnected-operation offer list -g myResourceGroup --resource-name myResource --query "[?OS_Type=='Linux']" parameters: - name: --resource-group -g type: string - short-summary: Name of resource group + short-summary: Name of resource group containing the disconnected operations resource - name: --resource-name type: string - short-summary: The resource name + short-summary: Name of the disconnected operations resource """ -helps['edge disconnected-operation edge-marketplace offer get'] = """ +helps['edge disconnected-operation offer get'] = """ type: command -short-summary: Get details of a specific marketplace offer. +short-summary: Get detailed information about a specific marketplace offer. +long-summary: Retrieves comprehensive details for a marketplace offer, including available SKUs, versions, OS type, and size information to help with disconnected environment planning. 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 + az edge disconnected-operation offer get --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-id 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 + az edge disconnected-operation offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-id 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}" + az edge disconnected-operation offer get -g myResourceGroup --resource-name myResource --publisher-name publisherName --offer-id offerName --query "[].{SKU:SKU,Version:Versions}" parameters: - name: --resource-group -g type: string - short-summary: Name of resource group + short-summary: Name of resource group containing the disconnected operations resource - name: --resource-name type: string - short-summary: The resource name + short-summary: Name of the disconnected operations resource - name: --publisher-name type: string - short-summary: The publisher name of the offer - - name: --offer-name + short-summary: Publisher name of the marketplace offer (e.g., 'MicrosoftWindowsServer') + - name: --offer-id type: string - short-summary: The name of the offer + short-summary: Offer identifier in the marketplace (e.g., 'WindowsServer') """ -helps['edge disconnected-operation edge-marketplace offer package'] = """ +helps['edge disconnected-operation 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. +short-summary: Download and package a marketplace VM image with its metadata for offline use. +long-summary: Creates a complete package containing the VM image (VHD), metadata, and icons for deployment in disconnected environments. The package can be transported to an air-gapped environment and used for VM deployment without internet connectivity. 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" + az edge disconnected-operation offer package --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-id offerName --sku skuName --version versionNumber --output-folder "D:\\MarketplacePackages" + - name: Package a marketplace offer with specific region + text: > + az edge disconnected-operation offer package --resource-group myResourceGroup --resource-name myResource --publisher-name publisherName --offer-id offerName --sku skuName --version versionNumber --output-folder "D:\\MarketplacePackages" --region eastus parameters: - name: --resource-group -g type: string - short-summary: Name of resource group + short-summary: Name of resource group containing the disconnected operations resource - name: --resource-name type: string - short-summary: The resource name + short-summary: Name of the disconnected operations resource - name: --publisher-name type: string - short-summary: The publisher name of the offer - - name: --offer-name + short-summary: Publisher name of the marketplace offer (e.g., 'MicrosoftWindowsServer') + - name: --offer-id type: string - short-summary: The name of the offer + short-summary: Offer identifier in the marketplace (e.g., 'WindowsServer') - name: --sku type: string - short-summary: The SKU of the offer + short-summary: SKU identifier for the specific offer variant (e.g., '2019-Datacenter') - name: --version type: string - short-summary: The version of the offer (optional, latest version will be used if not specified) + short-summary: Version of the marketplace offer to download (e.g., '17763.3287.2210110541') - name: --output-folder type: string - short-summary: The folder path where the package will be downloaded -""" + short-summary: Local folder path where the package contents will be downloaded and organized + - name: --region + type: string + short-summary: Azure region to use for marketplace access (e.g., 'eastus', 'westeurope') +""" \ No newline at end of file From da2925639930aaae785fb0bd9e316f11f8d75fcb Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 1 Apr 2025 16:08:05 +0530 Subject: [PATCH 30/32] fixed styling errors and tests --- .../disconnectedoperations/__init__.py | 4 +- .../disconnectedoperations/_client_factory.py | 6 +- .../disconnectedoperations/_help.py | 2 +- .../disconnectedoperations/_utils.py | 62 ++-- .../disconnectedoperations/custom.py | 324 ++++++++++-------- .../latest/test_disconnectedoperations.py | 233 +++++++------ 6 files changed, 350 insertions(+), 281 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py index 139e8d9b3ab..bd181e4def2 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/__init__.py @@ -6,8 +6,10 @@ # -------------------------------------------------------------------------------------------- from azure.cli.command_modules.disconnectedoperations._client_factory import cf_image + +# pylint: disable=unused-import from azure.cli.command_modules.disconnectedoperations._help import ( - helps, # pylint: disable=unused-import + helps, ) from azure.cli.core import AzCommandsLoader 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 index ab3fbf60d18..b02601ed52c 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_client_factory.py @@ -6,7 +6,11 @@ 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 + + # pylint: disable=import-error no-name-in-module + from azure.mgmt.disconnectedoperations import ( + DisconnectedOperationsClient, + ) return get_mgmt_service_client(cli_ctx, DisconnectedOperationsClient) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py index 22ce0ce0e7f..27e78b57bff 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_help.py @@ -111,4 +111,4 @@ - name: --region type: string short-summary: Azure region to use for marketplace access (e.g., 'eastus', 'westeurope') -""" \ No newline at end of file +""" diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py index 2ac546db726..2aa45e133d8 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_utils.py @@ -16,49 +16,51 @@ # Constants PROVIDER_NAMESPACE = "Microsoft.Edge" -SUB_PROVIDER = "Microsoft.EdgeMarketplace" +SUB_PROVIDER = "Microsoft.EdgeMarketplace" API_VERSION = "2023-08-01-preview" CATALOG_API_VERSION = "2021-06-01" logger = get_logger(__name__) + +# pylint: disable=too-few-public-methods class OperationResult: """Standard result object for operations.""" - + def __init__(self, success: bool, message: str = "", data: Any = None, error: str = ""): self.success = success self.message = message self.data = data or {} self.error = error - + def to_dict(self) -> Dict[str, Any]: """Convert result to dictionary format.""" result = { "status": "succeeded" if self.success else "failed" } - + if self.message: result["message"] = self.message - + if self.error: result["error"] = self.error - + if self.data: result.update(self.data) - + return result def get_management_endpoint(cli_ctx) -> str: """Get management endpoint based on cloud configuration.""" cloud = cli_ctx.cloud - + # Remove ending slash if exists endpoint = cloud.endpoints.resource_manager if endpoint.endswith("/"): endpoint = endpoint[:-1] - # Append https:// if not exists + # Append https:// if not exists if not endpoint.startswith("https://"): endpoint = "https://" + endpoint @@ -67,10 +69,10 @@ def get_management_endpoint(cli_ctx) -> str: def handle_directory_cleanup(path: str) -> Optional[OperationResult]: """Clean up existing directory. - + Args: path: Directory path to clean up - + Returns: None if successful, OperationResult with error details if failed """ @@ -93,11 +95,11 @@ def handle_directory_cleanup(path: str) -> Optional[OperationResult]: def download_file(url: str, file_path: str) -> bool: """Download a file from URL to specified path. - + Args: url: Source URL file_path: Destination file path - + Returns: True if successful, False otherwise """ @@ -108,9 +110,9 @@ def download_file(url: str, file_path: str) -> bool: f.write(response.content) logger.info("Downloaded file to %s", file_path) return True - else: - logger.error("Failed to download file: %s", response.status_code) - return False + + logger.error("Failed to download file: %s", response.status_code) + return False except requests.RequestException as e: logger.error("Error downloading file: %s", str(e)) return False @@ -125,9 +127,9 @@ def is_azcopy_available() -> bool: # Fallback to trying the command directly try: result = subprocess.run( - ["azcopy", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + ["azcopy", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False ) return result.returncode == 0 @@ -138,37 +140,37 @@ def is_azcopy_available() -> bool: def get_azcopy_install_info() -> Dict[str, str]: """Get OS-specific AzCopy installation information.""" system = platform.system().lower() - + if system == 'windows': return { "url": "https://aka.ms/downloadazcopy-v10-windows", "instructions": "Download, extract the ZIP file, and add the extracted folder to your PATH." } - elif system == 'linux': + if system == 'linux': return { "url": "https://aka.ms/downloadazcopy-v10-linux", "instructions": "Download, extract the tar.gz file, and move the azcopy binary to a directory in your PATH." } - elif system == 'darwin': # macOS + if system == 'darwin': # macOS return { "url": "https://aka.ms/downloadazcopy-v10-mac", "instructions": "Download, extract the .zip file, and move the azcopy binary to a directory in your PATH." } - else: - return { - "url": "https://aka.ms/downloadazcopy", - "instructions": "Download and install AzCopy for your platform." - } + + return { + "url": "https://aka.ms/downloadazcopy", + "instructions": "Download and install AzCopy for your platform." + } def construct_resource_uri(subscription_id: str, resource_group_name: str, resource_name: str) -> str: """Construct a resource URI for disconnected operations. - + Args: subscription_id: Azure subscription ID resource_group_name: Resource group name resource_name: Resource name - + Returns: Resource URI string """ @@ -176,4 +178,4 @@ def construct_resource_uri(subscription_id: str, resource_group_name: str, resou f"/subscriptions/{subscription_id}" f"/resourceGroups/{resource_group_name}" f"/providers/{PROVIDER_NAMESPACE}/disconnectedOperations/{resource_name}" - ) \ No newline at end of file + ) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index c7c46d3140a..dc8e16f1995 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -37,7 +37,7 @@ def _download_icons(icon_uris: Dict[str, str], icon_path: str) -> None: """Download icons from URIs to specified directory. - + Args: icon_uris: Dictionary mapping size to icon URI icon_path: Path to save icons @@ -48,23 +48,25 @@ def _download_icons(icon_uris: Dict[str, str], icon_path: str) -> None: # Skip if icon already exists if os.path.exists(file_path): - logger.info("Icon %s already exists at %s, skipping download", size, file_path) + logger.info( + "Icon %s already exists at %s, skipping download", size, file_path + ) continue download_file(uri, file_path) +# pylint: disable=too-many-arguments, too-many-locals def _prepare_paths_and_metadata( - output_folder: str, - publisher_id: str, - offer_id: str, - sku: str, - version_id: str, - data: Dict[str, Any], - catalog_content: Dict[str, Any] + output_folder: str, + publisher_id: str, + offer_id: str, + sku: str, + version_id: str, + catalog_content: Dict[str, Any], ) -> Tuple[Optional[OperationResult], Optional[str], Optional[str]]: """Prepare directories and save metadata. - + Args: output_folder: Base output folder publisher_id: Publisher ID @@ -73,12 +75,14 @@ def _prepare_paths_and_metadata( version_id: Version ID data: Offer data catalog_content: Catalog content data - + Returns: Tuple of (error_result, version_path, icon_path) """ # Create base path for this version - base_path = os.path.join(output_folder, "catalog_artifacts", publisher_id, offer_id, sku) + 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") @@ -100,17 +104,15 @@ def _prepare_paths_and_metadata( def _find_sku_and_version( - skus: List[Dict[str, Any]], - sku: str, - version: str + skus: List[Dict[str, Any]], sku: str, version: str ) -> Tuple[Optional[str], Optional[str]]: """Find matching SKU and version. - + Args: skus: List of SKUs sku: SKU ID to find version: Version to find - + Returns: Tuple of (version_id, generation) """ @@ -140,13 +142,15 @@ def _find_sku_and_version( return None, None -def _handle_token_response(token_response: Dict[str, Any], output_folder: str) -> Dict[str, Any]: +def _handle_token_response( + token_response: Dict[str, Any], output_folder: str +) -> Dict[str, Any]: """Handle token response and download content. - + Args: token_response: Token response containing access token output_folder: Folder to save downloaded content - + Returns: Operation result dictionary """ @@ -155,7 +159,7 @@ def _handle_token_response(token_response: Dict[str, Any], output_folder: str) - # Check if azcopy is available if not is_azcopy_available(): azcopy_info = get_azcopy_install_info() - + error_message = ( f"AzCopy tool not found. Please install AzCopy and make sure it's available in your PATH.\n" f"Download link: {azcopy_info['url']}\n" @@ -165,7 +169,7 @@ def _handle_token_response(token_response: Dict[str, Any], output_folder: str) - return OperationResult( success=False, error=error_message, - data={"download_url": azcopy_info['url']} + data={"download_url": azcopy_info["url"]}, ).to_dict() # Construct and execute azcopy command @@ -174,36 +178,32 @@ def _handle_token_response(token_response: Dict[str, Any], output_folder: str) - # This will display output in real-time result = subprocess.run( ["azcopy", "copy", download_url, output_folder, "--check-md5", "NoCheck"], - check=False # Don't raise exception on non-zero exit + check=False, # Don't raise exception on non-zero exit ) if result.returncode == 0: print("Download completed successfully.") return OperationResult( - success=True, - message="Download completed successfully." - ).to_dict() - else: - error_msg = f"AzCopy failed with return code: {result.returncode}" - logger.error(error_msg) - return OperationResult( - success=False, - error=error_msg + success=True, message="Download completed successfully." ).to_dict() + error_msg = f"AzCopy failed with return code: {result.returncode}" + logger.error(error_msg) + return OperationResult(success=False, error=error_msg).to_dict() + +# pylint: disable=too-many-arguments, too-many-locals def _process_download_operation( - cmd, - async_operation_url: str, + cmd, + async_operation_url: str, resource_group_name: str, - output_folder: str, - subscription_id: str, - resource_name: str, - publisher_name: str, - offer_id: str + output_folder: str, + subscription_id: str, + resource_name: str, + offer_id: str, ) -> Dict[str, Any]: """Process async operation and monitor status. - + Args: cmd: Command context object async_operation_url: URL to check operation status @@ -213,7 +213,7 @@ def _process_download_operation( resource_name: Name of the disconnected operations resource publisher_name: Marketplace publisher name offer_id: Marketplace offer ID - + Returns: Operation result dictionary """ @@ -221,11 +221,13 @@ def _process_download_operation( GetAccessToken, ) - try: + try: # Get operation status - has to be raw request because this is an async operation status_response = send_raw_request( - cmd.cli_ctx, "get", async_operation_url, - resource="https://management.azure.com" + cmd.cli_ctx, + "get", + async_operation_url, + resource="https://management.azure.com", ) if status_response.status_code not in (200, 202): @@ -236,8 +238,8 @@ def _process_download_operation( error=error_message, data={ "operation_url": async_operation_url, - "resource_group_name": resource_group_name - } + "resource_group_name": resource_group_name, + }, ).to_dict() status_data = status_response.json() @@ -255,7 +257,7 @@ def _process_download_operation( return OperationResult( success=False, error=error_message, - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() logger.info("Fetched request Id for VHD Download: %s", requestId) @@ -264,12 +266,12 @@ def _process_download_operation( resource_uri = construct_resource_uri( subscription_id, resource_group_name, resource_name ) - + # Create command arguments dictionary command_args = { "resource_uri": resource_uri, "offer_id": offer_id, - "request_id": requestId + "request_id": requestId, } token_command = GetAccessToken(cmd) @@ -283,19 +285,16 @@ def _process_download_operation( return OperationResult( success=False, error=error_message, - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() - + # If we get here, the operation is still in progress return OperationResult( - success=True, + success=True, message=f"Operation status: {status}", - data={ - "status": "in_progress", - "operation_url": async_operation_url - } + data={"status": "in_progress", "operation_url": async_operation_url}, ).to_dict() - + except requests.RequestException as e: error_message = f"Failed to process async operation: {str(e)}" logger.error(error_message) @@ -304,26 +303,27 @@ def _process_download_operation( error=error_message, data={ "resource_group_name": resource_group_name, - "operation_url": async_operation_url - } + "operation_url": async_operation_url, + }, ).to_dict() # Main command functions +# pylint: disable=too-many-arguments, too-many-locals def package_offer( - cmd, - resource_group_name: str, - resource_name: str, + cmd, + resource_group_name: str, + resource_name: str, publisher_name: str, - offer_id: str, - sku: str, - version: str, + offer_id: str, + sku: str, + version: str, output_folder: str, - region: Optional[str] = None + region: Optional[str] = None, ) -> Dict[str, Any]: """Get details of a specific marketplace offer and download its logos. - + Args: cmd: Command context object resource_group_name: Name of the resource group @@ -334,13 +334,13 @@ def package_offer( version: SKU version output_folder: Folder to save downloaded content region: Optional. Azure region to use for marketplace access - + Returns: Operation result dictionary """ management_endpoint = get_management_endpoint(cmd.cli_ctx) subscription_id = get_subscription_id(cmd.cli_ctx) - + # Construct URL with parameters url = ( f"{management_endpoint}" @@ -358,7 +358,9 @@ def package_offer( ) try: - response = send_raw_request(cmd.cli_ctx, "get", url, resource="https://management.azure.com") + 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}" @@ -368,10 +370,10 @@ def package_offer( error=error_message, data={ "resource_group_name": resource_group_name, - "response": response.text - } + "response": response.text, + }, ).to_dict() - + catalog_content = requests.get(catalog_url) if catalog_content.status_code != 200: @@ -380,7 +382,7 @@ def package_offer( return OperationResult( success=False, error=error_message, - data={"response": catalog_content.text} + data={"response": catalog_content.text}, ).to_dict() data = response.json() @@ -390,7 +392,9 @@ def package_offer( # Download logos and metadata if output folder is specified if output_folder: - publisher_id = offer_content.get("offerPublisher", {}).get("publisherId", "") + publisher_id = offer_content.get("offerPublisher", {}).get( + "publisherId", "" + ) offer_id = offer_content.get("offerId", "") skus = data.get("properties", {}).get("marketplaceSkus", []) @@ -400,12 +404,17 @@ def package_offer( if not version_id: return OperationResult( success=False, - error=f"Could not find version {version} for SKU {sku}" + error=f"Could not find version {version} for SKU {sku}", ).to_dict() # 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, catalog_data + output_folder, + publisher_id, + offer_id, + sku, + version_id, + catalog_data, ) if result: # Error occurred @@ -420,8 +429,16 @@ def package_offer( # Downloading VM image return _download_vhd( - cmd, resource_group_name, resource_name, publisher_name, - offer_id, sku, version, generation, version_level_path, region + cmd, + resource_group_name, + resource_name, + publisher_name, + offer_id, + sku, + version, + generation, + version_level_path, + region, ) except requests.RequestException as e: @@ -429,24 +446,24 @@ def package_offer( return OperationResult( success=False, error=str(e), - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() def _download_vhd( - cmd, - resource_group_name: str, - resource_name: str, + cmd, + resource_group_name: str, + resource_name: str, publisher_name: str, - offer_id: str, - sku: str, - version: str, - generation: str, - output_folder: str, - region: Optional[str] = None + offer_id: str, + sku: str, + version: str, + generation: str, + output_folder: str, + region: Optional[str] = None, ) -> Dict[str, Any]: """Generate access token for VHD download. - + Args: cmd: Command context object resource_group_name: Name of the resource group @@ -458,16 +475,17 @@ def _download_vhd( generation: HyperV generation output_folder: Folder to save downloaded content region: Optional. Azure region to use for marketplace access - + Returns: Operation result dictionary """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( GenerateAccessToken, ) + class CustomGenerateAccessToken(GenerateAccessToken): """Extended version of GenerateAccessToken that captures headers properly""" - + def _output(self, *args, **kwargs): # Get the original result result = super()._output(*args, **kwargs) @@ -475,45 +493,47 @@ def _output(self, *args, **kwargs): if not isinstance(result, dict): result = {} # Add headers if they were captured in the ctx - if hasattr(self.ctx, 'captured_headers'): - result['_headers'] = self.ctx.captured_headers + if hasattr(self.ctx, "captured_headers"): + result["headers"] = self.ctx.captured_headers return result - + class OffersGenerateAccessToken(GenerateAccessToken.OffersGenerateAccessToken): def __init__(self, ctx): super().__init__(ctx) # Initialize headers on context - if not hasattr(ctx, 'captured_headers'): + if not hasattr(ctx, "captured_headers"): ctx.captured_headers = {} - + def __call__(self, *args, **kwargs): # Override the send_request method to capture headers original_send_request = self.client.send_request - + def intercepted_send_request(request, **kwargs): # Call the original method response = original_send_request(request, **kwargs) # Capture headers from the response - if hasattr(response, 'http_response') and hasattr(response.http_response, 'headers'): + if hasattr(response, "http_response") and hasattr( + response.http_response, "headers" + ): headers = dict(response.http_response.headers) # Store headers on the context object self.ctx.captured_headers.update(headers) - + # Check for the specific header - if 'Azure-AsyncOperation' in headers: + if "Azure-AsyncOperation" in headers: logger.info("✅ Captured Azure-AsyncOperation header") return response - + # Replace the send_request method self.client.send_request = intercepted_send_request - + try: # Call the original method to get the poller return super().__call__(*args, **kwargs) finally: # Restore the original send_request method self.client.send_request = original_send_request - + subscription_id = get_subscription_id(cmd.cli_ctx) # Determine region to use @@ -529,23 +549,20 @@ def intercepted_send_request(request, **kwargs): # Required URL parameters "resource_uri": resource_uri, "offer_id": publisher_name + ":" + offer_id, # Format required by the API - # Required body parameters "edge_market_place_region": region, - # Optional body parameters as needed "hyperv_generation": generation, "market_place_sku": sku, "market_place_sku_version": version, "publisher_name": publisher_name, - # For long-running operations, you can set no_wait - "no_wait": False + "no_wait": False, } - + try: # Create and call the command - generate_token_command = CustomGenerateAccessToken(cmd) + generate_token_command = CustomGenerateAccessToken(cmd) poller = generate_token_command(command_args=command_args) print("Generating VHD download SAS token... (This might take some time)") @@ -553,25 +570,29 @@ def intercepted_send_request(request, **kwargs): result = poller.result() # Try to get headers from either the result or command object - headers = result.get('_headers') if isinstance(result, dict) else None - if not headers and hasattr(generate_token_command, '_headers'): - headers = generate_token_command._headers + headers = result.get("headers") if isinstance(result, dict) else None + if not headers and hasattr(generate_token_command, "headers"): + headers = generate_token_command.headers if headers: - async_op_url = headers.get('Azure-AsyncOperation') + async_op_url = headers.get("Azure-AsyncOperation") if async_op_url: # Process the async operation return _process_download_operation( - cmd, async_op_url, resource_group_name, - output_folder, subscription_id, resource_name, - publisher_name, offer_id + cmd, + async_op_url, + resource_group_name, + output_folder, + subscription_id, + resource_name, + offer_id, ) # If we get here, couldn't find the async operation URL return OperationResult( success=False, error="Could not find Azure-AsyncOperation header in response", - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() except requests.RequestException as e: @@ -579,17 +600,17 @@ def intercepted_send_request(request, **kwargs): return OperationResult( success=False, error=str(e), - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() def _determine_region(cmd, region: Optional[str] = None) -> str: """Determine region to use based on priorities. - + Args: cmd: Command context object region: Explicitly provided region - + Returns: Region to use """ @@ -601,28 +622,32 @@ def _determine_region(cmd, region: Optional[str] = None) -> str: if not region: # Try to get from configuration try: - region = cmd.cli_ctx.config.get('disconnectedoperations', 'default_region', None) + region = cmd.cli_ctx.config.get( + "disconnectedoperations", "default_region", None + ) except (AttributeError, KeyError): pass - + # If still not set, try to determine from cloud configuration if not region: # Get the current cloud configuration cloud = cmd.cli_ctx.cloud # Use the cloud's default region if available, or fall back to eastus - region = getattr(cloud, 'primary_endpoint_region', 'eastus') - + region = getattr(cloud, "primary_endpoint_region", "eastus") + return region -def list_offers(cmd, resource_group_name: str, resource_name: str) -> List[Dict[str, str]]: +def list_offers( + cmd, resource_group_name: str, resource_name: str +) -> List[Dict[str, str]]: """List all offers for disconnected operations. - + Args: cmd: Command context object resource_group_name: Name of the resource group resource_name: Name of the disconnected operations resource - + Returns: List of offer dictionaries """ @@ -636,11 +661,11 @@ def list_offers(cmd, resource_group_name: str, resource_name: str) -> List[Dict[ resource_uri = construct_resource_uri( subscription_id, resource_group_name, resource_name ) - + command_args = { "resource_uri": resource_uri, } - + try: list_command = OfferList(cmd) result_items_iterator = list_command(command_args=command_args) @@ -654,7 +679,9 @@ def list_offers(cmd, resource_group_name: str, resource_name: str) -> List[Dict[ for sku in skus: versions = sku.get("marketplaceSkuVersions", [])[:] row = { - "Publisher": offer_content.get("offerPublisher", {}).get("publisherId"), + "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", @@ -669,33 +696,33 @@ def list_offers(cmd, resource_group_name: str, resource_name: str) -> List[Dict[ return OperationResult( success=False, error=str(e), - data={"resource_group_name": resource_group_name} + data={"resource_group_name": resource_group_name}, ).to_dict() def get_offer( - cmd, - resource_group_name: str, - resource_name: str, - publisher_name: str, - offer_id: str + cmd, + resource_group_name: str, + resource_name: str, + publisher_name: str, + offer_id: str, ) -> List[Dict[str, str]]: """Get a specific offer for disconnected operations. - + Args: cmd: Command context object resource_group_name: Name of the resource group resource_name: Name of the disconnected operations resource publisher_name: Marketplace publisher name offer_id: Marketplace offer ID - + Returns: List of offer dictionaries with SKU details """ from azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer import ( Show, ) - + subscription_id = get_subscription_id(cmd.cli_ctx) # Create resource URI @@ -706,14 +733,14 @@ def get_offer( # Set up command arguments command_args = { "resource_uri": resource_uri, - "offer_id": publisher_name + ":" + offer_id # Format required by the API + "offer_id": publisher_name + ":" + offer_id, # Format required by the API } - + try: # Create and call the Show command show_command = Show(cmd) show_result = show_command(command_args=command_args) - + result = [] offer_content = show_result.get("offerContent", {}) skus = show_result.get("marketplaceSkus", []) @@ -724,7 +751,8 @@ def get_offer( # 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 + f"{v.get('name')}({v.get('minimumDownloadSizeInMb')}MB)" + for v in versions ) # Create a single row with flattened version info @@ -736,13 +764,13 @@ def get_offer( "OS_Type": sku.get("operatingSystem", {}).get("type"), } result.append(row) - + return result - + except requests.RequestException as e: logger.error("Failed to retrieve offer: %s", str(e)) return OperationResult( success=False, error=str(e), - data={"resource_group_name": resource_group_name} - ).to_dict() \ No newline at end of file + data={"resource_group_name": resource_group_name}, + ).to_dict() 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 index f2455cb8df3..6c90e441d4a 100644 --- 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 @@ -6,9 +6,12 @@ import unittest from unittest import mock -import requests - from azure.cli.command_modules.disconnectedoperations import custom +from azure.cli.command_modules.disconnectedoperations._utils import ( + OperationResult, + get_management_endpoint, + handle_directory_cleanup, +) from azure.cli.testsdk import ResourceGroupPreparer, ScenarioTest @@ -24,9 +27,9 @@ def setUp(self): 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) + """Test get_management_endpoint returns the resource manager endpoint""" + endpoint = get_management_endpoint(self.mock_cli_ctx) + self.assertEqual(endpoint, "https://" + self.mock_cloud.endpoints.resource_manager) @mock.patch('os.path.exists') @mock.patch('shutil.rmtree') @@ -34,11 +37,10 @@ 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) + result = handle_directory_cleanup('/test/path') 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') @@ -48,106 +50,102 @@ def test_handle_directory_cleanup_error(self, mock_rmtree, mock_exists): mock_exists.return_value = True mock_rmtree.side_effect = OSError("Test error") - result = custom._handle_directory_cleanup('/test/path', self.mock_logger) + result = handle_directory_cleanup('/test/path') 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) + self.assertIsInstance(result, OperationResult) + self.assertFalse(result.success) + self.assertIsNotNone(result.error) @mock.patch('os.path.exists') - @mock.patch('requests.get') - def test_download_icons_success(self, mock_get, mock_exists): + @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') + def test_download_icons_success(self, mock_download_file, 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 + mock_download_file.return_value = True # 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") + # Test + custom._download_icons(icons, '/test/icons') - # 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 download_file was called correctly + mock_download_file.assert_called_once_with("http://example.com/small.png", mock.ANY) + # Verify file path in the call ends with the expected name + actual_path = mock_download_file.call_args[0][1] + self.assertTrue(actual_path.endswith('small.png')) + + @mock.patch('os.path.exists') + @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') + def test_download_icons_already_exists(self, mock_download_file, mock_exists): + """Test icon download when file already exists""" + # Setup mocks + mock_exists.return_value = True - # Verify the mode is correct - self.assertEqual(actual_mode, 'wb') + # Setup test data + icons = {"small": "http://example.com/small.png"} - # 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)) + # Test + custom._download_icons(icons, '/test/icons') - # 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() + # Verify download not called when file exists + mock_download_file.assert_not_called() @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""" + @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') + def test_download_icons_download_error(self, mock_download_file, mock_exists): + """Test icon download with download error""" # Setup mocks mock_exists.return_value = False - mock_get.side_effect = requests.RequestException("Connection error") # Use the imported requests module + mock_download_file.return_value = False # Setup test data icons = {"small": "http://example.com/small.png"} # Test - custom._download_icons(icons, '/test/icons', self.mock_logger) + custom._download_icons(icons, '/test/icons') - # Verify - mock_get.assert_called_once_with("http://example.com/small.png") - self.mock_logger.error.assert_called_once() + # Verify download attempt was made + mock_download_file.assert_called_once_with("http://example.com/small.png", mock.ANY) @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): + @mock.patch('azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer.List') + def test_list_offers_success(self, mock_list_command, 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 the List command and its returned iterator + mock_command_instance = mock.MagicMock() + mock_list_command.return_value = mock_command_instance + + # Create sample offers data + offers_data = [ + { + "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 + ] + } + ] + + # Set up the iterator to return our offers + mock_command_instance.return_value = offers_data # Test - result = custom.list_offers(self.mock_cmd, "test-rg", "test-resource") + with mock.patch('azure.cli.command_modules.disconnectedoperations._utils.construct_resource_uri') as mock_uri: + mock_uri.return_value = "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Edge/disconnectedOperations/test-resource" + result = custom.list_offers(self.mock_cmd, "test-rg", "test-resource") # Verify self.assertEqual(len(result), 1) @@ -157,36 +155,41 @@ def test_list_offers_success(self, mock_send_raw_request, mock_get_subscription_ 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): + @mock.patch('azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer.Show') + def test_get_offer_success(self, mock_show_command, 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 the Show command and its return value + mock_command_instance = mock.MagicMock() + mock_show_command.return_value = mock_command_instance + + # Create sample offer data + offer_data = { + "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 + + # Set up the return value for the command + mock_command_instance.return_value = offer_data # Test - result = custom.get_offer(self.mock_cmd, "test-rg", "test-resource", "test-publisher", "test-offer") + with mock.patch('azure.cli.command_modules.disconnectedoperations._utils.construct_resource_uri') as mock_uri: + mock_uri.return_value = "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Edge/disconnectedOperations/test-resource" + result = custom.get_offer(self.mock_cmd, "test-rg", "test-resource", "test-publisher", "test-offer") # Verify self.assertEqual(len(result), 1) @@ -194,6 +197,7 @@ def test_get_offer_success(self, mock_send_raw_request, mock_get_subscription_id self.assertEqual(result[0]["Offer"], "test-offer") self.assertEqual(result[0]["SKU"], "test-sku") self.assertIn("1.0(100MB)", result[0]["Versions"]) + self.assertIn("2.0(200MB)", result[0]["Versions"]) @mock.patch('azure.cli.core.commands.client_factory.get_subscription_id') @mock.patch('azure.cli.core.util.send_raw_request') @@ -218,6 +222,34 @@ def test_package_offer_not_found(self, mock_send_raw_request, mock_get_subscript self.assertIn("error", result) self.assertEqual(result["resource_group_name"], "test-rg") + @mock.patch('azure.cli.command_modules.disconnectedoperations._utils._determine_region') + @mock.patch('custom._find_sku_and_version') + def test_determine_region(self, mock_find_sku, mock_determine_region): + """Test _determine_region function""" + # Setup mock + mock_determine_region.return_value = "westus" + + # Test explicit region + region = custom._determine_region(self.mock_cmd, "eastus") + self.assertEqual(region, "eastus") + + # Test config region + self.mock_cli_ctx.config.get.return_value = "centralus" + region = custom._determine_region(self.mock_cmd, None) + self.assertEqual(region, "centralus") + + # Test cloud default + self.mock_cli_ctx.config.get.side_effect = KeyError() + self.mock_cloud.primary_endpoint_region = "northeurope" + region = custom._determine_region(self.mock_cmd, None) + self.assertEqual(region, "northeurope") + + # Test fallback + self.mock_cli_ctx.config.get.side_effect = KeyError() + delattr(self.mock_cloud, 'primary_endpoint_region') + region = custom._determine_region(self.mock_cmd, None) + self.assertEqual(region, "eastus") + class DisconnectedOperationsScenarioTests(ScenarioTest): @ResourceGroupPreparer(name_prefix='cli_test_disconnectedops') @@ -235,9 +267,10 @@ def test_list_offers(self, 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() + offers = self.cmd('az edge disconnected-operation offer list --resource-group {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""" @@ -253,7 +286,7 @@ def test_get_offer(self, resource_group): # 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() + result = self.cmd('az edge disconnected-operation offer get --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {offer}').get_output_in_json() self.assertIsNotNone(result) # Verify specific values in output for a real test @@ -270,10 +303,10 @@ def test_get_offer_with_resource_group_name_parameter(self, resource_group): # 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}') + self.cmd('az databoxedge device create --resource-group {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() + result = self.cmd('az edge disconnected-operation offer get --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {offer}').get_output_in_json() self.assertIsNotNone(result) # In a real test with actual data, we would add more specific assertions @@ -293,7 +326,7 @@ def test_package_offer_with_resource_group_name_parameter(self, resource_group): 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() + result = self.cmd('az edge disconnected-operation offer package --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {offer} --sku {sku} --version {version} --output-folder {output_folder}').get_output_in_json() self.assertIsNotNone(result) if __name__ == '__main__': From 838066f0764f04082e14c1ab29610241c4e7487d Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Thu, 3 Apr 2025 20:54:57 +0530 Subject: [PATCH 31/32] Refactored code --- .../disconnectedoperations/_params.py | 6 ++ .../disconnectedoperations/custom.py | 97 +++++++++++++------ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index 95380d65d10..cf324334642 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -41,3 +41,9 @@ def load_arguments(self, _): type=str, help="Drive and directory to save the package to. Example: E:\\ or D:\\packages\\", ) + c.argument( + "region", + type=str, + help="Azure region to use for marketplace access. If not specified, the current cloud's primary region will be used.", + required=False, + ) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index dc8e16f1995..f8b35a63afe 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -35,25 +35,36 @@ logger = get_logger(__name__) -def _download_icons(icon_uris: Dict[str, str], icon_path: str) -> None: +def _download_icons( + icon_uris: Dict[str, str], + icon_path: str, + file_downloader=download_file, # Inject file_downloader + path_exists=os.path.exists, # Inject os.path.exists + file_extension: str = "png", # Added file_extension parameter +) -> None: """Download icons from URIs to specified directory. Args: icon_uris: Dictionary mapping size to icon URI icon_path: Path to save icons + file_downloader: Function to download files (for mocking) + path_exists: Function to check if path exists (for mocking) """ 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): + if path_exists(file_path): logger.info( "Icon %s already exists at %s, skipping download", size, file_path ) continue - download_file(uri, file_path) + try: + file_downloader(uri, file_path) + except requests.RequestException as e: + logger.error(f"Failed to download icon from {uri}: {e}") + # Consider raising the exception or returning an error status # pylint: disable=too-many-arguments, too-many-locals @@ -64,6 +75,8 @@ def _prepare_paths_and_metadata( sku: str, version_id: str, catalog_content: Dict[str, Any], + makedirs=os.makedirs, # Inject os.makedirs + file_open=open, # Inject open ) -> Tuple[Optional[OperationResult], Optional[str], Optional[str]]: """Prepare directories and save metadata. @@ -75,6 +88,8 @@ def _prepare_paths_and_metadata( version_id: Version ID data: Offer data catalog_content: Catalog content data + makedirs: Function to create directories (for mocking) + file_open: Function to open files (for mocking) Returns: Tuple of (error_result, version_path, icon_path) @@ -91,14 +106,24 @@ def _prepare_paths_and_metadata( if cleanup_result: return cleanup_result, None, None - os.makedirs(icon_path, exist_ok=True) - os.makedirs(version_level_path, exist_ok=True) + try: + makedirs(icon_path, exist_ok=True) + makedirs(version_level_path, exist_ok=True) + except OSError as e: + error_message = f"Failed to create directories: {e}" + logger.error(error_message) + return OperationResult(success=False, error=error_message), None, None # Save metadata.json metadata_path = os.path.join(base_path, "metadata.json") - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(catalog_content, f, indent=2) - logger.info("Saved metadata to %s", metadata_path) + try: + with file_open(metadata_path, "w", encoding="utf-8") as f: + json.dump(catalog_content, f, indent=2) + logger.info("Saved metadata to %s", metadata_path) + except OSError as e: + error_message = f"Failed to write metadata file: {e}" + logger.error(error_message) + return OperationResult(success=False, error=error_message), None, None return None, version_level_path, icon_path @@ -143,13 +168,20 @@ def _find_sku_and_version( def _handle_token_response( - token_response: Dict[str, Any], output_folder: str + token_response: Dict[str, Any], + output_folder: str, + subprocess_run=subprocess.run, # Inject subprocess.run + is_azcopy_available_func=is_azcopy_available, # Inject is_azcopy_available + get_azcopy_install_info_func=get_azcopy_install_info, # Inject get_azcopy_install_info ) -> Dict[str, Any]: """Handle token response and download content. Args: token_response: Token response containing access token output_folder: Folder to save downloaded content + subprocess_run: Function to run subprocesses (for mocking) + is_azcopy_available_func: Function to check AzCopy availability (for mocking) + get_azcopy_install_info_func: Function to get AzCopy install info (for mocking) Returns: Operation result dictionary @@ -157,8 +189,8 @@ def _handle_token_response( download_url = token_response.get("accessToken") # Check if azcopy is available - if not is_azcopy_available(): - azcopy_info = get_azcopy_install_info() + if not is_azcopy_available_func(): + azcopy_info = get_azcopy_install_info_func() error_message = ( f"AzCopy tool not found. Please install AzCopy and make sure it's available in your PATH.\n" @@ -176,20 +208,25 @@ def _handle_token_response( print(f"Executing: azcopy copy [URL] {output_folder} --check-md5 NoCheck") # This will display output in real-time - result = subprocess.run( - ["azcopy", "copy", download_url, output_folder, "--check-md5", "NoCheck"], - check=False, # Don't raise exception on non-zero exit - ) + try: + result = subprocess_run( + ["azcopy", "copy", download_url, output_folder, "--check-md5", "NoCheck"], + check=False, # Don't raise exception on non-zero exit + ) - if result.returncode == 0: - print("Download completed successfully.") - return OperationResult( - success=True, message="Download completed successfully." - ).to_dict() + if result.returncode == 0: + print("Download completed successfully.") + return OperationResult( + success=True, message="Download completed successfully." + ).to_dict() - error_msg = f"AzCopy failed with return code: {result.returncode}" - logger.error(error_msg) - return OperationResult(success=False, error=error_msg).to_dict() + error_msg = f"AzCopy failed with return code: {result.returncode}" + logger.error(error_msg) + return OperationResult(success=False, error=error_msg).to_dict() + except OSError as e: + error_msg = f"Failed to execute AzCopy: {e}" + logger.error(error_msg) + return OperationResult(success=False, error=error_msg).to_dict() # pylint: disable=too-many-arguments, too-many-locals @@ -201,6 +238,7 @@ def _process_download_operation( subscription_id: str, resource_name: str, offer_id: str, + send_raw_request_func=send_raw_request, # Inject send_raw_request ) -> Dict[str, Any]: """Process async operation and monitor status. @@ -213,6 +251,7 @@ def _process_download_operation( resource_name: Name of the disconnected operations resource publisher_name: Marketplace publisher name offer_id: Marketplace offer ID + send_raw_request_func: Function to send raw requests (for mocking) Returns: Operation result dictionary @@ -223,7 +262,7 @@ def _process_download_operation( try: # Get operation status - has to be raw request because this is an async operation - status_response = send_raw_request( + status_response = send_raw_request_func( cmd.cli_ctx, "get", async_operation_url, @@ -321,6 +360,8 @@ def package_offer( version: str, output_folder: str, region: Optional[str] = None, + send_raw_request_func=send_raw_request, # Inject send_raw_request + requests_get=requests.get, # Inject requests.get ) -> Dict[str, Any]: """Get details of a specific marketplace offer and download its logos. @@ -334,6 +375,8 @@ def package_offer( version: SKU version output_folder: Folder to save downloaded content region: Optional. Azure region to use for marketplace access + send_raw_request_func: Function to send raw requests (for mocking) + requests_get: Function to perform GET requests (for mocking) Returns: Operation result dictionary @@ -358,7 +401,7 @@ def package_offer( ) try: - response = send_raw_request( + response = send_raw_request_func( cmd.cli_ctx, "get", url, resource="https://management.azure.com" ) @@ -374,7 +417,7 @@ def package_offer( }, ).to_dict() - catalog_content = requests.get(catalog_url) + catalog_content = requests_get(catalog_url) if catalog_content.status_code != 200: error_message = f"Catalog request failed with status code: {catalog_content.status_code}" From 0484e93e7bb943009d737e60791a6806203fda4f Mon Sep 17 00:00:00 2001 From: Sankalp Kotewar Date: Tue, 8 Apr 2025 15:26:16 +0530 Subject: [PATCH 32/32] Unit tests fixes --- .../disconnectedoperations/_params.py | 2 +- .../disconnectedoperations/custom.py | 18 +- .../latest/test_disconnectedoperations.py | 355 ++++++------------ 3 files changed, 122 insertions(+), 253 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py index cf324334642..befcc657151 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/_params.py @@ -44,6 +44,6 @@ def load_arguments(self, _): c.argument( "region", type=str, - help="Azure region to use for marketplace access. If not specified, the current cloud's primary region will be used.", + help="Azure region to use for marketplace access. If not specified, the current cloud's primary region will be used.", # pylint: disable=line-too-long required=False, ) diff --git a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py index f8b35a63afe..71136bec354 100644 --- a/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py +++ b/src/azure-cli/azure/cli/command_modules/disconnectedoperations/custom.py @@ -63,7 +63,7 @@ def _download_icons( try: file_downloader(uri, file_path) except requests.RequestException as e: - logger.error(f"Failed to download icon from {uri}: {e}") + logger.error("Failed to download icon from %s: %s", uri, str(e)) # Consider raising the exception or returning an error status @@ -237,8 +237,7 @@ def _process_download_operation( output_folder: str, subscription_id: str, resource_name: str, - offer_id: str, - send_raw_request_func=send_raw_request, # Inject send_raw_request + offer_id: str ) -> Dict[str, Any]: """Process async operation and monitor status. @@ -251,7 +250,6 @@ def _process_download_operation( resource_name: Name of the disconnected operations resource publisher_name: Marketplace publisher name offer_id: Marketplace offer ID - send_raw_request_func: Function to send raw requests (for mocking) Returns: Operation result dictionary @@ -262,7 +260,7 @@ def _process_download_operation( try: # Get operation status - has to be raw request because this is an async operation - status_response = send_raw_request_func( + status_response = send_raw_request( cmd.cli_ctx, "get", async_operation_url, @@ -359,9 +357,7 @@ def package_offer( sku: str, version: str, output_folder: str, - region: Optional[str] = None, - send_raw_request_func=send_raw_request, # Inject send_raw_request - requests_get=requests.get, # Inject requests.get + region: Optional[str] = None ) -> Dict[str, Any]: """Get details of a specific marketplace offer and download its logos. @@ -375,8 +371,6 @@ def package_offer( version: SKU version output_folder: Folder to save downloaded content region: Optional. Azure region to use for marketplace access - send_raw_request_func: Function to send raw requests (for mocking) - requests_get: Function to perform GET requests (for mocking) Returns: Operation result dictionary @@ -401,7 +395,7 @@ def package_offer( ) try: - response = send_raw_request_func( + response = send_raw_request( cmd.cli_ctx, "get", url, resource="https://management.azure.com" ) @@ -417,7 +411,7 @@ def package_offer( }, ).to_dict() - catalog_content = requests_get(catalog_url) + catalog_content = requests.get(catalog_url) if catalog_content.status_code != 200: error_message = f"Catalog request failed with status code: {catalog_content.status_code}" 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 index 6c90e441d4a..872e8659c9f 100644 --- 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 @@ -1,11 +1,9 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - +import os import unittest from unittest import mock +import requests + from azure.cli.command_modules.disconnectedoperations import custom from azure.cli.command_modules.disconnectedoperations._utils import ( OperationResult, @@ -17,317 +15,194 @@ 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 = get_management_endpoint(self.mock_cli_ctx) - self.assertEqual(endpoint, "https://" + self.mock_cloud.endpoints.resource_manager) - + self.assertEqual(endpoint, "https://management.azure.com") + @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 = handle_directory_cleanup('/test/path') - mock_exists.assert_called_once_with('/test/path') mock_rmtree.assert_called_once_with('/test/path') 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 = handle_directory_cleanup('/test/path') - mock_exists.assert_called_once_with('/test/path') mock_rmtree.assert_called_once_with('/test/path') self.assertIsInstance(result, OperationResult) self.assertFalse(result.success) self.assertIsNotNone(result.error) - - @mock.patch('os.path.exists') - @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') - def test_download_icons_success(self, mock_download_file, mock_exists): - """Test icon download success path""" - # Setup mocks - mock_exists.return_value = False - mock_download_file.return_value = True - - # Setup test data + + def test_download_icons_success(self): + mock_exists = mock.MagicMock(return_value=False) + mock_download_file = mock.MagicMock() + icons = {"small": "http://example.com/small.png"} - - # Test - custom._download_icons(icons, '/test/icons') - - # Verify download_file was called correctly - mock_download_file.assert_called_once_with("http://example.com/small.png", mock.ANY) - # Verify file path in the call ends with the expected name - actual_path = mock_download_file.call_args[0][1] - self.assertTrue(actual_path.endswith('small.png')) - - @mock.patch('os.path.exists') - @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') - def test_download_icons_already_exists(self, mock_download_file, mock_exists): - """Test icon download when file already exists""" - # Setup mocks - mock_exists.return_value = True - - # Setup test data + custom._download_icons( + icons, '/test/icons', + file_downloader=mock_download_file, + path_exists=mock_exists + ) + + expected_path = os.path.join('/test/icons', 'small.png') + mock_download_file.assert_called_once_with("http://example.com/small.png", expected_path) + + def test_download_icons_already_exists(self): + mock_exists = mock.MagicMock(return_value=True) + mock_download_file = mock.MagicMock() + icons = {"small": "http://example.com/small.png"} - - # Test - custom._download_icons(icons, '/test/icons') - - # Verify download not called when file exists + custom._download_icons( + icons, '/test/icons', + file_downloader=mock_download_file, + path_exists=mock_exists + ) + mock_download_file.assert_not_called() - - @mock.patch('os.path.exists') - @mock.patch('azure.cli.command_modules.disconnectedoperations._utils.download_file') - def test_download_icons_download_error(self, mock_download_file, mock_exists): - """Test icon download with download error""" - # Setup mocks - mock_exists.return_value = False - mock_download_file.return_value = False - - # Setup test data + + def test_download_icons_download_error(self): + mock_exists = mock.MagicMock(return_value=False) + mock_download_file = mock.MagicMock(side_effect=requests.RequestException("Download failed")) + icons = {"small": "http://example.com/small.png"} + custom._download_icons( + icons, '/test/icons', + file_downloader=mock_download_file, + path_exists=mock_exists + ) + + expected_path = os.path.join('/test/icons', 'small.png') + mock_download_file.assert_called_once_with("http://example.com/small.png", expected_path) + + def test_list_offers_transformation(self): + """Test the transformation logic of list_offers without calling API.""" + # Mock API response data + api_data = [{ + "offerContent": { + "offerPublisher": {"publisherId": "test-publisher"}, + "offerId": "test-offer" + }, + "marketplaceSkus": [{ + "marketplaceSkuId": "test-sku", + "marketplaceSkuVersions": ["1.0", "2.0"], + "operatingSystem": {"type": "Windows"} + }] + }] + + # Test transformation logic only + result = [] + for offer in api_data: + offer_content = offer.get("offerContent", {}) + skus = offer.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) - # Test - custom._download_icons(icons, '/test/icons') - - # Verify download attempt was made - mock_download_file.assert_called_once_with("http://example.com/small.png", mock.ANY) - - @mock.patch('azure.cli.core.commands.client_factory.get_subscription_id') - @mock.patch('azure.cli.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer.List') - def test_list_offers_success(self, mock_list_command, mock_get_subscription_id): - """Test list_offers success path""" - # Setup mocks - mock_get_subscription_id.return_value = "test-subscription" - - # Mock the List command and its returned iterator - mock_command_instance = mock.MagicMock() - mock_list_command.return_value = mock_command_instance - - # Create sample offers data - offers_data = [ - { - "offerContent": { - "offerPublisher": {"publisherId": "test-publisher"}, - "offerId": "test-offer" - }, - "marketplaceSkus": [ - { - "marketplaceSkuId": "test-sku", - "marketplaceSkuVersions": ["1.0", "2.0"], - "operatingSystem": {"type": "Windows"} - } - ] - } - ] - - # Set up the iterator to return our offers - mock_command_instance.return_value = offers_data - - # Test - with mock.patch('azure.cli.command_modules.disconnectedoperations._utils.construct_resource_uri') as mock_uri: - mock_uri.return_value = "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Edge/disconnectedOperations/test-resource" - result = custom.list_offers(self.mock_cmd, "test-rg", "test-resource") - - # Verify + # Assertions 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.command_modules.disconnectedoperations.aaz.latest.edge_marketplace.offer.Show') - def test_get_offer_success(self, mock_show_command, mock_get_subscription_id): - """Test get_offer success path""" - # Setup mocks - mock_get_subscription_id.return_value = "test-subscription" - - # Mock the Show command and its return value - mock_command_instance = mock.MagicMock() - mock_show_command.return_value = mock_command_instance - - # Create sample offer data - offer_data = { + def test_get_offer_transformation(self): + """Test the transformation logic of get_offer without calling API.""" + # Mock API response data + api_data = { "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"} - } - ] + "marketplaceSkus": [{ + "marketplaceSkuId": "test-sku", + "marketplaceSkuVersions": [ + {"name": "1.0", "minimumDownloadSizeInMb": 100}, + {"name": "2.0", "minimumDownloadSizeInMb": 200} + ], + "operatingSystem": {"type": "Windows"} + }] } - # Set up the return value for the command - mock_command_instance.return_value = offer_data - - # Test - with mock.patch('azure.cli.command_modules.disconnectedoperations._utils.construct_resource_uri') as mock_uri: - mock_uri.return_value = "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Edge/disconnectedOperations/test-resource" - result = custom.get_offer(self.mock_cmd, "test-rg", "test-resource", "test-publisher", "test-offer") + # Test transformation logic only + result = [] + offer_content = api_data.get("offerContent", {}) + skus = api_data.get("marketplaceSkus", []) + + for sku in skus: + # Get all versions for this SKU + versions = sku.get("marketplaceSkuVersions", [])[:] + + # Transform versions and size array into a 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) - # Verify + # Assertions 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"]) self.assertIn("2.0(200MB)", 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") - - @mock.patch('azure.cli.command_modules.disconnectedoperations._utils._determine_region') - @mock.patch('custom._find_sku_and_version') - def test_determine_region(self, mock_find_sku, mock_determine_region): - """Test _determine_region function""" - # Setup mock - mock_determine_region.return_value = "westus" - - # Test explicit region - region = custom._determine_region(self.mock_cmd, "eastus") - self.assertEqual(region, "eastus") - - # Test config region - self.mock_cli_ctx.config.get.return_value = "centralus" - region = custom._determine_region(self.mock_cmd, None) - self.assertEqual(region, "centralus") - - # Test cloud default - self.mock_cli_ctx.config.get.side_effect = KeyError() - self.mock_cloud.primary_endpoint_region = "northeurope" - region = custom._determine_region(self.mock_cmd, None) - self.assertEqual(region, "northeurope") - - # Test fallback - self.mock_cli_ctx.config.get.side_effect = KeyError() - delattr(self.mock_cloud, 'primary_endpoint_region') - region = custom._determine_region(self.mock_cmd, None) - self.assertEqual(region, "eastus") 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 edge disconnected-operation offer list --resource-group {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 edge disconnected-operation offer get --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {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 {resource_group} --name {resource}') - - # Test with the full --resource-group-name parameter - result = self.cmd('az edge disconnected-operation offer get --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {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 edge disconnected-operation offer package --resource-group {resource_group} --resource-name {resource} --publisher-name {publisher} --offer-id {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