Skip to content

Commit

Permalink
fix(core): DefaultSynthesizer deployments are never skipped (#17099)
Browse files Browse the repository at this point in the history
The `DefaultSynthesizer` always adds an SSM parameter with the bootstrap
stack version, so that it can check that the bootstrap stack has
a recent enough version.

Unfortunately, the CDK CLI will refuse to short-circuit any deployment
that uses SSM parameters, because it can't tell if the parameter has
changed, and so it has to pessimize.

Adding a magic marker to the description of the parameter will now
exempt it from the check.

Fixes #16959.


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
rix0rrr authored Oct 22, 2021
1 parent a0d4ee7 commit c74b012
Show file tree
Hide file tree
Showing 13 changed files with 61 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ function addBootstrapVersionRule(stack: Stack, requiredVersion: number, bootstra

const param = new CfnParameter(stack, 'BootstrapVersion', {
type: 'AWS::SSM::Parameter::Value<String>',
description: 'Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store.',
description: `Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. ${cxapi.SSMPARAM_NO_INVALIDATE}`,
default: bootstrapStackVersionSsmParameter,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('new style synthesis', () => {
const template = app.synth().getStackByName('Stack').template;
expect(template?.Parameters?.BootstrapVersion?.Type).toEqual('AWS::SSM::Parameter::Value<String>');
expect(template?.Parameters?.BootstrapVersion?.Default).toEqual('/cdk-bootstrap/hnb659fds/version');
expect(template?.Parameters?.BootstrapVersion?.Description).toContain(cxapi.SSMPARAM_NO_INVALIDATE);

const assertions = template?.Rules?.CheckBootstrapVersion?.Assertions ?? [];
expect(assertions.length).toEqual(1);
Expand Down
11 changes: 10 additions & 1 deletion packages/@aws-cdk/cx-api/lib/cxapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@ export const CLI_VERSION_ENV = 'CDK_CLI_VERSION';
/**
* If a context value is an object with this key, it indicates an error
*/
export const PROVIDER_ERROR_KEY = '$providerError';
export const PROVIDER_ERROR_KEY = '$providerError';


/**
* This SSM parameter does not invalidate the template
*
* If this string occurs in the description of an SSM parameter, the CLI
* will not assume that the stack must always be redeployed.
*/
export const SSMPARAM_NO_INVALIDATE = '[cdk:skip]';
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('templateUrl', () => {
const sd = StageDeployment.fromStage(stage);

// THEN
expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-us-east-1.s3.us-east-1.amazonaws.com/4ef627170a212f66f5d1d9240d967ef306f4820ff9cb05b3a7ec703df6af6c3e.json');
expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-us-east-1.s3.us-east-1.amazonaws.com/93ae4de94f81d0905c37db64b7304f5d65233ca4d9581d3a32215743c9bb92dd.json');
});

test('without region', () => {
Expand All @@ -43,7 +43,7 @@ describe('templateUrl', () => {
const sd = StageDeployment.fromStage(stage);

// THEN
expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-.s3.amazonaws.com/$%7BAWS::Region%7D/4ef627170a212f66f5d1d9240d967ef306f4820ff9cb05b3a7ec703df6af6c3e.json');
expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-.s3.amazonaws.com/$%7BAWS::Region%7D/93ae4de94f81d0905c37db64b7304f5d65233ca4d9581d3a32215743c9bb92dd.json');
});

});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2368,7 +2368,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2303,7 +2303,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2366,7 +2366,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1519,7 +1519,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1576,7 +1576,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@
"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."
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
Expand Down
10 changes: 7 additions & 3 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { CfnEvaluationException } from './hotswap/evaluate-cloudformation-templa
import { ToolkitInfo } from './toolkit-info';
import {
changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet,
waitForStackDeploy, waitForStackDelete, ParameterValues,
waitForStackDeploy, waitForStackDelete, ParameterValues, ParameterChanges,
} from './util/cloudformation';
import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor';

Expand Down Expand Up @@ -466,7 +466,7 @@ export async function destroyStack(options: DestroyStackOptions) {
async function canSkipDeploy(
deployStackOptions: DeployStackOptions,
cloudFormationStack: CloudFormationStack,
parameterChanges: boolean): Promise<boolean> {
parameterChanges: ParameterChanges): Promise<boolean> {

const deployName = deployStackOptions.deployName || deployStackOptions.stack.stackName;
debug(`${deployName}: checking if we can skip deploy`);
Expand Down Expand Up @@ -509,7 +509,11 @@ async function canSkipDeploy(

// Parameters have changed
if (parameterChanges) {
debug(`${deployName}: parameters have changed`);
if (parameterChanges === 'ssm') {
debug(`${deployName}: some parameters come from SSM so we have to assume they may have changed`);
} else {
debug(`${deployName}: parameters have changed`);
}
return false;
}

Expand Down
13 changes: 9 additions & 4 deletions packages/aws-cdk/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SSMPARAM_NO_INVALIDATE } from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import { debug } from '../../logging';
import { deserializeStructure } from '../../serialize';
Expand All @@ -11,6 +12,7 @@ export type Template = {
interface TemplateParameter {
Type: string;
Default?: any;
Description?: string;
[key: string]: any;
}

Expand Down Expand Up @@ -424,11 +426,12 @@ export class ParameterValues {
/**
* Whether this set of parameter updates will change the actual stack values
*/
public hasChanges(currentValues: Record<string, string>): boolean {
public hasChanges(currentValues: Record<string, string>): ParameterChanges {
// If any of the parameters are SSM parameters, deploying must always happen
// because we can't predict what the values will be.
if (Object.values(this.formalParams).some(p => p.Type.startsWith('AWS::SSM::Parameter::'))) {
return true;
// because we can't predict what the values will be. We will allow some
// parameters to opt out of this check by having a magic string in their description.
if (Object.values(this.formalParams).some(p => p.Type.startsWith('AWS::SSM::Parameter::') && !p.Description?.includes(SSMPARAM_NO_INVALIDATE))) {
return 'ssm';
}

// Otherwise we're dirty if:
Expand All @@ -445,3 +448,5 @@ export class ParameterValues {
return false;
}
}

export type ParameterChanges = boolean | 'ssm';
27 changes: 25 additions & 2 deletions packages/aws-cdk/test/util/cloudformation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SSMPARAM_NO_INVALIDATE } from '@aws-cdk/cx-api';
import { CloudFormationStack, TemplateParameters } from '../../lib/api/util/cloudformation';
import { MockedObject, MockSdkProvider, SyncHandlerSubsetOf } from './mock-sdk';

Expand Down Expand Up @@ -81,10 +82,32 @@ test('if a parameter is retrieved from SSM, the parameters always count as chang
const oldValues = { Foo: '/Some/Key' };

// If we don't pass a new value
expect(params.updateExisting({}, oldValues).hasChanges(oldValues)).toEqual(true);
expect(params.updateExisting({}, oldValues).hasChanges(oldValues)).toEqual('ssm');

// If we do pass a new value but it's the same as the old one
expect(params.updateExisting({ Foo: '/Some/Key' }, oldValues).hasChanges(oldValues)).toEqual(true);
expect(params.updateExisting({ Foo: '/Some/Key' }, oldValues).hasChanges(oldValues)).toEqual('ssm');
});

test('if a parameter is retrieved from SSM, the parameters doesnt count as changed if it has the magic marker', () => {
const params = TemplateParameters.fromTemplate({
Parameters: {
Foo: {
Type: 'AWS::SSM::Parameter::Name',
Default: '/Some/Key',
Description: `blabla ${SSMPARAM_NO_INVALIDATE}`,
},
},
});
const oldValues = { Foo: '/Some/Key' };

// If we don't pass a new value
expect(params.updateExisting({}, oldValues).hasChanges(oldValues)).toEqual(false);

// If we do pass a new value but it's the same as the old one
expect(params.updateExisting({ Foo: '/Some/Key' }, oldValues).hasChanges(oldValues)).toEqual(false);

// If we do pass a new value and it's different
expect(params.updateExisting({ Foo: '/OTHER/Key' }, oldValues).hasChanges(oldValues)).toEqual(true);
});

test('empty string is a valid update value', () => {
Expand Down

0 comments on commit c74b012

Please sign in to comment.