Skip to content

Commit

Permalink
feat(servicecatalog): initial implementation of the Product construct (
Browse files Browse the repository at this point in the history
…#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 <arcrank@gmail.com>
Co-authored-by: Dillon Ponzo <dponzo18@gmail.com>
  • Loading branch information
3 people authored Jun 28, 2021
1 parent 74da5c1 commit fe3e0f2
Show file tree
Hide file tree
Showing 10 changed files with 747 additions and 0 deletions.
44 changes: 44 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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')),
},
]
});
```
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/index.ts
Original file line number Diff line number Diff line change
@@ -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';
23 changes: 23 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]';
Expand Down
212 changes: 212 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/product.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Loading

0 comments on commit fe3e0f2

Please sign in to comment.