From 69ef17ddd92903c57ad9ec567c380e0542856ce5 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 8 Aug 2023 17:40:18 +0100 Subject: [PATCH 01/20] Initial work for ServiceCatalog support. Started core endpoints including CloudFormation integration for creating a stack with ProvisionProduct. --- moto/__init__.py | 3 + moto/servicecatalog/__init__.py | 5 + moto/servicecatalog/exceptions.py | 3 + moto/servicecatalog/models.py | 368 ++++++++++++++++++ moto/servicecatalog/responses.py | 305 +++++++++++++++ moto/servicecatalog/urls.py | 10 + moto/servicecatalog/utils.py | 9 + tests/test_servicecatalog/__init__.py | 0 tests/test_servicecatalog/test_server.py | 13 + .../test_servicecatalog.py | 209 ++++++++++ 10 files changed, 925 insertions(+) create mode 100644 moto/servicecatalog/__init__.py create mode 100644 moto/servicecatalog/exceptions.py create mode 100644 moto/servicecatalog/models.py create mode 100644 moto/servicecatalog/responses.py create mode 100644 moto/servicecatalog/urls.py create mode 100644 moto/servicecatalog/utils.py create mode 100644 tests/test_servicecatalog/__init__.py create mode 100644 tests/test_servicecatalog/test_server.py create mode 100644 tests/test_servicecatalog/test_servicecatalog.py diff --git a/moto/__init__.py b/moto/__init__.py index a16d598d3d76..949592957c45 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -183,6 +183,9 @@ def f(*args: Any, **kwargs: Any) -> Any: mock_xray_client = lazy_load(".xray", "mock_xray_client") mock_wafv2 = lazy_load(".wafv2", "mock_wafv2") mock_textract = lazy_load(".textract", "mock_textract") +mock_servicecatalog = lazy_load( + ".servicecatalog", "mock_servicecatalog", boto3_name="servicecatalog" +) class MockAll(ContextDecorator): diff --git a/moto/servicecatalog/__init__.py b/moto/servicecatalog/__init__.py new file mode 100644 index 000000000000..d608da9e6c29 --- /dev/null +++ b/moto/servicecatalog/__init__.py @@ -0,0 +1,5 @@ +"""servicecatalog module initialization; sets value for base decorator.""" +from .models import servicecatalog_backends +from ..core.models import base_decorator + +mock_servicecatalog = base_decorator(servicecatalog_backends) \ No newline at end of file diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py new file mode 100644 index 000000000000..3908afa515be --- /dev/null +++ b/moto/servicecatalog/exceptions.py @@ -0,0 +1,3 @@ +"""Exceptions raised by the servicecatalog service.""" +from moto.core.exceptions import JsonRESTError + diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py new file mode 100644 index 000000000000..40a541aabfef --- /dev/null +++ b/moto/servicecatalog/models.py @@ -0,0 +1,368 @@ +"""ServiceCatalogBackend class with methods for supported APIs.""" +import string +from typing import Any, Dict, List, Optional, Union +from datetime import datetime + +from moto.core import BaseBackend, BackendDict, BaseModel +from moto.core.utils import unix_time +from moto.moto_api._internal import mock_random as random +from moto.utilities.tagging_service import TaggingService +from moto.cloudformation.utils import get_stack_from_s3_url + +from .utils import create_cloudformation_stack_from_template + + +class Portfolio(BaseModel): + def __init__( + self, + region: str, + accept_language: str, + display_name: str, + description: str, + provider_name: str, + tags: Dict[str, str], + idempotency_token: str, + backend: "ServiceCatalogBackend", + ): + self.portfolio_id = "".join( + random.choice(string.ascii_lowercase) for _ in range(8) + ) + self.created_date: datetime = unix_time() + self.region = region + self.accept_language = accept_language + self.display_name = display_name + self.description = description + self.provider_name = provider_name + self.idempotency_token = idempotency_token + self.backend = backend + + self.arn = f"arn:aws:servicecatalog:{region}::{self.portfolio_id}" + self.tags = tags + self.backend.tag_resource(self.arn, tags) + + def to_json(self) -> Dict[str, Any]: + met = { + "ARN": self.arn, + "CreatedTime": self.created_date, + "Description": self.description, + "DisplayName": self.display_name, + "Id": self.portfolio_id, + "ProviderName": self.provider_name, + } + return met + + +class ProvisioningArtifact(BaseModel): + def __init__( + self, + region: str, + active: bool, + name: str, + artifact_type: str = "CLOUD_FORMATION_TEMPLATE", + description: str = "", + source_revision: str = "", + guidance: str = "DEFAULT", + template: str = "", + ): + self.provisioning_artifact_id = "".join( + random.choice(string.ascii_lowercase) for _ in range(8) + ) # Id + self.region: str = region # RegionName + + self.active: bool = active # Active + self.created_date: datetime = unix_time() # CreatedTime + self.description = description # Description - 8192 + self.guidance = guidance # DEFAULT | DEPRECATED + self.name = name # 8192 + self.source_revision = source_revision # 512 + self.artifact_type = artifact_type # CLOUD_FORMATION_TEMPLATE | MARKETPLACE_AMI | MARKETPLACE_CAR | TERRAFORM_OPEN_SOURCE + self.template = template + + +class Product(BaseModel): + def __init__( + self, + region: str, + accept_language: str, + name: str, + description: str, + owner: str, + product_type: str, + tags: Dict[str, str], + backend: "ServiceCatalogBackend", + ): + self.product_id = "".join( + random.choice(string.ascii_lowercase) for _ in range(8) + ) + self.created_date: datetime = unix_time() + self.region = region + self.accept_language = accept_language + self.name = name + self.description = description + self.owner = owner + self.product_type = product_type + self.provisioning_artifact: "ProvisioningArtifact" = None + + self.backend = backend + self.arn = f"arn:aws:servicecatalog:{region}::product/{self.product_id}" + self.tags = tags + self.backend.tag_resource(self.arn, tags) + + def _create_provisioning_artifact( + self, + account_id, + name, + description, + artifact_type, + info, + disable_template_validation: bool = False, + ): + + # Load CloudFormation template from S3 + if "LoadTemplateFromURL" in info: + template_url = info["LoadTemplateFromURL"] + template = get_stack_from_s3_url( + template_url=template_url, account_id=account_id + ) + else: + raise NotImplementedError("Nope") + # elif "ImportFromPhysicalId" in info: + + provision_artifact = ProvisioningArtifact( + name=name, + description=description, + artifact_type=artifact_type, + region=self.region, + active=True, + template=template, + ) + self.provisioning_artifact = provision_artifact + + def to_product_view_detail_json(self) -> Dict[str, Any]: + met = { + "ProductARN": self.arn, + "CreatedTime": self.created_date, + "ProductViewSummary": { + "Description": self.description, + "Name": self.name, + "Id": self.product_id, + "Owner": self.owner, + }, + "Status": "AVAILABLE", + } + return met + + def to_provisioning_artifact_detail_json(self) -> Dict[str, Any]: + return { + "CreatedTime": self.provisioning_artifact.created_date, + "Active": self.provisioning_artifact.active, + "Id": self.provisioning_artifact.provisioning_artifact_id, + "Description": self.provisioning_artifact.description, + "Name": self.provisioning_artifact.name, + "Type": self.provisioning_artifact.artifact_type, + } + + def to_json(self) -> Dict[str, Any]: + return self.to_product_view_detail_json() + + +class ServiceCatalogBackend(BaseBackend): + """Implementation of ServiceCatalog APIs.""" + + def __init__(self, region_name, account_id): + super().__init__(region_name, account_id) + + self.portfolios: Dict[str, Portfolio] = dict() + self.products: Dict[str, Product] = dict() + + self.tagger = TaggingService() + + def create_portfolio( + self, + accept_language, + display_name, + description, + provider_name, + tags, + idempotency_token, + ): + portfolio = Portfolio( + region=self.region_name, + accept_language=accept_language, + display_name=display_name, + description=description, + provider_name=provider_name, + tags=tags, + idempotency_token=idempotency_token, + backend=self, + ) + self.portfolios[portfolio.portfolio_id] = portfolio + return portfolio, tags + + def list_portfolios(self, accept_language, page_token): + # implement here + portfolio_details = list(self.portfolios.values()) + next_page_token = None + return portfolio_details, next_page_token + + def create_product( + self, + accept_language, + name, + owner, + description, + distributor, + support_description, + support_email, + support_url, + product_type, + tags, + provisioning_artifact_parameters, + idempotency_token, + source_connection, + ): + # implement here + + product = Product( + region=self.region_name, + accept_language=accept_language, + owner=owner, + product_type=product_type, + name=name, + description=description, + tags=tags, + backend=self, + ) + + product._create_provisioning_artifact( + account_id=self.account_id, + name=provisioning_artifact_parameters["Name"], + description=provisioning_artifact_parameters["Description"], + artifact_type=provisioning_artifact_parameters["Type"], + info=provisioning_artifact_parameters["Info"], + ) + self.products[product.product_id] = product + + product_view_detail = product.to_product_view_detail_json() + provisioning_artifact_detail = product.to_provisioning_artifact_detail_json() + + return product_view_detail, provisioning_artifact_detail, tags + + def describe_provisioned_product(self, accept_language, id, name): + # implement here + provisioned_product_detail = {} + cloud_watch_dashboards = None + return provisioned_product_detail, cloud_watch_dashboards + + def search_products( + self, accept_language, filters, sort_by, sort_order, page_token + ): + # implement here + product_view_summaries = {} + product_view_aggregations = {} + next_page_token = {} + return product_view_summaries, product_view_aggregations, next_page_token + + def provision_product( + self, + accept_language, + product_id, + product_name, + provisioning_artifact_id, + provisioning_artifact_name, + path_id, + path_name, + provisioned_product_name, + provisioning_parameters, + provisioning_preferences, + tags, + notification_arns, + provision_token, + ): + # implement here + # TODO: Big damn cleanup before this counts as anything useful. + product = None + for product_id, item in self.products.items(): + if item.name == product_name: + product = item + + stack = create_cloudformation_stack_from_template( + stack_name=provisioned_product_name, + account_id=self.account_id, + region_name=self.region_name, + template=product.provisioning_artifact.template, + ) + + record_detail = {} + return record_detail + + def search_provisioned_products( + self, + accept_language, + access_level_filter, + filters, + sort_by, + sort_order, + page_token, + ): + # implement here + provisioned_products = {} + total_results_count = 0 + next_page_token = None + return provisioned_products, total_results_count, next_page_token + + def list_launch_paths(self, accept_language, product_id, page_token): + # implement here + launch_path_summaries = {} + next_page_token = None + + return launch_path_summaries, next_page_token + + def list_provisioning_artifacts(self, accept_language, product_id): + # implement here + provisioning_artifact_details = {} + next_page_token = None + + return provisioning_artifact_details, next_page_token + + def get_provisioned_product_outputs( + self, + accept_language, + provisioned_product_id, + provisioned_product_name, + output_keys, + page_token, + ): + # implement here + outputs = {} + next_page_token = None + return outputs, next_page_token + + def terminate_provisioned_product( + self, + provisioned_product_name, + provisioned_product_id, + terminate_token, + ignore_errors, + accept_language, + retain_physical_resources, + ): + # implement here + record_detail = {} + return record_detail + + def get_tags(self, resource_id: str) -> Dict[str, str]: + return self.tagger.get_tag_dict_for_resource(resource_id) + + def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: + tags_input = TaggingService.convert_dict_to_tags_input(tags or {}) + self.tagger.tag_resource(resource_arn, tags_input) + + def associate_product_with_portfolio( + self, accept_language, product_id, portfolio_id, source_portfolio_id + ): + # implement here + return + + +servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py new file mode 100644 index 000000000000..4249be68a951 --- /dev/null +++ b/moto/servicecatalog/responses.py @@ -0,0 +1,305 @@ +"""Handles incoming servicecatalog requests, invokes methods, returns responses.""" +import json + +from moto.core.responses import BaseResponse, TYPE_RESPONSE +from .models import servicecatalog_backends + + +class ServiceCatalogResponse(BaseResponse): + """Handler for ServiceCatalog requests and responses.""" + + def __init__(self) -> None: + super().__init__(service_name="servicecatalog") + + @property + def servicecatalog_backend(self): + """Return backend instance specific for this region.""" + return servicecatalog_backends[self.current_account][self.region] + + # add methods from here + + def create_portfolio(self): + accept_language = self._get_param("AcceptLanguage") + display_name = self._get_param("DisplayName") + description = self._get_param("Description") + provider_name = self._get_param("ProviderName") + tags = self._get_param("Tags") + idempotency_token = self._get_param("IdempotencyToken") + + portfolio_detail, tags = self.servicecatalog_backend.create_portfolio( + accept_language=accept_language, + display_name=display_name, + description=description, + provider_name=provider_name, + tags=tags, + idempotency_token=idempotency_token, + ) + # TODO: adjust response + return json.dumps(dict(PortfolioDetail=portfolio_detail.to_json(), Tags=tags)) + + def list_portfolios(self) -> str: + accept_language = self._get_param("AcceptLanguage") + page_token = self._get_param("PageToken") + page_size = self._get_param("PageSize") + (portfolios, next_page_token,) = self.servicecatalog_backend.list_portfolios( + accept_language=accept_language, + page_token=page_token, + ) + + portfolio_details = [portfolio.to_json() for portfolio in portfolios] + + # TODO: adjust response + + ret = json.dumps( + dict( + PortfolioDetails=portfolio_details, + NextPageToken=next_page_token, + ) + ) + + return ret + + def describe_provisioned_product(self): + accept_language = self._get_param("AcceptLanguage") + id = self._get_param("Id") + name = self._get_param("Name") + ( + provisioned_product_detail, + cloud_watch_dashboards, + ) = self.servicecatalog_backend.describe_provisioned_product( + accept_language=accept_language, + id=id, + name=name, + ) + # TODO: adjust response + return json.dumps( + dict( + provisionedProductDetail=provisioned_product_detail, + cloudWatchDashboards=cloud_watch_dashboards, + ) + ) + + # add templates from here + + def get_provisioned_product_outputs(self): + accept_language = self._get_param("AcceptLanguage") + provisioned_product_id = self._get_param("ProvisionedProductId") + provisioned_product_name = self._get_param("ProvisionedProductName") + output_keys = self._get_param("OutputKeys") + page_size = self._get_param("PageSize") + page_token = self._get_param("PageToken") + ( + outputs, + next_page_token, + ) = self.servicecatalog_backend.get_provisioned_product_outputs( + accept_language=accept_language, + provisioned_product_id=provisioned_product_id, + provisioned_product_name=provisioned_product_name, + output_keys=output_keys, + page_token=page_token, + ) + # TODO: adjust response + return json.dumps(dict(outputs=outputs, nextPageToken=next_page_token)) + + def search_provisioned_products(self): + accept_language = self._get_param("AcceptLanguage") + access_level_filter = self._get_param("AccessLevelFilter") + filters = self._get_param("Filters") + sort_by = self._get_param("SortBy") + sort_order = self._get_param("SortOrder") + page_size = self._get_param("PageSize") + page_token = self._get_param("PageToken") + ( + provisioned_products, + total_results_count, + next_page_token, + ) = self.servicecatalog_backend.search_provisioned_products( + accept_language=accept_language, + access_level_filter=access_level_filter, + filters=filters, + sort_by=sort_by, + sort_order=sort_order, + page_token=page_token, + ) + # TODO: adjust response + return json.dumps( + dict( + provisionedProducts=provisioned_products, + totalResultsCount=total_results_count, + nextPageToken=next_page_token, + ) + ) + + def terminate_provisioned_product(self): + provisioned_product_name = self._get_param("ProvisionedProductName") + provisioned_product_id = self._get_param("ProvisionedProductId") + terminate_token = self._get_param("TerminateToken") + ignore_errors = self._get_param("IgnoreErrors") + accept_language = self._get_param("AcceptLanguage") + retain_physical_resources = self._get_param("RetainPhysicalResources") + record_detail = self.servicecatalog_backend.terminate_provisioned_product( + provisioned_product_name=provisioned_product_name, + provisioned_product_id=provisioned_product_id, + terminate_token=terminate_token, + ignore_errors=ignore_errors, + accept_language=accept_language, + retain_physical_resources=retain_physical_resources, + ) + # TODO: adjust response + return json.dumps(dict(recordDetail=record_detail)) + + def search_products(self): + accept_language = self._get_param("AcceptLanguage") + filters = self._get_param("Filters") + page_size = self._get_param("PageSize") + sort_by = self._get_param("SortBy") + sort_order = self._get_param("SortOrder") + page_token = self._get_param("PageToken") + ( + product_view_summaries, + product_view_aggregations, + next_page_token, + ) = self.servicecatalog_backend.search_products( + accept_language=accept_language, + filters=filters, + sort_by=sort_by, + sort_order=sort_order, + page_token=page_token, + ) + # TODO: adjust response + return json.dumps( + dict( + productViewSummaries=product_view_summaries, + productViewAggregations=product_view_aggregations, + nextPageToken=next_page_token, + ) + ) + + def list_launch_paths(self): + accept_language = self._get_param("AcceptLanguage") + product_id = self._get_param("ProductId") + page_size = self._get_param("PageSize") + page_token = self._get_param("PageToken") + ( + launch_path_summaries, + next_page_token, + ) = self.servicecatalog_backend.list_launch_paths( + accept_language=accept_language, + product_id=product_id, + page_token=page_token, + ) + # TODO: adjust response + return json.dumps( + dict( + launchPathSummaries=launch_path_summaries, nextPageToken=next_page_token + ) + ) + + def list_provisioning_artifacts(self): + accept_language = self._get_param("AcceptLanguage") + product_id = self._get_param("ProductId") + ( + provisioning_artifact_details, + next_page_token, + ) = self.servicecatalog_backend.list_provisioning_artifacts( + accept_language=accept_language, + product_id=product_id, + ) + # TODO: adjust response + return json.dumps( + dict( + provisioningArtifactDetails=provisioning_artifact_details, + nextPageToken=next_page_token, + ) + ) + + def provision_product(self): + accept_language = self._get_param("AcceptLanguage") + product_id = self._get_param("ProductId") + product_name = self._get_param("ProductName") + provisioning_artifact_id = self._get_param("ProvisioningArtifactId") + provisioning_artifact_name = self._get_param("ProvisioningArtifactName") + path_id = self._get_param("PathId") + path_name = self._get_param("PathName") + provisioned_product_name = self._get_param("ProvisionedProductName") + provisioning_parameters = self._get_param("ProvisioningParameters") + provisioning_preferences = self._get_param("ProvisioningPreferences") + tags = self._get_param("Tags") + notification_arns = self._get_param("NotificationArns") + provision_token = self._get_param("ProvisionToken") + record_detail = self.servicecatalog_backend.provision_product( + accept_language=accept_language, + product_id=product_id, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioning_artifact_name=provisioning_artifact_name, + path_id=path_id, + path_name=path_name, + provisioned_product_name=provisioned_product_name, + provisioning_parameters=provisioning_parameters, + provisioning_preferences=provisioning_preferences, + tags=tags, + notification_arns=notification_arns, + provision_token=provision_token, + ) + # TODO: adjust response + return json.dumps(dict(recordDetail=record_detail)) + + def create_product(self): + accept_language = self._get_param("AcceptLanguage") + name = self._get_param("Name") + owner = self._get_param("Owner") + description = self._get_param("Description") + distributor = self._get_param("Distributor") + support_description = self._get_param("SupportDescription") + support_email = self._get_param("SupportEmail") + support_url = self._get_param("SupportUrl") + product_type = self._get_param("ProductType") + tags = self._get_param("Tags") + provisioning_artifact_parameters = self._get_param( + "ProvisioningArtifactParameters" + ) + idempotency_token = self._get_param("IdempotencyToken") + source_connection = self._get_param("SourceConnection") + ( + product_view_detail, + provisioning_artifact_detail, + tags, + ) = self.servicecatalog_backend.create_product( + accept_language=accept_language, + name=name, + owner=owner, + description=description, + distributor=distributor, + support_description=support_description, + support_email=support_email, + support_url=support_url, + product_type=product_type, + tags=tags, + provisioning_artifact_parameters=provisioning_artifact_parameters, + idempotency_token=idempotency_token, + source_connection=source_connection, + ) + # TODO: adjust response + return json.dumps( + dict( + ProductViewDetail=product_view_detail, + ProvisioningArtifactDetail=provisioning_artifact_detail, + Tags=tags, + ) + ) + + def associate_product_with_portfolio(self): + + accept_language = self._get_param("AcceptLanguage") + product_id = self._get_param("ProductId") + portfolio_id = self._get_param("PortfolioId") + source_portfolio_id = self._get_param("SourcePortfolioId") + self.servicecatalog_backend.associate_product_with_portfolio( + accept_language=accept_language, + product_id=product_id, + portfolio_id=portfolio_id, + source_portfolio_id=source_portfolio_id, + ) + # TODO: adjust response + return json.dumps(dict()) diff --git a/moto/servicecatalog/urls.py b/moto/servicecatalog/urls.py new file mode 100644 index 000000000000..3b0a8d472dae --- /dev/null +++ b/moto/servicecatalog/urls.py @@ -0,0 +1,10 @@ +"""servicecatalog base URL and path.""" +from .responses import ServiceCatalogResponse + +url_bases = [ + r"https?://servicecatalog\.(.+)\.amazonaws\.com", +] + +url_paths = { + "{0}/$": ServiceCatalogResponse.dispatch, +} diff --git a/moto/servicecatalog/utils.py b/moto/servicecatalog/utils.py new file mode 100644 index 000000000000..890fda0f9513 --- /dev/null +++ b/moto/servicecatalog/utils.py @@ -0,0 +1,9 @@ +def create_cloudformation_stack_from_template( + account_id: str, stack_name: str, region_name: str, template: str +): + from moto.cloudformation import models as cloudformation_models + + cf_backend = cloudformation_models.cloudformation_backends[account_id][region_name] + stack = cf_backend.create_stack(name=stack_name, template=template, parameters={}) + + return stack diff --git a/tests/test_servicecatalog/__init__.py b/tests/test_servicecatalog/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/test_servicecatalog/test_server.py b/tests/test_servicecatalog/test_server.py new file mode 100644 index 000000000000..a12e0d949937 --- /dev/null +++ b/tests/test_servicecatalog/test_server.py @@ -0,0 +1,13 @@ +"""Test different server responses.""" + +import moto.server as server + + +def test_servicecatalog_list(): + backend = server.create_backend_app("servicecatalog") + test_client = backend.test_client() + + resp = test_client.get("/") + + assert resp.status_code == 200 + assert "?" in str(resp.data) \ No newline at end of file diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py new file mode 100644 index 000000000000..ceb6d2e3ed8e --- /dev/null +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -0,0 +1,209 @@ +"""Unit tests for servicecatalog-supported APIs.""" +import boto3 +import uuid +from datetime import date +from moto import mock_servicecatalog, mock_s3 + +# See our Development Tips on writing tests for hints on how to write good tests: +# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html + + +@mock_servicecatalog +def test_create_portfolio(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.create_portfolio( + DisplayName="Test Portfolio", ProviderName="Test Provider" + ) + + assert resp is not None + assert "PortfolioDetail" in resp + + +@mock_servicecatalog +def test_list_portfolios(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 + + portfolio_id_1 = client.create_portfolio( + DisplayName="test-1", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] + portfolio_id_2 = client.create_portfolio( + DisplayName="test-2", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] + + assert len(client.list_portfolios()["PortfolioDetails"]) == 2 + portfolio_ids = [i["Id"] for i in client.list_portfolios()["PortfolioDetails"]] + + assert portfolio_id_1 in portfolio_ids + assert portfolio_id_2 in portfolio_ids + + +@mock_servicecatalog +def test_describe_provisioned_product(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.describe_provisioned_product() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_get_provisioned_product_outputs(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.get_provisioned_product_outputs() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_search_provisioned_products(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.search_provisioned_products() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_terminate_provisioned_product(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.terminate_provisioned_product() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_search_products(): + client = boto3.client("servicecatalog", region_name="us-east-2") + resp = client.search_products() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_list_launch_paths(): + client = boto3.client("servicecatalog", region_name="us-east-2") + resp = client.list_launch_paths() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_list_provisioning_artifacts(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.list_provisioning_artifacts() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +@mock_s3 +def test_create_product(): + cloud_stack = """--- + Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: cfn-quickstart-bucket + """ + region_name = "us-east-2" + cloud_bucket = "cf-servicecatalog" + cloud_s3_key = "sc-templates/test-product/stack.yaml" + cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" + s3_client = boto3.client("s3", region_name=region_name) + s3_client.create_bucket( + Bucket=cloud_bucket, + CreateBucketConfiguration={ + "LocationConstraint": region_name, + }, + ) + s3_client.put_object(Body=cloud_stack, Bucket=cloud_bucket, Key=cloud_s3_key) + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.create_product( + Name="test product", + Owner="owner arn", + Description="description", + SupportEmail="test@example.com", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Description": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + "Type": "CLOUD_FORMATION_TEMPLATE", + }, + IdempotencyToken=str(uuid.uuid4()), + ) + # TODO: Much more comprehensive + assert "ProductViewDetail" in resp + assert "ProvisioningArtifactDetail" in resp + + +@mock_servicecatalog +@mock_s3 +def test_provision_product(): + cloud_stack = """--- + Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: cfn-quickstart-bucket + """ + region_name = "us-east-2" + cloud_bucket = "cf-servicecatalog" + cloud_s3_key = "sc-templates/test-product/stack.yaml" + cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" + s3_client = boto3.client("s3", region_name=region_name) + s3_client.create_bucket( + Bucket=cloud_bucket, + CreateBucketConfiguration={ + "LocationConstraint": region_name, + }, + ) + s3_client.put_object(Body=cloud_stack, Bucket=cloud_bucket, Key=cloud_s3_key) + + client = boto3.client("servicecatalog", region_name=region_name) + + product_name = "test product" + + create_product_response = client.create_product( + Name=product_name, + Owner="owner arn", + Description="description", + SupportEmail="test@example.com", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Description": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + "Type": "CLOUD_FORMATION_TEMPLATE", + }, + IdempotencyToken=str(uuid.uuid4()), + ) + + provisioning_artifact_id = create_product_response["ProvisioningArtifactDetail"][ + "Id" + ] + + stack_id = uuid.uuid4().hex + today = date.today() + today = today.strftime("%Y%m%d") + requesting_user = "test-user" + provisioning_product_name = requesting_user + "-" + today + "_" + stack_id + + provisioned_product_response = client.provision_product( + ProvisionedProductName=provisioning_product_name, + ProvisioningArtifactId=provisioning_artifact_id, + ProductName=product_name, + ) + + all_buckets_response = s3_client.list_buckets() + bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] + + assert "cfn-quickstart-bucket" in bucket_names + + +@mock_servicecatalog +def test_associate_product_with_portfolio(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.associate_product_with_portfolio() + + raise Exception("NotYetImplemented") From f45022ed94d8fbca151d1edf3da65a1113fee31c Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 9 Aug 2023 17:33:26 +0100 Subject: [PATCH 02/20] Futher refinement of object types. --- moto/servicecatalog/models.py | 205 +++++++++++++++--- .../test_servicecatalog.py | 69 ++++-- 2 files changed, 224 insertions(+), 50 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 40a541aabfef..e635a77c8c94 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -1,6 +1,6 @@ """ServiceCatalogBackend class with methods for supported APIs.""" import string -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, OrderedDict, List, Optional, Union from datetime import datetime from moto.core import BaseBackend, BackendDict, BaseModel @@ -24,8 +24,8 @@ def __init__( idempotency_token: str, backend: "ServiceCatalogBackend", ): - self.portfolio_id = "".join( - random.choice(string.ascii_lowercase) for _ in range(8) + self.portfolio_id = "p" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) ) self.created_date: datetime = unix_time() self.region = region @@ -64,8 +64,8 @@ def __init__( guidance: str = "DEFAULT", template: str = "", ): - self.provisioning_artifact_id = "".join( - random.choice(string.ascii_lowercase) for _ in range(8) + self.provisioning_artifact_id = "pa-" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) ) # Id self.region: str = region # RegionName @@ -78,6 +78,16 @@ def __init__( self.artifact_type = artifact_type # CLOUD_FORMATION_TEMPLATE | MARKETPLACE_AMI | MARKETPLACE_CAR | TERRAFORM_OPEN_SOURCE self.template = template + def to_provisioning_artifact_detail_json(self) -> Dict[str, Any]: + return { + "CreatedTime": self.created_date, + "Active": self.active, + "Id": self.provisioning_artifact_id, + "Description": self.description, + "Name": self.name, + "Type": self.artifact_type, + } + class Product(BaseModel): def __init__( @@ -91,23 +101,30 @@ def __init__( tags: Dict[str, str], backend: "ServiceCatalogBackend", ): - self.product_id = "".join( - random.choice(string.ascii_lowercase) for _ in range(8) + self.product_view_summary_id = "prodview" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) ) - self.created_date: datetime = unix_time() + self.product_id = "prod" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) + ) + self.created_time: datetime = unix_time() self.region = region self.accept_language = accept_language self.name = name self.description = description self.owner = owner self.product_type = product_type - self.provisioning_artifact: "ProvisioningArtifact" = None + + self.provisioning_artifacts: OrderedDict[str, "ProvisioningArtifact"] = dict() self.backend = backend self.arn = f"arn:aws:servicecatalog:{region}::product/{self.product_id}" self.tags = tags self.backend.tag_resource(self.arn, tags) + def get_provisioning_artifact(self, artifact_id: str): + return self.provisioning_artifacts[artifact_id] + def _create_provisioning_artifact( self, account_id, @@ -128,7 +145,7 @@ def _create_provisioning_artifact( raise NotImplementedError("Nope") # elif "ImportFromPhysicalId" in info: - provision_artifact = ProvisioningArtifact( + provisioning_artifact = ProvisioningArtifact( name=name, description=description, artifact_type=artifact_type, @@ -136,36 +153,110 @@ def _create_provisioning_artifact( active=True, template=template, ) - self.provisioning_artifact = provision_artifact + self.provisioning_artifacts[ + provisioning_artifact.provisioning_artifact_id + ] = provisioning_artifact + + return provisioning_artifact def to_product_view_detail_json(self) -> Dict[str, Any]: - met = { + return { "ProductARN": self.arn, - "CreatedTime": self.created_date, + "CreatedTime": self.created_time, "ProductViewSummary": { - "Description": self.description, + "Id": self.product_view_summary_id, + "ProductId": self.product_id, "Name": self.name, - "Id": self.product_id, "Owner": self.owner, + "ShortDescription": self.description, + "Type": self.product_type, + # "Distributor": "Some person", + # "HasDefaultPath": false, + # "SupportEmail": "frank@stallone.example" }, "Status": "AVAILABLE", } - return met - - def to_provisioning_artifact_detail_json(self) -> Dict[str, Any]: - return { - "CreatedTime": self.provisioning_artifact.created_date, - "Active": self.provisioning_artifact.active, - "Id": self.provisioning_artifact.provisioning_artifact_id, - "Description": self.provisioning_artifact.description, - "Name": self.provisioning_artifact.name, - "Type": self.provisioning_artifact.artifact_type, - } def to_json(self) -> Dict[str, Any]: return self.to_product_view_detail_json() +class Record(BaseModel): + def __init__( + self, + region: str, + backend: "ServiceCatalogBackend", + ): + self.record_id = "rec-" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) + ) + self.region = region + + self.created_time: datetime = unix_time() + self.updated_time: datetime = self.created_time + + self.backend = backend + self.arn = f"arn:aws:servicecatalog:{self.region}::record/{self.record_id}" + + +class ProvisionedProduct(BaseModel): + def __init__( + self, + region: str, + accept_language: str, + name: str, + stack_id: str, + tags: Dict[str, str], + backend: "ServiceCatalogBackend", + ): + self.provisioned_product_id = "pp-" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) + ) + self.created_time: datetime = unix_time() + self.updated_time: datetime = self.created_time + self.region = region + self.accept_language = accept_language + + self.name = name + # CFN_STACK, CFN_STACKSET, TERRAFORM_OPEN_SOURCE, TERRAFORM_CLOUD + # self.product_type = product_type + # PROVISION_PRODUCT, UPDATE_PROVISIONED_PRODUCT, TERMINATE_PROVISIONED_PRODUCT + self.record_type = "PROVISION_PRODUCT" + self.product_id = "" + self.provisioning_artifcat_id = "" + self.path_id = "" + self.launch_role_arn = "" + + # self.records = link to records on actions + self.status: str = ( + "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR + ) + self.backend = backend + self.arn = ( + f"arn:aws:servicecatalog:{region}::provisioned_product/{self.product_id}" + ) + self.tags = tags + self.backend.tag_resource(self.arn, tags) + + def to_provisioned_product_detail_json(self) -> Dict[str, Any]: + return { + "Arn": self.arn, + "CreatedTime": self.created_date, + "Id": self.product_id, + "IdempotencyToken": "string", + "LastProvisioningRecordId": "string", # ProvisionedProduct, UpdateProvisionedProduct, ExecuteProvisionedProductPlan, TerminateProvisionedProduct + "LastRecordId": "string", + "LastSuccessfulProvisioningRecordId": "string", + "LaunchRoleArn": "string", + "Name": self.name, + "ProductId": "string", + "ProvisioningArtifactId": "string", + "Status": "AVAILABLE", + "StatusMessage": "string", + "Type": "string", + } + + class ServiceCatalogBackend(BaseBackend): """Implementation of ServiceCatalog APIs.""" @@ -174,6 +265,7 @@ def __init__(self, region_name, account_id): self.portfolios: Dict[str, Portfolio] = dict() self.products: Dict[str, Product] = dict() + self.provisioned_products: Dict[str, ProvisionedProduct] = dict() self.tagger = TaggingService() @@ -234,7 +326,7 @@ def create_product( backend=self, ) - product._create_provisioning_artifact( + provisioning_artifact = product._create_provisioning_artifact( account_id=self.account_id, name=provisioning_artifact_parameters["Name"], description=provisioning_artifact_parameters["Description"], @@ -244,13 +336,28 @@ def create_product( self.products[product.product_id] = product product_view_detail = product.to_product_view_detail_json() - provisioning_artifact_detail = product.to_provisioning_artifact_detail_json() + provisioning_artifact_detail = ( + provisioning_artifact.to_provisioning_artifact_detail_json() + ) return product_view_detail, provisioning_artifact_detail, tags def describe_provisioned_product(self, accept_language, id, name): # implement here - provisioned_product_detail = {} + + if id: + product = self.products[id] + else: + # get by name + product = self.products[id] + + # TODO + # "CloudWatchDashboards": [ + # { + # "Name": "string" + # } + # ], + provisioned_product_detail = product.to_provisioned_product_detail_json() cloud_watch_dashboards = None return provisioned_product_detail, cloud_watch_dashboards @@ -286,14 +393,50 @@ def provision_product( if item.name == product_name: product = item + # search product for specific provision_artifact_id or name + # TODO: ID vs name + provisioning_artifact = product.get_provisioning_artifact( + provisioning_artifact_id + ) + + # path + + # Instantiate stack stack = create_cloudformation_stack_from_template( stack_name=provisioned_product_name, account_id=self.account_id, region_name=self.region_name, - template=product.provisioning_artifact.template, + template=provisioning_artifact.template, ) - record_detail = {} + provisioned_product = ProvisionedProduct( + accept_language=accept_language, + region=self.region_name, + name=provisioned_product_name, + stack_id=stack.stack_id, + tags=[], + backend=self, + ) + record = Record(region=self.region_name, backend=self) + + # record object + + record_detail = { + "RecordId": record.record_id, + "CreatedTime": record.created_time, + "UpdatedTime": record.updated_time, + "ProvisionedProductId": provisioned_product.provisioned_product_id, + # "PathId": "lpv2-abcdg3jp6t5k6", + # "RecordErrors": [], + "ProductId": provisioned_product.product_id, + # "RecordType": "PROVISION_PRODUCT", + # "ProvisionedProductName": "mytestppname3", + # "ProvisioningArtifactId": "pa-pcz347abcdcfm", + # "RecordTags": [], + # "Status": "CREATED", + # "ProvisionedProductType": "CFN_STACK" + } + print(record_detail) return record_detail def search_provisioned_products( diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index ceb6d2e3ed8e..f724d84335de 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -7,6 +7,29 @@ # See our Development Tips on writing tests for hints on how to write good tests: # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html +BASIC_CLOUD_STACK = """--- +Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: cfn-quickstart-bucket +""" + + +def _create_cf_template_in_s3(region_name: str): + cloud_bucket = "cf-servicecatalog" + cloud_s3_key = "sc-templates/test-product/stack.yaml" + cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" + s3_client = boto3.client("s3", region_name=region_name) + s3_client.create_bucket( + Bucket=cloud_bucket, + CreateBucketConfiguration={ + "LocationConstraint": region_name, + }, + ) + s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) + return cloud_url + @mock_servicecatalog def test_create_portfolio(): @@ -21,6 +44,7 @@ def test_create_portfolio(): @mock_servicecatalog def test_list_portfolios(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") assert len(client.list_portfolios()["PortfolioDetails"]) == 0 @@ -39,11 +63,31 @@ def test_list_portfolios(): @mock_servicecatalog +@mock_s3 def test_describe_provisioned_product(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.describe_provisioned_product() + region_name = "us-east-2" + cloud_url = _create_cf_template_in_s3(region_name) - raise Exception("NotYetImplemented") + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.create_product( + Name="test product", + Owner="owner arn", + Description="description", + SupportEmail="test@example.com", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Description": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + "Type": "CLOUD_FORMATION_TEMPLATE", + }, + IdempotencyToken=str(uuid.uuid4()), + ) + + resp = client.describe_provisioned_product( + Id=resp["ProductViewDetail"]["ProductViewSummary"]["Id"] + ) + print(resp) @mock_servicecatalog @@ -97,13 +141,6 @@ def test_list_provisioning_artifacts(): @mock_servicecatalog @mock_s3 def test_create_product(): - cloud_stack = """--- - Resources: - LocalBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: cfn-quickstart-bucket - """ region_name = "us-east-2" cloud_bucket = "cf-servicecatalog" cloud_s3_key = "sc-templates/test-product/stack.yaml" @@ -115,7 +152,7 @@ def test_create_product(): "LocationConstraint": region_name, }, ) - s3_client.put_object(Body=cloud_stack, Bucket=cloud_bucket, Key=cloud_s3_key) + s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) client = boto3.client("servicecatalog", region_name=region_name) resp = client.create_product( @@ -140,13 +177,6 @@ def test_create_product(): @mock_servicecatalog @mock_s3 def test_provision_product(): - cloud_stack = """--- - Resources: - LocalBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: cfn-quickstart-bucket - """ region_name = "us-east-2" cloud_bucket = "cf-servicecatalog" cloud_s3_key = "sc-templates/test-product/stack.yaml" @@ -158,7 +188,7 @@ def test_provision_product(): "LocationConstraint": region_name, }, ) - s3_client.put_object(Body=cloud_stack, Bucket=cloud_bucket, Key=cloud_s3_key) + s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) client = boto3.client("servicecatalog", region_name=region_name) @@ -192,6 +222,7 @@ def test_provision_product(): provisioned_product_response = client.provision_product( ProvisionedProductName=provisioning_product_name, ProvisioningArtifactId=provisioning_artifact_id, + PathId="asdf", ProductName=product_name, ) From fb07ca2f149f3131c24b5a70e199ff3642fb01e4 Mon Sep 17 00:00:00 2001 From: David Connor Date: Thu, 10 Aug 2023 15:28:45 +0100 Subject: [PATCH 03/20] Added additional endpoints and refactored tests to reuse instantiation code. --- moto/servicecatalog/models.py | 334 ++++++++++++++---- moto/servicecatalog/responses.py | 36 +- .../test_servicecatalog.py | 319 ++++++++++------- 3 files changed, 493 insertions(+), 196 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index e635a77c8c94..37e69dc690db 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -24,7 +24,7 @@ def __init__( idempotency_token: str, backend: "ServiceCatalogBackend", ): - self.portfolio_id = "p" + "".join( + self.portfolio_id = "port-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) self.created_date: datetime = unix_time() @@ -36,10 +36,19 @@ def __init__( self.idempotency_token = idempotency_token self.backend = backend + self.product_ids = list() + self.arn = f"arn:aws:servicecatalog:{region}::{self.portfolio_id}" self.tags = tags self.backend.tag_resource(self.arn, tags) + def link_product(self, product_id: str): + if product_id not in self.product_ids: + self.product_ids.append(product_id) + + def has_product(self, product_id: str): + return product_id in self.product_ids + def to_json(self) -> Dict[str, Any]: met = { "ARN": self.arn, @@ -101,10 +110,10 @@ def __init__( tags: Dict[str, str], backend: "ServiceCatalogBackend", ): - self.product_view_summary_id = "prodview" + "".join( + self.product_view_summary_id = "prodview-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) - self.product_id = "prod" + "".join( + self.product_id = "prod-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) self.created_time: datetime = unix_time() @@ -181,11 +190,105 @@ def to_json(self) -> Dict[str, Any]: return self.to_product_view_detail_json() +class Constraint(BaseModel): + def __init__( + self, + constraint_type: str, + product_id: str, + portfolio_id: str, + backend: "ServiceCatalogBackend", + parameters: str = "", + description: str = "", + owner: str = "", + ): + self.constraint_id = "cons-" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) + ) + + self.created_time: datetime = unix_time() + self.updated_time: datetime = self.created_time + + # LAUNCH + # NOTIFICATION + # RESOURCE_UPDATE + # STACKSET + # TEMPLATE + + self.constraint_type = constraint_type # "LAUNCH" + # "Launch as arn:aws:iam::811011756959:role/LaunchRoleBad", + self.description = description + self.owner = owner # account_id = 811011756959 + self.product_id = product_id + self.portfolio_id = portfolio_id + self.parameters = parameters + + self.backend = backend + + def to_create_constraint_json(self): + return { + "ConstraintId": self.constraint_id, + "Type": self.constraint_type, + "Description": self.description, + "Owner": self.owner, + "ProductId": self.product_id, + "PortfolioId": self.portfolio_id, + } + + +class LaunchPath(BaseModel): + def __init__(self, name: str, backend: "ServiceCatalogBackend"): + self.path_id = "lpv3-" + "".join( + random.choice(string.ascii_lowercase) for _ in range(12) + ) + + self.created_time: datetime = unix_time() + self.updated_time: datetime = self.created_time + + self.name = name + + self.backend = backend + # "LaunchPathSummaries": [ + # { + # "Id": "lpv3-w4u2yosjlxdkw", + # "ConstraintSummaries": [ + # { + # "Type": "LAUNCH", + # "Description": "Launch as arn:aws:iam::811011756959:role/LaunchRoleBad" + # } + # ], + # "Tags": [ + # { + # "Key": "tag1", + # "Value": "value1" + # }, + # { + # "Key": "tag1", + # "Value": "something" + # } + # ], + # "Name": "First Portfolio" + # } + # ] + # self.arn = f"arn:aws:servicecatalog:{self.region}::record/{self.record_id}" + # { + # "Id": "lpv3-w4u2yosjlxdkw", + # "Name": "First Portfolio" + # } + + class Record(BaseModel): def __init__( self, region: str, + product_id: str, + provisioned_product_id: str, + path_id: str, + provisioned_product_name: str, + provisioning_artifact_id: str, backend: "ServiceCatalogBackend", + record_type: str = "PROVISION_PRODUCT", + provisioned_product_type: str = "CFN_STACK", + status: str = "CREATED", ): self.record_id = "rec-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) @@ -195,9 +298,38 @@ def __init__( self.created_time: datetime = unix_time() self.updated_time: datetime = self.created_time + self.product_id = product_id + self.provisioned_product_id = provisioned_product_id + self.path_id = path_id + + self.provisioned_product_name = provisioned_product_name + self.provisioned_product_type = provisioned_product_type + self.provisioning_artifact_id = provisioning_artifact_id + self.record_type = record_type + self.record_errors = list() + self.record_tags = list() + self.status = status + self.backend = backend self.arn = f"arn:aws:servicecatalog:{self.region}::record/{self.record_id}" + def to_record_detail_json(self): + return { + "RecordId": self.record_id, + "CreatedTime": self.created_time, + "UpdatedTime": self.updated_time, + "ProvisionedProductId": self.provisioned_product_id, + "PathId": self.path_id, + "RecordErrors": self.record_errors, + "ProductId": self.product_id, + "RecordType": self.record_type, + "ProvisionedProductName": self.provisioned_product_name, + "ProvisioningArtifactId": self.provisioning_artifact_id, + "RecordTags": self.record_tags, + "Status": self.status, + "ProvisionedProductType": self.provisioned_product_type, + } + class ProvisionedProduct(BaseModel): def __init__( @@ -206,6 +338,10 @@ def __init__( accept_language: str, name: str, stack_id: str, + product_id: str, + provisioning_artifact_id: str, + path_id: str, + launch_role_arn: str, tags: Dict[str, str], backend: "ServiceCatalogBackend", ): @@ -222,10 +358,10 @@ def __init__( # self.product_type = product_type # PROVISION_PRODUCT, UPDATE_PROVISIONED_PRODUCT, TERMINATE_PROVISIONED_PRODUCT self.record_type = "PROVISION_PRODUCT" - self.product_id = "" - self.provisioning_artifcat_id = "" - self.path_id = "" - self.launch_role_arn = "" + self.product_id = product_id + self.provisioning_artifact_id = provisioning_artifact_id + self.path_id = path_id + self.launch_role_arn = launch_role_arn # self.records = link to records on actions self.status: str = ( @@ -238,22 +374,24 @@ def __init__( self.tags = tags self.backend.tag_resource(self.arn, tags) - def to_provisioned_product_detail_json(self) -> Dict[str, Any]: + def to_provisioned_product_detail_json( + self, last_record: "Record" + ) -> Dict[str, Any]: return { "Arn": self.arn, - "CreatedTime": self.created_date, - "Id": self.product_id, + "CreatedTime": self.created_time, + "Id": self.provisioned_product_id, "IdempotencyToken": "string", - "LastProvisioningRecordId": "string", # ProvisionedProduct, UpdateProvisionedProduct, ExecuteProvisionedProductPlan, TerminateProvisionedProduct - "LastRecordId": "string", - "LastSuccessfulProvisioningRecordId": "string", - "LaunchRoleArn": "string", + "LastProvisioningRecordId": last_record.record_id, # ProvisionedProduct, UpdateProvisionedProduct, ExecuteProvisionedProductPlan, TerminateProvisionedProduct + "LastRecordId": last_record.record_id, + "LastSuccessfulProvisioningRecordId": last_record.record_id, + "LaunchRoleArn": self.launch_role_arn, "Name": self.name, - "ProductId": "string", - "ProvisioningArtifactId": "string", + "ProductId": self.product_id, + "ProvisioningArtifactId": self.provisioning_artifact_id, "Status": "AVAILABLE", "StatusMessage": "string", - "Type": "string", + "Type": self.record_type, } @@ -266,6 +404,8 @@ def __init__(self, region_name, account_id): self.portfolios: Dict[str, Portfolio] = dict() self.products: Dict[str, Product] = dict() self.provisioned_products: Dict[str, ProvisionedProduct] = dict() + self.records: OrderedDict[str, Record] = dict() + self.constraints: Dict[str, Constraint] = dict() self.tagger = TaggingService() @@ -291,12 +431,6 @@ def create_portfolio( self.portfolios[portfolio.portfolio_id] = portfolio return portfolio, tags - def list_portfolios(self, accept_language, page_token): - # implement here - portfolio_details = list(self.portfolios.values()) - next_page_token = None - return portfolio_details, next_page_token - def create_product( self, accept_language, @@ -342,33 +476,41 @@ def create_product( return product_view_detail, provisioning_artifact_detail, tags - def describe_provisioned_product(self, accept_language, id, name): + def create_constraint( + self, + accept_language, + portfolio_id, + product_id, + parameters, + constraint_type, + description, + idempotency_token, + ): # implement here - if id: - product = self.products[id] - else: - # get by name - product = self.products[id] + constraint = Constraint( + backend=self, + product_id=product_id, + portfolio_id=portfolio_id, + constraint_type=constraint_type, + parameters=parameters, + ) + self.constraints[constraint.constraint_id] = constraint - # TODO - # "CloudWatchDashboards": [ - # { - # "Name": "string" - # } - # ], - provisioned_product_detail = product.to_provisioned_product_detail_json() - cloud_watch_dashboards = None - return provisioned_product_detail, cloud_watch_dashboards + constraint_detail = constraint.to_create_constraint_json() + constraint_parameters = constraint.parameters - def search_products( - self, accept_language, filters, sort_by, sort_order, page_token + # AVAILABLE | CREATING | FAILED + status = "AVAILABLE" + + return constraint_detail, constraint_parameters, status + + def associate_product_with_portfolio( + self, accept_language, product_id, portfolio_id, source_portfolio_id ): - # implement here - product_view_summaries = {} - product_view_aggregations = {} - next_page_token = {} - return product_view_summaries, product_view_aggregations, next_page_token + portfolio = self.portfolios[portfolio_id] + portfolio.link_product(product_id) + return def provision_product( self, @@ -388,20 +530,23 @@ def provision_product( ): # implement here # TODO: Big damn cleanup before this counts as anything useful. + + # Get product by id or name product = None for product_id, item in self.products.items(): if item.name == product_name: product = item + # Get specified provisioning artifact from product by id or name # search product for specific provision_artifact_id or name # TODO: ID vs name provisioning_artifact = product.get_provisioning_artifact( provisioning_artifact_id ) - # path + # Verify path exists for product by id or name - # Instantiate stack + # Create initial stack in CloudFormation stack = create_cloudformation_stack_from_template( stack_name=provisioned_product_name, account_id=self.account_id, @@ -409,35 +554,82 @@ def provision_product( template=provisioning_artifact.template, ) + # Outputs will be a provisioned product and a record provisioned_product = ProvisionedProduct( accept_language=accept_language, region=self.region_name, name=provisioned_product_name, stack_id=stack.stack_id, + product_id=product.product_id, + provisioning_artifact_id=provisioning_artifact.provisioning_artifact_id, + path_id="asdf", + launch_role_arn="asdf2", tags=[], backend=self, ) - record = Record(region=self.region_name, backend=self) - - # record object - - record_detail = { - "RecordId": record.record_id, - "CreatedTime": record.created_time, - "UpdatedTime": record.updated_time, - "ProvisionedProductId": provisioned_product.provisioned_product_id, - # "PathId": "lpv2-abcdg3jp6t5k6", - # "RecordErrors": [], - "ProductId": provisioned_product.product_id, - # "RecordType": "PROVISION_PRODUCT", - # "ProvisionedProductName": "mytestppname3", - # "ProvisioningArtifactId": "pa-pcz347abcdcfm", - # "RecordTags": [], - # "Status": "CREATED", - # "ProvisionedProductType": "CFN_STACK" - } - print(record_detail) - return record_detail + self.provisioned_products[ + provisioned_product.provisioned_product_id + ] = provisioned_product + + record = Record( + region=self.region_name, + backend=self, + product_id=product.product_id, + provisioned_product_id=provisioned_product.provisioned_product_id, + provisioned_product_name=provisioned_product_name, + path_id="", + provisioning_artifact_id=provisioning_artifact.provisioning_artifact_id, + ) + self.records[record.record_id] = record + return record.to_record_detail_json() + + def list_portfolios(self, accept_language, page_token): + # implement here + portfolio_details = list(self.portfolios.values()) + next_page_token = None + return portfolio_details, next_page_token + + def get_last_record_for_provisioned_product(self, provisioned_product_id: str): + for record_key, record in reversed(self.records.items()): + if record.provisioned_product_id == provisioned_product_id: + return record + raise Exception("TODO") + + def describe_provisioned_product(self, accept_language, id, name): + # implement here + + if id: + provisioned_product = self.provisioned_products[id] + else: + # get by name + provisioned_product = self.provisioned_products[id] + + # TODO + # "CloudWatchDashboards": [ + # { + # "Name": "string" + # } + # ], + + last_record = self.get_last_record_for_provisioned_product( + provisioned_product.provisioned_product_id + ) + + provisioned_product_detail = ( + provisioned_product.to_provisioned_product_detail_json(last_record) + ) + + cloud_watch_dashboards = None + return provisioned_product_detail, cloud_watch_dashboards + + def search_products( + self, accept_language, filters, sort_by, sort_order, page_token + ): + # implement here + product_view_summaries = {} + product_view_aggregations = {} + next_page_token = {} + return product_view_summaries, product_view_aggregations, next_page_token def search_provisioned_products( self, @@ -501,11 +693,5 @@ def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: tags_input = TaggingService.convert_dict_to_tags_input(tags or {}) self.tagger.tag_resource(resource_arn, tags_input) - def associate_product_with_portfolio( - self, accept_language, product_id, portfolio_id, source_portfolio_id - ): - # implement here - return - servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 4249be68a951..d37159fe4b92 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -74,8 +74,8 @@ def describe_provisioned_product(self): # TODO: adjust response return json.dumps( dict( - provisionedProductDetail=provisioned_product_detail, - cloudWatchDashboards=cloud_watch_dashboards, + ProvisionedProductDetail=provisioned_product_detail, + CloudWatchDashboards=cloud_watch_dashboards, ) ) @@ -243,7 +243,7 @@ def provision_product(self): provision_token=provision_token, ) # TODO: adjust response - return json.dumps(dict(recordDetail=record_detail)) + return json.dumps(dict(RecordDetail=record_detail)) def create_product(self): accept_language = self._get_param("AcceptLanguage") @@ -303,3 +303,33 @@ def associate_product_with_portfolio(self): ) # TODO: adjust response return json.dumps(dict()) + + def create_constraint(self): + accept_language = self._get_param("AcceptLanguage") + portfolio_id = self._get_param("PortfolioId") + product_id = self._get_param("ProductId") + parameters = self._get_param("Parameters") + constraint_type = self._get_param("Type") + description = self._get_param("Description") + idempotency_token = self._get_param("IdempotencyToken") + ( + constraint_detail, + constraint_parameters, + status, + ) = self.servicecatalog_backend.create_constraint( + accept_language=accept_language, + portfolio_id=portfolio_id, + product_id=product_id, + parameters=parameters, + constraint_type=constraint_type, + description=description, + idempotency_token=idempotency_token, + ) + # TODO: adjust response + return json.dumps( + dict( + ConstraintDetail=constraint_detail, + ConstraintParameters=constraint_parameters, + Status=status, + ) + ) diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index f724d84335de..e820634404d2 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -31,46 +31,22 @@ def _create_cf_template_in_s3(region_name: str): return cloud_url -@mock_servicecatalog -def test_create_portfolio(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.create_portfolio( - DisplayName="Test Portfolio", ProviderName="Test Provider" - ) - - assert resp is not None - assert "PortfolioDetail" in resp - - -@mock_servicecatalog -def test_list_portfolios(): - - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - - portfolio_id_1 = client.create_portfolio( - DisplayName="test-1", ProviderName="prov-1" - )["PortfolioDetail"]["Id"] - portfolio_id_2 = client.create_portfolio( - DisplayName="test-2", ProviderName="prov-1" - )["PortfolioDetail"]["Id"] - - assert len(client.list_portfolios()["PortfolioDetails"]) == 2 - portfolio_ids = [i["Id"] for i in client.list_portfolios()["PortfolioDetails"]] - - assert portfolio_id_1 in portfolio_ids - assert portfolio_id_2 in portfolio_ids - +def _create_default_product_with_portfolio( + region_name: str, portfolio_name: str, product_name: str +): -@mock_servicecatalog -@mock_s3 -def test_describe_provisioned_product(): - region_name = "us-east-2" cloud_url = _create_cf_template_in_s3(region_name) client = boto3.client("servicecatalog", region_name=region_name) - resp = client.create_product( - Name="test product", + + # Create portfolio + create_portfolio_response = client.create_portfolio( + DisplayName=portfolio_name, ProviderName="Test Provider" + ) + + # Create Product + create_product_response = client.create_product( + Name=product_name, Owner="owner arn", Description="description", SupportEmail="test@example.com", @@ -84,75 +60,66 @@ def test_describe_provisioned_product(): IdempotencyToken=str(uuid.uuid4()), ) - resp = client.describe_provisioned_product( - Id=resp["ProductViewDetail"]["ProductViewSummary"]["Id"] - ) - print(resp) - - -@mock_servicecatalog -def test_get_provisioned_product_outputs(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.get_provisioned_product_outputs() - - raise Exception("NotYetImplemented") - - -@mock_servicecatalog -def test_search_provisioned_products(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - resp = client.search_provisioned_products() - - raise Exception("NotYetImplemented") - + return create_portfolio_response, create_product_response -@mock_servicecatalog -def test_terminate_provisioned_product(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - resp = client.terminate_provisioned_product() - raise Exception("NotYetImplemented") - - -@mock_servicecatalog -def test_search_products(): - client = boto3.client("servicecatalog", region_name="us-east-2") - resp = client.search_products() +def _create_default_product_with_constraint( + region_name: str, portfolio_name: str, product_name: str +): + portfolio, product = _create_default_product_with_portfolio( + region_name=region_name, + portfolio_name=portfolio_name, + product_name=product_name, + ) + client = boto3.client("servicecatalog", region_name=region_name) + create_constraint_response = client.create_constraint( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) + return create_constraint_response, portfolio, product - raise Exception("NotYetImplemented") +def _create_provisioned_product( + region_name: str, + product_name: str, + provisioning_artifact_id: str, + provisioned_product_name: str, +): + client = boto3.client("servicecatalog", region_name=region_name) -@mock_servicecatalog -def test_list_launch_paths(): - client = boto3.client("servicecatalog", region_name="us-east-2") - resp = client.list_launch_paths() + stack_id = uuid.uuid4().hex + today = date.today() + today = today.strftime("%Y%m%d") + requesting_user = "test-user" + provisioning_product_name = requesting_user + "-" + today + "_" + stack_id - raise Exception("NotYetImplemented") + provisioned_product_response = client.provision_product( + ProvisionedProductName=provisioned_product_name, + ProvisioningArtifactId=provisioning_artifact_id, + PathId="asdf", + ProductName=product_name, + ) + return provisioned_product_response @mock_servicecatalog -def test_list_provisioning_artifacts(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - resp = client.list_provisioning_artifacts() +def test_create_portfolio(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.create_portfolio( + DisplayName="Test Portfolio", ProviderName="Test Provider" + ) - raise Exception("NotYetImplemented") + assert resp is not None + assert "PortfolioDetail" in resp @mock_servicecatalog @mock_s3 def test_create_product(): region_name = "us-east-2" - cloud_bucket = "cf-servicecatalog" - cloud_s3_key = "sc-templates/test-product/stack.yaml" - cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" - s3_client = boto3.client("s3", region_name=region_name) - s3_client.create_bucket( - Bucket=cloud_bucket, - CreateBucketConfiguration={ - "LocationConstraint": region_name, - }, - ) - s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) + cloud_url = _create_cf_template_in_s3(region_name=region_name) client = boto3.client("servicecatalog", region_name=region_name) resp = client.create_product( @@ -176,42 +143,61 @@ def test_create_product(): @mock_servicecatalog @mock_s3 -def test_provision_product(): +def test_create_constraint(): region_name = "us-east-2" - cloud_bucket = "cf-servicecatalog" - cloud_s3_key = "sc-templates/test-product/stack.yaml" - cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" - s3_client = boto3.client("s3", region_name=region_name) - s3_client.create_bucket( - Bucket=cloud_bucket, - CreateBucketConfiguration={ - "LocationConstraint": region_name, - }, + product_name = "test product" + + portfolio, product = _create_default_product_with_portfolio( + region_name=region_name, + portfolio_name="My Portfolio", + product_name=product_name, ) - s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) client = boto3.client("servicecatalog", region_name=region_name) + resp = client.create_constraint( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) - product_name = "test product" + assert "ConstraintDetail" in resp + assert "ConstraintParameters" in resp + assert resp["Status"] == "AVAILABLE" - create_product_response = client.create_product( - Name=product_name, - Owner="owner arn", - Description="description", - SupportEmail="test@example.com", - ProductType="CLOUD_FORMATION_TEMPLATE", - ProvisioningArtifactParameters={ - "Name": "InitialCreation", - "Description": "InitialCreation", - "Info": {"LoadTemplateFromURL": cloud_url}, - "Type": "CLOUD_FORMATION_TEMPLATE", - }, - IdempotencyToken=str(uuid.uuid4()), + +@mock_servicecatalog +@mock_s3 +def test_associate_product_with_portfolio(): + region_name = "us-east-2" + portfolio, product = _create_default_product_with_portfolio( + region_name=region_name, + product_name="My PRoduct", + portfolio_name="The Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.associate_product_with_portfolio( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], ) - provisioning_artifact_id = create_product_response["ProvisioningArtifactDetail"][ - "Id" - ] + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@mock_servicecatalog +@mock_s3 +def test_provision_product(): + region_name = "us-east-2" + product_name = "My PRoduct" + portfolio, product = _create_default_product_with_portfolio( + region_name=region_name, + product_name=product_name, + portfolio_name="The Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] stack_id = uuid.uuid4().hex today = date.today() @@ -225,7 +211,9 @@ def test_provision_product(): PathId="asdf", ProductName=product_name, ) + print(provisioned_product_response) + s3_client = boto3.client("s3", region_name=region_name) all_buckets_response = s3_client.list_buckets() bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] @@ -233,8 +221,101 @@ def test_provision_product(): @mock_servicecatalog -def test_associate_product_with_portfolio(): +def test_list_portfolios(): + + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 + + portfolio_id_1 = client.create_portfolio( + DisplayName="test-1", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] + portfolio_id_2 = client.create_portfolio( + DisplayName="test-2", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] + + assert len(client.list_portfolios()["PortfolioDetails"]) == 2 + portfolio_ids = [i["Id"] for i in client.list_portfolios()["PortfolioDetails"]] + + assert portfolio_id_1 in portfolio_ids + assert portfolio_id_2 in portfolio_ids + + +@mock_servicecatalog +@mock_s3 +def test_describe_provisioned_product(): + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name="My Provisioned Product", + ) + client = boto3.client("servicecatalog", region_name=region_name) + + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + resp = client.describe_provisioned_product(Id=provisioned_product_id) + + assert resp["ProvisionedProductDetail"]["Id"] == provisioned_product_id + assert resp["ProvisionedProductDetail"]["ProductId"] == product_id + assert ( + resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] + == provisioning_artifact_id + ) + + +@mock_servicecatalog +def test_get_provisioned_product_outputs(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.associate_product_with_portfolio() + resp = client.get_provisioned_product_outputs() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_search_provisioned_products(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.search_provisioned_products() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_terminate_provisioned_product(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.terminate_provisioned_product() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_search_products(): + client = boto3.client("servicecatalog", region_name="us-east-2") + resp = client.search_products() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_list_launch_paths(): + client = boto3.client("servicecatalog", region_name="us-east-2") + resp = client.list_launch_paths() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +def test_list_provisioning_artifacts(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + resp = client.list_provisioning_artifacts() raise Exception("NotYetImplemented") From 3bc7badf750c3cd300a8ae9d1a79d2d6584d7732 Mon Sep 17 00:00:00 2001 From: David Connor Date: Thu, 10 Aug 2023 16:57:54 +0100 Subject: [PATCH 04/20] Hooked up more happy path endpoints and tests --- moto/servicecatalog/models.py | 141 +++++++++++++++--- moto/servicecatalog/responses.py | 20 +-- .../test_servicecatalog.py | 120 +++++++++++++-- 3 files changed, 236 insertions(+), 45 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 37e69dc690db..c08cc1e57a30 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -37,6 +37,7 @@ def __init__( self.backend = backend self.product_ids = list() + self.launch_path = LaunchPath(name="LP?", backend=backend) self.arn = f"arn:aws:servicecatalog:{region}::{self.portfolio_id}" self.tags = tags @@ -95,6 +96,7 @@ def to_provisioning_artifact_detail_json(self) -> Dict[str, Any]: "Description": self.description, "Name": self.name, "Type": self.artifact_type, + "Guidance": "DEFAULT", } @@ -224,6 +226,12 @@ def __init__( self.backend = backend + def to_summary_json(self): + return { + "Type": self.constraint_type, + "Description": self.description, + } + def to_create_constraint_json(self): return { "ConstraintId": self.constraint_id, @@ -247,6 +255,11 @@ def __init__(self, name: str, backend: "ServiceCatalogBackend"): self.name = name self.backend = backend + + def to_json(self): + return { + "Id": self.path_id, + } # "LaunchPathSummaries": [ # { # "Id": "lpv3-w4u2yosjlxdkw", @@ -364,27 +377,27 @@ def __init__( self.launch_role_arn = launch_role_arn # self.records = link to records on actions + self.last_record_id = None + self.last_provisioning_record_id = None + self.last_successful_provisioning_record_id = None + self.status: str = ( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) self.backend = backend - self.arn = ( - f"arn:aws:servicecatalog:{region}::provisioned_product/{self.product_id}" - ) + self.arn = f"arn:aws:servicecatalog:{region}:ACCOUNT_ID::stack/{self.name}/{self.provisioned_product_id}" self.tags = tags self.backend.tag_resource(self.arn, tags) - def to_provisioned_product_detail_json( - self, last_record: "Record" - ) -> Dict[str, Any]: + def to_provisioned_product_detail_json(self) -> Dict[str, Any]: return { "Arn": self.arn, "CreatedTime": self.created_time, "Id": self.provisioned_product_id, "IdempotencyToken": "string", - "LastProvisioningRecordId": last_record.record_id, # ProvisionedProduct, UpdateProvisionedProduct, ExecuteProvisionedProductPlan, TerminateProvisionedProduct - "LastRecordId": last_record.record_id, - "LastSuccessfulProvisioningRecordId": last_record.record_id, + "LastProvisioningRecordId": self.last_provisioning_record_id, # ProvisionedProduct, UpdateProvisionedProduct, ExecuteProvisionedProductPlan, TerminateProvisionedProduct + "LastRecordId": self.last_record_id, + "LastSuccessfulProvisioningRecordId": self.last_successful_provisioning_record_id, "LaunchRoleArn": self.launch_role_arn, "Name": self.name, "ProductId": self.product_id, @@ -581,6 +594,11 @@ def provision_product( provisioning_artifact_id=provisioning_artifact.provisioning_artifact_id, ) self.records[record.record_id] = record + + provisioned_product.last_record_id = record.record_id + provisioned_product.last_successful_provisioning_record_id = record.record_id + provisioned_product.last_provisioning_record_id = record.record_id + return record.to_record_detail_json() def list_portfolios(self, accept_language, page_token): @@ -611,12 +629,8 @@ def describe_provisioned_product(self, accept_language, id, name): # } # ], - last_record = self.get_last_record_for_provisioned_product( - provisioned_product.provisioned_product_id - ) - provisioned_product_detail = ( - provisioned_product.to_provisioned_product_detail_json(last_record) + provisioned_product.to_provisioned_product_detail_json() ) cloud_watch_dashboards = None @@ -625,10 +639,19 @@ def describe_provisioned_product(self, accept_language, id, name): def search_products( self, accept_language, filters, sort_by, sort_order, page_token ): - # implement here - product_view_summaries = {} - product_view_aggregations = {} - next_page_token = {} + product_view_summaries = list() + if filters is None: + for key, product in self.products.items(): + product_view_summaries.append( + product.to_product_view_detail_json()["ProductViewSummary"] + ) + + product_view_aggregations = { + "Owner": list(), + "ProductType": list(), + "Vendor": list(), + } + next_page_token = None return product_view_summaries, product_view_aggregations, next_page_token def search_provisioned_products( @@ -641,21 +664,73 @@ def search_provisioned_products( page_token, ): # implement here - provisioned_products = {} - total_results_count = 0 + provisioned_products = list() + if filters is None: + provisioned_products = [ + value.to_provisioned_product_detail_json() + for key, value in self.provisioned_products.items() + ] + + total_results_count = len(provisioned_products) next_page_token = None return provisioned_products, total_results_count, next_page_token def list_launch_paths(self, accept_language, product_id, page_token): # implement here - launch_path_summaries = {} - next_page_token = None + product = self.products[product_id] + + launch_path_summaries = list() + + for portfolio_id, portfolio in self.portfolios.items(): + launch_path_detail = portfolio.launch_path.to_json() + launch_path_detail["Name"] = portfolio.display_name + launch_path_detail["ConstraintSummaries"] = list() + if product.product_id in portfolio.product_ids: + constraint = self.get_constraint_by( + product_id=product.product_id, portfolio_id=portfolio_id + ) + launch_path_detail["ConstraintSummaries"].append( + constraint.to_summary_json() + ) + launch_path_summaries.append(launch_path_detail) + next_page_token = None + # { + # "LaunchPathSummaries": [ + # { + # "Id": "lpv3-w4u2yosjlxdkw", + # "ConstraintSummaries": [ + # { + # "Type": "LAUNCH", + # "Description": "Launch as arn:aws:iam::811011756959:role/LaunchRoleBad" + # } + # ], + # "Tags": [ + # { + # "Key": "tag1", + # "Value": "value1" + # }, + # { + # "Key": "tag1", + # "Value": "something" + # } + # ], + # "Name": "First Portfolio" + # } + # ] + # } return launch_path_summaries, next_page_token def list_provisioning_artifacts(self, accept_language, product_id): # implement here - provisioning_artifact_details = {} + + product = self.products[product_id] + provisioning_artifact_details = list() + for artifact_id, artifact in product.provisioning_artifacts.items(): + provisioning_artifact_details.append( + artifact.to_provisioning_artifact_detail_json() + ) + next_page_token = None return provisioning_artifact_details, next_page_token @@ -669,6 +744,18 @@ def get_provisioned_product_outputs( page_token, ): # implement here + provisioned_product = self.provisioned_products[provisioned_product_id] + # + # { + # "Outputs": [ + # { + # "OutputKey": "CloudformationStackARN", + # "OutputValue": "arn:aws:cloudformation:us-east-1:811011756959:stack/SC-811011756959-pp-vvva3s2aetxma/9426ece0-36ba-11ee-8ee2-0a01e48885af", + # "Description": "The ARN of the launched Cloudformation Stack" + # } + # ] + # } + outputs = {} next_page_token = None return outputs, next_page_token @@ -693,5 +780,13 @@ def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: tags_input = TaggingService.convert_dict_to_tags_input(tags or {}) self.tagger.tag_resource(resource_arn, tags_input) + def get_constraint_by(self, product_id: str, portfolio_id: str): + for constraint_id, constraint in self.constraints.items(): + if ( + constraint.product_id == product_id + and constraint.portfolio_id == portfolio_id + ): + return constraint + servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index d37159fe4b92..918f9b27f496 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -99,7 +99,7 @@ def get_provisioned_product_outputs(self): page_token=page_token, ) # TODO: adjust response - return json.dumps(dict(outputs=outputs, nextPageToken=next_page_token)) + return json.dumps(dict(Outputs=outputs, NextPageToken=next_page_token)) def search_provisioned_products(self): accept_language = self._get_param("AcceptLanguage") @@ -124,9 +124,9 @@ def search_provisioned_products(self): # TODO: adjust response return json.dumps( dict( - provisionedProducts=provisioned_products, - totalResultsCount=total_results_count, - nextPageToken=next_page_token, + ProvisionedProducts=provisioned_products, + TotalResultsCount=total_results_count, + NextPageToken=next_page_token, ) ) @@ -169,9 +169,9 @@ def search_products(self): # TODO: adjust response return json.dumps( dict( - productViewSummaries=product_view_summaries, - productViewAggregations=product_view_aggregations, - nextPageToken=next_page_token, + ProductViewSummaries=product_view_summaries, + ProductViewAggregations=product_view_aggregations, + NextPageToken=next_page_token, ) ) @@ -191,7 +191,7 @@ def list_launch_paths(self): # TODO: adjust response return json.dumps( dict( - launchPathSummaries=launch_path_summaries, nextPageToken=next_page_token + LaunchPathSummaries=launch_path_summaries, NextPageToken=next_page_token ) ) @@ -208,8 +208,8 @@ def list_provisioning_artifacts(self): # TODO: adjust response return json.dumps( dict( - provisioningArtifactDetails=provisioning_artifact_details, - nextPageToken=next_page_token, + ProvisioningArtifactDetails=provisioning_artifact_details, + NextPageToken=next_page_token, ) ) diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index e820634404d2..7faf6f6c9ef5 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -60,6 +60,13 @@ def _create_default_product_with_portfolio( IdempotencyToken=str(uuid.uuid4()), ) + # Associate product to portfolio + resp = client.associate_product_with_portfolio( + PortfolioId=create_portfolio_response["PortfolioDetail"]["Id"], + ProductId=create_product_response["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + ) return create_portfolio_response, create_product_response @@ -274,19 +281,63 @@ def test_describe_provisioned_product(): @mock_servicecatalog +@mock_s3 def test_get_provisioned_product_outputs(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.get_provisioned_product_outputs() + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.get_provisioned_product_outputs( + ProvisonedProductId=provisioned_product_id + ) raise Exception("NotYetImplemented") @mock_servicecatalog +@mock_s3 def test_search_provisioned_products(): - client = boto3.client("servicecatalog", region_name="eu-west-1") + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.search_provisioned_products() - raise Exception("NotYetImplemented") + pps = resp["ProvisionedProducts"] + assert len(pps) == 1 + assert pps[0]["Id"] == provisioned_product_id @mock_servicecatalog @@ -298,24 +349,69 @@ def test_terminate_provisioned_product(): @mock_servicecatalog +@mock_s3 def test_search_products(): - client = boto3.client("servicecatalog", region_name="us-east-2") + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) resp = client.search_products() - raise Exception("NotYetImplemented") + products = resp["ProductViewSummaries"] + + assert len(products) == 1 + assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] + assert ( + products[0]["ProductId"] + == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) @mock_servicecatalog +@mock_s3 def test_list_launch_paths(): - client = boto3.client("servicecatalog", region_name="us-east-2") - resp = client.list_launch_paths() + region_name = "us-east-2" + product_name = "test product" - raise Exception("NotYetImplemented") + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.list_launch_paths(ProductId=product_id) + + lps = resp["LaunchPathSummaries"] + assert len(lps) == 1 + assert len(lps[0]["ConstraintSummaries"]) == 1 + assert lps[0]["ConstraintSummaries"][0]["Type"] == "LAUNCH" @mock_servicecatalog +@mock_s3 def test_list_provisioning_artifacts(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - resp = client.list_provisioning_artifacts() + region_name = "us-east-2" + product_name = "test product" - raise Exception("NotYetImplemented") + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.list_provisioning_artifacts(ProductId=product_id) + + pad = resp["ProvisioningArtifactDetails"] + assert len(pad) == 1 + assert pad[0]["Id"] == product["ProvisioningArtifactDetail"]["Id"] From 6d5587d93616ee5c3585a46a6a2372beac8fb1f8 Mon Sep 17 00:00:00 2001 From: David Connor Date: Thu, 10 Aug 2023 17:57:29 +0100 Subject: [PATCH 05/20] Added outputs of cloud formation to provisioned product --- moto/servicecatalog/models.py | 62 +++++++++++-------- .../test_servicecatalog.py | 17 ++++- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index c08cc1e57a30..e241eea0bd10 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -351,6 +351,7 @@ def __init__( accept_language: str, name: str, stack_id: str, + stack_outputs, product_id: str, provisioning_artifact_id: str, path_id: str, @@ -381,16 +382,19 @@ def __init__( self.last_provisioning_record_id = None self.last_successful_provisioning_record_id = None + # CF info + self.stack_id = stack_id + self.stack_outputs = stack_outputs self.status: str = ( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) self.backend = backend self.arn = f"arn:aws:servicecatalog:{region}:ACCOUNT_ID::stack/{self.name}/{self.provisioned_product_id}" self.tags = tags - self.backend.tag_resource(self.arn, tags) + # self.backend.tag_resource(self.arn, tags) - def to_provisioned_product_detail_json(self) -> Dict[str, Any]: - return { + def to_provisioned_product_detail_json(self, include_tags=False) -> Dict[str, Any]: + detail = { "Arn": self.arn, "CreatedTime": self.created_time, "Id": self.provisioned_product_id, @@ -406,6 +410,9 @@ def to_provisioned_product_detail_json(self) -> Dict[str, Any]: "StatusMessage": "string", "Type": self.record_type, } + if include_tags: + detail["Tags"] = self.tags + return detail class ServiceCatalogBackend(BaseBackend): @@ -567,17 +574,21 @@ def provision_product( template=provisioning_artifact.template, ) + if tags is None: + tags = list() + # Outputs will be a provisioned product and a record provisioned_product = ProvisionedProduct( accept_language=accept_language, region=self.region_name, name=provisioned_product_name, stack_id=stack.stack_id, + stack_outputs=stack.stack_outputs, product_id=product.product_id, provisioning_artifact_id=provisioning_artifact.provisioning_artifact_id, path_id="asdf", launch_role_arn="asdf2", - tags=[], + tags=tags, backend=self, ) self.provisioned_products[ @@ -640,11 +651,11 @@ def search_products( self, accept_language, filters, sort_by, sort_order, page_token ): product_view_summaries = list() - if filters is None: - for key, product in self.products.items(): - product_view_summaries.append( - product.to_product_view_detail_json()["ProductViewSummary"] - ) + # TODO: Filter + for key, product in self.products.items(): + product_view_summaries.append( + product.to_product_view_detail_json()["ProductViewSummary"] + ) product_view_aggregations = { "Owner": list(), @@ -665,11 +676,12 @@ def search_provisioned_products( ): # implement here provisioned_products = list() - if filters is None: - provisioned_products = [ - value.to_provisioned_product_detail_json() - for key, value in self.provisioned_products.items() - ] + + # TODO: Filter + provisioned_products = [ + value.to_provisioned_product_detail_json(include_tags=True) + for key, value in self.provisioned_products.items() + ] total_results_count = len(provisioned_products) next_page_token = None @@ -745,18 +757,18 @@ def get_provisioned_product_outputs( ): # implement here provisioned_product = self.provisioned_products[provisioned_product_id] - # - # { - # "Outputs": [ - # { - # "OutputKey": "CloudformationStackARN", - # "OutputValue": "arn:aws:cloudformation:us-east-1:811011756959:stack/SC-811011756959-pp-vvva3s2aetxma/9426ece0-36ba-11ee-8ee2-0a01e48885af", - # "Description": "The ARN of the launched Cloudformation Stack" - # } - # ] - # } - outputs = {} + outputs = [] + + for output in provisioned_product.stack_outputs: + outputs.append( + { + "OutputKey": output.key, + "OutputValue": output.value, + "Description": output.description, + } + ) + next_page_token = None return outputs, next_page_token diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 7faf6f6c9ef5..044f84b89560 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -13,6 +13,10 @@ Type: AWS::S3::Bucket Properties: BucketName: cfn-quickstart-bucket +Outputs: + WebsiteURL: + Value: !GetAtt LocalBucket.WebsiteURL + Description: URL for website hosted on S3 """ @@ -107,6 +111,10 @@ def _create_provisioned_product( ProvisioningArtifactId=provisioning_artifact_id, PathId="asdf", ProductName=product_name, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], ) return provisioned_product_response @@ -217,6 +225,10 @@ def test_provision_product(): ProvisioningArtifactId=provisioning_artifact_id, PathId="asdf", ProductName=product_name, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], ) print(provisioned_product_response) @@ -304,10 +316,11 @@ def test_get_provisioned_product_outputs(): client = boto3.client("servicecatalog", region_name=region_name) resp = client.get_provisioned_product_outputs( - ProvisonedProductId=provisioned_product_id + ProvisionedProductId=provisioned_product_id ) - raise Exception("NotYetImplemented") + assert len(resp["Outputs"]) == 1 + assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" @mock_servicecatalog From 2654d504cbbfdc8ec5ece82df8ec11e8111e0449 Mon Sep 17 00:00:00 2001 From: David Connor Date: Fri, 11 Aug 2023 10:15:57 +0100 Subject: [PATCH 06/20] Search product by name or id --- moto/servicecatalog/models.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index e241eea0bd10..743f40ba6078 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -553,9 +553,12 @@ def provision_product( # Get product by id or name product = None - for product_id, item in self.products.items(): - if item.name == product_name: - product = item + if product_id: + product = self.products[product_id] + else: + for product_id, item in self.products.items(): + if item.name == product_name: + product = item # Get specified provisioning artifact from product by id or name # search product for specific provision_artifact_id or name From 8341ea7c81e8a40ff2bd5e5bbe6fdec8507db203 Mon Sep 17 00:00:00 2001 From: David Connor Date: Fri, 11 Aug 2023 10:16:07 +0100 Subject: [PATCH 07/20] Terminate product instance stub included --- moto/servicecatalog/models.py | 22 +++++++++++++- moto/servicecatalog/responses.py | 2 +- .../test_servicecatalog.py | 30 +++++++++++++++++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 743f40ba6078..677cf625ccc7 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -785,7 +785,27 @@ def terminate_provisioned_product( retain_physical_resources, ): # implement here - record_detail = {} + provisioned_product = self.provisioned_products[provisioned_product_id] + + record = Record( + region=self.region_name, + backend=self, + product_id=provisioned_product.product_id, + provisioned_product_id=provisioned_product.provisioned_product_id, + provisioned_product_name=provisioned_product.name, + path_id="", + provisioning_artifact_id=provisioned_product.provisioning_artifact_id, + record_type="TERMINATE_PROVISIONED_PRODUCT", + ) + + self.records[record.record_id] = record + + provisioned_product.last_record_id = record.record_id + provisioned_product.last_successful_provisioning_record_id = record.record_id + provisioned_product.last_provisioning_record_id = record.record_id + + record_detail = record.to_record_detail_json() + return record_detail def get_tags(self, resource_id: str) -> Dict[str, str]: diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 918f9b27f496..0db9a1628976 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -146,7 +146,7 @@ def terminate_provisioned_product(self): retain_physical_resources=retain_physical_resources, ) # TODO: adjust response - return json.dumps(dict(recordDetail=record_detail)) + return json.dumps(dict(RecordDetail=record_detail)) def search_products(self): accept_language = self._get_param("AcceptLanguage") diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 044f84b89560..9cfd11696f1f 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -354,11 +354,35 @@ def test_search_provisioned_products(): @mock_servicecatalog +@mock_s3 def test_terminate_provisioned_product(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - resp = client.terminate_provisioned_product() + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.terminate_provisioned_product( + ProvisionedProductId=provisioned_product_id + ) - raise Exception("NotYetImplemented") + rec = resp["RecordDetail"] + assert rec["RecordType"] == "TERMINATE_PROVISIONED_PRODUCT" + assert rec["ProductId"] == product_id + assert rec["ProvisionedProductId"] == provisioned_product_id @mock_servicecatalog From 69e8f5cdb3fb1028a338f538a4034b2fff944987 Mon Sep 17 00:00:00 2001 From: David Connor Date: Mon, 14 Aug 2023 11:27:25 +0100 Subject: [PATCH 08/20] Initial exception handling --- moto/servicecatalog/exceptions.py | 47 ++++++++++ moto/servicecatalog/models.py | 89 ++++++++++++------- .../test_servicecatalog.py | 52 +++++++++++ 3 files changed, 154 insertions(+), 34 deletions(-) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 3908afa515be..8c639410bdbe 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -1,3 +1,50 @@ """Exceptions raised by the servicecatalog service.""" from moto.core.exceptions import JsonRESTError +import json + +class ServiceCatalogClientError(JsonRESTError): + code = 400 + + +class ResourceNotFoundException(ServiceCatalogClientError): + code = 404 + + def __init__(self, message: str, resource_id: str, resource_type: str): + super().__init__("ResourceNotFoundException", message) + self.description = json.dumps( + { + "resourceId": resource_id, + "message": self.message, + "resourceType": resource_type, + } + ) + + +class ProductNotFound(ResourceNotFoundException): + code = 404 + + def __init__(self, product_id: str): + super().__init__( + "Product not found", + resource_id=product_id, + resource_type="AWS::ServiceCatalog::Product", + ) + + +class PortfolioNotFound(ResourceNotFoundException): + code = 404 + + def __init__(self, identifier: str, identifier_name: str): + super().__init__( + "ResourceNotFoundException", + resource_id=f"{identifier_name}={identifier}", + resource_type="AWS::ServiceCatalog::Portfolio", + ) + + +class InvalidParametersException(ServiceCatalogClientError): + code = 400 + + def __init__(self, message: str): + super().__init__("InvalidParametersException", message) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 677cf625ccc7..5cf13d941246 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -10,6 +10,7 @@ from moto.cloudformation.utils import get_stack_from_s3_url from .utils import create_cloudformation_stack_from_template +from .exceptions import ProductNotFound, PortfolioNotFound, InvalidParametersException class Portfolio(BaseModel): @@ -39,7 +40,9 @@ def __init__( self.product_ids = list() self.launch_path = LaunchPath(name="LP?", backend=backend) - self.arn = f"arn:aws:servicecatalog:{region}::{self.portfolio_id}" + self.arn = ( + f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:portfolio/{self.portfolio_id}" + ) self.tags = tags self.backend.tag_resource(self.arn, tags) @@ -129,7 +132,9 @@ def __init__( self.provisioning_artifacts: OrderedDict[str, "ProvisioningArtifact"] = dict() self.backend = backend - self.arn = f"arn:aws:servicecatalog:{region}::product/{self.product_id}" + self.arn = ( + f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:product/{self.product_id}" + ) self.tags = tags self.backend.tag_resource(self.arn, tags) @@ -389,7 +394,7 @@ def __init__( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) self.backend = backend - self.arn = f"arn:aws:servicecatalog:{region}:ACCOUNT_ID::stack/{self.name}/{self.provisioned_product_id}" + self.arn = f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:stack/{self.name}/{self.provisioned_product_id}" self.tags = tags # self.backend.tag_resource(self.arn, tags) @@ -429,6 +434,28 @@ def __init__(self, region_name, account_id): self.tagger = TaggingService() + def _get_product(self, identifier: str = "", name: str = "") -> bool: + if identifier in self.products: + return self.products[identifier] + + for product_id, product in self.products.items(): + if product.name == name: + return product + + raise ProductNotFound(product_id=identifier or name) + + def _get_portfolio(self, identifier: str = "", name: str = "") -> bool: + if identifier in self.portfolios: + return self.portfolios[identifier] + + for portfolio_id, portfolio in self.portfolios.items(): + if portfolio.display_name == name: + return portfolio + + raise PortfolioNotFound( + identifier=identifier or name, identifier_name="Name" if name else "Id" + ) + def create_portfolio( self, accept_language, @@ -438,6 +465,14 @@ def create_portfolio( tags, idempotency_token, ): + try: + self._get_portfolio(name=display_name) + raise InvalidParametersException( + message="Portfolio with this name already exists" + ) + except PortfolioNotFound: + pass + portfolio = Portfolio( region=self.region_name, accept_language=accept_language, @@ -467,7 +502,13 @@ def create_product( idempotency_token, source_connection, ): - # implement here + try: + self._get_product(name=name) + raise InvalidParametersException( + message="Product with this name already exists" + ) + except ProductNotFound: + pass product = Product( region=self.region_name, @@ -483,8 +524,10 @@ def create_product( provisioning_artifact = product._create_provisioning_artifact( account_id=self.account_id, name=provisioning_artifact_parameters["Name"], - description=provisioning_artifact_parameters["Description"], - artifact_type=provisioning_artifact_parameters["Type"], + description=provisioning_artifact_parameters.get("Description"), + artifact_type=provisioning_artifact_parameters.get( + "Type", "CLOUD_FORMATION_TEMPLATE" + ), info=provisioning_artifact_parameters["Info"], ) self.products[product.product_id] = product @@ -528,8 +571,11 @@ def create_constraint( def associate_product_with_portfolio( self, accept_language, product_id, portfolio_id, source_portfolio_id ): - portfolio = self.portfolios[portfolio_id] - portfolio.link_product(product_id) + # source_portfolio_id - not implemented yet as copying portfolios not implemented + product = self._get_product(identifier=product_id) + portfolio = self._get_portfolio(identifier=portfolio_id) + portfolio.link_product(product.product_id) + return def provision_product( @@ -678,8 +724,6 @@ def search_provisioned_products( page_token, ): # implement here - provisioned_products = list() - # TODO: Filter provisioned_products = [ value.to_provisioned_product_detail_json(include_tags=True) @@ -710,30 +754,7 @@ def list_launch_paths(self, accept_language, product_id, page_token): launch_path_summaries.append(launch_path_detail) next_page_token = None - # { - # "LaunchPathSummaries": [ - # { - # "Id": "lpv3-w4u2yosjlxdkw", - # "ConstraintSummaries": [ - # { - # "Type": "LAUNCH", - # "Description": "Launch as arn:aws:iam::811011756959:role/LaunchRoleBad" - # } - # ], - # "Tags": [ - # { - # "Key": "tag1", - # "Value": "value1" - # }, - # { - # "Key": "tag1", - # "Value": "something" - # } - # ], - # "Name": "First Portfolio" - # } - # ] - # } + return launch_path_summaries, next_page_token def list_provisioning_artifacts(self, accept_language, product_id): diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 9cfd11696f1f..3d79c7bc374d 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -1,8 +1,10 @@ """Unit tests for servicecatalog-supported APIs.""" +import pytest import boto3 import uuid from datetime import date from moto import mock_servicecatalog, mock_s3 +from botocore.exceptions import ClientError, ParamValidationError # See our Development Tips on writing tests for hints on how to write good tests: # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html @@ -130,6 +132,21 @@ def test_create_portfolio(): assert "PortfolioDetail" in resp +@mock_servicecatalog +def test_create_portfolio_duplicate(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + client.create_portfolio(DisplayName="Test Portfolio", ProviderName="Test Provider") + + with pytest.raises(ClientError) as exc: + client.create_portfolio( + DisplayName="Test Portfolio", ProviderName="Test Provider" + ) + + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Portfolio with this name already exists" + + @mock_servicecatalog @mock_s3 def test_create_product(): @@ -156,6 +173,41 @@ def test_create_product(): assert "ProvisioningArtifactDetail" in resp +@mock_servicecatalog +@mock_s3 +def test_create_product_duplicate(): + region_name = "us-east-2" + cloud_url = _create_cf_template_in_s3(region_name=region_name) + + client = boto3.client("servicecatalog", region_name=region_name) + client.create_product( + Name="test product", + Owner="owner arn", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + }, + IdempotencyToken=str(uuid.uuid4()), + ) + + with pytest.raises(ClientError) as exc: + client.create_product( + Name="test product", + Owner="owner arn", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + }, + IdempotencyToken=str(uuid.uuid4()), + ) + + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Product with this name already exists" + + @mock_servicecatalog @mock_s3 def test_create_constraint(): From d62aaa2da8fae332adaa043cf766cfa42538da49 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 15 Aug 2023 16:49:04 +0100 Subject: [PATCH 09/20] Added additional endpoints to support terraform testing --- moto/backend_index.py | 10 +- moto/servicecatalog/exceptions.py | 2 +- moto/servicecatalog/models.py | 102 ++++++++++++- moto/servicecatalog/responses.py | 142 ++++++++++++++++++ tests/test_servicecatalog/test_server.py | 27 +++- .../test_servicecatalog.py | 114 ++++++++++++++ 6 files changed, 385 insertions(+), 12 deletions(-) diff --git a/moto/backend_index.py b/moto/backend_index.py index 477175fb23c0..262320ee26d6 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -1,4 +1,4 @@ -# autogenerated by scripts/update_backend_index.py +# autogenerated by /Users/david.connor/Projects/epi2me-one-backend/moto/./scripts/update_backend_index.py import re backend_url_patterns = [ @@ -110,12 +110,9 @@ ), ( "meteringmarketplace", - re.compile("https?://metering.marketplace\\.(.+)\\.amazonaws.com"), - ), - ( - "meteringmarketplace", - re.compile("https?://aws-marketplace\\.(.+)\\.amazonaws.com"), + re.compile("https?://metering.marketplace.(.+).amazonaws.com"), ), + ("meteringmarketplace", re.compile("https?://aws-marketplace.(.+).amazonaws.com")), ("mq", re.compile("https?://mq\\.(.+)\\.amazonaws\\.com")), ("opsworks", re.compile("https?://opsworks\\.us-east-1\\.amazonaws.com")), ("organizations", re.compile("https?://organizations\\.(.+)\\.amazonaws\\.com")), @@ -155,6 +152,7 @@ ("scheduler", re.compile("https?://scheduler\\.(.+)\\.amazonaws\\.com")), ("sdb", re.compile("https?://sdb\\.(.+)\\.amazonaws\\.com")), ("secretsmanager", re.compile("https?://secretsmanager\\.(.+)\\.amazonaws\\.com")), + ("servicecatalog", re.compile("https?://servicecatalog\\.(.+)\\.amazonaws\\.com")), ( "servicediscovery", re.compile("https?://servicediscovery\\.(.+)\\.amazonaws\\.com"), diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 8c639410bdbe..462e0904d1ce 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -37,7 +37,7 @@ class PortfolioNotFound(ResourceNotFoundException): def __init__(self, identifier: str, identifier_name: str): super().__init__( - "ResourceNotFoundException", + "Portfolio not found", resource_id=f"{identifier_name}={identifier}", resource_type="AWS::ServiceCatalog::Portfolio", ) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 5cf13d941246..4e44b3ffd8f5 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -44,7 +44,7 @@ def __init__( f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:portfolio/{self.portfolio_id}" ) self.tags = tags - self.backend.tag_resource(self.arn, tags) + # self.backend.tag_resource(self.arn, tags) def link_product(self, product_id: str): if product_id not in self.product_ids: @@ -136,7 +136,7 @@ def __init__( f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:product/{self.product_id}" ) self.tags = tags - self.backend.tag_resource(self.arn, tags) + # self.backend.tag_resource(self.arn, tags) def get_provisioning_artifact(self, artifact_id: str): return self.provisioning_artifacts[artifact_id] @@ -844,5 +844,103 @@ def get_constraint_by(self, product_id: str, portfolio_id: str): ): return constraint + def describe_portfolio(self, accept_language, identifier): + portfolio = self._get_portfolio(identifier=identifier) + portfolio_detail = portfolio.to_json() + + tags = [] + tag_options = None + budgets = None + return portfolio_detail, tags, tag_options, budgets + + def describe_product_as_admin( + self, accept_language, identifier, name, source_portfolio_id + ): + # implement here + product = self._get_product(identifier=identifier, name=name) + product_view_detail = product.to_product_view_detail_json() + + provisioning_artifact_summaries = [] + for key, summary in product.provisioning_artifacts.items(): + provisioning_artifact_summaries.append( + summary.to_provisioning_artifact_detail_json() + ) + tags = [] + tag_options = None + budgets = None + + return ( + product_view_detail, + provisioning_artifact_summaries, + tags, + tag_options, + budgets, + ) + + def describe_product(self, accept_language, identifier, name): + + ( + product_view_summary, + provisioning_artifacts, + _, + _, + budgets, + ) = self.describe_product_as_admin(accept_language, identifier, name) + launch_paths = [] + return product_view_summary, provisioning_artifacts, budgets, launch_paths + + def update_portfolio( + self, + accept_language, + identifier, + display_name, + description, + provider_name, + add_tags, + remove_tags, + ): + portfolio = self._get_portfolio(identifier=identifier) + + portfolio.display_name = display_name + portfolio.description = description + portfolio.provider_name = provider_name + + # tags + + portfolio_detail = portfolio.to_json() + tags = [] + + return portfolio_detail, tags + + def update_product( + self, + accept_language, + identifier, + name, + owner, + description, + distributor, + support_description, + support_email, + support_url, + add_tags, + remove_tags, + source_connection, + ): + # implement here + product_view_detail = {} + tags = [] + return product_view_detail, tags + + def list_portfolios_for_product(self, accept_language, product_id, page_token): + portfolio_details = [] + for portfolio_id, portfolio in self.portfolios.items(): + if product_id in portfolio.product_ids: + portfolio_details.append(portfolio.to_json()) + + next_page_token = None + + return portfolio_details, next_page_token + servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 0db9a1628976..2704d57b8ab7 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -333,3 +333,145 @@ def create_constraint(self): Status=status, ) ) + + def describe_portfolio(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + ( + portfolio_detail, + tags, + tag_options, + budgets, + ) = self.servicecatalog_backend.describe_portfolio( + accept_language=accept_language, + identifier=identifier, + ) + # TODO: adjust response + return json.dumps( + dict( + PortfolioDetail=portfolio_detail, + Tags=tags, + TagOptions=tag_options, + Budgets=budgets, + ) + ) + + def describe_product_as_admin(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + name = self._get_param("Name") + source_portfolio_id = self._get_param("SourcePortfolioId") + ( + product_view_detail, + provisioning_artifact_summaries, + tags, + tag_options, + budgets, + ) = self.servicecatalog_backend.describe_product_as_admin( + accept_language=accept_language, + identifier=identifier, + name=name, + source_portfolio_id=source_portfolio_id, + ) + # TODO: adjust response + return json.dumps( + dict( + ProductViewDetail=product_view_detail, + ProvisioningArtifactSummaries=provisioning_artifact_summaries, + Tags=tags, + TagOptions=tag_options, + Budgets=budgets, + ) + ) + + def describe_product(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + name = self._get_param("Name") + ( + product_view_summary, + provisioning_artifacts, + budgets, + launch_paths, + ) = self.servicecatalog_backend.describe_product( + accept_language=accept_language, + identifier=identifier, + name=name, + ) + # TODO: adjust response + return json.dumps( + dict( + ProductViewSummary=product_view_summary, + ProvisioningArtifacts=provisioning_artifacts, + Budgets=budgets, + LaunchPaths=launch_paths, + ) + ) + + def update_portfolio(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + display_name = self._get_param("DisplayName") + description = self._get_param("Description") + provider_name = self._get_param("ProviderName") + add_tags = self._get_param("AddTags") + remove_tags = self._get_param("RemoveTags") + portfolio_detail, tags = self.servicecatalog_backend.update_portfolio( + accept_language=accept_language, + identifier=identifier, + display_name=display_name, + description=description, + provider_name=provider_name, + add_tags=add_tags, + remove_tags=remove_tags, + ) + # TODO: adjust response + return json.dumps(dict(PortfolioDetail=portfolio_detail, Tags=tags)) + + def update_product(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + name = self._get_param("Name") + owner = self._get_param("Owner") + description = self._get_param("Description") + distributor = self._get_param("Distributor") + support_description = self._get_param("SupportDescription") + support_email = self._get_param("SupportEmail") + support_url = self._get_param("SupportUrl") + add_tags = self._get_param("AddTags") + remove_tags = self._get_param("RemoveTags") + source_connection = self._get_param("SourceConnection") + product_view_detail, tags = self.servicecatalog_backend.update_product( + accept_language=accept_language, + identifier=identifier, + name=name, + owner=owner, + description=description, + distributor=distributor, + support_description=support_description, + support_email=support_email, + support_url=support_url, + add_tags=add_tags, + remove_tags=remove_tags, + source_connection=source_connection, + ) + # TODO: adjust response + return json.dumps(dict(ProductViewDetail=product_view_detail, Tags=tags)) + + def list_portfolios_for_product(self): + accept_language = self._get_param("AcceptLanguage") + product_id = self._get_param("ProductId") + page_token = self._get_param("PageToken") + page_size = self._get_param("PageSize") + ( + portfolio_details, + next_page_token, + ) = self.servicecatalog_backend.list_portfolios_for_product( + accept_language=accept_language, + product_id=product_id, + page_token=page_token, + ) + # TODO: adjust response + return json.dumps( + dict(PortfolioDetails=portfolio_details, NextPageToken=next_page_token) + ) diff --git a/tests/test_servicecatalog/test_server.py b/tests/test_servicecatalog/test_server.py index a12e0d949937..d66179a07fd3 100644 --- a/tests/test_servicecatalog/test_server.py +++ b/tests/test_servicecatalog/test_server.py @@ -1,13 +1,34 @@ """Test different server responses.""" import moto.server as server +from moto import mock_servicecatalog, mock_s3 -def test_servicecatalog_list(): +@mock_servicecatalog +def test_servicecatalog_create_portfolio(): backend = server.create_backend_app("servicecatalog") test_client = backend.test_client() - resp = test_client.get("/") + resp = test_client.post( + "/", + data={"Name": "Portfolio Name"}, + headers={"X-Amz-Target": "servicecatalog.CreatePortfolio"}, + ) assert resp.status_code == 200 - assert "?" in str(resp.data) \ No newline at end of file + assert "PortfolioDetail" in str(resp.data) + + +@mock_servicecatalog +def test_servicecatalog_create_product(): + backend = server.create_backend_app("servicecatalog") + test_client = backend.test_client() + + resp = test_client.post( + "/", + data={"Name": "Portfolio Name"}, + headers={"X-Amz-Target": "servicecatalog.CreateProduct"}, + ) + + assert resp.status_code == 200 + assert "PortfolioDetail" in str(resp.data) diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 3d79c7bc374d..7ba05f9bf145 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -311,6 +311,34 @@ def test_list_portfolios(): assert portfolio_id_2 in portfolio_ids +@mock_servicecatalog +def test_describe_portfolio(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 + + portfolio_id = client.create_portfolio(DisplayName="test-1", ProviderName="prov-1")[ + "PortfolioDetail" + ]["Id"] + + portfolio_response = client.describe_portfolio(Id=portfolio_id) + assert portfolio_id == portfolio_response["PortfolioDetail"]["Id"] + + +@mock_servicecatalog +def test_describe_portfolio_not_existing(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 + + portfolio_id = "not-found" + + with pytest.raises(ClientError) as exc: + portfolio_response = client.describe_portfolio(Id=portfolio_id) + + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Portfolio not found" + + @mock_servicecatalog @mock_s3 def test_describe_provisioned_product(): @@ -504,3 +532,89 @@ def test_list_provisioning_artifacts(): pad = resp["ProvisioningArtifactDetails"] assert len(pad) == 1 assert pad[0]["Id"] == product["ProvisioningArtifactDetail"]["Id"] + + +@mock_servicecatalog +@mock_s3 +def test_describe_product_as_admin(): + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.describe_product_as_admin(Id=product_id) + + assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id + assert len(resp["ProvisioningArtifactSummaries"]) == 1 + + +@mock_servicecatalog +@mock_s3 +def test_describe_product(): + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_default_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.describe_product_as_admin(Id=product_id) + + assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id + assert len(resp["ProvisioningArtifactSummaries"]) == 1 + + +@mock_servicecatalog +@mock_s3 +def test_update_portfolio(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + + create_portfolio_response = client.create_portfolio( + DisplayName="Original Name", ProviderName="Test Provider" + ) + + portfolio_id = create_portfolio_response["PortfolioDetail"]["Id"] + new_portfolio_name = "New Portfolio Name" + resp = client.update_portfolio( + Id=portfolio_id, + DisplayName=new_portfolio_name, + ) + + assert resp["PortfolioDetail"]["Id"] == portfolio_id + assert resp["PortfolioDetail"]["DisplayName"] == new_portfolio_name + + +@mock_servicecatalog +@mock_s3 +def test_update_product(): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.update_product() + + raise Exception("NotYetImplemented") + + +@mock_servicecatalog +@mock_s3 +def test_list_portfolios_for_product(): + region_name = "us-east-2" + product_name = "test product" + + portfolio, product = _create_default_product_with_portfolio( + region_name=region_name, + portfolio_name="My Portfolio", + product_name=product_name, + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.list_portfolios_for_product(ProductId=product_id) + + assert resp["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] From ad6ac3cf9c5fe2bbd5b82c188306f2e0fff336a0 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 15 Aug 2023 17:00:37 +0100 Subject: [PATCH 10/20] Implementation coverage output for servicecatalog endpoints --- IMPLEMENTATION_COVERAGE.md | 98 ++++++++++++++++++++- docs/docs/services/servicecatalog.rst | 120 ++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 docs/docs/services/servicecatalog.rst diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index c787190af453..a904f326453b 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -6488,6 +6488,102 @@ - [ ] untag_resource +## servicecatalog +
+21% implemented + +- [ ] accept_portfolio_share +- [ ] associate_budget_with_resource +- [ ] associate_principal_with_portfolio +- [X] associate_product_with_portfolio +- [ ] associate_service_action_with_provisioning_artifact +- [ ] associate_tag_option_with_resource +- [ ] batch_associate_service_action_with_provisioning_artifact +- [ ] batch_disassociate_service_action_from_provisioning_artifact +- [ ] copy_product +- [X] create_constraint +- [X] create_portfolio +- [ ] create_portfolio_share +- [X] create_product +- [ ] create_provisioned_product_plan +- [ ] create_provisioning_artifact +- [ ] create_service_action +- [ ] create_tag_option +- [ ] delete_constraint +- [ ] delete_portfolio +- [ ] delete_portfolio_share +- [ ] delete_product +- [ ] delete_provisioned_product_plan +- [ ] delete_provisioning_artifact +- [ ] delete_service_action +- [ ] delete_tag_option +- [ ] describe_constraint +- [ ] describe_copy_product_status +- [X] describe_portfolio +- [ ] describe_portfolio_share_status +- [ ] describe_portfolio_shares +- [X] describe_product +- [X] describe_product_as_admin +- [ ] describe_product_view +- [X] describe_provisioned_product +- [ ] describe_provisioned_product_plan +- [ ] describe_provisioning_artifact +- [ ] describe_provisioning_parameters +- [ ] describe_record +- [ ] describe_service_action +- [ ] describe_service_action_execution_parameters +- [ ] describe_tag_option +- [ ] disable_aws_organizations_access +- [ ] disassociate_budget_from_resource +- [ ] disassociate_principal_from_portfolio +- [ ] disassociate_product_from_portfolio +- [ ] disassociate_service_action_from_provisioning_artifact +- [ ] disassociate_tag_option_from_resource +- [ ] enable_aws_organizations_access +- [ ] execute_provisioned_product_plan +- [ ] execute_provisioned_product_service_action +- [ ] get_aws_organizations_access_status +- [X] get_provisioned_product_outputs +- [ ] import_as_provisioned_product +- [ ] list_accepted_portfolio_shares +- [ ] list_budgets_for_resource +- [ ] list_constraints_for_portfolio +- [X] list_launch_paths +- [ ] list_organization_portfolio_access +- [ ] list_portfolio_access +- [X] list_portfolios +- [X] list_portfolios_for_product +- [ ] list_principals_for_portfolio +- [ ] list_provisioned_product_plans +- [X] list_provisioning_artifacts +- [ ] list_provisioning_artifacts_for_service_action +- [ ] list_record_history +- [ ] list_resources_for_tag_option +- [ ] list_service_actions +- [ ] list_service_actions_for_provisioning_artifact +- [ ] list_stack_instances_for_provisioned_product +- [ ] list_tag_options +- [ ] notify_provision_product_engine_workflow_result +- [ ] notify_terminate_provisioned_product_engine_workflow_result +- [ ] notify_update_provisioned_product_engine_workflow_result +- [X] provision_product +- [ ] reject_portfolio_share +- [ ] scan_provisioned_products +- [X] search_products +- [ ] search_products_as_admin +- [X] search_provisioned_products +- [X] terminate_provisioned_product +- [ ] update_constraint +- [X] update_portfolio +- [ ] update_portfolio_share +- [X] update_product +- [ ] update_provisioned_product +- [ ] update_provisioned_product_properties +- [ ] update_provisioning_artifact +- [ ] update_service_action +- [ ] update_tag_option +
+ ## servicediscovery
61% implemented @@ -7486,4 +7582,4 @@ - workspaces - workspaces-web - xray -
\ No newline at end of file + diff --git a/docs/docs/services/servicecatalog.rst b/docs/docs/services/servicecatalog.rst new file mode 100644 index 000000000000..c25dfd4b2633 --- /dev/null +++ b/docs/docs/services/servicecatalog.rst @@ -0,0 +1,120 @@ +.. _implementedservice_servicecatalog: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +============== +servicecatalog +============== + +.. autoclass:: moto.servicecatalog.models.ServiceCatalogBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_servicecatalog + def test_servicecatalog_behaviour: + boto3.client("servicecatalog") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] accept_portfolio_share +- [ ] associate_budget_with_resource +- [ ] associate_principal_with_portfolio +- [X] associate_product_with_portfolio +- [ ] associate_service_action_with_provisioning_artifact +- [ ] associate_tag_option_with_resource +- [ ] batch_associate_service_action_with_provisioning_artifact +- [ ] batch_disassociate_service_action_from_provisioning_artifact +- [ ] copy_product +- [X] create_constraint +- [X] create_portfolio +- [ ] create_portfolio_share +- [X] create_product +- [ ] create_provisioned_product_plan +- [ ] create_provisioning_artifact +- [ ] create_service_action +- [ ] create_tag_option +- [ ] delete_constraint +- [ ] delete_portfolio +- [ ] delete_portfolio_share +- [ ] delete_product +- [ ] delete_provisioned_product_plan +- [ ] delete_provisioning_artifact +- [ ] delete_service_action +- [ ] delete_tag_option +- [ ] describe_constraint +- [ ] describe_copy_product_status +- [X] describe_portfolio +- [ ] describe_portfolio_share_status +- [ ] describe_portfolio_shares +- [X] describe_product +- [X] describe_product_as_admin +- [ ] describe_product_view +- [X] describe_provisioned_product +- [ ] describe_provisioned_product_plan +- [ ] describe_provisioning_artifact +- [ ] describe_provisioning_parameters +- [ ] describe_record +- [ ] describe_service_action +- [ ] describe_service_action_execution_parameters +- [ ] describe_tag_option +- [ ] disable_aws_organizations_access +- [ ] disassociate_budget_from_resource +- [ ] disassociate_principal_from_portfolio +- [ ] disassociate_product_from_portfolio +- [ ] disassociate_service_action_from_provisioning_artifact +- [ ] disassociate_tag_option_from_resource +- [ ] enable_aws_organizations_access +- [ ] execute_provisioned_product_plan +- [ ] execute_provisioned_product_service_action +- [ ] get_aws_organizations_access_status +- [X] get_provisioned_product_outputs +- [ ] import_as_provisioned_product +- [ ] list_accepted_portfolio_shares +- [ ] list_budgets_for_resource +- [ ] list_constraints_for_portfolio +- [X] list_launch_paths +- [ ] list_organization_portfolio_access +- [ ] list_portfolio_access +- [X] list_portfolios +- [X] list_portfolios_for_product +- [ ] list_principals_for_portfolio +- [ ] list_provisioned_product_plans +- [X] list_provisioning_artifacts +- [ ] list_provisioning_artifacts_for_service_action +- [ ] list_record_history +- [ ] list_resources_for_tag_option +- [ ] list_service_actions +- [ ] list_service_actions_for_provisioning_artifact +- [ ] list_stack_instances_for_provisioned_product +- [ ] list_tag_options +- [ ] notify_provision_product_engine_workflow_result +- [ ] notify_terminate_provisioned_product_engine_workflow_result +- [ ] notify_update_provisioned_product_engine_workflow_result +- [X] provision_product +- [ ] reject_portfolio_share +- [ ] scan_provisioned_products +- [X] search_products +- [ ] search_products_as_admin +- [X] search_provisioned_products +- [X] terminate_provisioned_product +- [ ] update_constraint +- [X] update_portfolio +- [ ] update_portfolio_share +- [X] update_product +- [ ] update_provisioned_product +- [ ] update_provisioned_product_properties +- [ ] update_provisioning_artifact +- [ ] update_service_action +- [ ] update_tag_option + From 2b5a18356800c1f05e7ad8d69b26ee0af6a32574 Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 16 Aug 2023 11:57:28 +0100 Subject: [PATCH 11/20] Cleanup of subset of tests including ARN generation --- moto/servicecatalog/exceptions.py | 9 +- moto/servicecatalog/models.py | 132 ++++++++++---- .../test_servicecatalog.py | 167 +++++++++++++----- 3 files changed, 218 insertions(+), 90 deletions(-) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 462e0904d1ce..6bc6d709798f 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -11,9 +11,10 @@ class ResourceNotFoundException(ServiceCatalogClientError): code = 404 def __init__(self, message: str, resource_id: str, resource_type: str): - super().__init__("ResourceNotFoundException", message) + super().__init__(error_type="ResourceNotFoundException", message=message) self.description = json.dumps( { + "__type": self.error_type, "resourceId": resource_id, "message": self.message, "resourceType": resource_type, @@ -26,7 +27,7 @@ class ProductNotFound(ResourceNotFoundException): def __init__(self, product_id: str): super().__init__( - "Product not found", + message="Product not found", resource_id=product_id, resource_type="AWS::ServiceCatalog::Product", ) @@ -37,7 +38,7 @@ class PortfolioNotFound(ResourceNotFoundException): def __init__(self, identifier: str, identifier_name: str): super().__init__( - "Portfolio not found", + message="Portfolio not found", resource_id=f"{identifier_name}={identifier}", resource_type="AWS::ServiceCatalog::Portfolio", ) @@ -47,4 +48,4 @@ class InvalidParametersException(ServiceCatalogClientError): code = 400 def __init__(self, message: str): - super().__init__("InvalidParametersException", message) + super().__init__(error_type="InvalidParametersException", message=message) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 4e44b3ffd8f5..fc61f9425427 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -25,26 +25,25 @@ def __init__( idempotency_token: str, backend: "ServiceCatalogBackend", ): + self.backend = backend self.portfolio_id = "port-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) - self.created_date: datetime = unix_time() self.region = region + + self.arn = f"arn:aws:servicecatalog:{self.region}:{self.backend.account_id}:portfolio/{self.portfolio_id}" + self.created_date: datetime = unix_time() + self.accept_language = accept_language self.display_name = display_name self.description = description self.provider_name = provider_name self.idempotency_token = idempotency_token - self.backend = backend self.product_ids = list() self.launch_path = LaunchPath(name="LP?", backend=backend) - self.arn = ( - f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:portfolio/{self.portfolio_id}" - ) - self.tags = tags - # self.backend.tag_resource(self.arn, tags) + self.backend.tag_resource(self.arn, tags) def link_product(self, product_id: str): if product_id not in self.product_ids: @@ -64,6 +63,10 @@ def to_json(self) -> Dict[str, Any]: } return met + @property + def tags(self): + return self.backend.get_tags(self.arn) + class ProvisioningArtifact(BaseModel): def __init__( @@ -114,29 +117,46 @@ def __init__( product_type: str, tags: Dict[str, str], backend: "ServiceCatalogBackend", + distributor: str = "", + support_email: str = "", + support_url: str = "", + support_description: str = "", + source_connection: str = "", ): + self.backend = backend self.product_view_summary_id = "prodview-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) self.product_id = "prod-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) - self.created_time: datetime = unix_time() self.region = region + self.arn = f"arn:aws:servicecatalog:{self.region}:{self.backend.account_id}:product/{self.product_id}" + + self.created_time: datetime = unix_time() + self.accept_language = accept_language self.name = name self.description = description self.owner = owner self.product_type = product_type + self.distributor = distributor + self.support_email = support_email + self.support_url = support_url + self.support_description = support_description + self.source_connection = source_connection self.provisioning_artifacts: OrderedDict[str, "ProvisioningArtifact"] = dict() - self.backend = backend - self.arn = ( - f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:product/{self.product_id}" - ) - self.tags = tags - # self.backend.tag_resource(self.arn, tags) + self.backend.tag_resource(self.arn, tags) + + # TODO: Implement `status` provisioning as a moto state machine to emulate the product deployment process. + # At the moment, the process is synchronous and goes straight to status=AVAILABLE + self.status = "AVAILABLE" + + @property + def tags(self): + return self.backend.get_tags(self.arn) def get_provisioning_artifact(self, artifact_id: str): return self.provisioning_artifacts[artifact_id] @@ -186,11 +206,13 @@ def to_product_view_detail_json(self) -> Dict[str, Any]: "Owner": self.owner, "ShortDescription": self.description, "Type": self.product_type, - # "Distributor": "Some person", + "Distributor": self.distributor, + "SupportEmail": self.support_email, + "SupportUrl": self.support_url, + "SupportDescription": self.support_description, # "HasDefaultPath": false, - # "SupportEmail": "frank@stallone.example" }, - "Status": "AVAILABLE", + "Status": self.status, } def to_json(self) -> Dict[str, Any]: @@ -364,15 +386,19 @@ def __init__( tags: Dict[str, str], backend: "ServiceCatalogBackend", ): + self.backend = backend self.provisioned_product_id = "pp-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) + self.region = region + self.name = name + self.arn = f"arn:aws:servicecatalog:{self.region}:{self.backend.account_id}:stack/{self.name}/{self.provisioned_product_id}" + self.created_time: datetime = unix_time() self.updated_time: datetime = self.created_time - self.region = region + self.accept_language = accept_language - self.name = name # CFN_STACK, CFN_STACKSET, TERRAFORM_OPEN_SOURCE, TERRAFORM_CLOUD # self.product_type = product_type # PROVISION_PRODUCT, UPDATE_PROVISIONED_PRODUCT, TERMINATE_PROVISIONED_PRODUCT @@ -393,10 +419,12 @@ def __init__( self.status: str = ( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) - self.backend = backend - self.arn = f"arn:aws:servicecatalog:{region}:ACCOUNT_ID:stack/{self.name}/{self.provisioned_product_id}" - self.tags = tags - # self.backend.tag_resource(self.arn, tags) + + self.backend.tag_resource(self.arn, tags) + + @property + def tags(self): + return self.backend.get_tags(self.arn) def to_provisioned_product_detail_json(self, include_tags=False) -> Dict[str, Any]: detail = { @@ -484,7 +512,10 @@ def create_portfolio( backend=self, ) self.portfolios[portfolio.portfolio_id] = portfolio - return portfolio, tags + + output_tags = portfolio.tags + + return portfolio, output_tags def create_product( self, @@ -517,6 +548,11 @@ def create_product( product_type=product_type, name=name, description=description, + distributor=distributor, + support_email=support_email, + support_url=support_url, + support_description=support_description, + source_connection=source_connection, tags=tags, backend=self, ) @@ -537,7 +573,9 @@ def create_product( provisioning_artifact.to_provisioning_artifact_detail_json() ) - return product_view_detail, provisioning_artifact_detail, tags + output_tags = product.tags + + return product_view_detail, provisioning_artifact_detail, output_tags def create_constraint( self, @@ -624,7 +662,7 @@ def provision_product( ) if tags is None: - tags = list() + tags = [] # Outputs will be a provisioned product and a record provisioned_product = ProvisionedProduct( @@ -830,11 +868,16 @@ def terminate_provisioned_product( return record_detail def get_tags(self, resource_id: str) -> Dict[str, str]: - return self.tagger.get_tag_dict_for_resource(resource_id) + """ + Returns tags in original input format: + [{"Key":"A key", "Value:"A Value"},...] + """ + return self.tagger.convert_dict_to_tags_input( + self.tagger.get_tag_dict_for_resource(resource_id) + ) def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: - tags_input = TaggingService.convert_dict_to_tags_input(tags or {}) - self.tagger.tag_resource(resource_arn, tags_input) + self.tagger.tag_resource(resource_arn, tags) def get_constraint_by(self, product_id: str, portfolio_id: str): for constraint_id, constraint in self.constraints.items(): @@ -878,16 +921,24 @@ def describe_product_as_admin( ) def describe_product(self, accept_language, identifier, name): + product = self._get_product(identifier=identifier, name=name) + product_view_detail = product.to_product_view_detail_json() - ( - product_view_summary, - provisioning_artifacts, - _, - _, - budgets, - ) = self.describe_product_as_admin(accept_language, identifier, name) + provisioning_artifact_summaries = [] + for key, summary in product.provisioning_artifacts.items(): + provisioning_artifact_summaries.append( + summary.to_provisioning_artifact_detail_json() + ) + + budgets = None launch_paths = [] - return product_view_summary, provisioning_artifacts, budgets, launch_paths + + return ( + product_view_detail, + provisioning_artifact_summaries, + budgets, + launch_paths, + ) def update_portfolio( self, @@ -927,8 +978,11 @@ def update_product( remove_tags, source_connection, ): - # implement here - product_view_detail = {} + product = self._get_product(identifier=identifier) + + product.name = name + + product_view_detail = product.to_product_view_detail_json() tags = [] return product_view_detail, tags diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 7ba05f9bf145..5f87853c384f 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -23,6 +23,9 @@ def _create_cf_template_in_s3(region_name: str): + """ + Creates a bucket and uploads a cloudformation template to be used in product provisioning + """ cloud_bucket = "cf-servicecatalog" cloud_s3_key = "sc-templates/test-product/stack.yaml" cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" @@ -37,19 +40,19 @@ def _create_cf_template_in_s3(region_name: str): return cloud_url -def _create_default_product_with_portfolio( - region_name: str, portfolio_name: str, product_name: str -): - - cloud_url = _create_cf_template_in_s3(region_name) - +def _create_portfolio(region_name: str, portfolio_name: str): client = boto3.client("servicecatalog", region_name=region_name) # Create portfolio create_portfolio_response = client.create_portfolio( DisplayName=portfolio_name, ProviderName="Test Provider" ) + return create_portfolio_response + +def _create_product(region_name: str, product_name: str): + cloud_url = _create_cf_template_in_s3(region_name) + client = boto3.client("servicecatalog", region_name=region_name) # Create Product create_product_response = client.create_product( Name=product_name, @@ -65,9 +68,27 @@ def _create_default_product_with_portfolio( }, IdempotencyToken=str(uuid.uuid4()), ) + return create_product_response + + +def _create_default_product_with_portfolio( + region_name: str, portfolio_name: str, product_name: str +): + """ + Create a portfolio and product with a uploaded cloud formation template + """ + + create_portfolio_response = _create_portfolio( + region_name=region_name, portfolio_name=portfolio_name + ) + create_product_response = _create_product( + region_name=region_name, product_name=product_name + ) + + client = boto3.client("servicecatalog", region_name=region_name) # Associate product to portfolio - resp = client.associate_product_with_portfolio( + client.associate_product_with_portfolio( PortfolioId=create_portfolio_response["PortfolioDetail"]["Id"], ProductId=create_product_response["ProductViewDetail"]["ProductViewSummary"][ "ProductId" @@ -77,8 +98,15 @@ def _create_default_product_with_portfolio( def _create_default_product_with_constraint( - region_name: str, portfolio_name: str, product_name: str + region_name: str, + portfolio_name: str, + product_name: str, + role_arn: str = "arn:aws:iam::123456789012:role/LaunchRole", ): + """ + Create a portfolio and product with a uploaded cloud formation template including + a launch constraint on the roleARN + """ portfolio, product = _create_default_product_with_portfolio( region_name=region_name, portfolio_name=portfolio_name, @@ -88,7 +116,7 @@ def _create_default_product_with_constraint( create_constraint_response = client.create_constraint( PortfolioId=portfolio["PortfolioDetail"]["Id"], ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Parameters=f"""{{"RoleArn": "{role_arn}"}}""", Type="LAUNCH", ) return create_constraint_response, portfolio, product @@ -100,18 +128,15 @@ def _create_provisioned_product( provisioning_artifact_id: str, provisioned_product_name: str, ): + """ + Create a provisioned product from the specified product_name + """ client = boto3.client("servicecatalog", region_name=region_name) - - stack_id = uuid.uuid4().hex - today = date.today() - today = today.strftime("%Y%m%d") - requesting_user = "test-user" - provisioning_product_name = requesting_user + "-" + today + "_" + stack_id - + # TODO: Path from launch object provisioned_product_response = client.provision_product( ProvisionedProductName=provisioned_product_name, ProvisioningArtifactId=provisioning_artifact_id, - PathId="asdf", + PathId="TODO: Launch path", ProductName=product_name, Tags=[ {"Key": "MyCustomTag", "Value": "A Value"}, @@ -125,11 +150,22 @@ def _create_provisioned_product( def test_create_portfolio(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") resp = client.create_portfolio( - DisplayName="Test Portfolio", ProviderName="Test Provider" + DisplayName="Test Portfolio", + ProviderName="Test Provider", + Tags=[ + {"Key": "FirstTag", "Value": "FirstTagValue"}, + {"Key": "SecondTag", "Value": "SecondTagValue"}, + ], ) - assert resp is not None assert "PortfolioDetail" in resp + portfolio = resp["PortfolioDetail"] + assert portfolio["DisplayName"] == "Test Portfolio" + assert portfolio["ProviderName"] == "Test Provider" + assert "Tags" in resp + assert len(resp["Tags"]) == 2 + assert resp["Tags"][0]["Key"] == "FirstTag" + assert resp["Tags"][0]["Value"] == "FirstTagValue" @mock_servicecatalog @@ -167,10 +203,29 @@ def test_create_product(): "Type": "CLOUD_FORMATION_TEMPLATE", }, IdempotencyToken=str(uuid.uuid4()), + Tags=[ + {"Key": "FirstTag", "Value": "FirstTagValue"}, + {"Key": "SecondTag", "Value": "SecondTagValue"}, + ], ) - # TODO: Much more comprehensive + assert "ProductViewDetail" in resp + assert resp["ProductViewDetail"]["Status"] == "AVAILABLE" + product = resp["ProductViewDetail"]["ProductViewSummary"] + assert product["Name"] == "test product" + assert product["Owner"] == "owner arn" + assert product["ShortDescription"] == "description" + assert product["SupportEmail"] == "test@example.com" + assert "ProvisioningArtifactDetail" in resp + artifact = resp["ProvisioningArtifactDetail"] + assert artifact["Name"] == "InitialCreation" + assert artifact["Type"] == "CLOUD_FORMATION_TEMPLATE" + + assert "Tags" in resp + assert len(resp["Tags"]) == 2 + assert resp["Tags"][0]["Key"] == "FirstTag" + assert resp["Tags"][0]["Value"] == "FirstTagValue" @mock_servicecatalog @@ -212,12 +267,10 @@ def test_create_product_duplicate(): @mock_s3 def test_create_constraint(): region_name = "us-east-2" - product_name = "test product" - portfolio, product = _create_default_product_with_portfolio( region_name=region_name, portfolio_name="My Portfolio", - product_name=product_name, + product_name="test product", ) client = boto3.client("servicecatalog", region_name=region_name) @@ -237,26 +290,36 @@ def test_create_constraint(): @mock_s3 def test_associate_product_with_portfolio(): region_name = "us-east-2" - portfolio, product = _create_default_product_with_portfolio( - region_name=region_name, - product_name="My PRoduct", - portfolio_name="The Portfolio", + + portfolio = _create_portfolio( + region_name=region_name, portfolio_name="The Portfolio" ) + product = _create_product(region_name=region_name, product_name="My Product") + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + # Verify product is not linked to portfolio client = boto3.client("servicecatalog", region_name=region_name) + linked = client.list_portfolios_for_product(ProductId=product_id) + assert len(linked["PortfolioDetails"]) == 0 + + # Link product to portfolio resp = client.associate_product_with_portfolio( PortfolioId=portfolio["PortfolioDetail"]["Id"], ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], ) - assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + # Verify product is now linked to portfolio + linked = client.list_portfolios_for_product(ProductId=product_id) + assert len(linked["PortfolioDetails"]) == 1 + assert linked["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] + @mock_servicecatalog @mock_s3 def test_provision_product(): region_name = "us-east-2" - product_name = "My PRoduct" + product_name = "My Product" portfolio, product = _create_default_product_with_portfolio( region_name=region_name, product_name=product_name, @@ -266,34 +329,43 @@ def test_provision_product(): client = boto3.client("servicecatalog", region_name=region_name) provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - stack_id = uuid.uuid4().hex - today = date.today() - today = today.strftime("%Y%m%d") - requesting_user = "test-user" - provisioning_product_name = requesting_user + "-" + today + "_" + stack_id - + # TODO: Paths provisioned_product_response = client.provision_product( - ProvisionedProductName=provisioning_product_name, + ProvisionedProductName="Provisioned Product Name", ProvisioningArtifactId=provisioning_artifact_id, - PathId="asdf", + PathId="TODO", ProductName=product_name, Tags=[ {"Key": "MyCustomTag", "Value": "A Value"}, {"Key": "MyOtherTag", "Value": "Another Value"}, ], ) - print(provisioned_product_response) + provisioned_product_id = provisioned_product_response["RecordDetail"][ + "ProvisionedProductId" + ] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + # Verify record details + rec = provisioned_product_response["RecordDetail"] + assert rec["ProvisionedProductName"] == "Provisioned Product Name" + assert rec["Status"] == "CREATED" + assert rec["ProductId"] == product_id + assert rec["ProvisionedProductId"] == provisioned_product_id + assert rec["ProvisionedProductType"] == "CFN_STACK" + assert rec["ProvisioningArtifactId"] == provisioning_artifact_id + assert rec["PathId"] == "" + assert rec["RecordType"] == "PROVISION_PRODUCT" + # tags + # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" s3_client = boto3.client("s3", region_name=region_name) all_buckets_response = s3_client.list_buckets() bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] - assert "cfn-quickstart-bucket" in bucket_names @mock_servicecatalog def test_list_portfolios(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") assert len(client.list_portfolios()["PortfolioDetails"]) == 0 @@ -329,13 +401,11 @@ def test_describe_portfolio_not_existing(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - portfolio_id = "not-found" - with pytest.raises(ClientError) as exc: - portfolio_response = client.describe_portfolio(Id=portfolio_id) + client.describe_portfolio(Id="not-found") err = exc.value.response["Error"] - assert err["Code"] == "InvalidParametersException" + assert err["Code"] == "ResourceNotFoundException" assert err["Message"] == "Portfolio not found" @@ -595,10 +665,13 @@ def test_update_portfolio(): @mock_servicecatalog @mock_s3 def test_update_product(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.update_product() + product = _create_product(region_name="ap-southeast-1", product_name="Test Product") + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - raise Exception("NotYetImplemented") + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.update_product(Id=product_id, Name="New Product Name") + new_product = resp["ProductViewDetail"]["ProductViewSummary"] + assert new_product["Name"] == "New Product Name" @mock_servicecatalog From 50a669fe7bd27bf200a308ed869b31c3d38b66c7 Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 16 Aug 2023 16:22:50 +0100 Subject: [PATCH 12/20] Added filters to search-provisioned-product and describe-record --- moto/servicecatalog/__init__.py | 2 +- moto/servicecatalog/exceptions.py | 22 ++ moto/servicecatalog/models.py | 123 ++++++-- moto/servicecatalog/responses.py | 69 ++++- .../test_servicecatalog.py | 265 ++++++++++++++---- 5 files changed, 386 insertions(+), 95 deletions(-) diff --git a/moto/servicecatalog/__init__.py b/moto/servicecatalog/__init__.py index d608da9e6c29..64425dbccbf0 100644 --- a/moto/servicecatalog/__init__.py +++ b/moto/servicecatalog/__init__.py @@ -2,4 +2,4 @@ from .models import servicecatalog_backends from ..core.models import base_decorator -mock_servicecatalog = base_decorator(servicecatalog_backends) \ No newline at end of file +mock_servicecatalog = base_decorator(servicecatalog_backends) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 6bc6d709798f..500c9063b9de 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -44,6 +44,28 @@ def __init__(self, identifier: str, identifier_name: str): ) +class ProvisionedProductNotFound(ResourceNotFoundException): + code = 404 + + def __init__(self, identifier: str, identifier_name: str): + super().__init__( + message="Provisioned product not found", + resource_id=f"{identifier_name}={identifier}", + resource_type="AWS::ServiceCatalog::Product", + ) + + +class RecordNotFound(ResourceNotFoundException): + code = 404 + + def __init__(self, identifier: str): + super().__init__( + message="Record not found", + resource_id=identifier, + resource_type="AWS::ServiceCatalog::Record", + ) + + class InvalidParametersException(ServiceCatalogClientError): code = 400 diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index fc61f9425427..ec39c87ff05d 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -1,6 +1,6 @@ """ServiceCatalogBackend class with methods for supported APIs.""" import string -from typing import Any, Dict, OrderedDict, List, Optional, Union +from typing import Any, Dict, OrderedDict, Optional from datetime import datetime from moto.core import BaseBackend, BackendDict, BaseModel @@ -10,7 +10,13 @@ from moto.cloudformation.utils import get_stack_from_s3_url from .utils import create_cloudformation_stack_from_template -from .exceptions import ProductNotFound, PortfolioNotFound, InvalidParametersException +from .exceptions import ( + ProductNotFound, + PortfolioNotFound, + ProvisionedProductNotFound, + RecordNotFound, + InvalidParametersException, +) class Portfolio(BaseModel): @@ -158,8 +164,15 @@ def __init__( def tags(self): return self.backend.get_tags(self.arn) - def get_provisioning_artifact(self, artifact_id: str): - return self.provisioning_artifacts[artifact_id] + def get_provisioning_artifact(self, artifact_id: str, artifact_name: str): + if artifact_id in self.provisioning_artifacts: + return self.provisioning_artifacts[artifact_id] + + for key, artifact in self.provisioning_artifacts.items(): + if artifact.name == artifact_name: + return artifact + + return None def _create_provisioning_artifact( self, @@ -420,8 +433,38 @@ def __init__( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) + self.searchable_fields = [ + "arn", + "createdTime", + "id", + "lastRecordId", + "idempotencyToken", + "name", + "physicalId", + "productId", + "provisioningArtifactId", + "type", + "status", + "tags", + "userArn", + "userArnSession", + "lastProvisioningRecordId", + "lastSuccessfulProvisioningRecordId", + "productName", + "provisioningArtifactName", + ] + self.backend.tag_resource(self.arn, tags) + def get_filter_value( + self, filter_name: str, method_name: Optional[str] = None + ) -> Any: + if filter_name == "arn": + return self.arn + elif filter_name == "name": + return self.name + return None + @property def tags(self): return self.backend.get_tags(self.arn) @@ -484,6 +527,18 @@ def _get_portfolio(self, identifier: str = "", name: str = "") -> bool: identifier=identifier or name, identifier_name="Name" if name else "Id" ) + def _get_provisioned_product(self, identifier: str = "", name: str = "") -> bool: + if identifier in self.provisioned_products: + return self.provisioned_products[identifier] + + for product_id, provisioned_product in self.provisioned_products.items(): + if provisioned_product.name == name: + return provisioned_product + + raise ProvisionedProductNotFound( + identifier=identifier or name, identifier_name="Name" if name else "Id" + ) + def create_portfolio( self, accept_language, @@ -632,26 +687,17 @@ def provision_product( notification_arns, provision_token, ): - # implement here - # TODO: Big damn cleanup before this counts as anything useful. - # Get product by id or name - product = None - if product_id: - product = self.products[product_id] - else: - for product_id, item in self.products.items(): - if item.name == product_name: - product = item + product = self._get_product(identifier=product_id, name=product_name) # Get specified provisioning artifact from product by id or name - # search product for specific provision_artifact_id or name - # TODO: ID vs name provisioning_artifact = product.get_provisioning_artifact( - provisioning_artifact_id + artifact_id=provisioning_artifact_id, + artifact_name=provisioning_artifact_name, ) # Verify path exists for product by id or name + # TODO: Paths # Create initial stack in CloudFormation stack = create_cloudformation_stack_from_template( @@ -673,8 +719,8 @@ def provision_product( stack_outputs=stack.stack_outputs, product_id=product.product_id, provisioning_artifact_id=provisioning_artifact.provisioning_artifact_id, - path_id="asdf", - launch_role_arn="asdf2", + path_id="TODO: paths", + launch_role_arn="TODO: launch role", tags=tags, backend=self, ) @@ -763,11 +809,24 @@ def search_provisioned_products( ): # implement here # TODO: Filter + + from moto.ec2.utils import generic_filter + + if filters: + source_provisioned_products = generic_filter( + filters, self.provisioned_products.values() + ) + else: + source_provisioned_products = self.provisioned_products.values() + provisioned_products = [ value.to_provisioned_product_detail_json(include_tags=True) - for key, value in self.provisioned_products.items() + for value in source_provisioned_products ] + # search-product - FullTextSearch | Owner | ProductType | SourceProductId - with arrays + # + total_results_count = len(provisioned_products) next_page_token = None return provisioned_products, total_results_count, next_page_token @@ -781,14 +840,14 @@ def list_launch_paths(self, accept_language, product_id, page_token): for portfolio_id, portfolio in self.portfolios.items(): launch_path_detail = portfolio.launch_path.to_json() launch_path_detail["Name"] = portfolio.display_name - launch_path_detail["ConstraintSummaries"] = list() + launch_path_detail["ConstraintSummaries"] = [] if product.product_id in portfolio.product_ids: - constraint = self.get_constraint_by( + if constraint := self.get_constraint_by( product_id=product.product_id, portfolio_id=portfolio_id - ) - launch_path_detail["ConstraintSummaries"].append( - constraint.to_summary_json() - ) + ): + launch_path_detail["ConstraintSummaries"].append( + constraint.to_summary_json() + ) launch_path_summaries.append(launch_path_detail) next_page_token = None @@ -996,5 +1055,17 @@ def list_portfolios_for_product(self, accept_language, product_id, page_token): return portfolio_details, next_page_token + def describe_record(self, accept_language, identifier, page_token): + try: + record = self.records[identifier] + except KeyError: + raise RecordNotFound(identifier=identifier) + + record_detail = record.to_record_detail_json() + record_outputs = None # TODO: Stack outputs + next_page_token = None + + return record_detail, record_outputs, next_page_token + servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 2704d57b8ab7..23db7796b472 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -1,7 +1,8 @@ """Handles incoming servicecatalog requests, invokes methods, returns responses.""" import json +import re -from moto.core.responses import BaseResponse, TYPE_RESPONSE +from moto.core.responses import BaseResponse from .models import servicecatalog_backends @@ -40,7 +41,7 @@ def create_portfolio(self): def list_portfolios(self) -> str: accept_language = self._get_param("AcceptLanguage") page_token = self._get_param("PageToken") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") (portfolios, next_page_token,) = self.servicecatalog_backend.list_portfolios( accept_language=accept_language, page_token=page_token, @@ -48,8 +49,6 @@ def list_portfolios(self) -> str: portfolio_details = [portfolio.to_json() for portfolio in portfolios] - # TODO: adjust response - ret = json.dumps( dict( PortfolioDetails=portfolio_details, @@ -86,7 +85,7 @@ def get_provisioned_product_outputs(self): provisioned_product_id = self._get_param("ProvisionedProductId") provisioned_product_name = self._get_param("ProvisionedProductName") output_keys = self._get_param("OutputKeys") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") ( outputs, @@ -107,8 +106,34 @@ def search_provisioned_products(self): filters = self._get_param("Filters") sort_by = self._get_param("SortBy") sort_order = self._get_param("SortOrder") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") + + # Change filter to match search-products style + new_filters = {} + if ( + filters + and "SearchQuery" in filters + and isinstance(filters["SearchQuery"], list) + ): + filters = filters["SearchQuery"] + + # Convert filters into style used by the Moto EC2 filtering. This is the format: + # { + # "FilterName": ["FilterValue"] + # ... + # } + # This allows the service-catalog endpoints with filters to reuse the backend filtering from EC2 + + for filter_value in filters: + parts = re.split(":|=", filter_value) + if len(parts) == 1: + parts = ["*", parts[0]] # wildcard filter + + if parts[0] not in new_filters: + new_filters[parts[0]] = [] + new_filters[parts[0]].append(parts[1]) + ( provisioned_products, total_results_count, @@ -116,7 +141,7 @@ def search_provisioned_products(self): ) = self.servicecatalog_backend.search_provisioned_products( accept_language=accept_language, access_level_filter=access_level_filter, - filters=filters, + filters=new_filters, sort_by=sort_by, sort_order=sort_order, page_token=page_token, @@ -151,10 +176,11 @@ def terminate_provisioned_product(self): def search_products(self): accept_language = self._get_param("AcceptLanguage") filters = self._get_param("Filters") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") sort_by = self._get_param("SortBy") sort_order = self._get_param("SortOrder") page_token = self._get_param("PageToken") + ( product_view_summaries, product_view_aggregations, @@ -178,7 +204,7 @@ def search_products(self): def list_launch_paths(self): accept_language = self._get_param("AcceptLanguage") product_id = self._get_param("ProductId") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") ( launch_path_summaries, @@ -462,7 +488,7 @@ def list_portfolios_for_product(self): accept_language = self._get_param("AcceptLanguage") product_id = self._get_param("ProductId") page_token = self._get_param("PageToken") - page_size = self._get_param("PageSize") + # page_size = self._get_param("PageSize") ( portfolio_details, next_page_token, @@ -475,3 +501,26 @@ def list_portfolios_for_product(self): return json.dumps( dict(PortfolioDetails=portfolio_details, NextPageToken=next_page_token) ) + + def describe_record(self): + accept_language = self._get_param("AcceptLanguage") + identifier = self._get_param("Id") + page_token = self._get_param("PageToken") + # page_size = self._get_param("PageSize") + ( + record_detail, + record_outputs, + next_page_token, + ) = self.servicecatalog_backend.describe_record( + accept_language=accept_language, + identifier=identifier, + page_token=page_token, + ) + + return json.dumps( + dict( + RecordDetail=record_detail, + RecordOutputs=record_outputs, + NextPageToken=next_page_token, + ) + ) diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 5f87853c384f..939be2e974b1 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -11,13 +11,24 @@ BASIC_CLOUD_STACK = """--- Resources: - LocalBucket: - Type: AWS::S3::Bucket + BucketWithSemiRandomName: + Type: "AWS::S3::Bucket" Properties: - BucketName: cfn-quickstart-bucket + BucketName: !Join + - "-" + - - "bucket-with-semi-random-name" + - !Select + - 0 + - !Split + - "-" + - !Select + - 2 + - !Split + - "/" + - !Ref "AWS::StackId" Outputs: WebsiteURL: - Value: !GetAtt LocalBucket.WebsiteURL + Value: !GetAtt BucketWithSemiRandomName.WebsiteURL Description: URL for website hosted on S3 """ @@ -71,7 +82,7 @@ def _create_product(region_name: str, product_name: str): return create_product_response -def _create_default_product_with_portfolio( +def _create_product_with_portfolio( region_name: str, portfolio_name: str, product_name: str ): """ @@ -97,7 +108,7 @@ def _create_default_product_with_portfolio( return create_portfolio_response, create_product_response -def _create_default_product_with_constraint( +def _create_product_with_constraint( region_name: str, portfolio_name: str, product_name: str, @@ -107,7 +118,7 @@ def _create_default_product_with_constraint( Create a portfolio and product with a uploaded cloud formation template including a launch constraint on the roleARN """ - portfolio, product = _create_default_product_with_portfolio( + portfolio, product = _create_product_with_portfolio( region_name=region_name, portfolio_name=portfolio_name, product_name=product_name, @@ -146,6 +157,30 @@ def _create_provisioned_product( return provisioned_product_response +def _create_portfolio_with_provisioned_product( + region_name: str, + portfolio_name: str, + product_name: str, + provisioned_product_name: str, +): + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name=portfolio_name, + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name=provisioned_product_name, + ) + + return constraint, portfolio, product, provisioned_product + + @mock_servicecatalog def test_create_portfolio(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") @@ -267,7 +302,7 @@ def test_create_product_duplicate(): @mock_s3 def test_create_constraint(): region_name = "us-east-2" - portfolio, product = _create_default_product_with_portfolio( + portfolio, product = _create_product_with_portfolio( region_name=region_name, portfolio_name="My Portfolio", product_name="test product", @@ -317,10 +352,10 @@ def test_associate_product_with_portfolio(): @mock_servicecatalog @mock_s3 -def test_provision_product(): +def test_provision_product_by_product_name_and_artifact_id(): region_name = "us-east-2" product_name = "My Product" - portfolio, product = _create_default_product_with_portfolio( + portfolio, product = _create_product_with_portfolio( region_name=region_name, product_name=product_name, portfolio_name="The Portfolio", @@ -361,7 +396,51 @@ def test_provision_product(): s3_client = boto3.client("s3", region_name=region_name) all_buckets_response = s3_client.list_buckets() bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] - assert "cfn-quickstart-bucket" in bucket_names + + assert any( + [name.startswith("bucket-with-semi-random-name") for name in bucket_names] + ) + + +@mock_servicecatalog +@mock_s3 +def test_provision_product_by_artifact_name_and_product_id(): + region_name = "us-east-2" + + portfolio, product = _create_product_with_portfolio( + region_name=region_name, + product_name="My Product", + portfolio_name="The Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + provisioning_artifact_name = product["ProvisioningArtifactDetail"]["Name"] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + # TODO: Paths + provisioned_product_response = client.provision_product( + ProvisionedProductName="Provisioned Product Name", + ProvisioningArtifactName=provisioning_artifact_name, + PathId="TODO", + ProductId=product_id, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], + ) + + # Verify record details + rec = provisioned_product_response["RecordDetail"] + assert rec["ProvisionedProductName"] == "Provisioned Product Name" + + # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" + s3_client = boto3.client("s3", region_name=region_name) + all_buckets_response = s3_client.list_buckets() + bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] + + assert any( + [name.startswith("bucket-with-semi-random-name") for name in bucket_names] + ) @mock_servicecatalog @@ -413,21 +492,19 @@ def test_describe_portfolio_not_existing(): @mock_s3 def test_describe_provisioned_product(): region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_default_product_with_constraint( + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( region_name=region_name, - product_name=product_name, + product_name="test product", portfolio_name="Test Portfolio", - ) - - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product = _create_provisioned_product( - region_name=region_name, - product_name=product_name, - provisioning_artifact_id=provisioning_artifact_id, provisioned_product_name="My Provisioned Product", ) + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + client = boto3.client("servicecatalog", region_name=region_name) provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] @@ -441,24 +518,22 @@ def test_describe_provisioned_product(): == provisioning_artifact_id ) + resp = client.search_provisioned_products(Filters={"SearchQuery": []}) + @mock_servicecatalog @mock_s3 def test_get_provisioned_product_outputs(): region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_default_product_with_constraint( + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( region_name=region_name, - product_name=product_name, + product_name="test product", portfolio_name="Test Portfolio", - ) - - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product = _create_provisioned_product( - region_name=region_name, - product_name=product_name, - provisioning_artifact_id=provisioning_artifact_id, provisioned_product_name="My Provisioned Product", ) provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] @@ -477,51 +552,82 @@ def test_get_provisioned_product_outputs(): @mock_s3 def test_search_provisioned_products(): region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_default_product_with_constraint( + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( region_name=region_name, - product_name=product_name, + product_name="test product", portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", ) - + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product = _create_provisioned_product( + provisioned_product_2 = _create_provisioned_product( region_name=region_name, - product_name=product_name, + product_name="test product", provisioning_artifact_id=provisioning_artifact_id, - provisioned_product_name="My Provisioned Product", + provisioned_product_name="Second Provisioned Product", ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + provisioned_product_id_2 = provisioned_product_2["RecordDetail"][ + "ProvisionedProductId" + ] client = boto3.client("servicecatalog", region_name=region_name) resp = client.search_provisioned_products() pps = resp["ProvisionedProducts"] - assert len(pps) == 1 + assert len(pps) == 2 assert pps[0]["Id"] == provisioned_product_id + assert pps[1]["Id"] == provisioned_product_id_2 @mock_servicecatalog @mock_s3 -def test_terminate_provisioned_product(): +def test_search_provisioned_products_filter_by(): region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_default_product_with_constraint( + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( region_name=region_name, - product_name=product_name, + product_name="test product", portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product = _create_provisioned_product( + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]} + ) + + assert len(resp["ProvisionedProducts"]) == 1 + assert resp["ProvisionedProducts"][0]["Id"] == provisioned_product_id + + +@mock_servicecatalog +@mock_s3 +def test_terminate_provisioned_product(): + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( region_name=region_name, - product_name=product_name, - provisioning_artifact_id=provisioning_artifact_id, + product_name="test product", + portfolio_name="Test Portfolio", provisioned_product_name="My Provisioned Product", ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] client = boto3.client("servicecatalog", region_name=region_name) @@ -541,7 +647,7 @@ def test_search_products(): region_name = "us-east-2" product_name = "test product" - constraint, portfolio, product = _create_default_product_with_constraint( + constraint, portfolio, product = _create_product_with_constraint( region_name=region_name, product_name=product_name, portfolio_name="Test Portfolio", @@ -566,7 +672,7 @@ def test_list_launch_paths(): region_name = "us-east-2" product_name = "test product" - constraint, portfolio, product = _create_default_product_with_constraint( + constraint, portfolio, product = _create_product_with_constraint( region_name=region_name, product_name=product_name, portfolio_name="Test Portfolio", @@ -582,13 +688,31 @@ def test_list_launch_paths(): assert lps[0]["ConstraintSummaries"][0]["Type"] == "LAUNCH" +@mock_servicecatalog +@mock_s3 +def test_list_launch_paths_no_constraints_attached(): + portfolio, product = _create_product_with_portfolio( + region_name="us-east-2", + product_name="test product", + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + client = boto3.client("servicecatalog", region_name="us-east-2") + resp = client.list_launch_paths(ProductId=product_id) + + lps = resp["LaunchPathSummaries"] + assert len(lps) == 1 + assert len(lps[0]["ConstraintSummaries"]) == 0 + + @mock_servicecatalog @mock_s3 def test_list_provisioning_artifacts(): region_name = "us-east-2" product_name = "test product" - constraint, portfolio, product = _create_default_product_with_constraint( + constraint, portfolio, product = _create_product_with_constraint( region_name=region_name, product_name=product_name, portfolio_name="Test Portfolio", @@ -610,7 +734,7 @@ def test_describe_product_as_admin(): region_name = "us-east-2" product_name = "test product" - constraint, portfolio, product = _create_default_product_with_constraint( + constraint, portfolio, product = _create_product_with_constraint( region_name=region_name, product_name=product_name, portfolio_name="Test Portfolio", @@ -629,7 +753,7 @@ def test_describe_product(): region_name = "us-east-2" product_name = "test product" - constraint, portfolio, product = _create_default_product_with_constraint( + constraint, portfolio, product = _create_product_with_constraint( region_name=region_name, product_name=product_name, portfolio_name="Test Portfolio", @@ -680,7 +804,7 @@ def test_list_portfolios_for_product(): region_name = "us-east-2" product_name = "test product" - portfolio, product = _create_default_product_with_portfolio( + portfolio, product = _create_product_with_portfolio( region_name=region_name, portfolio_name="My Portfolio", product_name=product_name, @@ -691,3 +815,28 @@ def test_list_portfolios_for_product(): resp = client.list_portfolios_for_product(ProductId=product_id) assert resp["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] + + +@mock_servicecatalog +@mock_s3 +def test_describe_record(): + region_name = "eu-west-1" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.describe_record(Id=provisioned_product["RecordDetail"]["RecordId"]) + + assert ( + resp["RecordDetail"]["RecordId"] + == provisioned_product["RecordDetail"]["RecordId"] + ) From 5fa61284ca9877089187ebf18b9e60690ef5e5a1 Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 16 Aug 2023 17:26:05 +0100 Subject: [PATCH 13/20] Added initial filter to search-product --- moto/servicecatalog/models.py | 75 ++++++++++++------- .../test_servicecatalog.py | 25 +++++++ 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index ec39c87ff05d..8438b18bd8c3 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -8,6 +8,8 @@ from moto.moto_api._internal import mock_random as random from moto.utilities.tagging_service import TaggingService from moto.cloudformation.utils import get_stack_from_s3_url +from moto.ec2.utils import generic_filter +from moto.ec2.exceptions import FilterNotImplementedError from .utils import create_cloudformation_stack_from_template from .exceptions import ( @@ -160,6 +162,18 @@ def __init__( # At the moment, the process is synchronous and goes straight to status=AVAILABLE self.status = "AVAILABLE" + def get_filter_value( + self, filter_name: str, method_name: Optional[str] = None + ) -> Any: + if filter_name == "Owner": + return self.owner + elif filter_name == "ProductType": + return self.product_type + + # Remaining fields: FullTextSearch | SourceProductId + + raise FilterNotImplementedError(filter_name, method_name) + @property def tags(self): return self.backend.get_tags(self.arn) @@ -433,37 +447,39 @@ def __init__( "SUCCEEDED" # CREATE,IN_PROGRESS,IN_PROGRESS_IN_ERROR,IN_PROGRESS_IN_ERROR ) - self.searchable_fields = [ - "arn", - "createdTime", - "id", - "lastRecordId", - "idempotencyToken", - "name", - "physicalId", - "productId", - "provisioningArtifactId", - "type", - "status", - "tags", - "userArn", - "userArnSession", - "lastProvisioningRecordId", - "lastSuccessfulProvisioningRecordId", - "productName", - "provisioningArtifactName", - ] - self.backend.tag_resource(self.arn, tags) def get_filter_value( self, filter_name: str, method_name: Optional[str] = None ) -> Any: - if filter_name == "arn": + if filter_name == "id": + return self.provisioned_product_id + elif filter_name == "arn": return self.arn elif filter_name == "name": return self.name - return None + elif filter_name == "createdTime": + return self.created_time + elif filter_name == "lastRecordId": + return self.last_record_id + elif filter_name == "productId": + return self.product_id + + # Remaining fields + # "idempotencyToken", + # "physicalId", + # "provisioningArtifactId", + # "type", + # "status", + # "tags", + # "userArn", + # "userArnSession", + # "lastProvisioningRecordId", + # "lastSuccessfulProvisioningRecordId", + # "productName", + # "provisioningArtifactName", + + raise FilterNotImplementedError(filter_name, method_name) @property def tags(self): @@ -783,9 +799,14 @@ def describe_provisioned_product(self, accept_language, id, name): def search_products( self, accept_language, filters, sort_by, sort_order, page_token ): - product_view_summaries = list() - # TODO: Filter - for key, product in self.products.items(): + + if filters: + products = generic_filter(filters, self.products.values()) + else: + products = self.products.values() + + product_view_summaries = [] + for product in products: product_view_summaries.append( product.to_product_view_detail_json()["ProductViewSummary"] ) @@ -810,8 +831,6 @@ def search_provisioned_products( # implement here # TODO: Filter - from moto.ec2.utils import generic_filter - if filters: source_provisioned_products = generic_filter( filters, self.provisioned_products.values() diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 939be2e974b1..708b0297a8a6 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -666,6 +666,31 @@ def test_search_products(): ) +@mock_servicecatalog +@mock_s3 +def test_search_products_by_filter(): + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.search_products(Filters={"Owner": ["owner arn"]}) + + products = resp["ProductViewSummaries"] + + assert len(products) == 1 + assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] + assert ( + products[0]["ProductId"] + == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) + + @mock_servicecatalog @mock_s3 def test_list_launch_paths(): From 3458c0a7451ff0203f05258d930cc3b39374f359 Mon Sep 17 00:00:00 2001 From: David Connor Date: Thu, 17 Aug 2023 17:38:00 +0100 Subject: [PATCH 14/20] Added test stubs for the remaining tests to implement for initial coverage. --- moto/servicecatalog/models.py | 106 ++++++------ moto/servicecatalog/responses.py | 4 +- .../test_servicecatalog.py | 158 +++++++++++++++++- 3 files changed, 210 insertions(+), 58 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 8438b18bd8c3..2056b3a8a97c 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -1,5 +1,6 @@ """ServiceCatalogBackend class with methods for supported APIs.""" import string +import warnings from typing import Any, Dict, OrderedDict, Optional from datetime import datetime @@ -158,8 +159,10 @@ def __init__( self.backend.tag_resource(self.arn, tags) - # TODO: Implement `status` provisioning as a moto state machine to emulate the product deployment process. + # TODO: Implement `status` provisioning as a moto state machine to emulate the product deployment process with + # additional states for CREATING, FAILED. # At the moment, the process is synchronous and goes straight to status=AVAILABLE + self.status = "AVAILABLE" def get_filter_value( @@ -205,8 +208,9 @@ def _create_provisioning_artifact( template_url=template_url, account_id=account_id ) else: - raise NotImplementedError("Nope") - # elif "ImportFromPhysicalId" in info: + warnings.warn( + "Only LoadTemplateFromURL is implemented as a template source." + ) provisioning_artifact = ProvisioningArtifact( name=name, @@ -706,6 +710,12 @@ def provision_product( # Get product by id or name product = self._get_product(identifier=product_id, name=product_name) + if notification_arns is not None: + warnings.warn("Notifications not implemented") + + if provisioning_preferences is not None: + warnings.warn("provisioning_preferences not implemented") + # Get specified provisioning artifact from product by id or name provisioning_artifact = product.get_provisioning_artifact( artifact_id=provisioning_artifact_id, @@ -762,26 +772,35 @@ def provision_product( return record.to_record_detail_json() def list_portfolios(self, accept_language, page_token): - # implement here portfolio_details = list(self.portfolios.values()) next_page_token = None return portfolio_details, next_page_token + def describe_portfolio(self, accept_language, identifier): + portfolio = self._get_portfolio(identifier=identifier) + portfolio_detail = portfolio.to_json() + + tags = [] + tag_options = None + budgets = None + return portfolio_detail, tags, tag_options, budgets + def get_last_record_for_provisioned_product(self, provisioned_product_id: str): for record_key, record in reversed(self.records.items()): if record.provisioned_product_id == provisioned_product_id: return record raise Exception("TODO") - def describe_provisioned_product(self, accept_language, id, name): - # implement here + def describe_provisioned_product(self, accept_language, identifier, name): + provisioned_product = self._get_provisioned_product( + identifier=identifier, name=name + ) - if id: - provisioned_product = self.provisioned_products[id] - else: - # get by name - provisioned_product = self.provisioned_products[id] + provisioned_product_detail = ( + provisioned_product.to_provisioned_product_detail_json() + ) + cloud_watch_dashboards = None # TODO # "CloudWatchDashboards": [ # { @@ -789,11 +808,6 @@ def describe_provisioned_product(self, accept_language, id, name): # } # ], - provisioned_product_detail = ( - provisioned_product.to_provisioned_product_detail_json() - ) - - cloud_watch_dashboards = None return provisioned_product_detail, cloud_watch_dashboards def search_products( @@ -828,9 +842,6 @@ def search_provisioned_products( sort_order, page_token, ): - # implement here - # TODO: Filter - if filters: source_provisioned_products = generic_filter( filters, self.provisioned_products.values() @@ -838,23 +849,24 @@ def search_provisioned_products( else: source_provisioned_products = self.provisioned_products.values() + # Access Filter + # User, Role, Account + # access_level_filter + provisioned_products = [ value.to_provisioned_product_detail_json(include_tags=True) for value in source_provisioned_products ] - # search-product - FullTextSearch | Owner | ProductType | SourceProductId - with arrays - # - total_results_count = len(provisioned_products) next_page_token = None + return provisioned_products, total_results_count, next_page_token def list_launch_paths(self, accept_language, product_id, page_token): - # implement here - product = self.products[product_id] + product = self._get_product(identifier=product_id) - launch_path_summaries = list() + launch_path_summaries = [] for portfolio_id, portfolio in self.portfolios.items(): launch_path_detail = portfolio.launch_path.to_json() @@ -874,10 +886,8 @@ def list_launch_paths(self, accept_language, product_id, page_token): return launch_path_summaries, next_page_token def list_provisioning_artifacts(self, accept_language, product_id): - # implement here - - product = self.products[product_id] - provisioning_artifact_details = list() + product = self._get_product(identifier=product_id) + provisioning_artifact_details = [] for artifact_id, artifact in product.provisioning_artifacts.items(): provisioning_artifact_details.append( artifact.to_provisioning_artifact_detail_json() @@ -895,8 +905,9 @@ def get_provisioned_product_outputs( output_keys, page_token, ): - # implement here - provisioned_product = self.provisioned_products[provisioned_product_id] + provisioned_product = self._get_provisioned_product( + identifier=provisioned_product_id, name=provisioned_product_name + ) outputs = [] @@ -945,18 +956,6 @@ def terminate_provisioned_product( return record_detail - def get_tags(self, resource_id: str) -> Dict[str, str]: - """ - Returns tags in original input format: - [{"Key":"A key", "Value:"A Value"},...] - """ - return self.tagger.convert_dict_to_tags_input( - self.tagger.get_tag_dict_for_resource(resource_id) - ) - - def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: - self.tagger.tag_resource(resource_arn, tags) - def get_constraint_by(self, product_id: str, portfolio_id: str): for constraint_id, constraint in self.constraints.items(): if ( @@ -965,15 +964,6 @@ def get_constraint_by(self, product_id: str, portfolio_id: str): ): return constraint - def describe_portfolio(self, accept_language, identifier): - portfolio = self._get_portfolio(identifier=identifier) - portfolio_detail = portfolio.to_json() - - tags = [] - tag_options = None - budgets = None - return portfolio_detail, tags, tag_options, budgets - def describe_product_as_admin( self, accept_language, identifier, name, source_portfolio_id ): @@ -1086,5 +1076,17 @@ def describe_record(self, accept_language, identifier, page_token): return record_detail, record_outputs, next_page_token + def get_tags(self, resource_id: str) -> Dict[str, str]: + """ + Returns tags in original input format: + [{"Key":"A key", "Value:"A Value"},...] + """ + return self.tagger.convert_dict_to_tags_input( + self.tagger.get_tag_dict_for_resource(resource_id) + ) + + def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: + self.tagger.tag_resource(resource_arn, tags) + servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 23db7796b472..0e0cb8b2ac39 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -60,14 +60,14 @@ def list_portfolios(self) -> str: def describe_provisioned_product(self): accept_language = self._get_param("AcceptLanguage") - id = self._get_param("Id") + identifier = self._get_param("Id") name = self._get_param("Name") ( provisioned_product_detail, cloud_watch_dashboards, ) = self.servicecatalog_backend.describe_provisioned_product( accept_language=accept_language, - id=id, + identifier=identifier, name=name, ) # TODO: adjust response diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 708b0297a8a6..a80705409e5a 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -203,6 +203,11 @@ def test_create_portfolio(): assert resp["Tags"][0]["Value"] == "FirstTagValue" +@mock_servicecatalog +def test_create_portfolio_missing_required(): + assert 1 == 2 + + @mock_servicecatalog def test_create_portfolio_duplicate(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") @@ -263,6 +268,12 @@ def test_create_product(): assert resp["Tags"][0]["Value"] == "FirstTagValue" +@mock_servicecatalog +@mock_s3 +def test_create_product_missing_required(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_create_product_duplicate(): @@ -321,6 +332,18 @@ def test_create_constraint(): assert resp["Status"] == "AVAILABLE" +@mock_servicecatalog +@mock_s3 +def test_create_constraint_duplicate(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_create_constraint_missing_required(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_associate_product_with_portfolio(): @@ -350,6 +373,12 @@ def test_associate_product_with_portfolio(): assert linked["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] +@mock_servicecatalog +@mock_s3 +def test_associate_product_with_portfolio_invalid_ids(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_provision_product_by_product_name_and_artifact_id(): @@ -443,6 +472,24 @@ def test_provision_product_by_artifact_name_and_product_id(): ) +@mock_servicecatalog +@mock_s3 +def test_provision_product_by_artifact_id_and_product_id_and_path_id(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_provision_product_by_artifact_id_and_product_id_and_path_name(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_provision_product_with_parameters(): + assert 1 == 2 + + @mock_servicecatalog def test_list_portfolios(): client = boto3.client("servicecatalog", region_name="ap-southeast-1") @@ -490,7 +537,7 @@ def test_describe_portfolio_not_existing(): @mock_servicecatalog @mock_s3 -def test_describe_provisioned_product(): +def test_describe_provisioned_product_by_id(): region_name = "us-east-2" ( constraint, @@ -519,11 +566,24 @@ def test_describe_provisioned_product(): ) resp = client.search_provisioned_products(Filters={"SearchQuery": []}) + # TODO: Verift @mock_servicecatalog @mock_s3 -def test_get_provisioned_product_outputs(): +def test_describe_provisioned_product_by_name(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_describe_provisioned_product_not_found(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_get_provisioned_product_outputs_by_id(): region_name = "us-east-2" ( constraint, @@ -548,6 +608,18 @@ def test_get_provisioned_product_outputs(): assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" +@mock_servicecatalog +@mock_s3 +def test_get_provisioned_product_outputs_filtered_output_by_name(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_get_provisioned_product_outputs_filtered_by_output_keys(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_search_provisioned_products(): @@ -612,6 +684,18 @@ def test_search_provisioned_products_filter_by(): assert resp["ProvisionedProducts"][0]["Id"] == provisioned_product_id +@mock_servicecatalog +@mock_s3 +def test_search_provisioned_products_access_level(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_search_provisioned_products_with_sort(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_terminate_provisioned_product(): @@ -691,6 +775,12 @@ def test_search_products_by_filter(): ) +@mock_servicecatalog +@mock_s3 +def test_search_products_with_sort(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_list_launch_paths(): @@ -755,7 +845,13 @@ def test_list_provisioning_artifacts(): @mock_servicecatalog @mock_s3 -def test_describe_product_as_admin(): +def test_list_provisioning_artifacts_product_not_found(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_describe_product_as_admin_by_id(): region_name = "us-east-2" product_name = "test product" @@ -774,7 +870,19 @@ def test_describe_product_as_admin(): @mock_servicecatalog @mock_s3 -def test_describe_product(): +def test_describe_product_as_admin_by_name(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_describe_product_as_admin_with_source_portfolio_id(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_describe_product_by_id(): region_name = "us-east-2" product_name = "test product" @@ -791,6 +899,12 @@ def test_describe_product(): assert len(resp["ProvisioningArtifactSummaries"]) == 1 +@mock_servicecatalog +@mock_s3 +def test_describe_product_by_name(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_update_portfolio(): @@ -811,6 +925,18 @@ def test_update_portfolio(): assert resp["PortfolioDetail"]["DisplayName"] == new_portfolio_name +@mock_servicecatalog +@mock_s3 +def test_update_portfolio_not_found(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_update_portfolio_invalid_fields(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_update_product(): @@ -823,6 +949,18 @@ def test_update_product(): assert new_product["Name"] == "New Product Name" +@mock_servicecatalog +@mock_s3 +def test_update_product_not_found(): + assert 1 == 2 + + +@mock_servicecatalog +@mock_s3 +def test_update_product_invalid_fields(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_list_portfolios_for_product(): @@ -842,6 +980,12 @@ def test_list_portfolios_for_product(): assert resp["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] +@mock_servicecatalog +@mock_s3 +def test_list_portfolios_for_product_not_found(): + assert 1 == 2 + + @mock_servicecatalog @mock_s3 def test_describe_record(): @@ -865,3 +1009,9 @@ def test_describe_record(): resp["RecordDetail"]["RecordId"] == provisioned_product["RecordDetail"]["RecordId"] ) + + +@mock_servicecatalog +@mock_s3 +def test_describe_record_not_found(): + assert 1 == 2 From e9a7990b866aa27b092ef4edb3832b926b4face2 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 22 Aug 2023 17:26:20 +0100 Subject: [PATCH 15/20] Made filtering match some of the behaviour on AWS --- moto/servicecatalog/models.py | 16 +++++++++-- moto/servicecatalog/responses.py | 4 ++- .../test_servicecatalog.py | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 2056b3a8a97c..58015c9ecb48 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -172,8 +172,20 @@ def get_filter_value( return self.owner elif filter_name == "ProductType": return self.product_type - - # Remaining fields: FullTextSearch | SourceProductId + elif filter_name == "FullTextSearch": + return [ + self.arn, + self.name, + self.description, + self.owner, + self.product_type, + self.product_id, + self.support_description, + self.support_url, + self.support_email, + ] + + # Remaining fields: SourceProductId raise FilterNotImplementedError(filter_name, method_name) diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 0e0cb8b2ac39..cef0ce2c1cc6 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -132,7 +132,9 @@ def search_provisioned_products(self): if parts[0] not in new_filters: new_filters[parts[0]] = [] - new_filters[parts[0]].append(parts[1]) + # Tack on wildcard pattern as search-provisioned-products matches as if it were startswith (need to double + # check it's not contains) + new_filters[parts[0]].append(f"{parts[1]}*") ( provisioned_products, diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index a80705409e5a..4ebbd10f53bf 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -775,6 +775,34 @@ def test_search_products_by_filter(): ) +@mock_servicecatalog +@mock_s3 +def test_search_products_by_filter_fulltext(): + """ + Fulltext searches more than a single field + """ + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.search_products(Filters={"FullTextSearch": ["owner arn"]}) + + products = resp["ProductViewSummaries"] + + assert len(products) == 1 + assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] + assert ( + products[0]["ProductId"] + == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) + + @mock_servicecatalog @mock_s3 def test_search_products_with_sort(): From 00eaa6f63fd6dc489229a7199f06335eca1e2974 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 22 Aug 2023 17:51:49 +0100 Subject: [PATCH 16/20] Added more tests covering resources not found --- moto/servicecatalog/exceptions.py | 7 + moto/servicecatalog/models.py | 13 +- .../test_servicecatalog.py | 196 ++++++++++++++++-- 3 files changed, 196 insertions(+), 20 deletions(-) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 500c9063b9de..53bf7d77ec01 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -71,3 +71,10 @@ class InvalidParametersException(ServiceCatalogClientError): def __init__(self, message: str): super().__init__(error_type="InvalidParametersException", message=message) + + +class DuplicateResourceException(ServiceCatalogClientError): + code = 400 + + def __init__(self, message: str): + super().__init__(error_type="DuplicateResourceException", message=message) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index 58015c9ecb48..e51a32daf3da 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -14,6 +14,7 @@ from .utils import create_cloudformation_stack_from_template from .exceptions import ( + DuplicateResourceException, ProductNotFound, PortfolioNotFound, ProvisionedProductNotFound, @@ -563,7 +564,7 @@ def _get_provisioned_product(self, identifier: str = "", name: str = "") -> bool if identifier in self.provisioned_products: return self.provisioned_products[identifier] - for product_id, provisioned_product in self.provisioned_products.items(): + for provisioned_product in self.provisioned_products.values(): if provisioned_product.name == name: return provisioned_product @@ -674,7 +675,15 @@ def create_constraint( description, idempotency_token, ): - # implement here + for constraint in self.constraints.values(): + if ( + constraint.product_id == product_id + and constraint.portfolio_id == portfolio_id + and constraint.constraint_type == constraint_type + ): + raise DuplicateResourceException( + message="Constraint with these links already exists" + ) constraint = Constraint( backend=self, diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 4ebbd10f53bf..5c1c55fa444d 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -1,6 +1,7 @@ """Unit tests for servicecatalog-supported APIs.""" import pytest import boto3 +from botocore.exceptions import ParamValidationError import uuid from datetime import date from moto import mock_servicecatalog, mock_s3 @@ -205,7 +206,11 @@ def test_create_portfolio(): @mock_servicecatalog def test_create_portfolio_missing_required(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + with pytest.raises(ParamValidationError) as exc: + client.create_portfolio() + + assert "DisplayName" in exc.value.args[0] @mock_servicecatalog @@ -271,7 +276,11 @@ def test_create_product(): @mock_servicecatalog @mock_s3 def test_create_product_missing_required(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + with pytest.raises(ParamValidationError) as exc: + client.create_product() + + assert "Name" in exc.value.args[0] @mock_servicecatalog @@ -335,13 +344,41 @@ def test_create_constraint(): @mock_servicecatalog @mock_s3 def test_create_constraint_duplicate(): - assert 1 == 2 + region_name = "us-east-2" + portfolio, product = _create_product_with_portfolio( + region_name=region_name, + portfolio_name="My Portfolio", + product_name="test product", + ) + + client = boto3.client("servicecatalog", region_name=region_name) + client.create_constraint( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) + with pytest.raises(ClientError) as exc: + client.create_constraint( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "DuplicateResourceException" + assert err["Message"] == "Constraint with these links already exists" @mock_servicecatalog @mock_s3 def test_create_constraint_missing_required(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + with pytest.raises(ParamValidationError) as exc: + client.create_constraint() + + assert "PortfolioId" in exc.value.args[0] @mock_servicecatalog @@ -376,7 +413,35 @@ def test_associate_product_with_portfolio(): @mock_servicecatalog @mock_s3 def test_associate_product_with_portfolio_invalid_ids(): - assert 1 == 2 + region_name = "us-east-2" + portfolio = _create_portfolio( + region_name=region_name, portfolio_name="The Portfolio" + ) + product = _create_product(region_name=region_name, product_name="My Product") + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + # Link product to portfolio + with pytest.raises(ClientError) as exc: + client.associate_product_with_portfolio( + PortfolioId="invalid_portfolio", + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Portfolio not found" + + with pytest.raises(ClientError) as exc: + client.associate_product_with_portfolio( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId="invalid_product", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog @@ -565,20 +630,48 @@ def test_describe_provisioned_product_by_id(): == provisioning_artifact_id ) - resp = client.search_provisioned_products(Filters={"SearchQuery": []}) - # TODO: Verift - @mock_servicecatalog @mock_s3 def test_describe_provisioned_product_by_name(): - assert 1 == 2 + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + + client = boto3.client("servicecatalog", region_name=region_name) + + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + resp = client.describe_provisioned_product(Name="My Provisioned Product") + + assert resp["ProvisionedProductDetail"]["Id"] == provisioned_product_id + assert resp["ProvisionedProductDetail"]["ProductId"] == product_id + assert ( + resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] + == provisioning_artifact_id + ) @mock_servicecatalog @mock_s3 def test_describe_provisioned_product_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_provisioned_product(Name="does not exist") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Provisioned product not found" @mock_servicecatalog @@ -874,7 +967,13 @@ def test_list_provisioning_artifacts(): @mock_servicecatalog @mock_s3 def test_list_provisioning_artifacts_product_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.list_provisioning_artifacts(ProductId="does_not_exist") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog @@ -899,7 +998,20 @@ def test_describe_product_as_admin_by_id(): @mock_servicecatalog @mock_s3 def test_describe_product_as_admin_by_name(): - assert 1 == 2 + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.describe_product_as_admin(Name=product_name) + + assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id + assert len(resp["ProvisioningArtifactSummaries"]) == 1 @mock_servicecatalog @@ -930,7 +1042,32 @@ def test_describe_product_by_id(): @mock_servicecatalog @mock_s3 def test_describe_product_by_name(): - assert 1 == 2 + region_name = "us-east-2" + product_name = "test product" + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name="Test Portfolio", + ) + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.describe_product_as_admin(Name=product_name) + + assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id + assert len(resp["ProvisioningArtifactSummaries"]) == 1 + + +@mock_servicecatalog +@mock_s3 +def test_describe_product_not_found(): + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_product(Id="does_not_exist") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog @@ -956,10 +1093,15 @@ def test_update_portfolio(): @mock_servicecatalog @mock_s3 def test_update_portfolio_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.update_portfolio(Id="does_not_exist", DisplayName="new value") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Portfolio not found" -@mock_servicecatalog @mock_s3 def test_update_portfolio_invalid_fields(): assert 1 == 2 @@ -980,7 +1122,13 @@ def test_update_product(): @mock_servicecatalog @mock_s3 def test_update_product_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.update_product(Id="does_not_exist", Name="New Product Name") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog @@ -1011,7 +1159,13 @@ def test_list_portfolios_for_product(): @mock_servicecatalog @mock_s3 def test_list_portfolios_for_product_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.list_portfolios_for_product(ProductId="does_not_exist") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Record not found" @mock_servicecatalog @@ -1042,4 +1196,10 @@ def test_describe_record(): @mock_servicecatalog @mock_s3 def test_describe_record_not_found(): - assert 1 == 2 + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_record(Id="does_not_exist") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Record not found" From d6bc350a3c0f44b6f49c27cd8cbe3603f85a1725 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 22 Aug 2023 17:54:27 +0100 Subject: [PATCH 17/20] Added no product found test for list_portfolios --- tests/test_servicecatalog/test_servicecatalog.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 5c1c55fa444d..7ceebad57e28 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -1159,13 +1159,17 @@ def test_list_portfolios_for_product(): @mock_servicecatalog @mock_s3 def test_list_portfolios_for_product_not_found(): - client = boto3.client("servicecatalog", region_name="us-east-1") - with pytest.raises(ClientError) as exc: - client.list_portfolios_for_product(ProductId="does_not_exist") + region_name = "us-east-2" + _create_product_with_portfolio( + region_name=region_name, + portfolio_name="My Portfolio", + product_name="test product", + ) - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Record not found" + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.list_portfolios_for_product(ProductId="no product") + + assert len(resp["PortfolioDetails"]) == 0 @mock_servicecatalog From 3c7a37e35465d09c7b9adaecc1e6e745142276d6 Mon Sep 17 00:00:00 2001 From: David Connor Date: Tue, 22 Aug 2023 21:40:39 +0100 Subject: [PATCH 18/20] Added sorting to endpoints that use sorting and added output_key filtering --- moto/servicecatalog/exceptions.py | 5 + moto/servicecatalog/models.py | 58 ++++- moto/servicecatalog/responses.py | 116 +++++++-- .../test_servicecatalog.py | 245 ++++++++++++++++-- 4 files changed, 368 insertions(+), 56 deletions(-) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index 53bf7d77ec01..e0f032cc1dba 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -78,3 +78,8 @@ class DuplicateResourceException(ServiceCatalogClientError): def __init__(self, message: str): super().__init__(error_type="DuplicateResourceException", message=message) + + +class ValidationException(ServiceCatalogClientError): + def __init__(self, message: str): + super().__init__("ValidationException", message) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index e51a32daf3da..f0585b5d4cdf 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -20,6 +20,7 @@ ProvisionedProductNotFound, RecordNotFound, InvalidParametersException, + ValidationException, ) @@ -846,6 +847,21 @@ def search_products( product.to_product_view_detail_json()["ProductViewSummary"] ) + if sort_by is not None: + # See https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/servicecatalog/client/search_products.html + # SortBy requires "Title" but the field in the response (and object) is Name. Why? Am I missing a field? + + if sort_by == "Title": + sort_by = "Name" + if sort_order is None: + sort_order = "ASCENDING" + + product_view_summaries = sorted( + product_view_summaries, + key=lambda d: d[sort_by], + reverse=True if sort_order == "DESCENDING" else False, + ) + product_view_aggregations = { "Owner": list(), "ProductType": list(), @@ -870,15 +886,25 @@ def search_provisioned_products( else: source_provisioned_products = self.provisioned_products.values() - # Access Filter - # User, Role, Account - # access_level_filter - provisioned_products = [ value.to_provisioned_product_detail_json(include_tags=True) for value in source_provisioned_products ] + if sort_by is not None: + # For some reason the SortBy is passed in as lowercase for search-provisioned-products but uppercase for + # search-products. + sort_by = sort_by.capitalize() + + if sort_order is None: + sort_order = "ASCENDING" + + provisioned_products = sorted( + provisioned_products, + key=lambda d: d[sort_by], + reverse=True if sort_order == "DESCENDING" else False, + ) + total_results_count = len(provisioned_products) next_page_token = None @@ -932,14 +958,24 @@ def get_provisioned_product_outputs( outputs = [] + if output_keys is not None: + existing_output_keys = [ + output.key for output in provisioned_product.stack_outputs + ] + missing_keys = set(output_keys).difference(existing_output_keys) + + if len(missing_keys) > 0: + raise InvalidParametersException(f"Invalid OutputKeys: {missing_keys}") + for output in provisioned_product.stack_outputs: - outputs.append( - { - "OutputKey": output.key, - "OutputValue": output.value, - "Description": output.description, - } - ) + if output_keys is None or output.key in output_keys: + outputs.append( + { + "OutputKey": output.key, + "OutputValue": output.value, + "Description": output.description, + } + ) next_page_token = None return outputs, next_page_token diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index cef0ce2c1cc6..220296a1d132 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -1,9 +1,11 @@ """Handles incoming servicecatalog requests, invokes methods, returns responses.""" import json import re +import warnings from moto.core.responses import BaseResponse from .models import servicecatalog_backends +from .exceptions import ValidationException class ServiceCatalogResponse(BaseResponse): @@ -35,13 +37,16 @@ def create_portfolio(self): tags=tags, idempotency_token=idempotency_token, ) - # TODO: adjust response return json.dumps(dict(PortfolioDetail=portfolio_detail.to_json(), Tags=tags)) def list_portfolios(self) -> str: accept_language = self._get_param("AcceptLanguage") page_token = self._get_param("PageToken") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") + + if page_size is not None: + warnings.warn("Pagination is not yet implemented for list_portfolios()") + (portfolios, next_page_token,) = self.servicecatalog_backend.list_portfolios( accept_language=accept_language, page_token=page_token, @@ -70,7 +75,7 @@ def describe_provisioned_product(self): identifier=identifier, name=name, ) - # TODO: adjust response + return json.dumps( dict( ProvisionedProductDetail=provisioned_product_detail, @@ -78,15 +83,22 @@ def describe_provisioned_product(self): ) ) - # add templates from here - def get_provisioned_product_outputs(self): accept_language = self._get_param("AcceptLanguage") provisioned_product_id = self._get_param("ProvisionedProductId") provisioned_product_name = self._get_param("ProvisionedProductName") output_keys = self._get_param("OutputKeys") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") + + if page_size is not None: + warnings.warn("Pagination is not yet implemented for list_portfolios()") + + if provisioned_product_id is None and provisioned_product_name is None: + raise ValidationException( + "ProvisionedProductId and ProvisionedProductName cannot both be null" + ) + ( outputs, next_page_token, @@ -97,7 +109,7 @@ def get_provisioned_product_outputs(self): output_keys=output_keys, page_token=page_token, ) - # TODO: adjust response + return json.dumps(dict(Outputs=outputs, NextPageToken=next_page_token)) def search_provisioned_products(self): @@ -106,9 +118,30 @@ def search_provisioned_products(self): filters = self._get_param("Filters") sort_by = self._get_param("SortBy") sort_order = self._get_param("SortOrder") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") + if page_size is not None: + warnings.warn("Pagination is not yet implemented for list_portfolios()") + + if access_level_filter is not None: + warnings.warn( + "The access_level_filter parameter is not yet implemented for search_provisioned_products()" + ) + + # Sort check + sort_by_fields = ["arn", "id", "name", "lastRecordId"] + if sort_by is not None and sort_by not in sort_by_fields: + raise ValidationException( + f"{sort_by} is not a supported sort field. It must be {sort_by_fields}" + ) + + sort_order_fields = ["ASCENDING", "DESCENDING"] + if sort_order is not None and sort_order not in sort_order_fields: + raise ValidationException( + f"1 validation error detected: Value '{sort_order}' at 'sortOrder' failed to satisfy constraint: Member must satisfy enum value set: {sort_order_fields}" + ) + # Change filter to match search-products style new_filters = {} if ( @@ -148,7 +181,7 @@ def search_provisioned_products(self): sort_order=sort_order, page_token=page_token, ) - # TODO: adjust response + return json.dumps( dict( ProvisionedProducts=provisioned_products, @@ -172,17 +205,33 @@ def terminate_provisioned_product(self): accept_language=accept_language, retain_physical_resources=retain_physical_resources, ) - # TODO: adjust response + return json.dumps(dict(RecordDetail=record_detail)) def search_products(self): accept_language = self._get_param("AcceptLanguage") filters = self._get_param("Filters") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") sort_by = self._get_param("SortBy") sort_order = self._get_param("SortOrder") page_token = self._get_param("PageToken") + if page_size is not None: + warnings.warn("Pagination is not yet implemented for search_products()") + + # Sort check + sort_by_fields = ["CreationDate", "VersionCount", "Title"] + if sort_by is not None and sort_by not in sort_by_fields: + raise ValidationException( + f"1 validation error detected: Value '{sort_by}' at 'sortBy' failed to satisfy constraint: Member must satisfy enum value set: {sort_by_fields}" + ) + + sort_order_fields = ["ASCENDING", "DESCENDING"] + if sort_order is not None and sort_order not in sort_order_fields: + raise ValidationException( + f"1 validation error detected: Value '{sort_order}' at 'sortOrder' failed to satisfy constraint: Member must satisfy enum value set: {sort_order_fields}" + ) + ( product_view_summaries, product_view_aggregations, @@ -194,7 +243,7 @@ def search_products(self): sort_order=sort_order, page_token=page_token, ) - # TODO: adjust response + return json.dumps( dict( ProductViewSummaries=product_view_summaries, @@ -206,8 +255,12 @@ def search_products(self): def list_launch_paths(self): accept_language = self._get_param("AcceptLanguage") product_id = self._get_param("ProductId") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") page_token = self._get_param("PageToken") + + if page_size is not None: + warnings.warn("Pagination is not yet implemented for list_launch_paths()") + ( launch_path_summaries, next_page_token, @@ -216,7 +269,7 @@ def list_launch_paths(self): product_id=product_id, page_token=page_token, ) - # TODO: adjust response + return json.dumps( dict( LaunchPathSummaries=launch_path_summaries, NextPageToken=next_page_token @@ -233,7 +286,7 @@ def list_provisioning_artifacts(self): accept_language=accept_language, product_id=product_id, ) - # TODO: adjust response + return json.dumps( dict( ProvisioningArtifactDetails=provisioning_artifact_details, @@ -270,7 +323,7 @@ def provision_product(self): notification_arns=notification_arns, provision_token=provision_token, ) - # TODO: adjust response + return json.dumps(dict(RecordDetail=record_detail)) def create_product(self): @@ -308,7 +361,7 @@ def create_product(self): idempotency_token=idempotency_token, source_connection=source_connection, ) - # TODO: adjust response + return json.dumps( dict( ProductViewDetail=product_view_detail, @@ -318,7 +371,6 @@ def create_product(self): ) def associate_product_with_portfolio(self): - accept_language = self._get_param("AcceptLanguage") product_id = self._get_param("ProductId") portfolio_id = self._get_param("PortfolioId") @@ -329,7 +381,7 @@ def associate_product_with_portfolio(self): portfolio_id=portfolio_id, source_portfolio_id=source_portfolio_id, ) - # TODO: adjust response + return json.dumps(dict()) def create_constraint(self): @@ -353,7 +405,7 @@ def create_constraint(self): description=description, idempotency_token=idempotency_token, ) - # TODO: adjust response + return json.dumps( dict( ConstraintDetail=constraint_detail, @@ -374,7 +426,7 @@ def describe_portfolio(self): accept_language=accept_language, identifier=identifier, ) - # TODO: adjust response + return json.dumps( dict( PortfolioDetail=portfolio_detail, @@ -401,7 +453,7 @@ def describe_product_as_admin(self): name=name, source_portfolio_id=source_portfolio_id, ) - # TODO: adjust response + return json.dumps( dict( ProductViewDetail=product_view_detail, @@ -426,7 +478,7 @@ def describe_product(self): identifier=identifier, name=name, ) - # TODO: adjust response + return json.dumps( dict( ProductViewSummary=product_view_summary, @@ -453,7 +505,7 @@ def update_portfolio(self): add_tags=add_tags, remove_tags=remove_tags, ) - # TODO: adjust response + return json.dumps(dict(PortfolioDetail=portfolio_detail, Tags=tags)) def update_product(self): @@ -483,14 +535,20 @@ def update_product(self): remove_tags=remove_tags, source_connection=source_connection, ) - # TODO: adjust response + return json.dumps(dict(ProductViewDetail=product_view_detail, Tags=tags)) def list_portfolios_for_product(self): accept_language = self._get_param("AcceptLanguage") product_id = self._get_param("ProductId") page_token = self._get_param("PageToken") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") + + if page_size is not None: + warnings.warn( + "Pagination is not yet implemented for list_portfolios_for_product()" + ) + ( portfolio_details, next_page_token, @@ -508,7 +566,11 @@ def describe_record(self): accept_language = self._get_param("AcceptLanguage") identifier = self._get_param("Id") page_token = self._get_param("PageToken") - # page_size = self._get_param("PageSize") + page_size = self._get_param("PageSize") + + if page_size is not None: + warnings.warn("Pagination is not yet implemented for describe_record()") + ( record_detail, record_outputs, diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 7ceebad57e28..5781d54763b8 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -31,10 +31,13 @@ WebsiteURL: Value: !GetAtt BucketWithSemiRandomName.WebsiteURL Description: URL for website hosted on S3 + BucketArn: + Value: !GetAtt BucketWithSemiRandomName.Arn + Description: ARN for bucket """ -def _create_cf_template_in_s3(region_name: str): +def _create_cf_template_in_s3(region_name: str, create_bucket: bool = True): """ Creates a bucket and uploads a cloudformation template to be used in product provisioning """ @@ -42,12 +45,13 @@ def _create_cf_template_in_s3(region_name: str): cloud_s3_key = "sc-templates/test-product/stack.yaml" cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" s3_client = boto3.client("s3", region_name=region_name) - s3_client.create_bucket( - Bucket=cloud_bucket, - CreateBucketConfiguration={ - "LocationConstraint": region_name, - }, - ) + if create_bucket: + s3_client.create_bucket( + Bucket=cloud_bucket, + CreateBucketConfiguration={ + "LocationConstraint": region_name, + }, + ) s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) return cloud_url @@ -62,8 +66,8 @@ def _create_portfolio(region_name: str, portfolio_name: str): return create_portfolio_response -def _create_product(region_name: str, product_name: str): - cloud_url = _create_cf_template_in_s3(region_name) +def _create_product(region_name: str, product_name: str, create_bucket=True): + cloud_url = _create_cf_template_in_s3(region_name, create_bucket) client = boto3.client("servicecatalog", region_name=region_name) # Create Product create_product_response = client.create_product( @@ -697,20 +701,124 @@ def test_get_provisioned_product_outputs_by_id(): ProvisionedProductId=provisioned_product_id ) - assert len(resp["Outputs"]) == 1 + assert len(resp["Outputs"]) == 2 assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" @mock_servicecatalog @mock_s3 -def test_get_provisioned_product_outputs_filtered_output_by_name(): - assert 1 == 2 +def test_get_provisioned_product_outputs_by_name(): + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.get_provisioned_product_outputs( + ProvisionedProductName="My Provisioned Product" + ) + + assert len(resp["Outputs"]) == 2 + assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" @mock_servicecatalog @mock_s3 def test_get_provisioned_product_outputs_filtered_by_output_keys(): - assert 1 == 2 + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.get_provisioned_product_outputs( + ProvisionedProductName="My Provisioned Product", OutputKeys=["BucketArn"] + ) + + assert len(resp["Outputs"]) == 1 + assert resp["Outputs"][0]["OutputKey"] == "BucketArn" + + +# aws servicecatalog get-provisioned-product-outputs --provisioned-product-id pp-r3t24eckqo6we --output-keys IamUserKeyArn WorkspaceBucketArn +# { +# "Outputs": [ +# { +# "OutputKey": "IamUserKeyArn", +# "OutputValue": "arn:aws:secretsmanager:eu-west-1:079312664296:secret:dconnor-workspace-5070d14ba5d547ae-keyid-3dzR2d", +# "Description": "The ARN of the Secret holding the IAM key ID" +# }, +# { +# "OutputKey": "WorkspaceBucketArn", +# "OutputValue": "arn:aws:s3:::dconnor-workspace-5070d14ba5d547ae", +# "Description": "The ARN of the bucket created for this workspace" +# } +# ] +# } + + +@mock_servicecatalog +@mock_s3 +def test_get_provisioned_product_outputs_missing_required(): + client = boto3.client("servicecatalog", region_name="us-east-1") + + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs() + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "ProvisionedProductId and ProvisionedProductName cannot both be null" + ) + + +@mock_servicecatalog +@mock_s3 +def test_get_provisioned_product_outputs_filtered_by_output_keys_invalid(): + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] + + client = boto3.client("servicecatalog", region_name=region_name) + + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs( + ProvisionedProductId=provisioned_product_id, OutputKeys=["Not a key"] + ) + + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Invalid OutputKeys: {'Not a key'}" @mock_servicecatalog @@ -779,14 +887,84 @@ def test_search_provisioned_products_filter_by(): @mock_servicecatalog @mock_s3 -def test_search_provisioned_products_access_level(): - assert 1 == 2 +def test_search_provisioned_products_sort(): + # arn , id , name , and lastRecordId + # sort order -ASCENDING + # DESCENDING + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="Z - My Provisioned Product", + ) + provisioned_product["RecordDetail"]["ProvisionedProductId"] + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product_2 = _create_provisioned_product( + region_name=region_name, + product_name="test product", + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name="Y - Second Provisioned Product", + ) + provisioned_product_2["RecordDetail"]["ProvisionedProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + + # Ascending Search + resp = client.search_provisioned_products(SortBy="name") + + assert len(resp["ProvisionedProducts"]) == 2 + assert resp["ProvisionedProducts"][0]["Name"] == "Y - Second Provisioned Product" + assert resp["ProvisionedProducts"][1]["Name"] == "Z - My Provisioned Product" + + # Descending Search + resp = client.search_provisioned_products(SortBy="name", SortOrder="DESCENDING") + + assert len(resp["ProvisionedProducts"]) == 2 + assert resp["ProvisionedProducts"][0]["Name"] == "Z - My Provisioned Product" + assert resp["ProvisionedProducts"][1]["Name"] == "Y - Second Provisioned Product" @mock_servicecatalog @mock_s3 -def test_search_provisioned_products_with_sort(): - assert 1 == 2 +def test_search_provisioned_products_sort_by_invalid_keys(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + with pytest.raises(ClientError) as exc: + client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]}, + SortBy="not_a_field", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "not_a_field is not a supported sort field. It must be ['arn', 'id', 'name', 'lastRecordId']" + ) + + +@mock_servicecatalog +@mock_s3 +def test_search_provisioned_products_sort_order_invalid_keys(): + client = boto3.client("servicecatalog", region_name="eu-west-1") + with pytest.raises(ClientError) as exc: + client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]}, + SortOrder="not_a_value", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "1 validation error detected: Value 'not_a_value' at 'sortOrder' failed to " + "satisfy constraint: Member must satisfy enum value set: ['ASCENDING', " + "'DESCENDING']" + ) @mock_servicecatalog @@ -899,7 +1077,34 @@ def test_search_products_by_filter_fulltext(): @mock_servicecatalog @mock_s3 def test_search_products_with_sort(): - assert 1 == 2 + region_name = "us-east-2" + _create_product_with_constraint( + region_name=region_name, + product_name="Z - test product", + portfolio_name="Test Portfolio", + ) + _create_product( + region_name=region_name, product_name="Y - Another Product", create_bucket=False + ) + + client = boto3.client("servicecatalog", region_name=region_name) + + resp = client.search_products(SortBy="Title") + products = resp["ProductViewSummaries"] + assert len(products) == 2 + assert products[0]["Name"] == "Y - Another Product" + assert products[1]["Name"] == "Z - test product" + + resp = client.search_products(SortBy="Title", SortOrder="DESCENDING") + products = resp["ProductViewSummaries"] + assert len(products) == 2 + assert products[0]["Name"] == "Z - test product" + assert products[1]["Name"] == "Y - Another Product" + + +# aws servicecatalog search-products --sort-by asdf +# +# An error occurred (ValidationException) when calling the SearchProducts operation: 1 validation error detected: Value 'asdf' at 'sortBy' failed to satisfy constraint: Member must satisfy enum value set: [CreationDate, VersionCount, Title] @mock_servicecatalog @@ -1020,6 +1225,10 @@ def test_describe_product_as_admin_with_source_portfolio_id(): assert 1 == 2 +# aws servicecatalog describe-product-as-admin --id prod-4sapxevj5x334 --source-portfolio-id port-bl325ushxntd6 +# I think provisioning artifact will be unique to the portfolio + + @mock_servicecatalog @mock_s3 def test_describe_product_by_id(): From 07979bff975d69dbfcafb112ae89e63a8431a535 Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 23 Aug 2023 09:00:07 +0100 Subject: [PATCH 19/20] Refactored tests to share data where possible --- tests/test_servicecatalog/setupdata.py | 178 ++ .../test_servicecatalog.py | 1722 +++++++---------- 2 files changed, 838 insertions(+), 1062 deletions(-) create mode 100644 tests/test_servicecatalog/setupdata.py diff --git a/tests/test_servicecatalog/setupdata.py b/tests/test_servicecatalog/setupdata.py new file mode 100644 index 000000000000..e825295ee575 --- /dev/null +++ b/tests/test_servicecatalog/setupdata.py @@ -0,0 +1,178 @@ +import boto3 +import uuid + + +BASIC_CLOUD_STACK = """--- +Resources: + BucketWithSemiRandomName: + Type: "AWS::S3::Bucket" + Properties: + BucketName: !Join + - "-" + - - "bucket-with-semi-random-name" + - !Select + - 0 + - !Split + - "-" + - !Select + - 2 + - !Split + - "/" + - !Ref "AWS::StackId" +Outputs: + WebsiteURL: + Value: !GetAtt BucketWithSemiRandomName.WebsiteURL + Description: URL for website hosted on S3 + BucketArn: + Value: !GetAtt BucketWithSemiRandomName.Arn + Description: ARN for bucket +""" + + +def _create_cf_template_in_s3(region_name: str, create_bucket: bool = True): + """ + Creates a bucket and uploads a cloudformation template to be used in product provisioning + """ + cloud_bucket = "cf-servicecatalog" + cloud_s3_key = "sc-templates/test-product/stack.yaml" + cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" + s3_client = boto3.client("s3", region_name=region_name) + if create_bucket: + s3_client.create_bucket( + Bucket=cloud_bucket, + CreateBucketConfiguration={ + "LocationConstraint": region_name, + }, + ) + s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) + return cloud_url + + +def _create_portfolio(region_name: str, portfolio_name: str): + client = boto3.client("servicecatalog", region_name=region_name) + + # Create portfolio + create_portfolio_response = client.create_portfolio( + DisplayName=portfolio_name, ProviderName="Test Provider" + ) + return create_portfolio_response + + +def _create_product(region_name: str, product_name: str, create_bucket=True): + cloud_url = _create_cf_template_in_s3(region_name, create_bucket) + client = boto3.client("servicecatalog", region_name=region_name) + # Create Product + create_product_response = client.create_product( + Name=product_name, + Owner="owner arn", + Description="description", + SupportEmail="test@example.com", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Description": "InitialCreation", + "Info": {"LoadTemplateFromURL": cloud_url}, + "Type": "CLOUD_FORMATION_TEMPLATE", + }, + IdempotencyToken=str(uuid.uuid4()), + ) + return create_product_response + + +def _create_product_with_portfolio( + region_name: str, portfolio_name: str, product_name: str +): + """ + Create a portfolio and product with a uploaded cloud formation template + """ + + create_portfolio_response = _create_portfolio( + region_name=region_name, portfolio_name=portfolio_name + ) + create_product_response = _create_product( + region_name=region_name, product_name=product_name + ) + + client = boto3.client("servicecatalog", region_name=region_name) + + # Associate product to portfolio + client.associate_product_with_portfolio( + PortfolioId=create_portfolio_response["PortfolioDetail"]["Id"], + ProductId=create_product_response["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + ) + return create_portfolio_response, create_product_response + + +def _create_product_with_constraint( + region_name: str, + portfolio_name: str, + product_name: str, + role_arn: str = "arn:aws:iam::123456789012:role/LaunchRole", +): + """ + Create a portfolio and product with a uploaded cloud formation template including + a launch constraint on the roleARN + """ + portfolio, product = _create_product_with_portfolio( + region_name=region_name, + portfolio_name=portfolio_name, + product_name=product_name, + ) + client = boto3.client("servicecatalog", region_name=region_name) + create_constraint_response = client.create_constraint( + PortfolioId=portfolio["PortfolioDetail"]["Id"], + ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + Parameters=f"""{{"RoleArn": "{role_arn}"}}""", + Type="LAUNCH", + ) + return create_constraint_response, portfolio, product + + +def _create_provisioned_product( + region_name: str, + product_name: str, + provisioning_artifact_id: str, + provisioned_product_name: str, +): + """ + Create a provisioned product from the specified product_name + """ + client = boto3.client("servicecatalog", region_name=region_name) + # TODO: Path from launch object + provisioned_product_response = client.provision_product( + ProvisionedProductName=provisioned_product_name, + ProvisioningArtifactId=provisioning_artifact_id, + PathId="TODO: Launch path", + ProductName=product_name, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], + ) + return provisioned_product_response + + +def _create_portfolio_with_provisioned_product( + region_name: str, + portfolio_name: str, + product_name: str, + provisioned_product_name: str, +): + + constraint, portfolio, product = _create_product_with_constraint( + region_name=region_name, + product_name=product_name, + portfolio_name=portfolio_name, + ) + + provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] + provisioned_product = _create_provisioned_product( + region_name=region_name, + product_name=product_name, + provisioning_artifact_id=provisioning_artifact_id, + provisioned_product_name=provisioned_product_name, + ) + + return constraint, portfolio, product, provisioned_product diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index 5781d54763b8..b65173ca0cc8 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -1,1110 +1,733 @@ """Unit tests for servicecatalog-supported APIs.""" import pytest import boto3 -from botocore.exceptions import ParamValidationError import uuid -from datetime import date + from moto import mock_servicecatalog, mock_s3 from botocore.exceptions import ClientError, ParamValidationError - -# See our Development Tips on writing tests for hints on how to write good tests: -# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html - -BASIC_CLOUD_STACK = """--- -Resources: - BucketWithSemiRandomName: - Type: "AWS::S3::Bucket" - Properties: - BucketName: !Join - - "-" - - - "bucket-with-semi-random-name" - - !Select - - 0 - - !Split - - "-" - - !Select - - 2 - - !Split - - "/" - - !Ref "AWS::StackId" -Outputs: - WebsiteURL: - Value: !GetAtt BucketWithSemiRandomName.WebsiteURL - Description: URL for website hosted on S3 - BucketArn: - Value: !GetAtt BucketWithSemiRandomName.Arn - Description: ARN for bucket -""" - - -def _create_cf_template_in_s3(region_name: str, create_bucket: bool = True): - """ - Creates a bucket and uploads a cloudformation template to be used in product provisioning - """ - cloud_bucket = "cf-servicecatalog" - cloud_s3_key = "sc-templates/test-product/stack.yaml" - cloud_url = f"https://s3.amazonaws.com/{cloud_bucket}/{cloud_s3_key}" - s3_client = boto3.client("s3", region_name=region_name) - if create_bucket: - s3_client.create_bucket( - Bucket=cloud_bucket, - CreateBucketConfiguration={ - "LocationConstraint": region_name, - }, +from .setupdata import ( + _create_product_with_portfolio, + _create_cf_template_in_s3, + _create_portfolio, + _create_product, + _create_portfolio_with_provisioned_product, + _create_provisioned_product, + _create_product_with_constraint, +) + + +@mock_servicecatalog +class TestPortfolio: + def test_create_portfolio(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.create_portfolio( + DisplayName="Test Portfolio", + ProviderName="Test Provider", + Tags=[ + {"Key": "FirstTag", "Value": "FirstTagValue"}, + {"Key": "SecondTag", "Value": "SecondTagValue"}, + ], ) - s3_client.put_object(Body=BASIC_CLOUD_STACK, Bucket=cloud_bucket, Key=cloud_s3_key) - return cloud_url - - -def _create_portfolio(region_name: str, portfolio_name: str): - client = boto3.client("servicecatalog", region_name=region_name) - - # Create portfolio - create_portfolio_response = client.create_portfolio( - DisplayName=portfolio_name, ProviderName="Test Provider" - ) - return create_portfolio_response - - -def _create_product(region_name: str, product_name: str, create_bucket=True): - cloud_url = _create_cf_template_in_s3(region_name, create_bucket) - client = boto3.client("servicecatalog", region_name=region_name) - # Create Product - create_product_response = client.create_product( - Name=product_name, - Owner="owner arn", - Description="description", - SupportEmail="test@example.com", - ProductType="CLOUD_FORMATION_TEMPLATE", - ProvisioningArtifactParameters={ - "Name": "InitialCreation", - "Description": "InitialCreation", - "Info": {"LoadTemplateFromURL": cloud_url}, - "Type": "CLOUD_FORMATION_TEMPLATE", - }, - IdempotencyToken=str(uuid.uuid4()), - ) - return create_product_response - - -def _create_product_with_portfolio( - region_name: str, portfolio_name: str, product_name: str -): - """ - Create a portfolio and product with a uploaded cloud formation template - """ - - create_portfolio_response = _create_portfolio( - region_name=region_name, portfolio_name=portfolio_name - ) - create_product_response = _create_product( - region_name=region_name, product_name=product_name - ) - - client = boto3.client("servicecatalog", region_name=region_name) - - # Associate product to portfolio - client.associate_product_with_portfolio( - PortfolioId=create_portfolio_response["PortfolioDetail"]["Id"], - ProductId=create_product_response["ProductViewDetail"]["ProductViewSummary"][ - "ProductId" - ], - ) - return create_portfolio_response, create_product_response - - -def _create_product_with_constraint( - region_name: str, - portfolio_name: str, - product_name: str, - role_arn: str = "arn:aws:iam::123456789012:role/LaunchRole", -): - """ - Create a portfolio and product with a uploaded cloud formation template including - a launch constraint on the roleARN - """ - portfolio, product = _create_product_with_portfolio( - region_name=region_name, - portfolio_name=portfolio_name, - product_name=product_name, - ) - client = boto3.client("servicecatalog", region_name=region_name) - create_constraint_response = client.create_constraint( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - Parameters=f"""{{"RoleArn": "{role_arn}"}}""", - Type="LAUNCH", - ) - return create_constraint_response, portfolio, product - - -def _create_provisioned_product( - region_name: str, - product_name: str, - provisioning_artifact_id: str, - provisioned_product_name: str, -): - """ - Create a provisioned product from the specified product_name - """ - client = boto3.client("servicecatalog", region_name=region_name) - # TODO: Path from launch object - provisioned_product_response = client.provision_product( - ProvisionedProductName=provisioned_product_name, - ProvisioningArtifactId=provisioning_artifact_id, - PathId="TODO: Launch path", - ProductName=product_name, - Tags=[ - {"Key": "MyCustomTag", "Value": "A Value"}, - {"Key": "MyOtherTag", "Value": "Another Value"}, - ], - ) - return provisioned_product_response - - -def _create_portfolio_with_provisioned_product( - region_name: str, - portfolio_name: str, - product_name: str, - provisioned_product_name: str, -): - - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name=portfolio_name, - ) - - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product = _create_provisioned_product( - region_name=region_name, - product_name=product_name, - provisioning_artifact_id=provisioning_artifact_id, - provisioned_product_name=provisioned_product_name, - ) - return constraint, portfolio, product, provisioned_product + assert "PortfolioDetail" in resp + portfolio = resp["PortfolioDetail"] + assert portfolio["DisplayName"] == "Test Portfolio" + assert portfolio["ProviderName"] == "Test Provider" + assert "Tags" in resp + assert len(resp["Tags"]) == 2 + assert resp["Tags"][0]["Key"] == "FirstTag" + assert resp["Tags"][0]["Value"] == "FirstTagValue" + def test_create_portfolio_missing_required(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + with pytest.raises(ParamValidationError) as exc: + client.create_portfolio() -@mock_servicecatalog -def test_create_portfolio(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.create_portfolio( - DisplayName="Test Portfolio", - ProviderName="Test Provider", - Tags=[ - {"Key": "FirstTag", "Value": "FirstTagValue"}, - {"Key": "SecondTag", "Value": "SecondTagValue"}, - ], - ) + assert "DisplayName" in exc.value.args[0] - assert "PortfolioDetail" in resp - portfolio = resp["PortfolioDetail"] - assert portfolio["DisplayName"] == "Test Portfolio" - assert portfolio["ProviderName"] == "Test Provider" - assert "Tags" in resp - assert len(resp["Tags"]) == 2 - assert resp["Tags"][0]["Key"] == "FirstTag" - assert resp["Tags"][0]["Value"] == "FirstTagValue" - - -@mock_servicecatalog -def test_create_portfolio_missing_required(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - with pytest.raises(ParamValidationError) as exc: - client.create_portfolio() - - assert "DisplayName" in exc.value.args[0] - - -@mock_servicecatalog -def test_create_portfolio_duplicate(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - client.create_portfolio(DisplayName="Test Portfolio", ProviderName="Test Provider") - - with pytest.raises(ClientError) as exc: + def test_create_portfolio_duplicate(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") client.create_portfolio( DisplayName="Test Portfolio", ProviderName="Test Provider" ) - err = exc.value.response["Error"] - assert err["Code"] == "InvalidParametersException" - assert err["Message"] == "Portfolio with this name already exists" + with pytest.raises(ClientError) as exc: + client.create_portfolio( + DisplayName="Test Portfolio", ProviderName="Test Provider" + ) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Portfolio with this name already exists" -@mock_servicecatalog -@mock_s3 -def test_create_product(): - region_name = "us-east-2" - cloud_url = _create_cf_template_in_s3(region_name=region_name) + def test_list_portfolios(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.create_product( - Name="test product", - Owner="owner arn", - Description="description", - SupportEmail="test@example.com", - ProductType="CLOUD_FORMATION_TEMPLATE", - ProvisioningArtifactParameters={ - "Name": "InitialCreation", - "Description": "InitialCreation", - "Info": {"LoadTemplateFromURL": cloud_url}, - "Type": "CLOUD_FORMATION_TEMPLATE", - }, - IdempotencyToken=str(uuid.uuid4()), - Tags=[ - {"Key": "FirstTag", "Value": "FirstTagValue"}, - {"Key": "SecondTag", "Value": "SecondTagValue"}, - ], - ) + portfolio_id_1 = client.create_portfolio( + DisplayName="test-1", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] + portfolio_id_2 = client.create_portfolio( + DisplayName="test-2", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] - assert "ProductViewDetail" in resp - assert resp["ProductViewDetail"]["Status"] == "AVAILABLE" - product = resp["ProductViewDetail"]["ProductViewSummary"] - assert product["Name"] == "test product" - assert product["Owner"] == "owner arn" - assert product["ShortDescription"] == "description" - assert product["SupportEmail"] == "test@example.com" + assert len(client.list_portfolios()["PortfolioDetails"]) == 2 + portfolio_ids = [i["Id"] for i in client.list_portfolios()["PortfolioDetails"]] - assert "ProvisioningArtifactDetail" in resp - artifact = resp["ProvisioningArtifactDetail"] - assert artifact["Name"] == "InitialCreation" - assert artifact["Type"] == "CLOUD_FORMATION_TEMPLATE" + assert portfolio_id_1 in portfolio_ids + assert portfolio_id_2 in portfolio_ids - assert "Tags" in resp - assert len(resp["Tags"]) == 2 - assert resp["Tags"][0]["Key"] == "FirstTag" - assert resp["Tags"][0]["Value"] == "FirstTagValue" + def test_describe_portfolio(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 + portfolio_id = client.create_portfolio( + DisplayName="test-1", ProviderName="prov-1" + )["PortfolioDetail"]["Id"] -@mock_servicecatalog -@mock_s3 -def test_create_product_missing_required(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - with pytest.raises(ParamValidationError) as exc: - client.create_product() + portfolio_response = client.describe_portfolio(Id=portfolio_id) + assert portfolio_id == portfolio_response["PortfolioDetail"]["Id"] + + def test_describe_portfolio_not_existing(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - assert "Name" in exc.value.args[0] + with pytest.raises(ClientError) as exc: + client.describe_portfolio(Id="not-found") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Portfolio not found" @mock_servicecatalog @mock_s3 -def test_create_product_duplicate(): - region_name = "us-east-2" - cloud_url = _create_cf_template_in_s3(region_name=region_name) +class TestProduct: + def setup_method(self, method): + self.region_name = "us-east-2" + self.cloud_url = _create_cf_template_in_s3(region_name=self.region_name) - client = boto3.client("servicecatalog", region_name=region_name) - client.create_product( - Name="test product", - Owner="owner arn", - ProductType="CLOUD_FORMATION_TEMPLATE", - ProvisioningArtifactParameters={ - "Name": "InitialCreation", - "Info": {"LoadTemplateFromURL": cloud_url}, - }, - IdempotencyToken=str(uuid.uuid4()), - ) + def test_create_product(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.create_product( + Name="test product", + Owner="owner arn", + Description="description", + SupportEmail="test@example.com", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Description": "InitialCreation", + "Info": {"LoadTemplateFromURL": self.cloud_url}, + "Type": "CLOUD_FORMATION_TEMPLATE", + }, + IdempotencyToken=str(uuid.uuid4()), + Tags=[ + {"Key": "FirstTag", "Value": "FirstTagValue"}, + {"Key": "SecondTag", "Value": "SecondTagValue"}, + ], + ) - with pytest.raises(ClientError) as exc: + assert "ProductViewDetail" in resp + assert resp["ProductViewDetail"]["Status"] == "AVAILABLE" + product = resp["ProductViewDetail"]["ProductViewSummary"] + assert product["Name"] == "test product" + assert product["Owner"] == "owner arn" + assert product["ShortDescription"] == "description" + assert product["SupportEmail"] == "test@example.com" + + assert "ProvisioningArtifactDetail" in resp + artifact = resp["ProvisioningArtifactDetail"] + assert artifact["Name"] == "InitialCreation" + assert artifact["Type"] == "CLOUD_FORMATION_TEMPLATE" + + assert "Tags" in resp + assert len(resp["Tags"]) == 2 + assert resp["Tags"][0]["Key"] == "FirstTag" + assert resp["Tags"][0]["Value"] == "FirstTagValue" + + def test_create_product_missing_required(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + with pytest.raises(ParamValidationError) as exc: + client.create_product() + + assert "Name" in exc.value.args[0] + + def test_create_product_duplicate(self): + client = boto3.client("servicecatalog", region_name=self.region_name) client.create_product( Name="test product", Owner="owner arn", ProductType="CLOUD_FORMATION_TEMPLATE", ProvisioningArtifactParameters={ "Name": "InitialCreation", - "Info": {"LoadTemplateFromURL": cloud_url}, + "Info": {"LoadTemplateFromURL": self.cloud_url}, }, IdempotencyToken=str(uuid.uuid4()), ) - err = exc.value.response["Error"] - assert err["Code"] == "InvalidParametersException" - assert err["Message"] == "Product with this name already exists" + with pytest.raises(ClientError) as exc: + client.create_product( + Name="test product", + Owner="owner arn", + ProductType="CLOUD_FORMATION_TEMPLATE", + ProvisioningArtifactParameters={ + "Name": "InitialCreation", + "Info": {"LoadTemplateFromURL": self.cloud_url}, + }, + IdempotencyToken=str(uuid.uuid4()), + ) - -@mock_servicecatalog -@mock_s3 -def test_create_constraint(): - region_name = "us-east-2" - portfolio, product = _create_product_with_portfolio( - region_name=region_name, - portfolio_name="My Portfolio", - product_name="test product", - ) - - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.create_constraint( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", - Type="LAUNCH", - ) - - assert "ConstraintDetail" in resp - assert "ConstraintParameters" in resp - assert resp["Status"] == "AVAILABLE" + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Product with this name already exists" @mock_servicecatalog @mock_s3 -def test_create_constraint_duplicate(): - region_name = "us-east-2" - portfolio, product = _create_product_with_portfolio( - region_name=region_name, - portfolio_name="My Portfolio", - product_name="test product", - ) +class TestConstraint: + def setup_method(self, method): + self.region_name = "us-east-2" + self.portfolio, self.product = _create_product_with_portfolio( + region_name=self.region_name, + portfolio_name="My Portfolio", + product_name="test product", + ) - client = boto3.client("servicecatalog", region_name=region_name) - client.create_constraint( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", - Type="LAUNCH", - ) - with pytest.raises(ClientError) as exc: - client.create_constraint( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], + def test_create_constraint(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.create_constraint( + PortfolioId=self.portfolio["PortfolioDetail"]["Id"], + ProductId=self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", Type="LAUNCH", ) - err = exc.value.response["Error"] - assert err["Code"] == "DuplicateResourceException" - assert err["Message"] == "Constraint with these links already exists" - - -@mock_servicecatalog -@mock_s3 -def test_create_constraint_missing_required(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - with pytest.raises(ParamValidationError) as exc: - client.create_constraint() - - assert "PortfolioId" in exc.value.args[0] - - -@mock_servicecatalog -@mock_s3 -def test_associate_product_with_portfolio(): - region_name = "us-east-2" - - portfolio = _create_portfolio( - region_name=region_name, portfolio_name="The Portfolio" - ) - product = _create_product(region_name=region_name, product_name="My Product") - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - - # Verify product is not linked to portfolio - client = boto3.client("servicecatalog", region_name=region_name) - linked = client.list_portfolios_for_product(ProductId=product_id) - assert len(linked["PortfolioDetails"]) == 0 - - # Link product to portfolio - resp = client.associate_product_with_portfolio( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - ) - assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 - - # Verify product is now linked to portfolio - linked = client.list_portfolios_for_product(ProductId=product_id) - assert len(linked["PortfolioDetails"]) == 1 - assert linked["PortfolioDetails"][0]["Id"] == portfolio["PortfolioDetail"]["Id"] - - -@mock_servicecatalog -@mock_s3 -def test_associate_product_with_portfolio_invalid_ids(): - region_name = "us-east-2" - portfolio = _create_portfolio( - region_name=region_name, portfolio_name="The Portfolio" - ) - product = _create_product(region_name=region_name, product_name="My Product") - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - - client = boto3.client("servicecatalog", region_name=region_name) - - # Link product to portfolio - with pytest.raises(ClientError) as exc: - client.associate_product_with_portfolio( - PortfolioId="invalid_portfolio", - ProductId=product["ProductViewDetail"]["ProductViewSummary"]["ProductId"], - ) - - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Portfolio not found" - - with pytest.raises(ClientError) as exc: - client.associate_product_with_portfolio( - PortfolioId=portfolio["PortfolioDetail"]["Id"], - ProductId="invalid_product", - ) - - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" - - -@mock_servicecatalog -@mock_s3 -def test_provision_product_by_product_name_and_artifact_id(): - region_name = "us-east-2" - product_name = "My Product" - portfolio, product = _create_product_with_portfolio( - region_name=region_name, - product_name=product_name, - portfolio_name="The Portfolio", - ) - - client = boto3.client("servicecatalog", region_name=region_name) - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - - # TODO: Paths - provisioned_product_response = client.provision_product( - ProvisionedProductName="Provisioned Product Name", - ProvisioningArtifactId=provisioning_artifact_id, - PathId="TODO", - ProductName=product_name, - Tags=[ - {"Key": "MyCustomTag", "Value": "A Value"}, - {"Key": "MyOtherTag", "Value": "Another Value"}, - ], - ) - provisioned_product_id = provisioned_product_response["RecordDetail"][ - "ProvisionedProductId" - ] - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - - # Verify record details - rec = provisioned_product_response["RecordDetail"] - assert rec["ProvisionedProductName"] == "Provisioned Product Name" - assert rec["Status"] == "CREATED" - assert rec["ProductId"] == product_id - assert rec["ProvisionedProductId"] == provisioned_product_id - assert rec["ProvisionedProductType"] == "CFN_STACK" - assert rec["ProvisioningArtifactId"] == provisioning_artifact_id - assert rec["PathId"] == "" - assert rec["RecordType"] == "PROVISION_PRODUCT" - # tags - - # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" - s3_client = boto3.client("s3", region_name=region_name) - all_buckets_response = s3_client.list_buckets() - bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] - - assert any( - [name.startswith("bucket-with-semi-random-name") for name in bucket_names] - ) - - -@mock_servicecatalog -@mock_s3 -def test_provision_product_by_artifact_name_and_product_id(): - region_name = "us-east-2" - - portfolio, product = _create_product_with_portfolio( - region_name=region_name, - product_name="My Product", - portfolio_name="The Portfolio", - ) - - client = boto3.client("servicecatalog", region_name=region_name) - provisioning_artifact_name = product["ProvisioningArtifactDetail"]["Name"] - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - - # TODO: Paths - provisioned_product_response = client.provision_product( - ProvisionedProductName="Provisioned Product Name", - ProvisioningArtifactName=provisioning_artifact_name, - PathId="TODO", - ProductId=product_id, - Tags=[ - {"Key": "MyCustomTag", "Value": "A Value"}, - {"Key": "MyOtherTag", "Value": "Another Value"}, - ], - ) - - # Verify record details - rec = provisioned_product_response["RecordDetail"] - assert rec["ProvisionedProductName"] == "Provisioned Product Name" - - # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" - s3_client = boto3.client("s3", region_name=region_name) - all_buckets_response = s3_client.list_buckets() - bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] - - assert any( - [name.startswith("bucket-with-semi-random-name") for name in bucket_names] - ) - - -@mock_servicecatalog -@mock_s3 -def test_provision_product_by_artifact_id_and_product_id_and_path_id(): - assert 1 == 2 - - -@mock_servicecatalog -@mock_s3 -def test_provision_product_by_artifact_id_and_product_id_and_path_name(): - assert 1 == 2 - - -@mock_servicecatalog -@mock_s3 -def test_provision_product_with_parameters(): - assert 1 == 2 - - -@mock_servicecatalog -def test_list_portfolios(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - - portfolio_id_1 = client.create_portfolio( - DisplayName="test-1", ProviderName="prov-1" - )["PortfolioDetail"]["Id"] - portfolio_id_2 = client.create_portfolio( - DisplayName="test-2", ProviderName="prov-1" - )["PortfolioDetail"]["Id"] - - assert len(client.list_portfolios()["PortfolioDetails"]) == 2 - portfolio_ids = [i["Id"] for i in client.list_portfolios()["PortfolioDetails"]] - - assert portfolio_id_1 in portfolio_ids - assert portfolio_id_2 in portfolio_ids - - -@mock_servicecatalog -def test_describe_portfolio(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - - portfolio_id = client.create_portfolio(DisplayName="test-1", ProviderName="prov-1")[ - "PortfolioDetail" - ]["Id"] - - portfolio_response = client.describe_portfolio(Id=portfolio_id) - assert portfolio_id == portfolio_response["PortfolioDetail"]["Id"] - - -@mock_servicecatalog -def test_describe_portfolio_not_existing(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - assert len(client.list_portfolios()["PortfolioDetails"]) == 0 - - with pytest.raises(ClientError) as exc: - client.describe_portfolio(Id="not-found") - - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Portfolio not found" - - -@mock_servicecatalog -@mock_s3 -def test_describe_provisioned_product_by_id(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - - client = boto3.client("servicecatalog", region_name=region_name) - - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - resp = client.describe_provisioned_product(Id=provisioned_product_id) - - assert resp["ProvisionedProductDetail"]["Id"] == provisioned_product_id - assert resp["ProvisionedProductDetail"]["ProductId"] == product_id - assert ( - resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] - == provisioning_artifact_id - ) - - -@mock_servicecatalog -@mock_s3 -def test_describe_provisioned_product_by_name(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - - client = boto3.client("servicecatalog", region_name=region_name) - - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - resp = client.describe_provisioned_product(Name="My Provisioned Product") - - assert resp["ProvisionedProductDetail"]["Id"] == provisioned_product_id - assert resp["ProvisionedProductDetail"]["ProductId"] == product_id - assert ( - resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] - == provisioning_artifact_id - ) - - -@mock_servicecatalog -@mock_s3 -def test_describe_provisioned_product_not_found(): - client = boto3.client("servicecatalog", region_name="us-east-1") - with pytest.raises(ClientError) as exc: - client.describe_provisioned_product(Name="does not exist") - - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Provisioned product not found" - - -@mock_servicecatalog -@mock_s3 -def test_get_provisioned_product_outputs_by_id(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - - client = boto3.client("servicecatalog", region_name=region_name) - - resp = client.get_provisioned_product_outputs( - ProvisionedProductId=provisioned_product_id - ) - - assert len(resp["Outputs"]) == 2 - assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" - - -@mock_servicecatalog -@mock_s3 -def test_get_provisioned_product_outputs_by_name(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - - client = boto3.client("servicecatalog", region_name=region_name) - - resp = client.get_provisioned_product_outputs( - ProvisionedProductName="My Provisioned Product" - ) - - assert len(resp["Outputs"]) == 2 - assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" - - -@mock_servicecatalog -@mock_s3 -def test_get_provisioned_product_outputs_filtered_by_output_keys(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - - client = boto3.client("servicecatalog", region_name=region_name) + assert "ConstraintDetail" in resp + assert "ConstraintParameters" in resp + assert resp["Status"] == "AVAILABLE" - resp = client.get_provisioned_product_outputs( - ProvisionedProductName="My Provisioned Product", OutputKeys=["BucketArn"] - ) + def test_create_constraint_duplicate(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + client.create_constraint( + PortfolioId=self.portfolio["PortfolioDetail"]["Id"], + ProductId=self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) + with pytest.raises(ClientError) as exc: + client.create_constraint( + PortfolioId=self.portfolio["PortfolioDetail"]["Id"], + ProductId=self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + Parameters="""{"RoleArn": "arn:aws:iam::123456789012:role/LaunchRole"}""", + Type="LAUNCH", + ) - assert len(resp["Outputs"]) == 1 - assert resp["Outputs"][0]["OutputKey"] == "BucketArn" + err = exc.value.response["Error"] + assert err["Code"] == "DuplicateResourceException" + assert err["Message"] == "Constraint with these links already exists" + def test_create_constraint_missing_required(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + with pytest.raises(ParamValidationError) as exc: + client.create_constraint() -# aws servicecatalog get-provisioned-product-outputs --provisioned-product-id pp-r3t24eckqo6we --output-keys IamUserKeyArn WorkspaceBucketArn -# { -# "Outputs": [ -# { -# "OutputKey": "IamUserKeyArn", -# "OutputValue": "arn:aws:secretsmanager:eu-west-1:079312664296:secret:dconnor-workspace-5070d14ba5d547ae-keyid-3dzR2d", -# "Description": "The ARN of the Secret holding the IAM key ID" -# }, -# { -# "OutputKey": "WorkspaceBucketArn", -# "OutputValue": "arn:aws:s3:::dconnor-workspace-5070d14ba5d547ae", -# "Description": "The ARN of the bucket created for this workspace" -# } -# ] -# } + assert "PortfolioId" in exc.value.args[0] @mock_servicecatalog @mock_s3 -def test_get_provisioned_product_outputs_missing_required(): - client = boto3.client("servicecatalog", region_name="us-east-1") +class TestAssociateProduct: + def setup_method(self, method): + self.region_name = "ap-northeast-1" + self.portfolio = _create_portfolio( + region_name=self.region_name, portfolio_name="The Portfolio" + ) + self.product = _create_product( + region_name=self.region_name, product_name="My Product" + ) + self.product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] + + def test_associate_product_with_portfolio(self): + # Verify product is not linked to portfolio + client = boto3.client("servicecatalog", region_name=self.region_name) + linked = client.list_portfolios_for_product(ProductId=self.product_id) + assert len(linked["PortfolioDetails"]) == 0 + + # Link product to portfolio + resp = client.associate_product_with_portfolio( + PortfolioId=self.portfolio["PortfolioDetail"]["Id"], + ProductId=self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + ) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Verify product is now linked to portfolio + linked = client.list_portfolios_for_product(ProductId=self.product_id) + assert len(linked["PortfolioDetails"]) == 1 + assert ( + linked["PortfolioDetails"][0]["Id"] + == self.portfolio["PortfolioDetail"]["Id"] + ) - with pytest.raises(ClientError) as exc: - client.get_provisioned_product_outputs() + def test_associate_product_with_portfolio_invalid_ids(self): + client = boto3.client("servicecatalog", region_name=self.region_name) - err = exc.value.response["Error"] - assert err["Code"] == "ValidationException" - assert ( - err["Message"] - == "ProvisionedProductId and ProvisionedProductName cannot both be null" - ) + # Link product to portfolio + with pytest.raises(ClientError) as exc: + client.associate_product_with_portfolio( + PortfolioId="invalid_portfolio", + ProductId=self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ], + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Portfolio not found" + + with pytest.raises(ClientError) as exc: + client.associate_product_with_portfolio( + PortfolioId=self.portfolio["PortfolioDetail"]["Id"], + ProductId="invalid_product", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog @mock_s3 -def test_get_provisioned_product_outputs_filtered_by_output_keys_invalid(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] +class TestProvisionProduct: + def setup_method(self, method): + self.region_name = "eu-west-1" - client = boto3.client("servicecatalog", region_name=region_name) + self.product_name = "My Product" + self.portfolio, self.product = _create_product_with_portfolio( + region_name=self.region_name, + product_name=self.product_name, + portfolio_name="The Portfolio", + ) - with pytest.raises(ClientError) as exc: - client.get_provisioned_product_outputs( - ProvisionedProductId=provisioned_product_id, OutputKeys=["Not a key"] + self.provisioning_artifact_id = self.product["ProvisioningArtifactDetail"]["Id"] + + def test_provision_product_by_product_name_and_artifact_id(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + + # TODO: Paths + provisioned_product_response = client.provision_product( + ProvisionedProductName="Provisioned Product Name", + ProvisioningArtifactId=self.provisioning_artifact_id, + PathId="TODO", + ProductName=self.product_name, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], + ) + provisioned_product_id = provisioned_product_response["RecordDetail"][ + "ProvisionedProductId" + ] + product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] + + # Verify record details + rec = provisioned_product_response["RecordDetail"] + assert rec["ProvisionedProductName"] == "Provisioned Product Name" + assert rec["Status"] == "CREATED" + assert rec["ProductId"] == product_id + assert rec["ProvisionedProductId"] == provisioned_product_id + assert rec["ProvisionedProductType"] == "CFN_STACK" + assert rec["ProvisioningArtifactId"] == self.provisioning_artifact_id + assert rec["PathId"] == "" + assert rec["RecordType"] == "PROVISION_PRODUCT" + # tags + + # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" + s3_client = boto3.client("s3", region_name=self.region_name) + all_buckets_response = s3_client.list_buckets() + bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] + + assert any( + [name.startswith("bucket-with-semi-random-name") for name in bucket_names] ) - err = exc.value.response["Error"] - assert err["Code"] == "InvalidParametersException" - assert err["Message"] == "Invalid OutputKeys: {'Not a key'}" + def test_provision_product_by_artifact_name_and_product_id( + self, + ): + client = boto3.client("servicecatalog", region_name=self.region_name) + provisioning_artifact_name = self.product["ProvisioningArtifactDetail"]["Name"] + product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] + # TODO: Paths + provisioned_product_response = client.provision_product( + ProvisionedProductName="Provisioned Product Name", + ProvisioningArtifactName=provisioning_artifact_name, + PathId="TODO", + ProductId=product_id, + Tags=[ + {"Key": "MyCustomTag", "Value": "A Value"}, + {"Key": "MyOtherTag", "Value": "Another Value"}, + ], + ) + # Verify record details + rec = provisioned_product_response["RecordDetail"] + assert rec["ProvisionedProductName"] == "Provisioned Product Name" -@mock_servicecatalog -@mock_s3 -def test_search_provisioned_products(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product_2 = _create_provisioned_product( - region_name=region_name, - product_name="test product", - provisioning_artifact_id=provisioning_artifact_id, - provisioned_product_name="Second Provisioned Product", - ) - provisioned_product_id_2 = provisioned_product_2["RecordDetail"][ - "ProvisionedProductId" - ] + # Verify cloud formation stack has been created - this example creates a bucket named "cfn-quickstart-bucket" + s3_client = boto3.client("s3", region_name=self.region_name) + all_buckets_response = s3_client.list_buckets() + bucket_names = [bucket["Name"] for bucket in all_buckets_response["Buckets"]] - client = boto3.client("servicecatalog", region_name=region_name) + assert any( + [name.startswith("bucket-with-semi-random-name") for name in bucket_names] + ) + + def test_provision_product_by_artifact_id_and_product_id_and_path_id(self): + assert 1 == 2 - resp = client.search_provisioned_products() + def test_provision_product_by_artifact_id_and_product_id_and_path_name(self): + assert 1 == 2 - pps = resp["ProvisionedProducts"] - assert len(pps) == 2 - assert pps[0]["Id"] == provisioned_product_id - assert pps[1]["Id"] == provisioned_product_id_2 + def test_provision_product_with_parameters(self): + assert 1 == 2 @mock_servicecatalog @mock_s3 -def test_search_provisioned_products_filter_by(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] +class TestListAndDescribeProvisionedProduct: + def setup_method(self, method): + self.region_name = "eu-west-1" - client = boto3.client("servicecatalog", region_name=region_name) + ( + self.constraint, + self.portfolio, + self.product, + self.provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=self.region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) + self.provisioning_artifact_id = self.product["ProvisioningArtifactDetail"]["Id"] + self.provisioned_product_id = self.provisioned_product["RecordDetail"][ + "ProvisionedProductId" + ] + self.product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] - resp = client.search_provisioned_products( - Filters={"SearchQuery": ["name:My Provisioned Product"]} - ) + def test_describe_provisioned_product_by_id(self): + client = boto3.client("servicecatalog", region_name=self.region_name) - assert len(resp["ProvisionedProducts"]) == 1 - assert resp["ProvisionedProducts"][0]["Id"] == provisioned_product_id + resp = client.describe_provisioned_product(Id=self.provisioned_product_id) + assert resp["ProvisionedProductDetail"]["Id"] == self.provisioned_product_id + assert resp["ProvisionedProductDetail"]["ProductId"] == self.product_id + assert ( + resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] + == self.provisioning_artifact_id + ) -@mock_servicecatalog -@mock_s3 -def test_search_provisioned_products_sort(): - # arn , id , name , and lastRecordId - # sort order -ASCENDING - # DESCENDING - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="Z - My Provisioned Product", - ) - provisioned_product["RecordDetail"]["ProvisionedProductId"] - provisioning_artifact_id = product["ProvisioningArtifactDetail"]["Id"] - provisioned_product_2 = _create_provisioned_product( - region_name=region_name, - product_name="test product", - provisioning_artifact_id=provisioning_artifact_id, - provisioned_product_name="Y - Second Provisioned Product", - ) - provisioned_product_2["RecordDetail"]["ProvisionedProductId"] - client = boto3.client("servicecatalog", region_name=region_name) + def test_describe_provisioned_product_by_name(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.describe_provisioned_product(Name="My Provisioned Product") - # Ascending Search - resp = client.search_provisioned_products(SortBy="name") + assert resp["ProvisionedProductDetail"]["Id"] == self.provisioned_product_id + assert resp["ProvisionedProductDetail"]["ProductId"] == self.product_id + assert ( + resp["ProvisionedProductDetail"]["ProvisioningArtifactId"] + == self.provisioning_artifact_id + ) - assert len(resp["ProvisionedProducts"]) == 2 - assert resp["ProvisionedProducts"][0]["Name"] == "Y - Second Provisioned Product" - assert resp["ProvisionedProducts"][1]["Name"] == "Z - My Provisioned Product" + def test_describe_provisioned_product_not_found(self): + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_provisioned_product(Name="does not exist") - # Descending Search - resp = client.search_provisioned_products(SortBy="name", SortOrder="DESCENDING") + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Provisioned product not found" - assert len(resp["ProvisionedProducts"]) == 2 - assert resp["ProvisionedProducts"][0]["Name"] == "Z - My Provisioned Product" - assert resp["ProvisionedProducts"][1]["Name"] == "Y - Second Provisioned Product" + def test_get_provisioned_product_outputs_by_id(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.get_provisioned_product_outputs( + ProvisionedProductId=self.provisioned_product_id + ) + assert len(resp["Outputs"]) == 2 + assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" -@mock_servicecatalog -@mock_s3 -def test_search_provisioned_products_sort_by_invalid_keys(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - with pytest.raises(ClientError) as exc: - client.search_provisioned_products( - Filters={"SearchQuery": ["name:My Provisioned Product"]}, - SortBy="not_a_field", + def test_get_provisioned_product_outputs_by_name(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.get_provisioned_product_outputs( + ProvisionedProductName="My Provisioned Product" ) - err = exc.value.response["Error"] - assert err["Code"] == "ValidationException" - assert ( - err["Message"] - == "not_a_field is not a supported sort field. It must be ['arn', 'id', 'name', 'lastRecordId']" - ) + assert len(resp["Outputs"]) == 2 + assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" + def test_get_provisioned_product_outputs_filtered_by_output_keys(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.get_provisioned_product_outputs( + ProvisionedProductName="My Provisioned Product", OutputKeys=["BucketArn"] + ) -@mock_servicecatalog -@mock_s3 -def test_search_provisioned_products_sort_order_invalid_keys(): - client = boto3.client("servicecatalog", region_name="eu-west-1") - with pytest.raises(ClientError) as exc: - client.search_provisioned_products( - Filters={"SearchQuery": ["name:My Provisioned Product"]}, - SortOrder="not_a_value", + assert len(resp["Outputs"]) == 1 + assert resp["Outputs"][0]["OutputKey"] == "BucketArn" + + def test_get_provisioned_product_outputs_missing_required(self): + client = boto3.client("servicecatalog", region_name="us-east-1") + + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs() + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "ProvisionedProductId and ProvisionedProductName cannot both be null" ) - err = exc.value.response["Error"] - assert err["Code"] == "ValidationException" - assert ( - err["Message"] - == "1 validation error detected: Value 'not_a_value' at 'sortOrder' failed to " - "satisfy constraint: Member must satisfy enum value set: ['ASCENDING', " - "'DESCENDING']" - ) + def test_get_provisioned_product_outputs_filtered_by_output_keys_invalid(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs( + ProvisionedProductId=self.provisioned_product_id, + OutputKeys=["Not a key"], + ) + + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParametersException" + assert err["Message"] == "Invalid OutputKeys: {'Not a key'}" + + def test_search_provisioned_products(self): + provisioned_product_2 = _create_provisioned_product( + region_name=self.region_name, + product_name="test product", + provisioning_artifact_id=self.provisioning_artifact_id, + provisioned_product_name="Second Provisioned Product", + ) + provisioned_product_id_2 = provisioned_product_2["RecordDetail"][ + "ProvisionedProductId" + ] + client = boto3.client("servicecatalog", region_name=self.region_name) -@mock_servicecatalog -@mock_s3 -def test_terminate_provisioned_product(): - region_name = "us-east-2" - ( - constraint, - portfolio, - product, - provisioned_product, - ) = _create_portfolio_with_provisioned_product( - region_name=region_name, - product_name="test product", - portfolio_name="Test Portfolio", - provisioned_product_name="My Provisioned Product", - ) + resp = client.search_provisioned_products() - provisioned_product_id = provisioned_product["RecordDetail"]["ProvisionedProductId"] - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.terminate_provisioned_product( - ProvisionedProductId=provisioned_product_id - ) + pps = resp["ProvisionedProducts"] + assert len(pps) == 2 + assert pps[0]["Id"] == self.provisioned_product_id + assert pps[1]["Id"] == provisioned_product_id_2 - rec = resp["RecordDetail"] - assert rec["RecordType"] == "TERMINATE_PROVISIONED_PRODUCT" - assert rec["ProductId"] == product_id - assert rec["ProvisionedProductId"] == provisioned_product_id + def test_search_provisioned_products_filter_by(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]} + ) + assert len(resp["ProvisionedProducts"]) == 1 + assert resp["ProvisionedProducts"][0]["Id"] == self.provisioned_product_id + + def test_search_provisioned_products_sort(self): + # arn , id , name , and lastRecordId + # sort order -ASCENDING + # DESCENDING + provisioned_product_2 = _create_provisioned_product( + region_name=self.region_name, + product_name="test product", + provisioning_artifact_id=self.provisioning_artifact_id, + provisioned_product_name="A - Second Provisioned Product", + ) + provisioned_product_2["RecordDetail"]["ProvisionedProductId"] + client = boto3.client("servicecatalog", region_name=self.region_name) -@mock_servicecatalog -@mock_s3 -def test_search_products(): - region_name = "us-east-2" - product_name = "test product" + # Ascending Search + resp = client.search_provisioned_products(SortBy="name") - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) + assert len(resp["ProvisionedProducts"]) == 2 + assert ( + resp["ProvisionedProducts"][0]["Name"] == "A - Second Provisioned Product" + ) + assert resp["ProvisionedProducts"][1]["Name"] == "My Provisioned Product" - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.search_products() + # Descending Search + resp = client.search_provisioned_products(SortBy="name", SortOrder="DESCENDING") - products = resp["ProductViewSummaries"] + assert len(resp["ProvisionedProducts"]) == 2 + assert resp["ProvisionedProducts"][0]["Name"] == "My Provisioned Product" + assert ( + resp["ProvisionedProducts"][1]["Name"] == "A - Second Provisioned Product" + ) - assert len(products) == 1 - assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] - assert ( - products[0]["ProductId"] - == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - ) + def test_search_provisioned_products_sort_by_invalid_keys(self): + client = boto3.client("servicecatalog", region_name="eu-west-1") + with pytest.raises(ClientError) as exc: + client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]}, + SortBy="not_a_field", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "not_a_field is not a supported sort field. It must be ['arn', 'id', 'name', 'lastRecordId']" + ) + + def test_search_provisioned_products_sort_order_invalid_keys(self): + client = boto3.client("servicecatalog", region_name="eu-west-1") + with pytest.raises(ClientError) as exc: + client.search_provisioned_products( + Filters={"SearchQuery": ["name:My Provisioned Product"]}, + SortOrder="not_a_value", + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert ( + err["Message"] + == "1 validation error detected: Value 'not_a_value' at 'sortOrder' failed to " + "satisfy constraint: Member must satisfy enum value set: ['ASCENDING', " + "'DESCENDING']" + ) @mock_servicecatalog @mock_s3 -def test_search_products_by_filter(): - region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) - - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.search_products(Filters={"Owner": ["owner arn"]}) +class TestTerminateProvisionedProduct: + def test_terminate_provisioned_product(self): + region_name = "us-east-2" + ( + constraint, + portfolio, + product, + provisioned_product, + ) = _create_portfolio_with_provisioned_product( + region_name=region_name, + product_name="test product", + portfolio_name="Test Portfolio", + provisioned_product_name="My Provisioned Product", + ) - products = resp["ProductViewSummaries"] + provisioned_product_id = provisioned_product["RecordDetail"][ + "ProvisionedProductId" + ] + product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + client = boto3.client("servicecatalog", region_name=region_name) + resp = client.terminate_provisioned_product( + ProvisionedProductId=provisioned_product_id + ) - assert len(products) == 1 - assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] - assert ( - products[0]["ProductId"] - == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - ) + rec = resp["RecordDetail"] + assert rec["RecordType"] == "TERMINATE_PROVISIONED_PRODUCT" + assert rec["ProductId"] == product_id + assert rec["ProvisionedProductId"] == provisioned_product_id @mock_servicecatalog @mock_s3 -def test_search_products_by_filter_fulltext(): - """ - Fulltext searches more than a single field - """ - region_name = "us-east-2" - product_name = "test product" +class TestSearchProducts: + def setup_method(self, method): + self.region_name = "eu-west-1" + self.product_name = "test product" - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) + self.constraint, self.portfolio, self.product = _create_product_with_constraint( + region_name=self.region_name, + product_name=self.product_name, + portfolio_name="Test Portfolio", + ) - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.search_products(Filters={"FullTextSearch": ["owner arn"]}) + def test_search_products(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.search_products() - products = resp["ProductViewSummaries"] + products = resp["ProductViewSummaries"] - assert len(products) == 1 - assert products[0]["Id"] == product["ProductViewDetail"]["ProductViewSummary"]["Id"] - assert ( - products[0]["ProductId"] - == product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - ) + assert len(products) == 1 + assert ( + products[0]["Id"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["Id"] + ) + assert ( + products[0]["ProductId"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) + def test_search_products_by_filter(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.search_products(Filters={"Owner": ["owner arn"]}) -@mock_servicecatalog -@mock_s3 -def test_search_products_with_sort(): - region_name = "us-east-2" - _create_product_with_constraint( - region_name=region_name, - product_name="Z - test product", - portfolio_name="Test Portfolio", - ) - _create_product( - region_name=region_name, product_name="Y - Another Product", create_bucket=False - ) + products = resp["ProductViewSummaries"] - client = boto3.client("servicecatalog", region_name=region_name) + assert len(products) == 1 + assert ( + products[0]["Id"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["Id"] + ) + assert ( + products[0]["ProductId"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) - resp = client.search_products(SortBy="Title") - products = resp["ProductViewSummaries"] - assert len(products) == 2 - assert products[0]["Name"] == "Y - Another Product" - assert products[1]["Name"] == "Z - test product" + def test_search_products_by_filter_fulltext(self): + """ + Fulltext searches more than a single field + """ + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.search_products(Filters={"FullTextSearch": ["owner arn"]}) - resp = client.search_products(SortBy="Title", SortOrder="DESCENDING") - products = resp["ProductViewSummaries"] - assert len(products) == 2 - assert products[0]["Name"] == "Z - test product" - assert products[1]["Name"] == "Y - Another Product" + products = resp["ProductViewSummaries"] + assert len(products) == 1 + assert ( + products[0]["Id"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["Id"] + ) + assert ( + products[0]["ProductId"] + == self.product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + ) + + def test_search_products_with_sort(self): + _create_product( + region_name=self.region_name, + product_name="A - Another Product", + create_bucket=False, + ) -# aws servicecatalog search-products --sort-by asdf -# -# An error occurred (ValidationException) when calling the SearchProducts operation: 1 validation error detected: Value 'asdf' at 'sortBy' failed to satisfy constraint: Member must satisfy enum value set: [CreationDate, VersionCount, Title] + client = boto3.client("servicecatalog", region_name=self.region_name) + + resp = client.search_products(SortBy="Title") + products = resp["ProductViewSummaries"] + assert len(products) == 2 + assert products[0]["Name"] == "A - Another Product" + assert products[1]["Name"] == "test product" + + resp = client.search_products(SortBy="Title", SortOrder="DESCENDING") + products = resp["ProductViewSummaries"] + assert len(products) == 2 + assert products[0]["Name"] == "test product" + assert products[1]["Name"] == "A - Another Product" + + # aws servicecatalog search-products --sort-by asdf + # + # An error occurred (ValidationException) when calling the SearchProducts operation: 1 validation error detected: Value 'asdf' at 'sortBy' failed to satisfy constraint: Member must satisfy enum value set: [CreationDate, VersionCount, Title] @mock_servicecatalog @@ -1183,100 +806,75 @@ def test_list_provisioning_artifacts_product_not_found(): @mock_servicecatalog @mock_s3 -def test_describe_product_as_admin_by_id(): - region_name = "us-east-2" - product_name = "test product" - - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.describe_product_as_admin(Id=product_id) - - assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id - assert len(resp["ProvisioningArtifactSummaries"]) == 1 - - -@mock_servicecatalog -@mock_s3 -def test_describe_product_as_admin_by_name(): - region_name = "us-east-2" - product_name = "test product" +class TestDescribeProduct: + def setup_method(self, method): + self.region_name = "us-east-2" + self.product_name = "test product" - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.describe_product_as_admin(Name=product_name) - - assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id - assert len(resp["ProvisioningArtifactSummaries"]) == 1 - - -@mock_servicecatalog -@mock_s3 -def test_describe_product_as_admin_with_source_portfolio_id(): - assert 1 == 2 + self.constraint, self.portfolio, self.product = _create_product_with_constraint( + region_name=self.region_name, + product_name=self.product_name, + portfolio_name="Test Portfolio", + ) + self.product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] + def test_describe_product_as_admin_by_id(self): -# aws servicecatalog describe-product-as-admin --id prod-4sapxevj5x334 --source-portfolio-id port-bl325ushxntd6 -# I think provisioning artifact will be unique to the portfolio + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.describe_product_as_admin(Id=self.product_id) + assert ( + resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + == self.product_id + ) + assert len(resp["ProvisioningArtifactSummaries"]) == 1 -@mock_servicecatalog -@mock_s3 -def test_describe_product_by_id(): - region_name = "us-east-2" - product_name = "test product" + def test_describe_product_as_admin_by_name(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.describe_product_as_admin(Name=self.product_name) - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.describe_product_as_admin(Id=product_id) + assert ( + resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + == self.product_id + ) + assert len(resp["ProvisioningArtifactSummaries"]) == 1 - assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id - assert len(resp["ProvisioningArtifactSummaries"]) == 1 + def test_describe_product_as_admin_with_source_portfolio_id(self): + assert 1 == 2 + # aws servicecatalog describe-product-as-admin --id prod-4sapxevj5x334 --source-portfolio-id port-bl325ushxntd6 + # I think provisioning artifact will be unique to the portfolio -@mock_servicecatalog -@mock_s3 -def test_describe_product_by_name(): - region_name = "us-east-2" - product_name = "test product" + def test_describe_product_by_id(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.describe_product_as_admin(Id=self.product_id) - constraint, portfolio, product = _create_product_with_constraint( - region_name=region_name, - product_name=product_name, - portfolio_name="Test Portfolio", - ) - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] - client = boto3.client("servicecatalog", region_name=region_name) - resp = client.describe_product_as_admin(Name=product_name) + assert ( + resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + == self.product_id + ) + assert len(resp["ProvisioningArtifactSummaries"]) == 1 - assert resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] == product_id - assert len(resp["ProvisioningArtifactSummaries"]) == 1 + def test_describe_product_by_name(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.describe_product_as_admin(Name=self.product_name) + assert ( + resp["ProductViewDetail"]["ProductViewSummary"]["ProductId"] + == self.product_id + ) + assert len(resp["ProvisioningArtifactSummaries"]) == 1 -@mock_servicecatalog -@mock_s3 -def test_describe_product_not_found(): - client = boto3.client("servicecatalog", region_name="us-east-1") - with pytest.raises(ClientError) as exc: - client.describe_product(Id="does_not_exist") + def test_describe_product_not_found(self): + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_product(Id="does_not_exist") - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "Product not found" @mock_servicecatalog From 745e22329432aad5b2912ab2ea22c167ffeee2f5 Mon Sep 17 00:00:00 2001 From: David Connor Date: Wed, 23 Aug 2023 10:28:41 +0100 Subject: [PATCH 20/20] Improved tagging and filtering --- moto/servicecatalog/exceptions.py | 6 +- moto/servicecatalog/models.py | 93 +++++---- moto/servicecatalog/responses.py | 12 ++ tests/test_servicecatalog/setupdata.py | 7 +- .../test_servicecatalog.py | 187 ++++++++++++------ 5 files changed, 209 insertions(+), 96 deletions(-) diff --git a/moto/servicecatalog/exceptions.py b/moto/servicecatalog/exceptions.py index e0f032cc1dba..50e3239fec3e 100644 --- a/moto/servicecatalog/exceptions.py +++ b/moto/servicecatalog/exceptions.py @@ -27,7 +27,7 @@ class ProductNotFound(ResourceNotFoundException): def __init__(self, product_id: str): super().__init__( - message="Product not found", + message=f"{product_id} does not exist or access was denied", resource_id=product_id, resource_type="AWS::ServiceCatalog::Product", ) @@ -38,7 +38,7 @@ class PortfolioNotFound(ResourceNotFoundException): def __init__(self, identifier: str, identifier_name: str): super().__init__( - message="Portfolio not found", + message=f"There are no local or default portfolios with id {identifier}", resource_id=f"{identifier_name}={identifier}", resource_type="AWS::ServiceCatalog::Portfolio", ) @@ -49,7 +49,7 @@ class ProvisionedProductNotFound(ResourceNotFoundException): def __init__(self, identifier: str, identifier_name: str): super().__init__( - message="Provisioned product not found", + message=f"No stack named {identifier} exists.", resource_id=f"{identifier_name}={identifier}", resource_type="AWS::ServiceCatalog::Product", ) diff --git a/moto/servicecatalog/models.py b/moto/servicecatalog/models.py index f0585b5d4cdf..97a18c533515 100644 --- a/moto/servicecatalog/models.py +++ b/moto/servicecatalog/models.py @@ -20,7 +20,6 @@ ProvisionedProductNotFound, RecordNotFound, InvalidParametersException, - ValidationException, ) @@ -64,7 +63,7 @@ def has_product(self, product_id: str): return product_id in self.product_ids def to_json(self) -> Dict[str, Any]: - met = { + return { "ARN": self.arn, "CreatedTime": self.created_date, "Description": self.description, @@ -72,7 +71,6 @@ def to_json(self) -> Dict[str, Any]: "Id": self.portfolio_id, "ProviderName": self.provider_name, } - return met @property def tags(self): @@ -94,14 +92,14 @@ def __init__( self.provisioning_artifact_id = "pa-" + "".join( random.choice(string.ascii_lowercase) for _ in range(12) ) # Id - self.region: str = region # RegionName + self.region: str = region - self.active: bool = active # Active - self.created_date: datetime = unix_time() # CreatedTime - self.description = description # Description - 8192 + self.active: bool = active + self.created_date: datetime = unix_time() + self.description = description self.guidance = guidance # DEFAULT | DEPRECATED - self.name = name # 8192 - self.source_revision = source_revision # 512 + self.name = name + self.source_revision = source_revision self.artifact_type = artifact_type # CLOUD_FORMATION_TEMPLATE | MARKETPLACE_AMI | MARKETPLACE_CAR | TERRAFORM_OPEN_SOURCE self.template = template @@ -126,6 +124,7 @@ def __init__( description: str, owner: str, product_type: str, + idempotency_token: str, tags: Dict[str, str], backend: "ServiceCatalogBackend", distributor: str = "", @@ -156,6 +155,7 @@ def __init__( self.support_url = support_url self.support_description = support_description self.source_connection = source_connection + self.idempotency_token = idempotency_token self.provisioning_artifacts: OrderedDict[str, "ProvisioningArtifact"] = dict() @@ -214,7 +214,6 @@ def _create_provisioning_artifact( info, disable_template_validation: bool = False, ): - # Load CloudFormation template from S3 if "LoadTemplateFromURL" in info: template_url = info["LoadTemplateFromURL"] @@ -255,6 +254,7 @@ def to_product_view_detail_json(self) -> Dict[str, Any]: "SupportEmail": self.support_email, "SupportUrl": self.support_url, "SupportDescription": self.support_description, + # TODO: Improve path support # "HasDefaultPath": false, }, "Status": self.status, @@ -270,6 +270,7 @@ def __init__( constraint_type: str, product_id: str, portfolio_id: str, + idempotency_token: str, backend: "ServiceCatalogBackend", parameters: str = "", description: str = "", @@ -295,6 +296,7 @@ def __init__( self.product_id = product_id self.portfolio_id = portfolio_id self.parameters = parameters + self.idempotency_token = idempotency_token self.backend = backend @@ -484,20 +486,27 @@ def get_filter_value( return self.product_id # Remaining fields - # "idempotencyToken", - # "physicalId", - # "provisioningArtifactId", - # "type", - # "status", - # "tags", - # "userArn", - # "userArnSession", - # "lastProvisioningRecordId", - # "lastSuccessfulProvisioningRecordId", - # "productName", - # "provisioningArtifactName", + unimplemented_fields = [ + "idempotencyToken", + "physicalId", + "provisioningArtifactId", + "type", + "status", + "tags", + "userArn", + "userArnSession", + "lastProvisioningRecordId", + "lastSuccessfulProvisioningRecordId", + "productName", + "provisioningArtifactName", + ] + if filter_name in unimplemented_fields: + raise FilterNotImplementedError(filter_name, method_name) - raise FilterNotImplementedError(filter_name, method_name) + # In AWS, this filter doesn't error on invalid key names. It just returns no matches since it's assuming that + # there was no data because there was no right key. + + return "" @property def tags(self): @@ -642,6 +651,7 @@ def create_product( support_url=support_url, support_description=support_description, source_connection=source_connection, + idempotency_token=idempotency_token, tags=tags, backend=self, ) @@ -692,6 +702,8 @@ def create_constraint( portfolio_id=portfolio_id, constraint_type=constraint_type, parameters=parameters, + description=description, + idempotency_token=idempotency_token, ) self.constraints[constraint.constraint_id] = constraint @@ -822,13 +834,8 @@ def describe_provisioned_product(self, accept_language, identifier, name): provisioned_product.to_provisioned_product_detail_json() ) - cloud_watch_dashboards = None - # TODO - # "CloudWatchDashboards": [ - # { - # "Name": "string" - # } - # ], + # TODO: Support cloudWatch dashboards + cloud_watch_dashboards = [] return provisioned_product_detail, cloud_watch_dashboards @@ -989,7 +996,6 @@ def terminate_provisioned_product( accept_language, retain_physical_resources, ): - # implement here provisioned_product = self.provisioned_products[provisioned_product_id] record = Record( @@ -1024,7 +1030,6 @@ def get_constraint_by(self, product_id: str, portfolio_id: str): def describe_product_as_admin( self, accept_language, identifier, name, source_portfolio_id ): - # implement here product = self._get_product(identifier=identifier, name=name) product_view_detail = product.to_product_view_detail_json() @@ -1081,10 +1086,12 @@ def update_portfolio( portfolio.description = description portfolio.provider_name = provider_name - # tags + # Process tags + self.tag_resource(portfolio.arn, add_tags) + self.untag_resource_using_names(portfolio.arn, remove_tags) portfolio_detail = portfolio.to_json() - tags = [] + tags = portfolio.tags return portfolio_detail, tags @@ -1106,9 +1113,20 @@ def update_product( product = self._get_product(identifier=identifier) product.name = name + product.owner = owner + product.description = description + product.distributor = distributor + product.support_description = support_description + product.support_email = support_email + product.support_url = support_url + + # Process tags + self.tag_resource(product.arn, add_tags) + self.untag_resource_using_names(product.arn, remove_tags) product_view_detail = product.to_product_view_detail_json() - tags = [] + tags = product.tags + return product_view_detail, tags def list_portfolios_for_product(self, accept_language, product_id, page_token): @@ -1145,5 +1163,10 @@ def get_tags(self, resource_id: str) -> Dict[str, str]: def tag_resource(self, resource_arn: str, tags: Dict[str, str]) -> None: self.tagger.tag_resource(resource_arn, tags) + def untag_resource_using_names( + self, resource_arn: str, tag_names: list[str] + ) -> None: + self.tagger.untag_resource_using_names(resource_arn, tag_names) + servicecatalog_backends = BackendDict(ServiceCatalogBackend, "servicecatalog") diff --git a/moto/servicecatalog/responses.py b/moto/servicecatalog/responses.py index 220296a1d132..276a637b789c 100644 --- a/moto/servicecatalog/responses.py +++ b/moto/servicecatalog/responses.py @@ -375,6 +375,12 @@ def associate_product_with_portfolio(self): product_id = self._get_param("ProductId") portfolio_id = self._get_param("PortfolioId") source_portfolio_id = self._get_param("SourcePortfolioId") + + if source_portfolio_id is not None: + warnings.warn( + "source_portfolio_id is not yet implemented for associate_product_with_portfolio()" + ) + self.servicecatalog_backend.associate_product_with_portfolio( accept_language=accept_language, product_id=product_id, @@ -441,6 +447,12 @@ def describe_product_as_admin(self): identifier = self._get_param("Id") name = self._get_param("Name") source_portfolio_id = self._get_param("SourcePortfolioId") + + if source_portfolio_id is not None: + warnings.warn( + "source_portfolio_id is not yet implemented for describe_product_as_admin()" + ) + ( product_view_detail, provisioning_artifact_summaries, diff --git a/tests/test_servicecatalog/setupdata.py b/tests/test_servicecatalog/setupdata.py index e825295ee575..609effbd1e23 100644 --- a/tests/test_servicecatalog/setupdata.py +++ b/tests/test_servicecatalog/setupdata.py @@ -58,9 +58,13 @@ def _create_portfolio(region_name: str, portfolio_name: str): return create_portfolio_response -def _create_product(region_name: str, product_name: str, create_bucket=True): +def _create_product(region_name: str, product_name: str, tags=None, create_bucket=True): cloud_url = _create_cf_template_in_s3(region_name, create_bucket) client = boto3.client("servicecatalog", region_name=region_name) + + if tags is None: + tags = [] + # Create Product create_product_response = client.create_product( Name=product_name, @@ -75,6 +79,7 @@ def _create_product(region_name: str, product_name: str, create_bucket=True): "Type": "CLOUD_FORMATION_TEMPLATE", }, IdempotencyToken=str(uuid.uuid4()), + Tags=tags, ) return create_product_response diff --git a/tests/test_servicecatalog/test_servicecatalog.py b/tests/test_servicecatalog/test_servicecatalog.py index b65173ca0cc8..13a9673e64e3 100644 --- a/tests/test_servicecatalog/test_servicecatalog.py +++ b/tests/test_servicecatalog/test_servicecatalog.py @@ -97,7 +97,10 @@ def test_describe_portfolio_not_existing(self): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Portfolio not found" + assert ( + err["Message"] + == "There are no local or default portfolios with id not-found" + ) @mock_servicecatalog @@ -293,7 +296,10 @@ def test_associate_product_with_portfolio_invalid_ids(self): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Portfolio not found" + assert ( + err["Message"] + == "There are no local or default portfolios with id invalid_portfolio" + ) with pytest.raises(ClientError) as exc: client.associate_product_with_portfolio( @@ -303,7 +309,7 @@ def test_associate_product_with_portfolio_invalid_ids(self): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" + assert err["Message"] == "invalid_product does not exist or access was denied" @mock_servicecatalog @@ -461,7 +467,7 @@ def test_describe_provisioned_product_not_found(self): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Provisioned product not found" + assert err["Message"] == "No stack named does not exist exists." def test_get_provisioned_product_outputs_by_id(self): client = boto3.client("servicecatalog", region_name=self.region_name) @@ -481,6 +487,23 @@ def test_get_provisioned_product_outputs_by_name(self): assert len(resp["Outputs"]) == 2 assert resp["Outputs"][0]["OutputKey"] == "WebsiteURL" + def test_get_provisioned_product_outputs_not_found(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs(ProvisionedProductName="not found") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == f"No stack named not found exists." + + with pytest.raises(ClientError) as exc: + client.get_provisioned_product_outputs(ProvisionedProductId="id not found") + + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == f"No stack named id not found exists." + def test_get_provisioned_product_outputs_filtered_by_output_keys(self): client = boto3.client("servicecatalog", region_name=self.region_name) resp = client.get_provisioned_product_outputs( @@ -544,6 +567,14 @@ def test_search_provisioned_products_filter_by(self): assert len(resp["ProvisionedProducts"]) == 1 assert resp["ProvisionedProducts"][0]["Id"] == self.provisioned_product_id + def test_search_provisioned_products_filter_by_invalid_key(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.search_provisioned_products( + Filters={"SearchQuery": ["not_a_key:My Provisioned Product"]} + ) + + assert len(resp["ProvisionedProducts"]) == 0 + def test_search_provisioned_products_sort(self): # arn , id , name , and lastRecordId # sort order -ASCENDING @@ -801,7 +832,7 @@ def test_list_provisioning_artifacts_product_not_found(): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" + assert err["Message"] == "does_not_exist does not exist or access was denied" @mock_servicecatalog @@ -841,12 +872,6 @@ def test_describe_product_as_admin_by_name(self): ) assert len(resp["ProvisioningArtifactSummaries"]) == 1 - def test_describe_product_as_admin_with_source_portfolio_id(self): - assert 1 == 2 - - # aws servicecatalog describe-product-as-admin --id prod-4sapxevj5x334 --source-portfolio-id port-bl325ushxntd6 - # I think provisioning artifact will be unique to the portfolio - def test_describe_product_by_id(self): client = boto3.client("servicecatalog", region_name=self.region_name) resp = client.describe_product_as_admin(Id=self.product_id) @@ -874,74 +899,122 @@ def test_describe_product_not_found(self): err = exc.value.response["Error"] assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" + assert err["Message"] == "does_not_exist does not exist or access was denied" @mock_servicecatalog @mock_s3 -def test_update_portfolio(): - client = boto3.client("servicecatalog", region_name="ap-southeast-1") +class TestUpdatePortfolio: + def setup_method(self, method): + self.region_name = "ap-southeast-1" - create_portfolio_response = client.create_portfolio( - DisplayName="Original Name", ProviderName="Test Provider" - ) + client = boto3.client("servicecatalog", region_name=self.region_name) - portfolio_id = create_portfolio_response["PortfolioDetail"]["Id"] - new_portfolio_name = "New Portfolio Name" - resp = client.update_portfolio( - Id=portfolio_id, - DisplayName=new_portfolio_name, - ) + self.portfolio = client.create_portfolio( + DisplayName="Original Name", + ProviderName="Test Provider", + Tags=[{"Key": "MyCustomTag", "Value": "A Value"}], + ) - assert resp["PortfolioDetail"]["Id"] == portfolio_id - assert resp["PortfolioDetail"]["DisplayName"] == new_portfolio_name + self.portfolio_id = self.portfolio["PortfolioDetail"]["Id"] + def test_update_portfolio(self): + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + new_portfolio_name = "New Portfolio Name" + resp = client.update_portfolio( + Id=self.portfolio_id, + DisplayName=new_portfolio_name, + AddTags=[ + {"Key": "NewTag", "Value": "A Value"}, + ], + RemoveTags=["MyCustomTag"], + ) -@mock_servicecatalog -@mock_s3 -def test_update_portfolio_not_found(): - client = boto3.client("servicecatalog", region_name="us-east-1") - with pytest.raises(ClientError) as exc: - client.update_portfolio(Id="does_not_exist", DisplayName="new value") + assert resp["PortfolioDetail"]["Id"] == self.portfolio_id + assert resp["PortfolioDetail"]["DisplayName"] == new_portfolio_name - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Portfolio not found" + assert "Tags" in resp + assert len(resp["Tags"]) == 1 + assert resp["Tags"][0]["Key"] == "NewTag" + assert resp["Tags"][0]["Value"] == "A Value" + def test_update_portfolio_not_found(self): + client = boto3.client("servicecatalog", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.update_portfolio(Id="does_not_exist", DisplayName="new value") -@mock_s3 -def test_update_portfolio_invalid_fields(): - assert 1 == 2 + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert ( + err["Message"] + == "There are no local or default portfolios with id does_not_exist" + ) + + def test_update_portfolio_invalid_tags(self): + """ + Invalid tag doesn't remove any tags + """ + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.update_portfolio(Id=self.portfolio_id, RemoveTags=["not_a_tag"]) + + assert "Tags" in resp + assert len(resp["Tags"]) == 1 + assert resp["Tags"][0]["Key"] == "MyCustomTag" + assert resp["Tags"][0]["Value"] == "A Value" @mock_servicecatalog @mock_s3 -def test_update_product(): - product = _create_product(region_name="ap-southeast-1", product_name="Test Product") - product_id = product["ProductViewDetail"]["ProductViewSummary"]["ProductId"] +class TestUpdateProduct: + def setup_method(self, method): + self.region_name = "ap-southeast-1" + self.product = _create_product( + region_name=self.region_name, + product_name="Test Product", + tags=[{"Key": "MyCustomTag", "Value": "A Value"}], + ) + self.product_id = self.product["ProductViewDetail"]["ProductViewSummary"][ + "ProductId" + ] - client = boto3.client("servicecatalog", region_name="ap-southeast-1") - resp = client.update_product(Id=product_id, Name="New Product Name") - new_product = resp["ProductViewDetail"]["ProductViewSummary"] - assert new_product["Name"] == "New Product Name" + def test_update_product(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + resp = client.update_product( + Id=self.product_id, + Name="New Product Name", + AddTags=[ + {"Key": "NewTag", "Value": "A Value"}, + ], + RemoveTags=["MyCustomTag"], + ) + new_product = resp["ProductViewDetail"]["ProductViewSummary"] + assert new_product["Name"] == "New Product Name" + assert "Tags" in resp + assert len(resp["Tags"]) == 1 + assert resp["Tags"][0]["Key"] == "NewTag" + assert resp["Tags"][0]["Value"] == "A Value" -@mock_servicecatalog -@mock_s3 -def test_update_product_not_found(): - client = boto3.client("servicecatalog", region_name="us-east-1") - with pytest.raises(ClientError) as exc: - client.update_product(Id="does_not_exist", Name="New Product Name") + def test_update_product_not_found(self): + client = boto3.client("servicecatalog", region_name=self.region_name) + with pytest.raises(ClientError) as exc: + client.update_product(Id="does_not_exist", Name="New Product Name") - err = exc.value.response["Error"] - assert err["Code"] == "ResourceNotFoundException" - assert err["Message"] == "Product not found" + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert err["Message"] == "does_not_exist does not exist or access was denied" + def test_update_product_invalid_tags(self): + """ + Invalid tag doesn't remove any tags + """ + client = boto3.client("servicecatalog", region_name="ap-southeast-1") + resp = client.update_product(Id=self.product_id, RemoveTags=["not_a_tag"]) -@mock_servicecatalog -@mock_s3 -def test_update_product_invalid_fields(): - assert 1 == 2 + assert "Tags" in resp + assert len(resp["Tags"]) == 1 + assert resp["Tags"][0]["Key"] == "MyCustomTag" + assert resp["Tags"][0]["Value"] == "A Value" @mock_servicecatalog