packages/aws-cdk-lib/custom-resources/lib/aws-custom-resource/aws-custom-resource.ts (217 lines of code) (raw):
import { Construct } from 'constructs';
import { Logging } from './logging';
import * as ec2 from '../../../aws-ec2';
import * as iam from '../../../aws-iam';
import * as logs from '../../../aws-logs';
import * as cdk from '../../../core';
import { Annotations } from '../../../core';
import { AwsCustomResourceSingletonFunction } from '../../../custom-resource-handlers/dist/custom-resources/aws-custom-resource-provider.generated';
import * as cxapi from '../../../cx-api';
import { awsSdkToIamAction } from '../helpers-internal/sdk-info';
// Shared definition with packages/@aws-cdk/custom-resource-handlers/lib/custom-resources/aws-custom-resource-handler/shared.ts
const PHYSICAL_RESOURCE_ID_REFERENCE = 'PHYSICAL:RESOURCEID:';
/**
* Reference to the physical resource id that can be passed to the AWS operation as a parameter.
*/
export class PhysicalResourceIdReference implements cdk.IResolvable {
public readonly creationStack: string[] = cdk.captureStackTrace();
/**
* toJSON serialization to replace `PhysicalResourceIdReference` with a magic string.
*/
public toJSON() {
return PHYSICAL_RESOURCE_ID_REFERENCE;
}
public resolve(_context: cdk.IResolveContext): any {
return PHYSICAL_RESOURCE_ID_REFERENCE;
}
public toString(): string {
return PHYSICAL_RESOURCE_ID_REFERENCE;
}
}
/**
* Physical ID of the custom resource.
*/
export class PhysicalResourceId {
/**
* Extract the physical resource id from the path (dot notation) to the data in the API call response.
*/
public static fromResponse(responsePath: string): PhysicalResourceId {
return new PhysicalResourceId(responsePath, undefined);
}
/**
* Explicit physical resource id.
*/
public static of(id: string): PhysicalResourceId {
return new PhysicalResourceId(undefined, id);
}
/**
* @param responsePath Path to a response data element to be used as the physical id.
* @param id Literal string to be used as the physical id.
*/
private constructor(public readonly responsePath?: string, public readonly id?: string) { }
}
/**
* An AWS SDK call.
*
* @example
*
* new cr.AwsCustomResource(this, 'GetParameterCustomResource', {
* onUpdate: { // will also be called for a CREATE event
* service: 'SSM',
* action: 'getParameter',
* parameters: {
* Name: 'my-parameter',
* WithDecryption: true,
* },
* physicalResourceId: cr.PhysicalResourceId.fromResponse('Parameter.ARN'),
* },
* policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
* resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE,
* }),
* });
*
*/
export interface AwsSdkCall {
/**
* The service to call
*
* This is the name of an AWS service, in one of the following forms:
*
* - An AWS SDK for JavaScript v3 package name (`@aws-sdk/client-api-gateway`)
* - An AWS SDK for JavaScript v3 client name (`api-gateway`)
* - An AWS SDK for JavaScript v2 constructor name (`APIGateway`)
* - A lowercase AWS SDK for JavaScript v2 constructor name (`apigateway`)
*
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html
*/
readonly service: string;
/**
* The service action to call
*
* This is the name of an AWS API call, in one of the following forms:
*
* - An API call name as found in the API Reference documentation (`GetObject`)
* - The API call name starting with a lowercase letter (`getObject`)
* - The AWS SDK for JavaScript v3 command class name (`GetObjectCommand`)
*
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html
*/
readonly action: string;
/**
* The parameters for the service action
*
* @default - no parameters
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html
*/
readonly parameters?: any;
/**
* The physical resource id of the custom resource for this call.
* Mandatory for onCreate call.
* In onUpdate, you can omit this to passthrough it from request.
*
* @default - no physical resource id
*/
readonly physicalResourceId?: PhysicalResourceId;
/**
* The regex pattern to use to catch API errors. The `code` property of the
* `Error` object will be tested against this pattern. If there is a match an
* error will not be thrown.
*
* @default - do not catch errors
*/
readonly ignoreErrorCodesMatching?: string;
/**
* API version to use for the service
*
* @see https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/locking-api-versions.html
* @default - use latest available API version
*/
readonly apiVersion?: string;
/**
* The region to send service requests to.
* **Note: Cross-region operations are generally considered an anti-pattern.**
* **Consider first deploying a stack in that region.**
*
* @default - the region where this custom resource is deployed
*/
readonly region?: string;
/**
* Restrict the data returned by the custom resource to a specific path in
* the API response. Use this to limit the data returned by the custom
* resource if working with API calls that could potentially result in custom
* response objects exceeding the hard limit of 4096 bytes.
*
* Example for ECS / updateService: 'service.deploymentConfiguration.maximumPercent'
*
* @default - return all data
*
* @deprecated use outputPaths instead
*/
readonly outputPath?: string;
/**
* Restrict the data returned by the custom resource to specific paths in
* the API response. Use this to limit the data returned by the custom
* resource if working with API calls that could potentially result in custom
* response objects exceeding the hard limit of 4096 bytes.
*
* Example for ECS / updateService: ['service.deploymentConfiguration.maximumPercent']
*
* @default - return all data
*/
readonly outputPaths?: string[];
/**
* Used for running the SDK calls in underlying lambda with a different role.
* Can be used primarily for cross-account requests to for example connect
* hostedzone with a shared vpc.
* Region controls where assumeRole call is made.
*
* Example for Route53 / associateVPCWithHostedZone
*
* @default - run without assuming role
*/
readonly assumedRoleArn?: string;
/**
* A property used to configure logging during lambda function execution.
*
* Note: The default Logging configuration is all. This configuration will enable logging on all logged data
* in the lambda handler. This includes:
* - The event object that is received by the lambda handler
* - The response received after making a API call
* - The response object that the lambda handler will return
* - SDK versioning information
* - Caught and uncaught errors
*
* @default Logging.all()
*/
readonly logging?: Logging;
}
/**
* Options for the auto-generation of policies based on the configured SDK calls.
*/
export interface SdkCallsPolicyOptions {
/**
* The resources that the calls will have access to.
*
* It is best to use specific resource ARN's when possible. However, you can also use `AwsCustomResourcePolicy.ANY_RESOURCE`
* to allow access to all resources. For example, when `onCreate` is used to create a resource which you don't
* know the physical name of in advance.
*
* Note that will apply to ALL SDK calls.
*/
readonly resources: string[];
}
/**
* The IAM Policy that will be applied to the different calls.
*/
export class AwsCustomResourcePolicy {
/**
* Use this constant to configure access to any resource.
*/
public static readonly ANY_RESOURCE = ['*'];
/**
* Explicit IAM Policy Statements.
*
* @param statements the statements to propagate to the SDK calls.
*/
public static fromStatements(statements: iam.PolicyStatement[]) {
return new AwsCustomResourcePolicy(statements, undefined);
}
/**
* Generate IAM Policy Statements from the configured SDK calls.
*
* Each SDK call with be translated to an IAM Policy Statement in the form of: `call.service:call.action` (e.g `s3:PutObject`).
*
* This policy generator assumes the IAM policy name has the same name as the API
* call. This is true in 99% of cases, but there are exceptions (for example,
* S3's `PutBucketLifecycleConfiguration` requires
* `s3:PutLifecycleConfiguration` permissions, Lambda's `Invoke` requires
* `lambda:InvokeFunction` permissions). Use `fromStatements` if you want to
* do a call that requires different IAM action names.
*
* @param options options for the policy generation
*/
public static fromSdkCalls(options: SdkCallsPolicyOptions) {
return new AwsCustomResourcePolicy([], options.resources);
}
/**
* @param statements statements for explicit policy.
* @param resources resources for auto-generated from SDK calls.
*/
private constructor(public readonly statements: iam.PolicyStatement[], public readonly resources?: string[]) { }
}
/**
* Properties for AwsCustomResource.
*
* Note that at least onCreate, onUpdate or onDelete must be specified.
*/
export interface AwsCustomResourceProps {
/**
* Cloudformation Resource type.
*
* @default - Custom::AWS
*/
readonly resourceType?: string;
/**
* The AWS SDK call to make when the resource is created.
*
* @default - the call when the resource is updated
*/
readonly onCreate?: AwsSdkCall;
/**
* The AWS SDK call to make when the resource is updated
*
* @default - no call
*/
readonly onUpdate?: AwsSdkCall;
/**
* The AWS SDK call to make when the resource is deleted
*
* @default - no call
*/
readonly onDelete?: AwsSdkCall;
/**
* The policy that will be added to the execution role of the Lambda
* function implementing this custom resource provider.
*
* The custom resource also implements `iam.IGrantable`, making it possible
* to use the `grantXxx()` methods.
*
* As this custom resource uses a singleton Lambda function, it's important
* to note the that function's role will eventually accumulate the
* permissions/grants from all resources.
*
* Note that a policy must be specified if `role` is not provided, as
* by default a new role is created which requires policy changes to access
* resources.
*
* @default - no policy added
*
* @see Policy.fromStatements
* @see Policy.fromSdkCalls
*/
readonly policy?: AwsCustomResourcePolicy;
/**
* The execution role for the singleton Lambda function implementing this custom
* resource provider. This role will apply to all `AwsCustomResource`
* instances in the stack. The role must be assumable by the
* `lambda.amazonaws.com` service principal.
*
* @default - a new role is created
*/
readonly role?: iam.IRole;
/**
* The timeout for the singleton Lambda function implementing this custom resource.
*
* @default Duration.minutes(2)
*/
readonly timeout?: cdk.Duration;
/**
* The memory size for the singleton Lambda function implementing this custom resource.
*
* @default 512 mega in case if installLatestAwsSdk is false.
*/
readonly memorySize?: number;
/**
* The number of days log events of the singleton Lambda function implementing
* this custom resource are kept in CloudWatch Logs.
*
* This is a legacy API and we strongly recommend you migrate to `logGroup` if you can.
* `logGroup` allows you to create a fully customizable log group and instruct the Lambda function to send logs to it.
*
* @default logs.RetentionDays.INFINITE
*/
readonly logRetention?: logs.RetentionDays;
/**
* The Log Group used for logging of events emitted by the custom resource's lambda function.
*
* Providing a user-controlled log group was rolled out to commercial regions on 2023-11-16.
* If you are deploying to another type of region, please check regional availability first.
*
* @default - a default log group created by AWS Lambda
*/
readonly logGroup?: logs.ILogGroup;
/**
* Whether to install the latest AWS SDK v3.
*
* If not specified, this uses whatever JavaScript SDK version is the default in
* AWS Lambda at the time of execution.
*
* Otherwise, installs the latest version from 'npmjs.com'. The installation takes
* around 60 seconds and requires internet connectivity.
*
* The default can be controlled using the context key
* `@aws-cdk/customresources:installLatestAwsSdkDefault` is.
*
* @default - The value of `@aws-cdk/customresources:installLatestAwsSdkDefault`, otherwise `true`
*/
readonly installLatestAwsSdk?: boolean;
/**
* A name for the singleton Lambda function implementing this custom resource.
* The function name will remain the same after the first AwsCustomResource is created in a stack.
*
* @default - AWS CloudFormation generates a unique physical ID and uses that
* ID for the function's name. For more information, see Name Type.
*/
readonly functionName?: string;
/**
* The policy to apply when this resource is removed from the application.
*
* @default cdk.RemovalPolicy.Destroy
*/
readonly removalPolicy?: cdk.RemovalPolicy;
/**
* The vpc to provision the lambda function in.
*
* @default - the function is not provisioned inside a vpc.
*/
readonly vpc?: ec2.IVpc;
/**
* Which subnets from the VPC to place the lambda function in.
*
* Only used if 'vpc' is supplied. Note: internet access for Lambdas
* requires a NAT gateway, so picking Public subnets is not allowed.
*
* @default - the Vpc default strategy if not specified
*/
readonly vpcSubnets?: ec2.SubnetSelection;
/**
* The maximum time that can elapse before a custom resource operation times out.
*
* You should not need to set this property. It is intended to allow quick turnaround
* even if the implementor of the custom resource forgets to include a `try/catch`.
* We have included the `try/catch`, and AWS service calls usually do not take an hour
* to complete.
*
* The value must be between 1 second and 3600 seconds.
*
* @default Duration.seconds(3600)
*/
readonly serviceTimeout?: cdk.Duration;
}
/**
* Defines a custom resource that is materialized using specific AWS API calls. These calls are created using
* a singleton Lambda function.
*
* Use this to bridge any gap that might exist in the CloudFormation Coverage.
* You can specify exactly which calls are invoked for the 'CREATE', 'UPDATE' and 'DELETE' life cycle events.
*
*/
export class AwsCustomResource extends Construct implements iam.IGrantable {
/**
* The uuid of the custom resource provider singleton lambda function.
*/
public static readonly PROVIDER_FUNCTION_UUID = '679f53fa-c002-430c-b0da-5b7982bd2287';
private static breakIgnoreErrorsCircuit(sdkCalls: Array<AwsSdkCall | undefined>, caller: string) {
for (const call of sdkCalls) {
if (call?.ignoreErrorCodesMatching) {
throw new Error(`\`${caller}\`` + ' cannot be called along with `ignoreErrorCodesMatching`.');
}
}
}
public readonly grantPrincipal: iam.IPrincipal;
private readonly customResource: cdk.CustomResource;
private readonly props: AwsCustomResourceProps;
// 'props' cannot be optional, even though all its properties are optional.
// this is because at least one sdk call must be provided.
constructor(scope: Construct, id: string, props: AwsCustomResourceProps) {
super(scope, id);
if (!props.onCreate && !props.onUpdate && !props.onDelete) {
throw new Error('At least `onCreate`, `onUpdate` or `onDelete` must be specified.');
}
if (!props.role && !props.policy) {
throw new Error('At least one of `policy` or `role` (or both) must be specified.');
}
if (props.onCreate && !props.onCreate.physicalResourceId) {
throw new Error("'physicalResourceId' must be specified for 'onCreate' call.");
}
if (!props.onCreate && props.onUpdate && !props.onUpdate.physicalResourceId) {
throw new Error("'physicalResourceId' must be specified for 'onUpdate' call when 'onCreate' is omitted.");
}
for (const call of [props.onCreate, props.onUpdate, props.onDelete]) {
if (call?.physicalResourceId?.responsePath) {
AwsCustomResource.breakIgnoreErrorsCircuit([call], 'PhysicalResourceId.fromResponse');
}
}
if (includesPhysicalResourceIdRef(props.onCreate?.parameters)) {
throw new Error('`PhysicalResourceIdReference` must not be specified in `onCreate` parameters.');
}
this.props = props;
let memorySize = props.memorySize;
if (props.installLatestAwsSdk) {
memorySize ??= 512;
}
const provider = new AwsCustomResourceSingletonFunction(this, 'Provider', {
uuid: AwsCustomResource.PROVIDER_FUNCTION_UUID,
lambdaPurpose: 'AWS',
memorySize: memorySize,
timeout: props.timeout || cdk.Duration.minutes(2),
role: props.role,
// props.logRetention is deprecated, make sure we only set it if it is actually provided
// otherwise jsii will print warnings even for users that don't use this directly
...(props.logRetention ? { logRetention: props.logRetention } : {}),
logGroup: props.logGroup,
functionName: props.functionName,
vpc: props.vpc,
vpcSubnets: props.vpcSubnets,
});
this.grantPrincipal = provider.grantPrincipal;
const installLatestAwsSdk = (props.installLatestAwsSdk
?? this.node.tryGetContext(cxapi.AWS_CUSTOM_RESOURCE_LATEST_SDK_DEFAULT)
?? true);
if (installLatestAwsSdk && props.installLatestAwsSdk === undefined) {
// This is dangerous. Add a warning.
Annotations.of(this).addWarningV2('@aws-cdk/custom-resources:installLatestAwsSdkNotSpecified', [
'installLatestAwsSdk was not specified, and defaults to true. You probably do not want this.',
`Set the global context flag \'${cxapi.AWS_CUSTOM_RESOURCE_LATEST_SDK_DEFAULT}\' to false to switch this behavior off project-wide,`,
'or set the property explicitly to true if you know you need to call APIs that are not in Lambda\'s built-in SDK version.',
].join(' '));
}
const create = props.onCreate || props.onUpdate;
this.customResource = new cdk.CustomResource(this, 'Resource', {
resourceType: props.resourceType || 'Custom::AWS',
serviceToken: provider.functionArn,
serviceTimeout: props.serviceTimeout,
pascalCaseProperties: true,
removalPolicy: props.removalPolicy,
properties: {
create: create && this.formatSdkCall(create),
update: props.onUpdate && this.formatSdkCall(props.onUpdate),
delete: props.onDelete && this.formatSdkCall(props.onDelete),
installLatestAwsSdk,
},
});
// Create the policy statements for the custom resource function role, or use the user-provided ones
if (props.policy) {
const statements = [];
if (props.policy.statements.length !== 0) {
// Use custom statements provided by the user
for (const statement of props.policy.statements) {
statements.push(statement);
}
} else {
// Derive statements from AWS SDK calls
for (const call of [props.onCreate, props.onUpdate, props.onDelete]) {
if (call && call.assumedRoleArn == null) {
const statement = new iam.PolicyStatement({
actions: [awsSdkToIamAction(call.service, call.action)],
resources: props.policy.resources,
});
statements.push(statement);
} else if (call && call.assumedRoleArn != null) {
const statement = new iam.PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [call.assumedRoleArn],
});
statements.push(statement);
}
}
}
const policy = new iam.Policy(this, 'CustomResourcePolicy', {
statements: statements,
});
if (provider.role !== undefined) {
policy.attachToRole(provider.role);
}
// If the policy was deleted first, then the function might lose permissions to delete the custom resource
// This is here so that the policy doesn't get removed before onDelete is called
this.customResource.node.addDependency(policy);
}
}
/**
* Returns response data for the AWS SDK call.
*
* Example for S3 / listBucket : 'Buckets.0.Name'
*
* Use `Token.asXxx` to encode the returned `Reference` as a specific type or
* use the convenience `getDataString` for string attributes.
*
* Note that you cannot use this method if `ignoreErrorCodesMatching`
* is configured for any of the SDK calls. This is because in such a case,
* the response data might not exist, and will cause a CloudFormation deploy time error.
*
* @param dataPath the path to the data
*/
public getResponseFieldReference(dataPath: string) {
AwsCustomResource.breakIgnoreErrorsCircuit([this.props.onCreate, this.props.onUpdate], 'getData');
return this.customResource.getAtt(dataPath);
}
/**
* Returns response data for the AWS SDK call as string.
*
* Example for S3 / listBucket : 'Buckets.0.Name'
*
* Note that you cannot use this method if `ignoreErrorCodesMatching`
* is configured for any of the SDK calls. This is because in such a case,
* the response data might not exist, and will cause a CloudFormation deploy time error.
*
* @param dataPath the path to the data
*/
public getResponseField(dataPath: string): string {
AwsCustomResource.breakIgnoreErrorsCircuit([this.props.onCreate, this.props.onUpdate], 'getDataString');
return this.customResource.getAttString(dataPath);
}
private formatSdkCall(sdkCall: AwsSdkCall) {
const { logging, ...call } = sdkCall;
const renderedLogging = (logging ?? Logging.all())._render(this);
return this.encodeJson({
...call,
...renderedLogging,
});
}
private encodeJson(obj: any) {
return cdk.Lazy.uncachedString({ produce: () => cdk.Stack.of(this).toJsonString(obj) });
}
}
/**
* Returns true if `obj` includes a `PhysicalResourceIdReference` in one of the
* values.
* @param obj Any object.
*/
function includesPhysicalResourceIdRef(obj: any | undefined) {
if (obj === undefined) {
return false;
}
let foundRef = false;
// we use JSON.stringify as a way to traverse all values in the object.
JSON.stringify(obj, (_, v) => {
if (v === PHYSICAL_RESOURCE_ID_REFERENCE) {
foundRef = true;
}
return v;
});
return foundRef;
}