diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 1bd9e491e34e4..ac399df3008df 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -364,6 +364,7 @@ Hotswapping is currently supported for the following changes - Code asset changes of AWS Lambda functions. - Definition changes of AWS Step Functions State Machines. - Container asset changes of AWS ECS Services. +- Website asset changes of AWS S3 Bucket Deployments. **⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments. For this reason, only use it for development purposes. diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index 0d8a4165f9401..b40ba030588ac 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -7,6 +7,7 @@ import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, Hotswappabl import { isHotswappableEcsServiceChange } from './hotswap/ecs-services'; import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template'; import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions'; +import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments'; import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines'; import { CloudFormationStack } from './util/cloudformation'; @@ -73,6 +74,7 @@ async function findAllHotswappableChanges( isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), + isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), ]); } }); diff --git a/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts index 5dc9f948a4250..011a9b28fc394 100644 --- a/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts @@ -50,6 +50,11 @@ export class EvaluateCloudFormationTemplate { return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId; } + public async findLogicalIdForPhysicalName(physicalName: string): Promise { + const stackResources = await this.stackResources.listStackResources(); + return stackResources.find(sr => sr.PhysicalResourceId === physicalName)?.LogicalResourceId; + } + public findReferencesTo(logicalId: string): Array { const ret = new Array(); for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) { diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index c45bb432ac65d..9a02fc2ba2d2f 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -3,8 +3,8 @@ import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, Hotswap import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; /** - * Returns `false` if the change cannot be short-circuited, - * `true` if the change is irrelevant from a short-circuit perspective + * Returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if the change cannot be short-circuited, + * `ChangeHotswapImpact.IRRELEVANT` if the change is irrelevant from a short-circuit perspective * (like a change to CDKMetadata), * or a LambdaFunctionResource if the change can be short-circuited. */ diff --git a/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts b/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts new file mode 100644 index 0000000000000..49ba4ce129759 --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts @@ -0,0 +1,122 @@ +import { ISDK } from '../aws-auth'; +import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate/*, establishResourcePhysicalName*/ } from './common'; +import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; + +/** + * This means that the value is required to exist by CloudFormation's API (or our S3 Bucket Deployment Lambda) + * but the actual value specified is irrelevant + */ +export const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn'; + +export async function isHotswappableS3BucketDeploymentChange( + logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + // In old-style synthesis, the policy used by the lambda to copy assets Ref's the assets directly, + // meaning that the changes made to the Policy are artifacts that can be safely ignored + if (change.newValue.Type === 'AWS::IAM::Policy') { + return changeIsForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate); + } + + if (change.newValue.Type !== 'Custom::CDKBucketDeployment') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + // note that this gives the ARN of the lambda, not the name. This is fine though, the invoke() sdk call will take either + const functionName = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue.Properties?.ServiceToken); + if (!functionName) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const customResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression({ + ...change.newValue.Properties, + ServiceToken: undefined, + }); + + return new S3BucketDeploymentHotswapOperation(functionName, customResourceProperties); +} + +class S3BucketDeploymentHotswapOperation implements HotswapOperation { + public readonly service = 'custom-s3-deployment'; + + constructor(private readonly functionName: string, private readonly customResourceProperties: any) { + } + + public async apply(sdk: ISDK): Promise { + return sdk.lambda().invoke({ + FunctionName: this.functionName, + // Lambda refuses to take a direct JSON object and requires it to be stringify()'d + Payload: JSON.stringify({ + RequestType: 'Update', + ResponseURL: REQUIRED_BY_CFN, + PhysicalResourceId: REQUIRED_BY_CFN, + StackId: REQUIRED_BY_CFN, + RequestId: REQUIRED_BY_CFN, + LogicalResourceId: REQUIRED_BY_CFN, + ResourceProperties: stringifyObject(this.customResourceProperties), // JSON.stringify() doesn't turn the actual objects to strings, but the lambda expects strings + }), + }).promise(); + } +} + +async function changeIsForS3DeployCustomResourcePolicy( + iamPolicyLogicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const roles = change.newValue.Properties?.Roles; + if (!roles) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + for (const role of roles) { + const roleLogicalId = await evaluateCfnTemplate.findLogicalIdForPhysicalName(await evaluateCfnTemplate.evaluateCfnExpression(role)); + if (!roleLogicalId) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const roleRefs = evaluateCfnTemplate.findReferencesTo(roleLogicalId); + for (const roleRef of roleRefs) { + if (roleRef.Type === 'AWS::Lambda::Function') { + const lambdaRefs = evaluateCfnTemplate.findReferencesTo(roleRef.LogicalId); + for (const lambdaRef of lambdaRefs) { + // If S3Deployment -> Lambda -> Role and IAM::Policy -> Role, then this IAM::Policy change is an + // artifact of old-style synthesis + if (lambdaRef.Type !== 'Custom::CDKBucketDeployment') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } + } else if (roleRef.Type === 'AWS::IAM::Policy') { + if (roleRef.LogicalId !== iamPolicyLogicalId) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } else { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } + } + + return new EmptyHotswapOperation(); +} + +function stringifyObject(obj: any): any { + if (obj == null) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(stringifyObject); + } + if (typeof obj !== 'object') { + return obj.toString(); + } + + const ret: { [k: string]: any } = {}; + for (const [k, v] of Object.entries(obj)) { + ret[k] = stringifyObject(v); + } + return ret; +} + +class EmptyHotswapOperation implements HotswapOperation { + readonly service = 'empty'; + public async apply(sdk: ISDK): Promise { + return Promise.resolve(sdk); + } +} diff --git a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts index f96b419b94b20..056e4728ff354 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -42,7 +42,8 @@ export function pushStackResourceSummaries(...items: CloudFormation.StackResourc } export function setCurrentCfnStackTemplate(template: Template) { - currentCfnStack.setTemplate(template); + const templateDeepCopy = JSON.parse(JSON.stringify(template)); // deep copy the template, so our tests can mutate one template instead of creating two + currentCfnStack.setTemplate(templateDeepCopy); } export function stackSummaryOf(logicalId: string, resourceType: string, physicalResourceId: string): CloudFormation.StackResourceSummary { @@ -87,6 +88,12 @@ export class HotswapMockSdkProvider { }); } + public setInvokeLambdaMock(mockInvokeLambda: (input: lambda.InvocationRequest) => lambda.InvocationResponse) { + this.mockSdkProvider.stubLambda({ + invoke: mockInvokeLambda, + }); + } + public stubEcs(stubs: SyncHandlerSubsetOf, additionalProperties: { [key: string]: any } = {}): void { this.mockSdkProvider.stubEcs(stubs, additionalProperties); } diff --git a/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts new file mode 100644 index 0000000000000..3a015308fcadc --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts @@ -0,0 +1,738 @@ +import { Lambda } from 'aws-sdk'; +import { REQUIRED_BY_CFN } from '../../../lib/api/hotswap/s3-bucket-deployments'; +import * as setup from './hotswap-test-setup'; + +let mockLambdaInvoke: (params: Lambda.Types.InvocationRequest) => Lambda.Types.InvocationResponse; +let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; + +const payloadWithoutCustomResProps = { + RequestType: 'Update', + ResponseURL: REQUIRED_BY_CFN, + PhysicalResourceId: REQUIRED_BY_CFN, + StackId: REQUIRED_BY_CFN, + RequestId: REQUIRED_BY_CFN, + LogicalResourceId: REQUIRED_BY_CFN, +}; + +beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + mockLambdaInvoke = jest.fn(); + hotswapMockSdkProvider.setInvokeLambdaMock(mockLambdaInvoke); +}); + +test('calls the lambdaInvoke() API when it receives only an asset difference in an S3 bucket deployment and evaluates CFN expressions in S3 Deployment Properties', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: 'a-lambda-arn', + SourceBucketNames: ['src-bucket'], + SourceObjectKeys: ['src-key-old'], + DestinationBucketName: 'dest-bucket', + DestinationBucketKeyPrefix: 'my-key/some-old-prefix', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: 'a-lambda-arn', + SourceBucketNames: ['src-bucket'], + SourceObjectKeys: { + 'Fn::Split': [ + '-', + 'key1-key2-key3', + ], + }, + DestinationBucketName: 'dest-bucket', + DestinationBucketKeyPrefix: 'my-key/some-new-prefix', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + + expect(mockLambdaInvoke).toHaveBeenCalledWith({ + FunctionName: 'a-lambda-arn', + Payload: JSON.stringify({ + ...payloadWithoutCustomResProps, + ResourceProperties: { + SourceBucketNames: ['src-bucket'], + SourceObjectKeys: ['key1', 'key2', 'key3'], + DestinationBucketName: 'dest-bucket', + DestinationBucketKeyPrefix: 'my-key/some-new-prefix', + }, + }), + }); +}); + +test('does not call the invoke() API when a resource with type that is not Custom::CDKBucketDeployment but has the same properties is changed', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + S3Deployment: { + Type: 'Custom::NotCDKBucketDeployment', + Properties: { + SourceObjectKeys: ['src-key-old'], + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + S3Deployment: { + Type: 'Custom::NotCDKBucketDeployment', + Properties: { + SourceObjectKeys: ['src-key-new'], + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); +}); + +test('does not call the invokeLambda() api if the updated Policy has no Roles', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Parameters: { + WebsiteBucketParamOld: { Type: 'String' }, + WebsiteBucketParamNew: { Type: 'String' }, + }, + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: 'a-lambda-arn', + SourceObjectKeys: ['src-key-old'], + }, + }, + Policy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy', + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'WebsiteBucketParamOld', + }, + }, + ], + }, + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Parameters: { + WebsiteBucketParamOld: { Type: 'String' }, + WebsiteBucketParamNew: { Type: 'String' }, + }, + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: 'a-lambda-arn', + SourceObjectKeys: ['src-key-new'], + }, + }, + Policy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy', + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'WebsiteBucketParamNew', + }, + }, + ], + }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); +}); + +test('throws an error when the serviceToken fails evaluation in the template', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + Ref: 'BadLamba', + }, + SourceBucketNames: ['src-bucket'], + SourceObjectKeys: ['src-key-old'], + DestinationBucketName: 'dest-bucket', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + S3Deployment: { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + Ref: 'BadLamba', + }, + SourceBucketNames: ['src-bucket'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'dest-bucket', + }, + }, + }, + }, + }); + + // WHEN + await expect(() => + hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact), + ).rejects.toThrow(/Parameter or resource 'BadLamba' could not be found for evaluation/); + + expect(mockLambdaInvoke).not.toHaveBeenCalled(); +}); + +describe('old-style synthesis', () => { + const parameters = { + WebsiteBucketParamOld: { Type: 'String' }, + WebsiteBucketParamNew: { Type: 'String' }, + DifferentBucketParamNew: { Type: 'String' }, + }; + + const serviceRole = { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'lambda.amazonaws.com', + }, + }, + ], + Version: '2012-10-17', + }, + }, + }; + + const policyOld = { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy-old', + Roles: [ + { Ref: 'ServiceRole' }, + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'WebsiteBucketParamOld', + }, + }, + ], + }, + }, + }; + + const policyNew = { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy-new', + Roles: [ + { Ref: 'ServiceRole' }, + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'WebsiteBucketParamNew', + }, + }, + ], + }, + }, + }; + + const policy2Old = { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy-old-2', + Roles: [ + { Ref: 'ServiceRole' }, + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'WebsiteBucketParamOld', + }, + }, + ], + }, + }, + }; + + const policy2New = { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'my-policy-new-2', + Roles: [ + { Ref: 'ServiceRole2' }, + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + Ref: 'DifferentBucketParamOld', + }, + }, + ], + }, + }, + }; + + const deploymentLambda = { + Type: 'AWS::Lambda::Function', + Role: { + 'Fn::GetAtt': [ + 'ServiceRole', + 'Arn', + ], + }, + }; + + const s3DeploymentOld = { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda', + 'Arn', + ], + }, + SourceBucketNames: ['src-bucket-old'], + SourceObjectKeys: ['src-key-old'], + DestinationBucketName: 'WebsiteBucketOld', + }, + }; + + const s3DeploymentNew = { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda', + 'Arn', + ], + }, + SourceBucketNames: ['src-bucket-new'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'WebsiteBucketNew', + }, + }; + + beforeEach(() => { + setup.pushStackResourceSummaries( + setup.stackSummaryOf('S3DeploymentLambda', 'AWS::Lambda::Function', 'my-deployment-lambda'), + setup.stackSummaryOf('ServiceRole', 'AWS::IAM::Role', 'my-service-role'), + ); + }); + + test('calls the lambdaInvoke() API when it receives an asset difference in an S3 bucket deployment and an IAM Policy difference using old-style synthesis', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Parameters: parameters, + ServiceRole: serviceRole, + Policy: policyOld, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentOld, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Parameters: parameters, + ServiceRole: serviceRole, + Policy: policyNew, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentNew, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact, { WebsiteBucketParamOld: 'WebsiteBucketOld', WebsiteBucketParamNew: 'WebsiteBucketNew' }); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockLambdaInvoke).toHaveBeenCalledWith({ + FunctionName: 'arn:aws:lambda:here:123456789012:function:my-deployment-lambda', + Payload: JSON.stringify({ + ...payloadWithoutCustomResProps, + ResourceProperties: { + SourceBucketNames: ['src-bucket-new'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'WebsiteBucketNew', + }, + }), + }); + }); + + test('does not call the lambdaInvoke() API when the difference in the S3 deployment is referred to in one IAM policy change but not another', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + ServiceRole: serviceRole, + Policy1: policyOld, + Policy2: policy2Old, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentOld, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + ServiceRole: serviceRole, + Policy1: policyNew, + Policy2: { + Properties: { + Roles: [ + { Ref: 'ServiceRole' }, + 'different-role', + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + 'Fn::GetAtt': [ + 'DifferentBucketNew', + 'Arn', + ], + }, + }, + ], + }, + }, + }, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentNew, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); + }); + + test('does not call the lambdaInvoke() API when the lambda that references the role is referred to by something other than an S3 deployment', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + ServiceRole: serviceRole, + Policy: policyOld, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentOld, + Endpoint: { + Type: 'AWS::Lambda::Permission', + Properties: { + Action: 'lambda:InvokeFunction', + FunctionName: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda', + 'Arn', + ], + }, + Principal: 'apigateway.amazonaws.com', + }, + }, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + ServiceRole: serviceRole, + Policy: policyNew, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentNew, + Endpoint: { + Type: 'AWS::Lambda::Permission', + Properties: { + Action: 'lambda:InvokeFunction', + FunctionName: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda', + 'Arn', + ], + }, + Principal: 'apigateway.amazonaws.com', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); + }); + + test('calls the lambdaInvoke() API when it receives an asset difference in two S3 bucket deployments and IAM Policy differences using old-style synthesis', async () => { + // GIVEN + const s3Deployment2Old = { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda2', + 'Arn', + ], + }, + SourceBucketNames: ['src-bucket-old'], + SourceObjectKeys: ['src-key-old'], + DestinationBucketName: 'DifferentBucketOld', + }, + }; + + const s3Deployment2New = { + Type: 'Custom::CDKBucketDeployment', + Properties: { + ServiceToken: { + 'Fn::GetAtt': [ + 'S3DeploymentLambda2', + 'Arn', + ], + }, + SourceBucketNames: ['src-bucket-new'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'DifferentBucketNew', + }, + }; + + setup.setCurrentCfnStackTemplate({ + Resources: { + ServiceRole: serviceRole, + ServiceRole2: serviceRole, + Policy1: policyOld, + Policy2: policy2Old, + S3DeploymentLambda: deploymentLambda, + S3DeploymentLambda2: deploymentLambda, + S3Deployment: s3DeploymentOld, + S3Deployment2: s3Deployment2Old, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Parameters: parameters, + ServiceRole: serviceRole, + ServiceRole2: serviceRole, + Policy1: policyNew, + Policy2: policy2New, + S3DeploymentLambda: deploymentLambda, + S3DeploymentLambda2: deploymentLambda, + S3Deployment: s3DeploymentNew, + S3Deployment2: s3Deployment2New, + }, + }, + }); + + // WHEN + setup.pushStackResourceSummaries( + setup.stackSummaryOf('S3DeploymentLambda2', 'AWS::Lambda::Function', 'my-deployment-lambda-2'), + setup.stackSummaryOf('ServiceRole2', 'AWS::IAM::Role', 'my-service-role-2'), + ); + + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact, { + WebsiteBucketParamOld: 'WebsiteBucketOld', + WebsiteBucketParamNew: 'WebsiteBucketNew', + DifferentBucketParamNew: 'WebsiteBucketNew', + }); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockLambdaInvoke).toHaveBeenCalledWith({ + FunctionName: 'arn:aws:lambda:here:123456789012:function:my-deployment-lambda', + Payload: JSON.stringify({ + ...payloadWithoutCustomResProps, + ResourceProperties: { + SourceBucketNames: ['src-bucket-new'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'WebsiteBucketNew', + }, + }), + }); + + expect(mockLambdaInvoke).toHaveBeenCalledWith({ + FunctionName: 'arn:aws:lambda:here:123456789012:function:my-deployment-lambda-2', + Payload: JSON.stringify({ + ...payloadWithoutCustomResProps, + ResourceProperties: { + SourceBucketNames: ['src-bucket-new'], + SourceObjectKeys: ['src-key-new'], + DestinationBucketName: 'DifferentBucketNew', + }, + }), + }); + }); + + test('does not call the lambdaInvoke() API when it receives an asset difference in an S3 bucket deployment that references two different policies', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + ServiceRole: serviceRole, + Policy1: policyOld, + Policy2: policy2Old, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentOld, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + ServiceRole: serviceRole, + Policy1: policyNew, + Policy2: { + Properties: { + Roles: [ + { Ref: 'ServiceRole' }, + ], + PolicyDocument: { + Statement: [ + { + Action: ['s3:GetObject*'], + Effect: 'Allow', + Resource: { + 'Fn::GetAtt': [ + 'DifferentBucketNew', + 'Arn', + ], + }, + }, + ], + }, + }, + }, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentNew, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); + }); + + test('does not call the lambdaInvoke() API when a policy is referenced by a resource that is not an S3 deployment', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + ServiceRole: serviceRole, + Policy1: policyOld, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentOld, + NotADeployment: { + Type: 'AWS::Not::S3Deployment', + Properties: { + Prop: { + Ref: 'ServiceRole', + }, + }, + }, + }, + }); + + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + ServiceRole: serviceRole, + Policy1: policyNew, + S3DeploymentLambda: deploymentLambda, + S3Deployment: s3DeploymentNew, + NotADeployment: { + Type: 'AWS::Not::S3Deployment', + Properties: { + Prop: { + Ref: 'ServiceRole', + }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockLambdaInvoke).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file