From c36e78d388e2cc2d4fa7570c43ae83d1fd155888 Mon Sep 17 00:00:00 2001 From: Bob Jacobs Date: Fri, 5 Apr 2024 14:32:05 -0400 Subject: [PATCH] feat: Jenny/ma submission (#228) * initial scaffolding of submission v1 call * fixing clients setup * added http method to get reseller_configuration_branch * adding notes * added in method to get variant resources * updates to submission * adding returned submission mapping * cleanup * removing print from factory * code cleanup * light refactoring * style fixes * adding comment for design choice clarity * removed print statement * adding CLI error on unsupported offer types * fixing list and get commands * removing print statement * removing dead code and adding timeout to client * modifying general exception to CLIError --------- Co-authored-by: Jenny Chen --- .../clients/offer_client.py | 2 +- .../clients/offer_submission_client.py | 215 ++++++++++++++++-- .../models/application_submission.py | 45 ++++ .../models/pending_update_info.py | 22 ++ .../models/submission_publish_option.py | 22 ++ .../models/submission_variant_resource.py | 18 ++ .../azext_partnercenter/models/type_value.py | 18 ++ .../marketplace_offer_submission/custom.py | 3 +- ...stion_api_models_submissions_submission.py | 2 +- 9 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 partnercenter/azext_partnercenter/models/application_submission.py create mode 100644 partnercenter/azext_partnercenter/models/pending_update_info.py create mode 100644 partnercenter/azext_partnercenter/models/submission_publish_option.py create mode 100644 partnercenter/azext_partnercenter/models/submission_variant_resource.py create mode 100644 partnercenter/azext_partnercenter/models/type_value.py diff --git a/partnercenter/azext_partnercenter/clients/offer_client.py b/partnercenter/azext_partnercenter/clients/offer_client.py index bf6c2091..a6fd5f5e 100644 --- a/partnercenter/azext_partnercenter/clients/offer_client.py +++ b/partnercenter/azext_partnercenter/clients/offer_client.py @@ -171,7 +171,7 @@ def _get_sdk_product_by_external_offer_id(self, offer_external_id): """Package Internal helper method to get the SDK product object by Offer ID""" filter_expr = self._get_sdk_odata_filter_expression_by_external_offer_id(offer_external_id) products = self._sdk.product_client.products_get(self._get_access_token(), filter=filter_expr) - + print(f"products is {products}") if products is None or len(products.value) == 0: return None diff --git a/partnercenter/azext_partnercenter/clients/offer_submission_client.py b/partnercenter/azext_partnercenter/clients/offer_submission_client.py index dc7228c0..398d691b 100644 --- a/partnercenter/azext_partnercenter/clients/offer_submission_client.py +++ b/partnercenter/azext_partnercenter/clients/offer_submission_client.py @@ -4,9 +4,21 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long -from azext_partnercenter.vendored_sdks.production_ingestion.models import Submission -from azext_partnercenter.models import OfferSubmission +import datetime +import requests +from knack.cli import CLIError +from azext_partnercenter.models.pending_update_info import PendingUpdateInfo +from azext_partnercenter.models.offer_submission import OfferSubmission +from azext_partnercenter.models.application_submission import ApplicationSubmission +from azext_partnercenter.models.type_value import TypeValue +from azext_partnercenter.models.submission_variant_resource import SubmissionVariantResource +from azext_partnercenter.models.submission_publish_option import SubmissionPublishOption from azext_partnercenter.clients import OfferClient +from azext_partnercenter.vendored_sdks.v1.partnercenter.model.microsoft_ingestion_api_models_submissions_submission_creation_request import ( + MicrosoftIngestionApiModelsSubmissionsSubmissionCreationRequest) +from azext_partnercenter.vendored_sdks.v1.partnercenter.model.microsoft_ingestion_api_models_submissions_submission import ( + MicrosoftIngestionApiModelsSubmissionsSubmission) + from ._base_client import BaseClient @@ -15,28 +27,201 @@ def __init__(self, cli_ctx, *_): super().__init__(cli_ctx, *_) self._offer_client = OfferClient(cli_ctx, *_) - def get(self, offer_external_id, submission_id) -> OfferSubmission: + def get(self, offer_external_id, submission_id): offer = self._offer_client.get(offer_external_id) - result = self._graph_api_client.get_submission(offer.resource.durable_id, submission_id) - return self._map_submission(result) + + if offer.type == "AzureContainer": + result = self._graph_api_client.get_submission(submission_id, offer.resource.durable_id) + return self._map_submission(result) + + if offer.type == "AzureApplication": + result = self._sdk.submission_client.products_product_id_submissions_submission_id_get(offer.resource.durable_id, submission_id, self._get_access_token()) + return self._map_application_submission(result) + + raise CLIError("Only AzureContainer and AzureApplication offers are supported for submission commands") def list(self, offer_external_id): offer = self._offer_client.get(offer_external_id) - result = self._graph_api_client.get_submissions(offer.resource.durable_id) - return list(map(self._map_submission, result)) + + if offer.type == "AzureContainer": + result = self._graph_api_client.get_submissions(offer.resource.durable_id) + return list(map(self._map_submission, result)) + + if offer.type == "AzureApplication": + result = self._sdk.submission_client.products_product_id_submissions_get(offer.resource.durable_id, self._get_access_token()) + return list(map(self._map_application_submission, result.value)) + + raise CLIError("Only AzureContainer and AzureApplication offers are supported for submission commands") + + def _get_offer_draft_instance(self, offer_durable_id, module): + branches = self._sdk.branches_client.products_product_id_branches_get_by_module_modulemodule_get( + offer_durable_id, module, self._get_access_token() + ) + + if len(branches.value) == 0: + return None + + variant_package_branch = next((b for b in branches.value if not hasattr(b, 'variant_id')), None) + return variant_package_branch + + def _get_reseller_configuration(self, offer_external_id): + # currently using a raw http client for ResellerConfiguration because the SDK does not support it + # it is not listed as an avaialbile module in the openapi spec but the REST API does support it + url = f"https://api.partner.microsoft.com/v1.0/ingestion/products/{offer_external_id}/branches/getByModule(module=ResellerConfiguration)" + bearer_token = f"Bearer {self._get_access_token()}" + headers = { + "Content-Type": "application/json", + "Authorization": bearer_token + } + response = requests.get(url, headers=headers, timeout=60) + + if response.status_code != 200: + raise CLIError(f"Failed to get offer branches for {offer_external_id}, status code: {response.status_code}") + + reseller_configuration = response.json().get("value") + + if not reseller_configuration: + return None + + reseller_configuration_obj = reseller_configuration[0] + current_draft_instance_id = reseller_configuration_obj.get("currentDraftInstanceID") + return current_draft_instance_id + + def _map_application_submission(self, submission): + return ApplicationSubmission( + id=submission.id if hasattr(submission, 'id') else None, + resource_type=submission.resource_type if hasattr(submission, 'resource_type') else None, + state=submission.state if hasattr(submission, 'state') else None, + substate=submission.substate if hasattr(submission, 'substate') else None, + targets=submission.targets if hasattr(submission, 'targets') else [], + resources=self._map_list_to_type_value(submission.resources) if hasattr(submission, 'resources') else [], + variant_resources=self._map_list_to_variant_resources(submission.variant_resources) if hasattr(submission, 'variant_resources') else [], + publish_option=self._map_submission_publish_option(submission.publish_option) if hasattr(submission, 'publish_option') else None, + published_time_in_utc=submission.published_time_in_utc.isoformat() if hasattr(submission, 'published_time_in_utc') else None, + pending_update_info=self._map_pending_update_info(submission.pending_update_info) if hasattr(submission, 'pending_update_info') else None, + extended_properties=self._map_list_to_type_value(submission.extended_properties) if hasattr(submission, 'extended_properties') else [], + release_number=submission.release_number if hasattr(submission, 'release_number') else 0, + friendly_name=submission.friendly_name if hasattr(submission, 'friendly_name') else None, + are_resources_ready=submission.are_resources_ready if hasattr(submission, 'are_resources_ready') else False + ) + + def _map_submission_publish_option(self, publish_option): + return SubmissionPublishOption( + release_time_in_utc=publish_option.release_time_in_utc if hasattr(publish_option, 'release_time_in_utc') else None, + is_manual_publish=publish_option.is_manual_publish if hasattr(publish_option, 'is_manual_publish') else False, + is_auto_promote=publish_option.is_auto_promote if hasattr(publish_option, 'is_auto_promote') else False, + certification_notes=publish_option.certification_notes if hasattr(publish_option, 'certification_notes') else None + ) + + def _map_pending_update_info(self, pending_update_info): + return PendingUpdateInfo( + update_type=pending_update_info.update_type if hasattr(pending_update_info, 'update_type') else None, + status=pending_update_info.status if hasattr(pending_update_info, 'status') else None, + href=pending_update_info.href if hasattr(pending_update_info, 'href') else None, + failure_reason=pending_update_info.failure_reason if hasattr(pending_update_info, 'failure_reason') else None) + + def _map_list_to_type_value(self, type_value_list): + return [TypeValue(type=type_value.type, value=type_value.value) for type_value in type_value_list] + + def _get_managed_application_variants(self, durable_id): + variants = self._sdk.variant_client.products_product_id_variants_get(durable_id, self._get_access_token()) + return {v.get("id") for v in variants.value if v.get("resourceType") == "AzureSkuVariant" and v.get("subType") in ("managed-application", "solution-template")} + + def _map_list_to_variant_resources(self, variant_resource_list): + return [SubmissionVariantResource(variant_id=variant_resource.variant_id, resources=self._map_list_to_type_value(variant_resource.resources)) for variant_resource in variant_resource_list] def publish(self, offer_external_id, submission_id, target): offer = self._offer_client.get(offer_external_id) - result = self._graph_api_client.publish_submission(target, offer.resource.durable_id, submission_id) - return result + + if offer.type == "AzureContainer": + return self._graph_api_client.publish_submission(target, offer.resource.durable_id, submission_id) + if offer.type == "AzureApplication": + managed_application_variants = self._get_managed_application_variants(offer.resource.durable_id) + resources, variant_resources_list = self._get_resources_and_variant_resources(offer.resource.durable_id, managed_application_variants) + + reseller_instance_id = self._get_reseller_configuration(offer.resource.durable_id) + resources.append({"type": "ResellerConfiguration", "value": reseller_instance_id}) + + offer_submission_dict = self._get_offer_submission_dictionary(resources, variant_resources_list) + + offer_creation_request = MicrosoftIngestionApiModelsSubmissionsSubmissionCreationRequest(**offer_submission_dict) + result = self._sdk.submission_client.products_product_id_submissions_post( + offer.resource.durable_id, + self._get_access_token(), + microsoft_ingestion_api_models_submissions_submission_creation_request=offer_creation_request + ) + return self._map_application_submission(result) + + raise CLIError("Only AzureContainer and AzureApplication offers are supported for publishing") + + def _get_offer_submission_dictionary(self, resources, variant_resources_list): + offer_submission_dict = { + "resourceType": "SubmissionCreationRequest", + "targets": [ + { + "type": "Scope", + "value": "preview" + } + ], + "resources": resources, + "variantResources": variant_resources_list, + "publishOption": { + "releaseTimeInUtc": datetime.datetime.utcnow().isoformat(), + "isManualPublish": True, + "isAutoPromote": False, + "certificationNotes": "Submission automatically generated" + }, + "extendedProperties": [] + } + return offer_submission_dict + + def _get_resources_and_variant_resources(self, durable_id, managed_application_variants): + resources = [] + variant_resources_dict = {} + + modules = ["Availability", "Listing", "Package", "Property"] + for m in modules: + branches = self._sdk.branches_client.products_product_id_branches_get_by_module_modulemodule_get( + durable_id, m, self._get_access_token() + ) + + for b in branches.value: + resource = {"type": m, "value": b.current_draft_instance_id} + if not hasattr(b, 'variant_id'): + resources.append(resource) + else: + variant_id = getattr(b, 'variant_id') + if variant_id in managed_application_variants: + variant_resources_dict.setdefault(variant_id, []).append(resource) + + variant_resources_list = [{"variantID": variant_id, "resources": resources} for variant_id, resources in variant_resources_dict.items()] + return resources, variant_resources_list + +# TODO: understand attribute mapping +# v1 has + # 'resource_type': (str,), # noqa: E501 + # 'state': (str,), # noqa: E501 + # 'substate': (str,), # noqa: E501 + # 'targets': ([MicrosoftIngestionApiModelsCommonTypeValuePair],), # noqa: E501 + # 'resources': ([MicrosoftIngestionApiModelsCommonTypeValuePair],), # noqa: E501 + # 'variant_resources': ([MicrosoftIngestionApiModelsSubmissionsVariantResource],), # noqa: E501 + # 'publish_option': (MicrosoftIngestionApiModelsSubmissionsPublishOption,), # noqa: E501 + # 'published_time_in_utc': (datetime,), # noqa: E501 + # 'pending_update_info': (MicrosoftIngestionApiModelsSubmissionsPendingUpdateInfo,), # noqa: E501 + # 'extended_properties': ([MicrosoftIngestionApiModelsCommonTypeValuePair],), # noqa: E501 + # 'release_number': (int,), # noqa: E501 + # 'friendly_name': (str,), # noqa: E501 + # 'are_resources_ready': (bool,), # noqa: E501 + # 'id': (str,), # noqa: E501 @staticmethod - def _map_submission(s: Submission) -> OfferSubmission: + def _map_submission(s: MicrosoftIngestionApiModelsSubmissionsSubmission) -> OfferSubmission: + print(f"Mapping submission {s}") return OfferSubmission( id=s.id.root.split('/')[-1], - lifecycle_state=s.lifecycle_state, - target=s.target.target_type, - status=s.status, - result=s.result, - created=s.created + # lifecycle_state=s.substate, + # target=s.target.target_type, + # status=s.state, + # result=s.result, + created=s.published_time_in_utc ) diff --git a/partnercenter/azext_partnercenter/models/application_submission.py b/partnercenter/azext_partnercenter/models/application_submission.py new file mode 100644 index 00000000..2af6122f --- /dev/null +++ b/partnercenter/azext_partnercenter/models/application_submission.py @@ -0,0 +1,45 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=too-many-instance-attributes +# flake8: noqa: R0902 + +from msrest.serialization import Model + + +class ApplicationSubmission(Model): + _attribute_map = { + 'resource_type': {'key': 'resource_type', 'type': 'str'}, + 'state': {'key': 'state', 'type': 'str'}, + 'substate': {'key': 'substate', 'type': 'str'}, + 'targets': {'key': 'targets', 'type': '[TypeValue]'}, + 'resources': {'key': 'resources', 'type': '[TypeValue]'}, + 'variant_resources': {'key': 'variant_resources', 'type': '[SubmissionVariantResource]'}, + 'publish_option': {'key': 'publish_option', 'type': 'SubmissionPublishOption'}, + 'published_time_in_utc': {'key': 'published_time_in_utc', 'type': 'str'}, + 'pending_update_info': {'key': 'pending_update_info', 'type': 'PendingUpdateInfo'}, + 'extended_properties': {'key': 'extended_properties', 'type': '[TypeValue]'}, + 'release_number': {'key': 'release_number', 'type': 'int'}, + 'friendly_name': {'key': 'friendly_name', 'type': 'str'}, + 'are_resources_ready': {'key': 'are_resources_ready', 'type': 'bool'}, + 'id': {'key': 'id', 'type': 'str'} + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.resource_type = kwargs.get('resource_type', None) + self.state = kwargs.get('state', None) + self.substate = kwargs.get('substate', None) + self.targets = kwargs.get('targets', []) + self.resources = kwargs.get('resources', []) + self.variant_resources = kwargs.get('variant_resources', []) + self.publish_option = kwargs.get('publish_option', None) + self.published_time_in_utc = kwargs.get('published_time_in_utc', None) + self.pending_update_info = kwargs.get('pending_update_info', None) + self.extended_properties = kwargs.get('extended_properties', []) + self.release_number = kwargs.get('release_number', 0) + self.friendly_name = kwargs.get('friendly_name', None) + self.are_resources_ready = kwargs.get('are_resources_ready', False) + self.id = kwargs.get('id', None) diff --git a/partnercenter/azext_partnercenter/models/pending_update_info.py b/partnercenter/azext_partnercenter/models/pending_update_info.py new file mode 100644 index 00000000..fc65c91a --- /dev/null +++ b/partnercenter/azext_partnercenter/models/pending_update_info.py @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class PendingUpdateInfo(Model): + _attribute_map = { + 'update_type': {'key': 'update_type', 'type': 'str'}, + 'status': {'key': 'status', 'type': 'str'}, + 'href': {'key': 'href', 'type': 'str'}, + 'failure_reason': {'key': 'failure_reason', 'type': 'str'} + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.update_type = kwargs.get('update_type', None) + self.status = kwargs.get('status', None) + self.href = kwargs.get('href', None) + self.failure_reason = kwargs.get('failure_reason', None) diff --git a/partnercenter/azext_partnercenter/models/submission_publish_option.py b/partnercenter/azext_partnercenter/models/submission_publish_option.py new file mode 100644 index 00000000..dbf903ff --- /dev/null +++ b/partnercenter/azext_partnercenter/models/submission_publish_option.py @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class SubmissionPublishOption(Model): + _attribute_map = { + 'release_time_in_utc': {'key': 'release_time_in_utc', 'type': 'str'}, + 'is_manual_publish': {'key': 'is_manual_publish', 'type': 'bool'}, + 'is_auto_promote': {'key': 'is_auto_promote', 'type': 'bool'}, + 'certification_notes': {'key': 'certification_notes', 'type': 'str'} + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.release_time_in_utc = kwargs.get('release_time_in_utc', None) + self.is_manual_publish = kwargs.get('is_manual_publish', False) + self.is_auto_promote = kwargs.get('is_auto_promote', False) + self.certification_notes = kwargs.get('certification_notes', None) diff --git a/partnercenter/azext_partnercenter/models/submission_variant_resource.py b/partnercenter/azext_partnercenter/models/submission_variant_resource.py new file mode 100644 index 00000000..1ae523f0 --- /dev/null +++ b/partnercenter/azext_partnercenter/models/submission_variant_resource.py @@ -0,0 +1,18 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class SubmissionVariantResource(Model): + _attribute_map = { + 'variant_id': {'key': 'variant_id', 'type': 'str'}, + 'resources': {'key': 'resources', 'type': '[TypeValue]'} + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.id = kwargs.get('variant_id', None) + self.name = kwargs.get('resources', []) diff --git a/partnercenter/azext_partnercenter/models/type_value.py b/partnercenter/azext_partnercenter/models/type_value.py new file mode 100644 index 00000000..3b35bf73 --- /dev/null +++ b/partnercenter/azext_partnercenter/models/type_value.py @@ -0,0 +1,18 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class TypeValue(Model): + _attribute_map = { + 'type': {'key': 'type', 'type': 'str'}, + 'value': {'key': 'value', 'type': 'str'} + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.type = kwargs.get('type', None) + self.value = kwargs.get('value', None) diff --git a/partnercenter/azext_partnercenter/operations/marketplace_offer_submission/custom.py b/partnercenter/azext_partnercenter/operations/marketplace_offer_submission/custom.py index 44d79c1f..d32f6587 100644 --- a/partnercenter/azext_partnercenter/operations/marketplace_offer_submission/custom.py +++ b/partnercenter/azext_partnercenter/operations/marketplace_offer_submission/custom.py @@ -13,4 +13,5 @@ def list_submission(client, offer_id): def publish_submission(client, offer_id, submission_id, target): - return client.publish(offer_id, submission_id, target) + result = client.publish(offer_id, submission_id, target) + return result diff --git a/partnercenter/azext_partnercenter/vendored_sdks/v1/partnercenter/model/microsoft_ingestion_api_models_submissions_submission.py b/partnercenter/azext_partnercenter/vendored_sdks/v1/partnercenter/model/microsoft_ingestion_api_models_submissions_submission.py index 917ecf61..ce041bbb 100644 --- a/partnercenter/azext_partnercenter/vendored_sdks/v1/partnercenter/model/microsoft_ingestion_api_models_submissions_submission.py +++ b/partnercenter/azext_partnercenter/vendored_sdks/v1/partnercenter/model/microsoft_ingestion_api_models_submissions_submission.py @@ -69,7 +69,7 @@ class MicrosoftIngestionApiModelsSubmissionsSubmission(ModelNormal): 'SUBMISSION': "Submission", }, ('state',): { - 'INPROGRESS': "Inprogress", + 'INPROGRESS': "InProgress", 'PUBLISHED': "Published", }, ('substate',): {