Skip to content

Commit

Permalink
feat(core): CfnResource dependency methods (aws#23383)
Browse files Browse the repository at this point in the history
Reopening aws#20419 from a personal fork to allow maintainer edits. @comcalvi 👋 

Add some new methods to allow a minimal interface for viewing and editing resource-to-resource dependencies that mirrors the behavior of CfnResource.addDependsOn().

Related to aws#20418 - details for justification are there

----

### All Submissions:

* [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md)

### Adding new Construct Runtime Dependencies:

* [ ] This PR adds new construct runtime dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-construct-runtime-dependencies)

### New Features

* [x] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)?
	* [x] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)?

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
Justin Frahm authored and Brennan Ho committed Feb 22, 2023
1 parent 2de2611 commit 574ef24
Show file tree
Hide file tree
Showing 54 changed files with 4,520 additions and 75 deletions.
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-appsync/lib/appsync-function.ts
Expand Up @@ -142,7 +142,7 @@ export class AppsyncFunction extends Resource implements IAppsyncFunction {
this.functionId = this.function.attrFunctionId;
this.dataSource = props.dataSource;

this.function.addDependsOn(this.dataSource.ds);
this.function.addDependency(this.dataSource.ds);
props.api.addSchemaDependency(this.function);
}
}
}
6 changes: 3 additions & 3 deletions packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts
Expand Up @@ -518,15 +518,15 @@ export class GraphqlApi extends GraphqlApiBase {
apiId: this.apiId,
});

domainNameAssociation.addDependsOn(this.domainNameResource);
domainNameAssociation.addDependency(this.domainNameResource);
}

if (modes.some((mode) => mode.authorizationType === AuthorizationType.API_KEY)) {
const config = modes.find((mode: AuthorizationMode) => {
return mode.authorizationType === AuthorizationType.API_KEY && mode.apiKeyConfig;
})?.apiKeyConfig;
this.apiKeyResource = this.createAPIKey(config);
this.apiKeyResource.addDependsOn(this.schemaResource);
this.apiKeyResource.addDependency(this.schemaResource);
this.apiKey = this.apiKeyResource.attrApiKey;
}

