From fe3e0f2058ce48413eca88b247cddf0532374a74 Mon Sep 17 00:00:00 2001 From: wanjacki <83792238+wanjacki@users.noreply.github.com> Date: Mon, 28 Jun 2021 15:19:22 -0700 Subject: [PATCH] feat(servicecatalog): initial implementation of the Product construct (#15185) This is the first minimal release of an L2 construct for a service catalog product. In a future PR, functionality to associate with a Portfolio and add constraints will be added. Testing done ------------------ * `yarn build && yarn test` * `yarn integ` ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* Co-authored-by: Aidan Crank Co-authored-by: Dillon Ponzo --- .../@aws-cdk/aws-servicecatalog/README.md | 44 ++++ .../lib/cloudformation-template.ts | 89 ++++++++ .../@aws-cdk/aws-servicecatalog/lib/index.ts | 2 + .../lib/private/validation.ts | 23 ++ .../aws-servicecatalog/lib/product.ts | 212 ++++++++++++++++++ .../@aws-cdk/aws-servicecatalog/package.json | 6 + .../development-environment.template.json | 98 ++++++++ .../test/integ.product.expected.json | 84 +++++++ .../aws-servicecatalog/test/integ.product.ts | 23 ++ .../aws-servicecatalog/test/product.test.ts | 166 ++++++++++++++ 10 files changed, 747 insertions(+) create mode 100644 packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts create mode 100644 packages/@aws-cdk/aws-servicecatalog/lib/product.ts create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/development-environment.template.json create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/product.test.ts diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index fdf8a2f599cf6..74073c55b37ce 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -29,6 +29,7 @@ enables organizations to create and manage catalogs of products for their end us - [Portfolio](#portfolio) - [Granting access to a portfolio](#granting-access-to-a-portfolio) - [Sharing a portfolio with another AWS account](#sharing-a-portfolio-with-another-aws-account) +- [Product](#product) The `@aws-cdk/aws-servicecatalog` package contains resources that enable users to automate governance and management of their AWS resources at scale. @@ -97,3 +98,46 @@ A portfolio can be programatically shared with other accounts so that specified ```ts fixture=basic-portfolio portfolio.shareWithAccount('012345678901'); ``` + +## Product + +Products are the resources you are allowing end users to provision and utilize. +The CDK currently only supports adding products of type Cloudformation product. +Using the CDK, a new Product can be created with the `CloudFormationProduct` construct. +`CloudFormationTemplate.fromUrl` can be utilized to create a Product using a Cloudformation template directly from an URL: + +```ts +const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', { + productName: "My Product", + owner: "Product Owner", + productVersions: [ + { + productVersionName: "v1", + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl( + 'https://raw.githubusercontent.com/awslabs/aws-cloudformation-templates/master/aws/services/ServiceCatalog/Product.yaml'), + }, + ] +}); +``` + +A `CloudFormationProduct` can also be created using a Cloudformation template from an Asset. +Assets are files that are uploaded to an S3 Bucket before deployment. +`CloudFormationTemplate.fromAsset` can be utilized to create a Product by passing the path to a local template file on your disk: + +```ts +const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', { + productName: "My Product", + owner: "Product Owner", + productVersions: [ + { + productVersionName: "v1", + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl( + 'https://raw.githubusercontent.com/awslabs/aws-cloudformation-templates/master/aws/services/ServiceCatalog/Product.yaml'), + }, + { + productVersionName: "v2", + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'development-environment.template.json')), + }, + ] +}); +``` diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts new file mode 100644 index 0000000000000..670ff1bd4de1f --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts @@ -0,0 +1,89 @@ +import * as s3_assets from '@aws-cdk/aws-s3-assets'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +/** + * Represents the Product Provisioning Artifact Template. + */ +export abstract class CloudFormationTemplate { + /** + * Template from URL + * @param url The url that points to the provisioning artifacts template + */ + public static fromUrl(url: string): CloudFormationTemplate { + return new CloudFormationUrlTemplate(url); + } + + /** + * Loads the provisioning artifacts template from a local disk path. + * + * @param path A file containing the provisioning artifacts + */ + public static fromAsset(path: string, options?: s3_assets.AssetOptions): CloudFormationTemplate { + return new CloudFormationAssetTemplate(path, options); + } + + /** + * Called when the product is initialized to allow this object to bind + * to the stack, add resources and have fun. + * + * @param scope The binding scope. Don't be smart about trying to down-cast or + * assume it's initialized. You may just use it as a construct scope. + */ + public abstract bind(scope: Construct): CloudFormationTemplateConfig; +} + +/** + * Result of binding `Template` into a `Product`. + */ +export interface CloudFormationTemplateConfig { + /** + * The http url of the template in S3. + */ + readonly httpUrl: string; +} + +/** + * Template code from a Url. + */ +class CloudFormationUrlTemplate extends CloudFormationTemplate { + constructor(private readonly url: string) { + super(); + } + + public bind(_scope: Construct): CloudFormationTemplateConfig { + return { + httpUrl: this.url, + }; + } +} + +/** + * Template from a local file. + */ +class CloudFormationAssetTemplate extends CloudFormationTemplate { + private asset?: s3_assets.Asset; + + /** + * @param path The path to the asset file. + */ + constructor(public readonly path: string, private readonly options: s3_assets.AssetOptions = { }) { + super(); + } + + public bind(scope: Construct): CloudFormationTemplateConfig { + // If the same AssetCode is used multiple times, retain only the first instantiation. + if (!this.asset) { + this.asset = new s3_assets.Asset(scope, 'Template', { + path: this.path, + ...this.options, + }); + } + + return { + httpUrl: this.asset.httpUrl, + }; + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts index 04cf902c29069..330513de79bf6 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts @@ -1,5 +1,7 @@ export * from './common'; +export * from './cloudformation-template'; export * from './portfolio'; +export * from './product'; // AWS::ServiceCatalog CloudFormation Resources: export * from './servicecatalog.generated'; diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts index 054da93c0aa9d..3beaa42552eff 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts @@ -13,6 +13,29 @@ export class InputValidator { } } + /** + * Validates string matches the allowed regex pattern. + */ + public static validateRegex(resourceName: string, inputName: string, regexp: RegExp, inputString?: string): void { + if (!cdk.Token.isUnresolved(inputString) && inputString !== undefined && !regexp.test(inputString)) { + throw new Error(`Invalid ${inputName} for resource ${resourceName}, must match regex pattern ${regexp}, got: '${this.truncateString(inputString, 100)}'`); + } + } + + /** + * Validates string matches the valid URL regex pattern. + */ + public static validateUrl(resourceName: string, inputName: string, inputString?: string): void { + this.validateRegex(resourceName, inputName, /^https?:\/\/.*/, inputString); + } + + /** + * Validates string matches the valid email regex pattern. + */ + public static validateEmail(resourceName: string, inputName: string, inputString?: string): void { + this.validateRegex(resourceName, inputName, /^[\w\d.%+\-]+@[a-z\d.\-]+\.[a-z]{2,4}$/i, inputString); + } + private static truncateString(string: string, maxLength: number): string { if (string.length > maxLength) { return string.substring(0, maxLength) + '[truncated]'; diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts new file mode 100644 index 0000000000000..3de0ad8387460 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts @@ -0,0 +1,212 @@ +import { ArnFormat, IResource, Resource, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CloudFormationTemplate } from './cloudformation-template'; +import { AcceptLanguage } from './common'; +import { InputValidator } from './private/validation'; +import { CfnCloudFormationProduct } from './servicecatalog.generated'; + +/** + * A Service Catalog product, currently only supports type CloudFormationProduct + */ +export interface IProduct extends IResource { + /** + * The ARN of the product. + * @attribute + */ + readonly productArn: string; + + /** + * The id of the product + * @attribute + */ + readonly productId: string; +} + +abstract class ProductBase extends Resource implements IProduct { + public abstract readonly productArn: string; + public abstract readonly productId: string; +} + +/** + * Properties of product version (also known as a provisioning artifact). + */ +export interface CloudFormationProductVersion { + /** + * The description of the product version + * @default - No description provided + */ + readonly description?: string; + + /** + * Whether the specified product template will be validated by CloudFormation. + * If turned off, an invalid template configuration can be stored. + * @default true + */ + readonly validateTemplate?: boolean; + + /** + * The S3 template that points to the provisioning version template + */ + readonly cloudFormationTemplate: CloudFormationTemplate; + + /** + * The name of the product version. + * @default - No product version name provided + */ + readonly productVersionName?: string; +} + +/** + * Properties for a Cloudformation Product + */ +export interface CloudFormationProductProps { + /** + * The owner of the product. + */ + readonly owner: string; + + /** + * The name of the product. + */ + readonly productName: string; + + /** + * The configuration of the product version. + */ + readonly productVersions: CloudFormationProductVersion[]; + + /** + * The language code. + * @default - No accept language provided + */ + readonly acceptLanguage?: AcceptLanguage; + + /** + * The description of the product. + * @default - No description provided + */ + readonly description?: string; + + /** + * The distributor of the product. + * @default - No distributor provided + */ + readonly distributor?: string; + + /** + * Whether to give provisioning artifacts a new unique identifier when the product attributes or provisioning artifacts is updated + * @default false + */ + readonly replaceProductVersionIds?: boolean; + + /** + * The support information about the product + * @default - No support description provided + */ + readonly supportDescription?: string; + + /** + * The contact email for product support. + * @default - No support email provided + */ + readonly supportEmail?: string; + + /** + * The contact URL for product support. + * @default - No support URL provided + */ + readonly supportUrl?: string; +} + +/** + * Abstract class for Service Catalog Product. + */ +export abstract class Product extends ProductBase { + /** + * Creates a Product construct that represents an external product. + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param productArn Product Arn + */ + public static fromProductArn(scope: Construct, id: string, productArn: string): IProduct { + const arn = Stack.of(scope).splitArn(productArn, ArnFormat.SLASH_RESOURCE_NAME); + const productId = arn.resourceName; + + if (!productId) { + throw new Error('Missing required Portfolio ID from Portfolio ARN: ' + productArn); + } + + return new class extends ProductBase { + public readonly productId = productId!; + public readonly productArn = productArn; + }(scope, id); + } +} + +/** + * A Service Catalog Cloudformation Product. + */ +export class CloudFormationProduct extends Product { + public readonly productArn: string; + public readonly productId: string; + + constructor(scope: Construct, id: string, props: CloudFormationProductProps) { + super(scope, id); + + this.validateProductProps(props); + + const product = new CfnCloudFormationProduct(this, 'Resource', { + acceptLanguage: props.acceptLanguage, + description: props.description, + distributor: props.distributor, + name: props.productName, + owner: props.owner, + provisioningArtifactParameters: this.renderProvisioningArtifacts(props), + replaceProvisioningArtifacts: props.replaceProductVersionIds, + supportDescription: props.supportDescription, + supportEmail: props.supportEmail, + supportUrl: props.supportUrl, + }); + + this.productArn = Stack.of(this).formatArn({ + service: 'catalog', + resource: 'product', + resourceName: product.ref, + }); + + this.productId = product.ref; + } + + private renderProvisioningArtifacts( + props: CloudFormationProductProps): CfnCloudFormationProduct.ProvisioningArtifactPropertiesProperty[] { + return props.productVersions.map(productVersion => { + const template = productVersion.cloudFormationTemplate.bind(this); + InputValidator.validateUrl(this.node.path, 'provisioning template url', template.httpUrl); + return { + name: productVersion.productVersionName, + description: productVersion.description, + disableTemplateValidation: productVersion.validateTemplate === false ? true : false, + info: { + LoadTemplateFromURL: template.httpUrl, + }, + }; + }); + }; + + private validateProductProps(props: CloudFormationProductProps) { + InputValidator.validateLength(this.node.path, 'product product name', 1, 100, props.productName); + InputValidator.validateLength(this.node.path, 'product owner', 1, 8191, props.owner); + InputValidator.validateLength(this.node.path, 'product description', 0, 8191, props.description); + InputValidator.validateLength(this.node.path, 'product distributor', 0, 8191, props.distributor); + InputValidator.validateEmail(this.node.path, 'support email', props.supportEmail); + InputValidator.validateUrl(this.node.path, 'support url', props.supportUrl); + InputValidator.validateLength(this.node.path, 'support description', 0, 8191, props.supportDescription); + if (props.productVersions.length == 0) { + throw new Error(`Invalid product versions for resource ${this.node.path}, must contain at least 1 product version`); + } + props.productVersions.forEach(productVersion => { + InputValidator.validateLength(this.node.path, 'provisioning artifact name', 0, 100, productVersion.productVersionName); + InputValidator.validateLength(this.node.path, 'provisioning artifact description', 0, 8191, productVersion.description); + }); + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/package.json b/packages/@aws-cdk/aws-servicecatalog/package.json index c7cbe70da6986..2c7ea4a279712 100644 --- a/packages/@aws-cdk/aws-servicecatalog/package.json +++ b/packages/@aws-cdk/aws-servicecatalog/package.json @@ -82,12 +82,14 @@ }, "dependencies": { "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, @@ -96,6 +98,10 @@ }, "awslint": { "exclude": [ + "resource-attribute:@aws-cdk/aws-servicecatalog.CloudFormationProduct.cloudFormationProductProductName", + "resource-attribute:@aws-cdk/aws-servicecatalog.CloudFormationProduct.cloudFormationProductProvisioningArtifactIds", + "resource-attribute:@aws-cdk/aws-servicecatalog.CloudFormationProduct.cloudFormationProductProvisioningArtifactNames", + "props-physical-name:@aws-cdk/aws-servicecatalog.CloudFormationProductProps", "resource-attribute:@aws-cdk/aws-servicecatalog.Portfolio.portfolioName", "props-physical-name:@aws-cdk/aws-servicecatalog.PortfolioProps" ] diff --git a/packages/@aws-cdk/aws-servicecatalog/test/development-environment.template.json b/packages/@aws-cdk/aws-servicecatalog/test/development-environment.template.json new file mode 100644 index 0000000000000..69c3138be93f4 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/development-environment.template.json @@ -0,0 +1,98 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + + "Description" : "AWS Service Catalog sample template. Creates an Amazon EC2 instance running the Amazon Linux AMI. The AMI is chosen based on the region in which the stack is run. This example creates an EC2 security group for the instance to give you SSH access. **WARNING** This template creates an Amazon EC2 instance. You will be billed for the AWS resources used if you create a stack from this template.", + + "Parameters" : { + "KeyName": { + "Description" : "Name of an existing EC2 key pair for SSH access to the EC2 instance.", + "Type": "AWS::EC2::KeyPair::KeyName" + }, + + "InstanceType" : { + "Description" : "EC2 instance type.", + "Type" : "String", + "Default" : "t2.micro", + "AllowedValues" : [ "t2.micro", "t2.small", "t2.medium", "m3.medium", "m3.large", "m3.xlarge", "m3.2xlarge" ] + }, + + "SSHLocation" : { + "Description" : "The IP address range that can SSH to the EC2 instance.", + "Type": "String", + "MinLength": "9", + "MaxLength": "18", + "Default": "0.0.0.0/0", + "AllowedPattern": "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2})", + "ConstraintDescription": "Must be a valid IP CIDR range of the form x.x.x.x/x." + } + }, + + "Metadata" : { + "AWS::CloudFormation::Interface" : { + "ParameterGroups" : [{ + "Label" : {"default": "Instance configuration"}, + "Parameters" : ["InstanceType"] + },{ + "Label" : {"default": "Security configuration"}, + "Parameters" : ["KeyName", "SSHLocation"] + }], + "ParameterLabels" : { + "InstanceType": {"default": "Server size:"}, + "KeyName": {"default": "Key pair:"}, + "SSHLocation": {"default": "CIDR range:"} + } + } + }, + + "Mappings" : { + "AWSRegionArch2AMI" : { + "us-east-1" : { "HVM64" : "ami-08842d60" }, + "us-west-2" : { "HVM64" : "ami-8786c6b7" }, + "us-west-1" : { "HVM64" : "ami-cfa8a18a" }, + "eu-west-1" : { "HVM64" : "ami-748e2903" }, + "ap-southeast-1" : { "HVM64" : "ami-d6e1c584" }, + "ap-northeast-1" : { "HVM64" : "ami-35072834" }, + "ap-southeast-2" : { "HVM64" : "ami-fd4724c7" }, + "sa-east-1" : { "HVM64" : "ami-956cc688" }, + "cn-north-1" : { "HVM64" : "ami-ac57c595" }, + "eu-central-1" : { "HVM64" : "ami-b43503a9" } + } + + }, + + "Resources" : { + "EC2Instance" : { + "Type" : "AWS::EC2::Instance", + "Properties" : { + "InstanceType" : { "Ref" : "InstanceType" }, + "SecurityGroups" : [ { "Ref" : "InstanceSecurityGroup" } ], + "KeyName" : { "Ref" : "KeyName" }, + "ImageId" : { "Fn::FindInMap" : [ "AWSRegionArch2AMI", { "Ref" : "AWS::Region" }, "HVM64" ] } + } + }, + + "InstanceSecurityGroup" : { + "Type" : "AWS::EC2::SecurityGroup", + "Properties" : { + "GroupDescription" : "Enable SSH access via port 22", + "SecurityGroupIngress" : [ { + "IpProtocol" : "tcp", + "FromPort" : "22", + "ToPort" : "22", + "CidrIp" : { "Ref" : "SSHLocation"} + } ] + } + } + }, + + "Outputs" : { + "PublicDNSName" : { + "Description" : "Public DNS name of the new EC2 instance", + "Value" : { "Fn::GetAtt" : [ "EC2Instance", "PublicDnsName" ] } + }, + "PublicIPAddress" : { + "Description" : "Public IP address of the new EC2 instance", + "Value" : { "Fn::GetAtt" : [ "EC2Instance", "PublicIp" ] } + } + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json new file mode 100644 index 0000000000000..91767f1182eb6 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json @@ -0,0 +1,84 @@ +{ + "Resources": { + "TestProduct7606930B": { + "Type": "AWS::ServiceCatalog::CloudFormationProduct", + "Properties": { + "Name": "testProduct", + "Owner": "testOwner", + "ProvisioningArtifactParameters": [ + { + "DisableTemplateValidation": true, + "Info": { + "LoadTemplateFromURL": "https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template" + } + }, + { + "DisableTemplateValidation": false, + "Info": { + "LoadTemplateFromURL": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eS3BucketCF629374" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eS3VersionKeyC94BC7EE" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eS3VersionKeyC94BC7EE" + } + ] + } + ] + } + ] + ] + } + } + } + ] + } + } + }, + "Parameters": { + "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eS3BucketCF629374": { + "Type": "String", + "Description": "S3 bucket for asset \"b59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09e\"" + }, + "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eS3VersionKeyC94BC7EE": { + "Type": "String", + "Description": "S3 key for asset version \"b59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09e\"" + }, + "AssetParametersb59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09eArtifactHashB9EF04B2": { + "Type": "String", + "Description": "Artifact hash for asset \"b59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09e\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts new file mode 100644 index 0000000000000..578f85e5e1d68 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts @@ -0,0 +1,23 @@ +import * as path from 'path'; +import * as cdk from '@aws-cdk/core'; +import * as servicecatalog from '../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'integ-servicecatalog-product'); + +new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + validateTemplate: false, + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl( + 'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'development-environment.template.json')), + }, + ], +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts new file mode 100644 index 0000000000000..7325549db63d3 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts @@ -0,0 +1,166 @@ +import '@aws-cdk/assert-internal/jest'; +import * as path from 'path'; +import * as cdk from '@aws-cdk/core'; +import * as servicecatalog from '../lib'; + +/* eslint-disable quote-props */ +describe('Product', () => { + let app: cdk.App; + let stack: cdk.Stack; + + beforeEach(() => { + app = new cdk.App(); + stack = new cdk.Stack(app); + }); + + test('default product test', () => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + ], + }); + + expect(stack).toHaveResourceLike('AWS::ServiceCatalog::CloudFormationProduct', { + Name: 'testProduct', + Owner: 'testOwner', + ProvisioningArtifactParameters: [ + { + 'DisableTemplateValidation': false, + 'Info': { + 'LoadTemplateFromURL': 'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template', + }, + }, + ], + }); + }), + + test('default product test with validation disabled', () => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + validateTemplate: false, + }, + ], + }); + + expect(stack).toHaveResourceLike('AWS::ServiceCatalog::CloudFormationProduct', { + Name: 'testProduct', + Owner: 'testOwner', + ProvisioningArtifactParameters: [ + { + 'DisableTemplateValidation': true, + 'Info': { + 'LoadTemplateFromURL': 'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template', + }, + }, + ], + }); + }), + + test('product test from Asset', () => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'development-environment.template.json')), + }, + ], + }); + + const assembly = app.synth(); + const synthesized = assembly.stacks[0]; + expect(synthesized.assets.length).toEqual(1); + }), + + test('product test from multiple sources', () => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + productVersionName: 'v1', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + { + productVersionName: 'v2', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment-v2.template'), + }, + { + productVersionName: 'v3', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'development-environment.template.json')), + }, + ], + }); + + expect(stack).toHaveResourceLike('AWS::ServiceCatalog::CloudFormationProduct', { + Name: 'testProduct', + Owner: 'testOwner', + ProvisioningArtifactParameters: [ + { + 'Info': { + 'LoadTemplateFromURL': 'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template', + }, + }, + ], + }); + }), + + test('product from attributes', () => { + const product = servicecatalog.Product.fromProductArn(stack, 'MyProduct', 'arn:aws:catalog:region:account-id:product/prod-djh8932wr'); + + expect(product.productArn).toEqual('arn:aws:catalog:region:account-id:product/prod-djh8932wr'); + }), + + test('fails product from attributes without resource name in arn', () => { + expect(() => { + servicecatalog.Product.fromProductArn(stack, 'MyProduct', 'arn:aws:catalog:region:account-id:product'); + }).toThrowError('Missing required Portfolio ID from Portfolio ARN: arn:aws:catalog:region:account-id:product'); + }), + + test('fails product creation with invalid email', () => { + expect(() => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + ], + supportEmail: 'invalid email', + }); + }).toThrowError(/Invalid support email for resource Default\/MyProduct/); + }), + + test('fails product creation with invalid url', () => { + expect(() => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('invalid url'), + }, + ], + }); + }).toThrowError(/Invalid provisioning template url for resource Default\/MyProduct/); + }), + + test('fails product creation with empty productVersions', () => { + expect(() => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [], + }); + }).toThrowError(/Invalid product versions for resource Default\/MyProduct/); + }); +});