packages/@aws-cdk/example-construct-library/lib/example-resource.ts (138 lines of code) (raw):
/*
* We always import other construct libraries entirely with a prefix -
* we never import individual classes from them without a qualifier
* (the prefix makes it more obvious where a given dependency comes from,
* and prevents conflicting names causing issues).
* Our linter also enforces ES6-style imports -
* we don't use TypeScript's import a = require('a') imports.
*/
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as events from 'aws-cdk-lib/aws-events';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
// for files that are part of this package or part of core, we do import individual classes or functions
import { CfnWaitCondition, CfnWaitConditionHandle, Fn, IResource, RemovalPolicy, Resource, Stack, Token } from 'aws-cdk-lib/core';
import { exampleResourceArnComponents } from './private/example-resource-common';
import { addConstructMetadata } from 'aws-cdk-lib/core/lib/metadata-resource';
/**
* The interface that represents the ExampleResource resource.
* We always use an interface, because each L2 resource type in the CDK can occur in two aspects:
*
* 1. It can be a resource that's created and managed by the CDK.
* Those resources are represented by the class with the name identical to the resource -
* `ExampleResource` in our case, which implements `IExampleResource`.
* 2. It can be a resource that exists already, and is not managed by the CDK code,
* but needs to be referenced in your infrastructure definition code.
* Those kinds of instances are returned from static `fromXyz(Name/Arn/Attributes)` methods -
* in our case, the `ExampleResource.fromExampleResourceName` method.
* In general, those kinds of resources do not allow any sort of mutating operations to be performed on them
* (the exception is when they can be changed by creating a different resource -
* IAM Roles, which you can attach multiple IAM Policies to,
* are the canonical example of this sort of resource),
* as they are not part of the CloudFormation stack that is created by the CDK.
*
* So, an interface like `IExampleResource` represents a resource that *might* be mutable,
* while the `ExampleResource` class represents a resource that definitely is mutable.
* Whenever a type that represents this resource needs to referenced in other code,
* you want to use `IExampleResource` as the type, not `ExampleResource`.
*
* The interface for the resource should have at least 2 (readonly) properties
* that represent the ARN and the physical name of the resource -
* in our example, those are `exampleResourceArn` and `exampleResourceName`.
*
* The interface defines the behaviors the resource exhibits.
* Common behaviors are:
* - `addToRolePolicy` for resources that are tied to an IAM Role
* - grantXyz() methods (represented by `grantRead` in this example)
* - onXyz() CloudWatch Events methods (represented by `onEvent` in this example)
* - metricXyz() CloudWatch Metric methods (represented by `metricCount` in this example)
*
* Of course, other behaviors are possible -
* it all depends on the capabilities of the underlying resource that is being modeled.
*
* This interface must always extend the IResource interface from the core module.
* It can also extend some other common interfaces that add various default behaviors -
* some examples are shown below.
*/
export interface IExampleResource extends
// all L2 interfaces need to extend IResource
IResource,
// Only for resources that have an associated IAM Role.
// Allows this resource to be the target in calls like bucket.grantRead(exampleResource).
iam.IGrantable,
// only for resources that are in a VPC and have SecurityGroups controlling their traffic
ec2.IConnectable {
/**
* The ARN of example resource.
* Equivalent to doing `{ 'Fn::GetAtt': ['LogicalId', 'Arn' ]}`
* in CloudFormation if the underlying CloudFormation resource
* surfaces the ARN as a return value -
* if not, we usually construct the ARN "by hand" in the construct,
* using the Fn::Join function.
*
* It needs to be annotated with '@attribute' if the underlying CloudFormation resource
* surfaces the ARN as a return value.
*
* @attribute
*/
readonly exampleResourceArn: string;
/**
* The physical name of the example resource.
* Often, equivalent to doing `{ 'Ref': 'LogicalId' }`
* (but not always - depends on the particular resource modeled)
* in CloudFormation.
* Also needs to be annotated with '@attribute'.
*
* @attribute
*/
readonly exampleResourceName: string;
/**
* For resources that have an associated IAM Role,
* surface that Role as a property,
* so that other classes can add permissions to it.
* Make it optional,
* as resources imported with `ExampleResource.fromExampleResourceName`
* will not have this set.
*/
readonly role?: iam.IRole;
/**
* For resources that have an associated IAM Role,
* surface a method that allows you to conditionally
* add a statement to that Role if it's known.
* This is just a convenience,
* so that clients of your interface don't have to check `role` for null.
* Many such methods in the CDK return void;
* you can also return a boolean indicating whether the permissions were in fact added
* (so, when `role` is not null).
*/
addToRolePolicy(policyStatement: iam.PolicyStatement): boolean;
/**
* An example of a method that grants the given IAM identity
* permissions to this resource
* (in this case - read permissions).
*/
grantRead(identity: iam.IGrantable): iam.Grant;
/**
* Add a CloudWatch rule that will use this resource as the source of events.
* Resources that emit events have a bunch of methods like these,
* that allow different resources to be triggered on various events happening to this resource
* (like item added, item updated, item deleted, ect.) -
* exactly which methods you need depends on the resource you're modeling.
*/
onEvent(id: string, options?: events.OnEventOptions): events.Rule;
/**
* Standard method that allows you to capture metrics emitted by this resource,
* and use them in dashboards and alarms.
* The details of which metric methods you should have of course depends on the
* resource that is being modeled.
*/
metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;
}
/**
* A common abstract superclass that implements the `IExampleResource` interface.
* We often have these classes to share code between the `ExampleResource`
* class and the `IExampleResource` instances returned from methods like
* `ExampleResource.fromExampleResourceName`.
* It has to extend the Resource class from the core module.
*
* Notice that the class is not exported - it's not part of the public API of this module!
*/
abstract class ExampleResourceBase extends Resource implements IExampleResource {
// these stay abstract at this level
public abstract readonly exampleResourceArn: string;
public abstract readonly exampleResourceName: string;
public abstract readonly role?: iam.IRole;
// this property is needed for the iam.IGrantable interface
public abstract readonly grantPrincipal: iam.IPrincipal;
// This is needed for the ec2.IConnectable interface.
// Allow subclasses to write this field.
// JSII requires all member starting with an underscore to be annotated with '@internal'.
/** @internal */
protected _connections: ec2.Connections | undefined;
/** Implement the ec2.IConnectable interface, using the _connections field. */
public get connections(): ec2.Connections {
if (!this._connections) {
throw new Error('An imported ExampleResource cannot manage its security groups');
}
return this._connections;
}
/** Implement the convenience `IExampleResource.addToRolePolicy` method. */
public addToRolePolicy(policyStatement: iam.PolicyStatement): boolean {
if (this.role) {
this.role.addToPrincipalPolicy(policyStatement);
return true;
} else {
return false;
}
}
/** Implement the `IExampleResource.grantRead` method. */
public grantRead(identity: iam.IGrantable): iam.Grant {
// usually, we would grant some service-specific permissions here,
// but since this is just an example, let's use S3
return iam.Grant.addToPrincipal({
grantee: identity,
actions: ['s3:Get*'], // as many actions as you need
resourceArns: [this.exampleResourceArn],
});
}
/**
* Implement the `IExampleResource.onEvent` method.
* Notice that we change 'options' from an optional argument to an argument with a default value -
* that's a common trick in the CDK
* (you're not allowed to have default values for arguments in interface methods in TypeScript),
* as it simplifies the implementation code (less branching).
*/
public onEvent(id: string, options: events.OnEventOptions = {}): events.Rule {
const rule = new events.Rule(this, id, options);
rule.addTarget(options.target);
rule.addEventPattern({
// obviously, you would put your resource-specific values here
source: ['aws.cloudformation'],
detail: {
'example-resource-name': [this.exampleResourceName],
},
});
return rule;
}
/** Implement the `IExampleResource.metricCount` method. */
public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric {
return new cloudwatch.Metric({
// of course, you would put your resource-specific values here
namespace: 'AWS/ExampleResource',
metricName: 'Count',
dimensionsMap: { ExampleResource: this.exampleResourceName },
...props,
}).attachTo(this);
}
}
/**
* Construction properties for `ExampleResource`.
* All constructs have the same construction pattern:
* you provide a scope of type Construct,
* a string identifier, and a third argument,
* representing the properties specific to that resource.
* That third type is represented in the CDK by an interface
* with only readonly simple properties (no methods),
* sometimes called, in JSII terminology, a 'struct'.
* This is this struct for the `ExampleResource` class.
*
* This interface is always called '<ResourceName>Props'.
*/
export interface ExampleResourceProps {
/**
* The physical name of the resource.
* If you don't provide one, CloudFormation will generate one for you.
* Almost all resources, with only a few exceptions,
* allow setting their physical name.
* The name is a little silly,
* because of the @resource annotation on the `ExampleResource` class
* (CDK linters make sure those two names are aligned).
*
* @default - CloudFormation-generated name
*/
readonly waitConditionHandleName?: string;
/**
* Many resources require an IAM Role to function.
* While a customer can provide one,
* the CDK will always create a new one
* (with the correct assumeRole service principal) if it wasn't provided.
*
* @default - a new Role will be created
*/
readonly role?: iam.IRole;
/**
* Many resources allow passing in an optional S3 Bucket.
* Buckets can also have KMS Keys associated with them,
* so any encryption settings in your resource should check
* for the presence of that property on the passed Bucket.
*
* @default - no Bucket will be used
*/
readonly bucket?: s3.IBucket;
/**
* Many resources can be attached to a VPC.
* If your resource cannot function without a VPC,
* make this property required -
* do NOT make it optional, and then create a VPC implicitly!
* This is different than what we do for IAM Roles, for example.
*
* @default - no VPC will be used
*/
readonly vpc?: ec2.IVpc;
/**
* Whenever you have IVpc as a property,
* like we have in `vpc`,
* you need to provide an optional property of type ec2.SubnetSelection,
* which can be used to specify which subnets of the VPC should the resource use.
* The default is usually all private subnets,
* however you can change that default in your resource if it makes sense
* (for example, to all public subnets).
*
* @default - default subnet selection strategy, see the EC2 module for details
*/
readonly vpcSubnets?: ec2.SubnetSelection;
/**
* If your resource interface extends ec2.IConnectable,
* that means it needs security groups to control traffic coming to and from it.
* Allow the customer to specify these security groups.
* If none were specified, we will create a new one implicitly,
* similarly like we do for IAM Roles.
*
* **Note**: a few resources in the CDK only allow you to provide a single SecurityGroup.
* This is generally considered a historical mistake,
* and all new code should allow an array of security groups to be passed.
*
* @default - a new security group will be created
*/
readonly securityGroups?: ec2.ISecurityGroup[];
/**
* What to do when this resource is deleted from a stack.
* Some stateful resources cannot be deleted if they have any contents
* (S3 Buckets are the canonical example),
* so we set their deletion policy to RETAIN by default.
* If your resource also behaves like that,
* you need to allow your customers to override this behavior if they need to.
*
* @default RemovalPolicy.RETAIN
*/
readonly removalPolicy?: RemovalPolicy;
}
/**
* The actual L2 class for the ExampleResource.
* Extends ExampleResourceBase.
* Represents a resource completely managed by the CDK, and thus mutable.
* You can add additional methods to the public API of this class not present in `IExampleResource`,
* although you should strive to minimize that as much as possible,
* and have the entire API available in `IExampleResource`
* (but perhaps some of it not having any effect,
* like `IExampleResource.addToRolePolicy`).
*
* Usually, the CDK is able to figure out what's the equivalent CloudFormation resource for this L2,
* but sometimes (like in this example), we need to specify it explicitly.
* You do it with the '@resource' annotation:
*
* @resource AWS::CloudFormation::WaitConditionHandle
*/
export class ExampleResource extends ExampleResourceBase {
/**
* Reference an existing ExampleResource,
* defined outside of the CDK code, by name.
*
* The class might contain more methods for referencing an existing resource,
* like fromExampleResourceArn,
* or fromExampleResourceAttributes
* (the last one if you want the importing behavior to be more customizable).
*/
public static fromExampleResourceName(scope: Construct, id: string, exampleResourceName: string): IExampleResource {
// Imports are almost always implemented as a module-private
// inline class in the method itself.
// We extend ExampleResourceBase to reuse all of the logic inside it.
class Import extends ExampleResourceBase {
// we don't have an associated Role in this case
public readonly role = undefined;
// for imported resources, you always use the UnknownPrincipal,
// which ignores all modifications
public readonly grantPrincipal = new iam.UnknownPrincipal({ resource: this });
public readonly exampleResourceName = exampleResourceName;
// Since we have the name, we have to generate the ARN,
// using the Stack.formatArn helper method from the core library.
// We have to know the ARN components of ExampleResource in a few places, so,
// to avoid duplication, extract that into a module-private function
public readonly exampleResourceArn = Stack.of(scope)
.formatArn(exampleResourceArnComponents(exampleResourceName));
}
return new Import(scope, id);
}
// implement all fields that are abstract in ExampleResourceBase
public readonly exampleResourceArn: string;
public readonly exampleResourceName: string;
// while we know 'role' will actually never be undefined in this class,
// JSII does not allow changing the optionality of a field
// when overriding it, so it has to be 'role?'
public readonly role?: iam.IRole;
public readonly grantPrincipal: iam.IPrincipal;
/**
* The constructor of a construct has always 3 arguments:
* the parent Construct, the string identifier
* (locally unique within the scope of the parent),
* and a properties struct.
*
* If the props only have optional properties, like in our case,
* make sure to add a default value of an empty object to the props argument.
*/
constructor(scope: Construct, id: string, props: ExampleResourceProps = {}) {
// Call the constructor from Resource superclass,
// which attaches this construct to the construct tree.
super(scope, id, {
// You need to let the Resource superclass know which of your properties
// signifies the resource's physical name.
// If your resource doesn't have a physical name,
// don't set this property.
// For more information on what exactly is a physical name,
// see the CDK guide: https://docs.aws.amazon.com/cdk/latest/guide/resources.html#resources_physical_names
physicalName: props.waitConditionHandleName,
});
// Enhanced CDK Analytics Telemetry
addConstructMetadata(this, props);
// We often add validations for properties,
// so that customers receive feedback about incorrect properties
// sooner than a CloudFormation deployment.
// However, when validating string (and number!) properties,
// it's important to remember that the value can be a CFN function
// (think a { Ref: ParameterName } expression in CloudFormation),
// and that sort of value would be also encoded as a string;
// so, we need to use the Token.isUnresolved() method from the core library
// to skip validation in that case.
if (props.waitConditionHandleName !== undefined &&
!Token.isUnresolved(props.waitConditionHandleName) &&
!/^[_a-zA-Z]+$/.test(props.waitConditionHandleName)) {
throw new Error('waitConditionHandleName must be non-empty and contain only letters and underscores, ' +
`got: '${props.waitConditionHandleName}'`);
}
// Inside the implementation of the L2,
// we very often use L1 classes (those whose names begin with 'Cfn').
// However, it's important we don't 'leak' that fact to the API of the L2 class -
// so, we should never take L1 types as inputs in our props,
// and we should not surface any L1 classes in public fields or methods of the class.
// The 'Cfn*' class is purely an implementation detail.
// If this was a real resource, we would use a specific L1 for that resource
// (like a CfnBucket inside the Bucket class),
// but since this is just an example,
// we'll use CloudFormation wait conditions.
// Remember to always, always, pass 'this' as the first argument
// when creating any constructs inside your L2s!
// This guarantees that they get scoped correctly,
// and the CDK will make sure their locally-unique identifiers
// are globally unique, which makes your L2 compose.
const waitConditionHandle = new CfnWaitConditionHandle(this, 'WaitConditionHandle');
// The 'main' L1 you create should always have the logical ID 'Resource'.
// This is important, so that the ConstructNode.defaultChild method works correctly.
// The local variable representing the L1 is often called 'resource' as well.
const resource = new CfnWaitCondition(this, 'Resource', {
count: 0,
handle: waitConditionHandle.ref,
timeout: '10',
});
// The resource's physical name and ARN are set using
// some protected methods from the Resource superclass
// that correctly resolve when your L2 is used in another resource
// that is in a different AWS region or account than this one.
this.exampleResourceName = this.getResourceNameAttribute(
// A lot of the CloudFormation resources return their physical name
// when the Ref function is used on them.
// If your resource is like that, simply pass 'resource.ref' here.
// However, if Ref for your resource returns something else,
// it's often still possible to use CloudFormation functions to get out the physical name;
// for example, if Ref for your resource returns the ARN,
// and the ARN for your resource is of the form 'arn:aws:<service>:<region>:<account>:resource/physical-name',
// which is quite common,
// you can use Fn::Select and Fn::Split to take out the part after the '/' from the ARN:
Fn.select(1, Fn.split('/', resource.ref)),
);
this.exampleResourceArn = this.getResourceArnAttribute(
// A lot of the L1 classes have an 'attrArn' property -
// if yours does, use it here.
// However, if it doesn't,
// you can often formulate the ARN yourself,
// using the Stack.formatArn helper function.
// Here, we assume resource.ref returns the physical name of the resource.
Stack.of(this).formatArn(exampleResourceArnComponents(resource.ref)),
// always use the protected physicalName property for this second argument
exampleResourceArnComponents(this.physicalName));
// if a role wasn't passed, create one
const role = props.role || new iam.Role(this, 'Role', {
// of course, fill your correct service principal here
assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'),
});
this.role = role;
// we need this to correctly implement the iam.IGrantable interface
this.grantPrincipal = role;
// implement the ec2.IConnectable interface,
// by writing to the _connections field in ExampleResourceBase,
// if a VPC was passed in props
if (props.vpc) {
const securityGroups = (props.securityGroups ?? []).length === 0
// no security groups were provided - create one
? [new ec2.SecurityGroup(this, 'SecurityGroup', {
vpc: props.vpc,
})]
: props.securityGroups;
this._connections = new ec2.Connections({ securityGroups });
// this is how you would use the VPC inputs to fill a subnetIds property of an L1:
new ec2.CfnVPCEndpoint(this, 'VpcEndpoint', {
vpcId: props.vpc.vpcId,
serviceName: 'ServiceName',
subnetIds: props.vpc.selectSubnets(props.vpcSubnets).subnetIds,
});
}
// this is how you apply the removal policy
resource.applyRemovalPolicy(props.removalPolicy, {
// this is the default to apply if props.removalPolicy is undefined
default: RemovalPolicy.RETAIN,
});
}
}