Expand Down Expand Up @@ -631,7 +631,7 @@ export class GraphqlApi extends GraphqlApiBase {
* @param construct the dependee
*/
public addSchemaDependency(construct: CfnResource): boolean {
construct.addDependsOn(this.schemaResource);
construct.addDependency(this.schemaResource);
return true;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-appsync/lib/resolver.ts
Expand Up @@ -122,7 +122,7 @@ export class Resolver extends Construct {
});
props.api.addSchemaDependency(this.resolver);
if (props.dataSource) {
this.resolver.addDependsOn(props.dataSource.ds);
this.resolver.addDependency(props.dataSource.ds);
}
this.arn = this.resolver.attrResolverArn;
}
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-cloudformation/package.json
Expand Up @@ -78,6 +78,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@aws-cdk/assertions": "0.0.0",
"@aws-cdk/aws-logs": "0.0.0",
"@aws-cdk/aws-events": "0.0.0",
"@aws-cdk/aws-s3-assets": "0.0.0",
"@aws-cdk/aws-sns-subscriptions": "0.0.0",
Expand Down
@@ -0,0 +1,19 @@
{
"version": "21.0.0",
"files": {
"21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": {
"source": {
"path": "DependsOnTestDefaultTestDeployAssert3B3B50E2.template.json",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
}
},
"dockerImages": {}
}
@@ -0,0 +1,36 @@
{
"Parameters": {
"BootstrapVersion": {
"Type": "AWS::SSM::Parameter::Value<String>",
"Default": "/cdk-bootstrap/hnb659fds/version",
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
"CheckBootstrapVersion": {
"Assertions": [
{
"Assert": {
"Fn::Not": [
{
"Fn::Contains": [
[
"1",
"2",
"3",
"4",
"5"
],
{
"Ref": "BootstrapVersion"
}
]
}
]
},
"AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI."
}
]
}
}
}
@@ -0,0 +1 @@
export declare function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context): Promise<void>;

Large diffs are not rendered by default.

@@ -0,0 +1,221 @@
/* eslint-disable no-console */

// eslint-disable-next-line import/no-extraneous-dependencies
import * as AWS from 'aws-sdk';
// eslint-disable-next-line import/no-extraneous-dependencies
import type { RetryDelayOptions } from 'aws-sdk/lib/config-base';

interface SdkRetryOptions {
maxRetries?: number;
retryOptions?: RetryDelayOptions;
}

/**
* Creates a log group and doesn't throw if it exists.
*
* @param logGroupName the name of the log group to create.
* @param region to create the log group in
* @param options CloudWatch API SDK options.
*/
async function createLogGroupSafe(logGroupName: string, region?: string, options?: SdkRetryOptions) {
// If we set the log retention for a lambda, then due to the async nature of
// Lambda logging there could be a race condition when the same log group is
// already being created by the lambda execution. This can sometime result in
// an error "OperationAbortedException: A conflicting operation is currently
// in progress...Please try again."
// To avoid an error, we do as requested and try again.
let retryCount = options?.maxRetries == undefined ? 10 : options.maxRetries;
const delay = options?.retryOptions?.base == undefined ? 10 : options.retryOptions.base;
do {
try {
const cloudwatchlogs = new AWS.CloudWatchLogs({ apiVersion: '2014-03-28', region, ...options });
await cloudwatchlogs.createLogGroup({ logGroupName }).promise();
return;
} catch (error) {
if (error.code === 'ResourceAlreadyExistsException') {
// The log group is already created by the lambda execution
return;
}
if (error.code === 'OperationAbortedException') {
if (retryCount > 0) {
retryCount--;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
} else {
// The log group is still being created by another execution but we are out of retries
throw new Error('Out of attempts to create a logGroup');
}
}
throw error;
}
} while (true); // exit happens on retry count check
}

//delete a log group
async function deleteLogGroup(logGroupName: string, region?: string, options?: SdkRetryOptions) {
let retryCount = options?.maxRetries == undefined ? 10 : options.maxRetries;
const delay = options?.retryOptions?.base == undefined ? 10 : options.retryOptions.base;
do {
try {
const cloudwatchlogs = new AWS.CloudWatchLogs({ apiVersion: '2014-03-28', region, ...options });
await cloudwatchlogs.deleteLogGroup({ logGroupName }).promise();
return;
} catch (error) {
if (error.code === 'ResourceNotFoundException') {
// The log group doesn't exist
return;
}
if (error.code === 'OperationAbortedException') {
if (retryCount > 0) {
retryCount--;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
} else {
// The log group is still being deleted by another execution but we are out of retries
throw new Error('Out of attempts to delete a logGroup');
}
}
throw error;
}
} while (true); // exit happens on retry count check
}

/**
* Puts or deletes a retention policy on a log group.
*
* @param logGroupName the name of the log group to create
* @param region the region of the log group
* @param options CloudWatch API SDK options.
* @param retentionInDays the number of days to retain the log events in the specified log group.
*/
async function setRetentionPolicy(logGroupName: string, region?: string, options?: SdkRetryOptions, retentionInDays?: number) {
// The same as in createLogGroupSafe(), here we could end up with the race
// condition where a log group is either already being created or its retention
// policy is being updated. This would result in an OperationAbortedException,
// which we will try to catch and retry the command a number of times before failing
let retryCount = options?.maxRetries == undefined ? 10 : options.maxRetries;
const delay = options?.retryOptions?.base == undefined ? 10 : options.retryOptions.base;
do {
try {
const cloudwatchlogs = new AWS.CloudWatchLogs({ apiVersion: '2014-03-28', region, ...options });
if (!retentionInDays) {
await cloudwatchlogs.deleteRetentionPolicy({ logGroupName }).promise();
} else {
await cloudwatchlogs.putRetentionPolicy({ logGroupName, retentionInDays }).promise();
}
return;

} catch (error) {
if (error.code === 'OperationAbortedException') {
if (retryCount > 0) {
retryCount--;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
} else {
// The log group is still being created by another execution but we are out of retries
throw new Error('Out of attempts to create a logGroup');
}
}
throw error;
}
} while (true); // exit happens on retry count check
}

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
try {
console.log(JSON.stringify({ ...event, ResponseURL: '...' }));

// The target log group
const logGroupName = event.ResourceProperties.LogGroupName;

// The region of the target log group
const logGroupRegion = event.ResourceProperties.LogGroupRegion;

// Parse to AWS SDK retry options
const retryOptions = parseRetryOptions(event.ResourceProperties.SdkRetry);

if (event.RequestType === 'Create' || event.RequestType === 'Update') {
// Act on the target log group
await createLogGroupSafe(logGroupName, logGroupRegion, retryOptions);
await setRetentionPolicy(logGroupName, logGroupRegion, retryOptions, parseInt(event.ResourceProperties.RetentionInDays, 10));

if (event.RequestType === 'Create') {
// Set a retention policy of 1 day on the logs of this very function.
// Due to the async nature of the log group creation, the log group for this function might
// still be not created yet at this point. Therefore we attempt to create it.
// In case it is being created, createLogGroupSafe will handle the conflict.
const region = process.env.AWS_REGION;
await createLogGroupSafe(`/aws/lambda/${context.functionName}`, region, retryOptions);
// If createLogGroupSafe fails, the log group is not created even after multiple attempts.
// In this case we have nothing to set the retention policy on but an exception will skip
// the next line.
await setRetentionPolicy(`/aws/lambda/${context.functionName}`, region, retryOptions, 1);
}
}

//When the requestType is delete, delete the log group if the removal policy is delete
if (event.RequestType === 'Delete' && event.ResourceProperties.RemovalPolicy === 'destroy') {
await deleteLogGroup(logGroupName, logGroupRegion, retryOptions);
//else retain the log group
}

await respond('SUCCESS', 'OK', logGroupName);
} catch (e) {
console.log(e);

await respond('FAILED', e.message, event.ResourceProperties.LogGroupName);
}

function respond(responseStatus: string, reason: string, physicalResourceId: string) {
const responseBody = JSON.stringify({
Status: responseStatus,
Reason: reason,
PhysicalResourceId: physicalResourceId,
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
Data: {
// Add log group name as part of the response so that it's available via Fn::GetAtt
LogGroupName: event.ResourceProperties.LogGroupName,
},
});

console.log('Responding', responseBody);

// eslint-disable-next-line @typescript-eslint/no-require-imports
const parsedUrl = require('url').parse(event.ResponseURL);
const requestOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: 'PUT',
headers: { 'content-type': '', 'content-length': responseBody.length },
};

return new Promise((resolve, reject) => {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const request = require('https').request(requestOptions, resolve);
request.on('error', reject);
request.write(responseBody);
request.end();
} catch (e) {
reject(e);
}
});
}

function parseRetryOptions(rawOptions: any): SdkRetryOptions {
const retryOptions: SdkRetryOptions = {};
if (rawOptions) {
if (rawOptions.maxRetries) {
retryOptions.maxRetries = parseInt(rawOptions.maxRetries, 10);
}
if (rawOptions.base) {
retryOptions.retryOptions = {
base: parseInt(rawOptions.base, 10),
};
}
}
return retryOptions;
}
}
@@ -0,0 +1 @@
{"version":"21.0.0"}
@@ -0,0 +1,13 @@
{
"version": "21.0.0",
"testCases": {
"DependsOnTest/DefaultTest": {
"stacks": [
"replace-depends-on-test",
"nested-stack-depends-test"
],
"assertionStack": "DependsOnTest/DefaultTest/DeployAssert",
"assertionStackName": "DependsOnTestDefaultTestDeployAssert3B3B50E2"
}
}
}

0 comments on commit 574ef24

Please sign in to comment.