From bae655419c2f0805c4fa3ea7ef20704539bbb44c Mon Sep 17 00:00:00 2001 From: Cory Hall <43035978+corymhall@users.noreply.github.com> Date: Sun, 2 Oct 2022 02:39:36 -0400 Subject: [PATCH] fix(certificatemanager): unable to set removal policy on DnsValidatedCertificate (#22122) This PR adds a method override for applyRemovalPolicy which allows the user to specify a removal policy for the DnsValidatedCertificate construct. Since this construct is backed by a custom resource, the lambda handler was updated to no longer delete the certificate if the RemovalPolicy is set to retain. This is also needed to allow for an easier migration from DnsValidatedCertificate -> Certificate reroll of #22040 This has the same changes as #22040 with the addition of some logic to handle only processing updates for certain parameters. If `RemovalPolicy` is changed for example, the update will not be processed. I also added an integration test with some manual instructions. In order to test ACM certificates I also updated the integ-runner to handle some additional special env variables. fixes #20649, fixes #14519 ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] 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* --- .../lib/index.js | 88 +++- .../test/handler.test.js | 237 ++++++++++ .../lib/dns-validated-certificate.ts | 6 + .../aws-certificatemanager/package.json | 2 + .../index.js | 437 ++++++++++++++++++ .../cdk.out | 1 + ...nteg-dns-validated-certificate.assets.json | 32 ++ ...eg-dns-validated-certificate.template.json | 188 ++++++++ .../integ.json | 14 + ...efaultTestDeployAssert24D5C536.assets.json | 19 + ...aultTestDeployAssert24D5C536.template.json | 36 ++ .../manifest.json | 135 ++++++ .../tree.json | 285 ++++++++++++ .../test/dns-validated-certificate.test.ts | 36 +- .../test/integ.dns-validated-certificate.ts | 50 ++ .../integ-runner/lib/runner/runner-base.ts | 3 + 16 files changed, 1543 insertions(+), 26 deletions(-) create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/asset.ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672/index.js create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/cdk.out create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.assets.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.template.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/manifest.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/tree.json create mode 100644 packages/@aws-cdk/aws-certificatemanager/test/integ.dns-validated-certificate.ts diff --git a/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/lib/index.js b/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/lib/index.js index 48261e12d82e5..3794bfcee0769 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/lib/index.js +++ b/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/lib/index.js @@ -65,6 +65,23 @@ let report = function (event, context, responseStatus, physicalResourceId, respo }); }; +/** + * Adds tags to an existing certificate + * + * @param {string} certificateArn the ARN of the certificate to add tags to + * @param {string} region the region the certificate exists in + * @param {map} tags Tags to add to the requested certificate + */ +const addTags = async function(certificateArn, region, tags) { + const result = Array.from(Object.entries(tags)).map(([Key, Value]) => ({ Key, Value })) + const acm = new aws.ACM({ region }); + + await acm.addTagsToCertificate({ + CertificateArn: certificateArn, + Tags: result, + }).promise(); +} + /** * Requests a public certificate from AWS Certificate Manager, using DNS validation. * The hosted zone ID must refer to a **public** Route53-managed DNS zone that is authoritative @@ -75,10 +92,9 @@ let report = function (event, context, responseStatus, physicalResourceId, respo * @param {string} requestId the CloudFormation request ID * @param {string} domainName the Common Name (CN) field for the requested certificate * @param {string} hostedZoneId the Route53 Hosted Zone ID - * @param {map} tags Tags to add to the requested certificate * @returns {string} Validated certificate ARN */ -const requestCertificate = async function (requestId, domainName, subjectAlternativeNames, certificateTransparencyLoggingPreference, hostedZoneId, region, route53Endpoint, tags) { +const requestCertificate = async function (requestId, domainName, subjectAlternativeNames, certificateTransparencyLoggingPreference, hostedZoneId, region, route53Endpoint) { const crypto = require('crypto'); const acm = new aws.ACM({ region }); const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53(); @@ -101,16 +117,6 @@ const requestCertificate = async function (requestId, domainName, subjectAlterna console.log(`Certificate ARN: ${reqCertResponse.CertificateArn}`); - - if (!!tags) { - const result = Array.from(Object.entries(tags)).map(([Key, Value]) => ({ Key, Value })) - - await acm.addTagsToCertificate({ - CertificateArn: reqCertResponse.CertificateArn, - Tags: result, - }).promise(); - } - console.log('Waiting for ACM to provide DNS records for validation...'); let records = []; @@ -275,6 +281,25 @@ async function commitRoute53Records(route53, records, hostedZoneId, action = 'UP }).promise(); } +/** + * Determines whether an update request should request a new certificate + * + * @param {map} oldParams the previously process request parameters + * @param {map} newParams the current process request parameters + * @param {string} physicalResourceId the physicalResourceId + * @returns {boolean} whether or not to request a new certificate + */ +function shouldUpdate(oldParams, newParams, physicalResourceId) { + if (!oldParams) return true; + if (oldParams.DomainName !== newParams.DomainName) return true; + if (oldParams.SubjectAlternativeNames !== newParams.SubjectAlternativeNames) return true; + if (oldParams.CertificateTransparencyLoggingPreference !== newParams.CertificateTransparencyLoggingPreference) return true; + if (oldParams.HostedZoneId !== newParams.HostedZoneId) return true; + if (oldParams.Region !== newParams.Region) return true; + if (!physicalResourceId || !physicalResourceId.startsWith('arn:')) return true; + return false; +} + /** * Main handler, invoked by Lambda */ @@ -282,28 +307,43 @@ exports.certificateRequestHandler = async function (event, context) { var responseData = {}; var physicalResourceId; var certificateArn; + async function processRequest() { + certificateArn = await requestCertificate( + event.RequestId, + event.ResourceProperties.DomainName, + event.ResourceProperties.SubjectAlternativeNames, + event.ResourceProperties.CertificateTransparencyLoggingPreference, + event.ResourceProperties.HostedZoneId, + event.ResourceProperties.Region, + event.ResourceProperties.Route53Endpoint, + ); + responseData.Arn = physicalResourceId = certificateArn; + } try { switch (event.RequestType) { case 'Create': + await processRequest(); + if (event.ResourceProperties.Tags && physicalResourceId.startsWith('arn:')) { + await addTags(physicalResourceId, event.ResourceProperties.Region, event.ResourceProperties.Tags); + } + break; case 'Update': - certificateArn = await requestCertificate( - event.RequestId, - event.ResourceProperties.DomainName, - event.ResourceProperties.SubjectAlternativeNames, - event.ResourceProperties.CertificateTransparencyLoggingPreference, - event.ResourceProperties.HostedZoneId, - event.ResourceProperties.Region, - event.ResourceProperties.Route53Endpoint, - event.ResourceProperties.Tags, - ); - responseData.Arn = physicalResourceId = certificateArn; + if (shouldUpdate(event.OldResourceProperties, event.ResourceProperties, event.PhysicalResourceId)) { + await processRequest(); + } else { + responseData.Arn = physicalResourceId = event.PhysicalResourceId; + } + if (event.ResourceProperties.Tags && physicalResourceId.startsWith('arn:')) { + await addTags(physicalResourceId, event.ResourceProperties.Region, event.ResourceProperties.Tags); + } break; case 'Delete': physicalResourceId = event.PhysicalResourceId; + const removalPolicy = event.ResourceProperties.RemovalPolicy ?? 'destroy'; // If the resource didn't create correctly, the physical resource ID won't be the // certificate ARN, so don't try to delete it in that case. - if (physicalResourceId.startsWith('arn:')) { + if (physicalResourceId.startsWith('arn:') && removalPolicy === 'destroy') { await deleteCertificate( physicalResourceId, event.ResourceProperties.Region, diff --git a/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/test/handler.test.js b/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/test/handler.test.js index 37697f69b6e2e..be4f4fb20ba21 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/test/handler.test.js +++ b/packages/@aws-cdk/aws-certificatemanager/lambda-packages/dns_validated_certificate_handler/test/handler.test.js @@ -869,6 +869,243 @@ describe('DNS Validated Certificate Handler', () => { }); }); + test('Update operation requests a certificate', () => { + const requestCertificateFake = sinon.fake.resolves({ + CertificateArn: testCertificateArn, + }); + + const describeCertificateFake = sinon.stub(); + describeCertificateFake.onFirstCall().resolves({ + Certificate: { + CertificateArn: testCertificateArn + } + }); + describeCertificateFake.resolves({ + Certificate: { + CertificateArn: testCertificateArn, + DomainValidationOptions: [{ + ValidationStatus: 'SUCCESS', + ResourceRecord: { + Name: testRRName, + Type: 'CNAME', + Value: testRRValue + } + }] + } + }); + + const addTagsToCertificateFake = sinon.fake.resolves({}); + + const changeResourceRecordSetsFake = sinon.fake.resolves({ + ChangeInfo: { + Id: 'bogus' + } + }); + + AWS.mock('ACM', 'requestCertificate', requestCertificateFake); + AWS.mock('ACM', 'describeCertificate', describeCertificateFake); + AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake); + AWS.mock('ACM', 'addTagsToCertificate', addTagsToCertificateFake); + + const request = nock(ResponseURL).put('/', body => { + return body.Status === 'SUCCESS'; + }).reply(200); + + return LambdaTester(handler.certificateRequestHandler) + .event({ + RequestType: 'Update', + RequestId: testRequestId, + OldResourceProperties: { + DomainName: 'example.com', + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: testTags + }, + ResourceProperties: { + DomainName: testDomainName, + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: testTags + } + }) + .expectResolve(() => { + sinon.assert.calledWith(requestCertificateFake, sinon.match({ + DomainName: testDomainName, + ValidationMethod: 'DNS', + Options: { + CertificateTransparencyLoggingPreference: undefined + } + })); + sinon.assert.calledWith(changeResourceRecordSetsFake, sinon.match({ + ChangeBatch: { + Changes: [{ + Action: 'UPSERT', + ResourceRecordSet: { + Name: testRRName, + Type: 'CNAME', + TTL: 60, + ResourceRecords: [{ + Value: testRRValue + }] + } + }] + }, + HostedZoneId: testHostedZoneId + })); + sinon.assert.calledWith(addTagsToCertificateFake, sinon.match({ + "CertificateArn": testCertificateArn, + "Tags": testTagsValue, + })); + expect(request.isDone()).toBe(true); + }); + }); + + test('Update operation updates tags only', () => { + const requestCertificateFake = sinon.fake.resolves({ + CertificateArn: testCertificateArn, + }); + + const describeCertificateFake = sinon.stub(); + describeCertificateFake.onFirstCall().resolves({ + Certificate: { + CertificateArn: testCertificateArn + } + }); + describeCertificateFake.resolves({ + Certificate: { + CertificateArn: testCertificateArn, + DomainValidationOptions: [{ + ValidationStatus: 'SUCCESS', + ResourceRecord: { + Name: testRRName, + Type: 'CNAME', + Value: testRRValue + } + }] + } + }); + + const addTagsToCertificateFake = sinon.fake.resolves({}); + + const changeResourceRecordSetsFake = sinon.fake.resolves({ + ChangeInfo: { + Id: 'bogus' + } + }); + + AWS.mock('ACM', 'requestCertificate', requestCertificateFake); + AWS.mock('ACM', 'describeCertificate', describeCertificateFake); + AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake); + AWS.mock('ACM', 'addTagsToCertificate', addTagsToCertificateFake); + + const request = nock(ResponseURL).put('/', body => { + return body.Status === 'SUCCESS'; + }).reply(200); + + return LambdaTester(handler.certificateRequestHandler) + .event({ + RequestType: 'Update', + RequestId: testRequestId, + PhysicalResourceId: testCertificateArn, + OldResourceProperties: { + DomainName: testDomainName, + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: testTags, + }, + ResourceProperties: { + DomainName: testDomainName, + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: { + ...testTags, + Tag4: 'Value4', + }, + } + }) + .expectResolve(() => { + sinon.assert.notCalled(requestCertificateFake); + sinon.assert.notCalled(changeResourceRecordSetsFake); + sinon.assert.calledWith(addTagsToCertificateFake, sinon.match({ + "CertificateArn": testCertificateArn, + "Tags": [{ Key: 'Tag1', Value: 'Test1' }, { Key: 'Tag2', Value: 'Test2' }, { Key: 'Tag4', Value: 'Value4' }], + })); + expect(request.isDone()).toBe(true); + }); + }); + + test('Update operation does not request certificate if removal policy is changed', () => { + const requestCertificateFake = sinon.fake.resolves({ + CertificateArn: testCertificateArn, + }); + + const describeCertificateFake = sinon.stub(); + describeCertificateFake.onFirstCall().resolves({ + Certificate: { + CertificateArn: testCertificateArn + } + }); + describeCertificateFake.resolves({ + Certificate: { + CertificateArn: testCertificateArn, + DomainValidationOptions: [{ + ValidationStatus: 'SUCCESS', + ResourceRecord: { + Name: testRRName, + Type: 'CNAME', + Value: testRRValue + } + }] + } + }); + + const addTagsToCertificateFake = sinon.fake.resolves({}); + + const changeResourceRecordSetsFake = sinon.fake.resolves({ + ChangeInfo: { + Id: 'bogus' + } + }); + + AWS.mock('ACM', 'requestCertificate', requestCertificateFake); + AWS.mock('ACM', 'describeCertificate', describeCertificateFake); + AWS.mock('Route53', 'changeResourceRecordSets', changeResourceRecordSetsFake); + AWS.mock('ACM', 'addTagsToCertificate', addTagsToCertificateFake); + + const request = nock(ResponseURL).put('/', body => { + return body.Status === 'SUCCESS'; + }).reply(200); + + return LambdaTester(handler.certificateRequestHandler) + .event({ + RequestType: 'Update', + RequestId: testRequestId, + PhysicalResourceId: testCertificateArn, + OldResourceProperties: { + DomainName: testDomainName, + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: testTags, + }, + ResourceProperties: { + DomainName: testDomainName, + HostedZoneId: testHostedZoneId, + Region: 'us-east-1', + Tags: testTags, + RemovalPolicy: 'retain', + } + }) + .expectResolve(() => { + sinon.assert.notCalled(requestCertificateFake); + sinon.assert.notCalled(changeResourceRecordSetsFake); + sinon.assert.calledWith(addTagsToCertificateFake, sinon.match({ + "CertificateArn": testCertificateArn, + "Tags": testTagsValue, + })); + expect(request.isDone()).toBe(true); + }); + }); + test('Delete operation succeeds if certificate becomes not-in-use', () => { const usedByArn = 'arn:aws:cloudfront::123456789012:distribution/d111111abcdef8'; diff --git a/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts b/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts index eb4044cb7833f..b01062021fb2a 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts +++ b/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts @@ -79,6 +79,7 @@ export class DnsValidatedCertificate extends CertificateBase implements ICertifi private normalizedZoneName: string; private hostedZoneId: string; private domainName: string; + private _removalPolicy?: cdk.RemovalPolicy; constructor(scope: Construct, id: string, props: DnsValidatedCertificateProps) { super(scope, id); @@ -132,6 +133,7 @@ export class DnsValidatedCertificate extends CertificateBase implements ICertifi HostedZoneId: this.hostedZoneId, Region: props.region, Route53Endpoint: props.route53Endpoint, + RemovalPolicy: cdk.Lazy.any({ produce: () => this._removalPolicy }), // Custom resources properties are always converted to strings; might as well be explict here. CleanupRecords: props.cleanupRoute53Records ? 'true' : undefined, Tags: cdk.Lazy.list({ produce: () => this.tags.renderTags() }), @@ -143,6 +145,10 @@ export class DnsValidatedCertificate extends CertificateBase implements ICertifi this.node.addValidation({ validate: () => this.validateDnsValidatedCertificate() }); } + public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { + this._removalPolicy = policy; + } + private validateDnsValidatedCertificate(): string[] { const errors: string[] = []; // Ensure the zone name is a parent zone of the certificate domain name diff --git a/packages/@aws-cdk/aws-certificatemanager/package.json b/packages/@aws-cdk/aws-certificatemanager/package.json index 2b396cdee2abe..5a6c030175093 100644 --- a/packages/@aws-cdk/aws-certificatemanager/package.json +++ b/packages/@aws-cdk/aws-certificatemanager/package.json @@ -81,6 +81,8 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/assertions": "0.0.0", + "@aws-cdk/integ-tests": "0.0.0", + "@aws-cdk/integ-runner": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/asset.ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672/index.js b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/asset.ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672/index.js new file mode 100644 index 0000000000000..3794bfcee0769 --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/asset.ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672/index.js @@ -0,0 +1,437 @@ +'use strict'; + +const aws = require('aws-sdk'); + +const defaultSleep = function (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +// These are used for test purposes only +let defaultResponseURL; +let waiter; +let sleep = defaultSleep; +let random = Math.random; +let maxAttempts = 10; + +/** + * Upload a CloudFormation response object to S3. + * + * @param {object} event the Lambda event payload received by the handler function + * @param {object} context the Lambda context received by the handler function + * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' + * @param {string} physicalResourceId CloudFormation physical resource ID + * @param {object} [responseData] arbitrary response data object + * @param {string} [reason] reason for failure, if any, to convey to the user + * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response + */ +let report = function (event, context, responseStatus, physicalResourceId, responseData, reason) { + return new Promise((resolve, reject) => { + const https = require('https'); + const { URL } = require('url'); + + var responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData + }); + + const parsedUrl = new URL(event.ResponseURL || defaultResponseURL); + const options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'PUT', + headers: { + 'Content-Type': '', + 'Content-Length': responseBody.length + } + }; + + https.request(options) + .on('error', reject) + .on('response', res => { + res.resume(); + if (res.statusCode >= 400) { + reject(new Error(`Server returned error ${res.statusCode}: ${res.statusMessage}`)); + } else { + resolve(); + } + }) + .end(responseBody, 'utf8'); + }); +}; + +/** + * Adds tags to an existing certificate + * + * @param {string} certificateArn the ARN of the certificate to add tags to + * @param {string} region the region the certificate exists in + * @param {map} tags Tags to add to the requested certificate + */ +const addTags = async function(certificateArn, region, tags) { + const result = Array.from(Object.entries(tags)).map(([Key, Value]) => ({ Key, Value })) + const acm = new aws.ACM({ region }); + + await acm.addTagsToCertificate({ + CertificateArn: certificateArn, + Tags: result, + }).promise(); +} + +/** + * Requests a public certificate from AWS Certificate Manager, using DNS validation. + * The hosted zone ID must refer to a **public** Route53-managed DNS zone that is authoritative + * for the suffix of the certificate's Common Name (CN). For example, if the CN is + * `*.example.com`, the hosted zone ID must point to a Route 53 zone authoritative + * for `example.com`. + * + * @param {string} requestId the CloudFormation request ID + * @param {string} domainName the Common Name (CN) field for the requested certificate + * @param {string} hostedZoneId the Route53 Hosted Zone ID + * @returns {string} Validated certificate ARN + */ +const requestCertificate = async function (requestId, domainName, subjectAlternativeNames, certificateTransparencyLoggingPreference, hostedZoneId, region, route53Endpoint) { + const crypto = require('crypto'); + const acm = new aws.ACM({ region }); + const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53(); + if (waiter) { + // Used by the test suite, since waiters aren't mockable yet + route53.waitFor = acm.waitFor = waiter; + } + + console.log(`Requesting certificate for ${domainName}`); + + const reqCertResponse = await acm.requestCertificate({ + DomainName: domainName, + SubjectAlternativeNames: subjectAlternativeNames, + Options: { + CertificateTransparencyLoggingPreference: certificateTransparencyLoggingPreference + }, + IdempotencyToken: crypto.createHash('sha256').update(requestId).digest('hex').slice(0, 32), + ValidationMethod: 'DNS' + }).promise(); + + console.log(`Certificate ARN: ${reqCertResponse.CertificateArn}`); + + console.log('Waiting for ACM to provide DNS records for validation...'); + + let records = []; + for (let attempt = 0; attempt < maxAttempts && !records.length; attempt++) { + const { Certificate } = await acm.describeCertificate({ + CertificateArn: reqCertResponse.CertificateArn + }).promise(); + + records = getDomainValidationRecords(Certificate); + if (!records.length) { + // Exponential backoff with jitter based on 200ms base + // component of backoff fixed to ensure minimum total wait time on + // slow targets. + const base = Math.pow(2, attempt); + await sleep(random() * base * 50 + base * 150); + } + } + if (!records.length) { + throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`) + } + + console.log(`Upserting ${records.length} DNS records into zone ${hostedZoneId}:`); + + await commitRoute53Records(route53, records, hostedZoneId); + + console.log('Waiting for validation...'); + await acm.waitFor('certificateValidated', { + // Wait up to 9 minutes and 30 seconds + $waiter: { + delay: 30, + maxAttempts: 19 + }, + CertificateArn: reqCertResponse.CertificateArn + }).promise(); + + return reqCertResponse.CertificateArn; +}; + +/** + * Deletes a certificate from AWS Certificate Manager (ACM) by its ARN. + * If the certificate does not exist, the function will return normally. + * + * @param {string} arn The certificate ARN + */ +const deleteCertificate = async function (arn, region, hostedZoneId, route53Endpoint, cleanupRecords) { + const acm = new aws.ACM({ region }); + const route53 = route53Endpoint ? new aws.Route53({ endpoint: route53Endpoint }) : new aws.Route53(); + if (waiter) { + // Used by the test suite, since waiters aren't mockable yet + route53.waitFor = acm.waitFor = waiter; + } + + try { + console.log(`Waiting for certificate ${arn} to become unused`); + + let inUseByResources; + let records = []; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const { Certificate } = await acm.describeCertificate({ + CertificateArn: arn + }).promise(); + + if (cleanupRecords) { + records = getDomainValidationRecords(Certificate); + } + inUseByResources = Certificate.InUseBy || []; + + if (inUseByResources.length || !records.length) { + // Exponential backoff with jitter based on 200ms base + // component of backoff fixed to ensure minimum total wait time on + // slow targets. + const base = Math.pow(2, attempt); + await sleep(random() * base * 50 + base * 150); + } else { + break; + } + } + + if (inUseByResources.length) { + throw new Error(`Response from describeCertificate did not contain an empty InUseBy list after ${maxAttempts} attempts.`) + } + if (cleanupRecords && !records.length) { + throw new Error(`Response from describeCertificate did not contain DomainValidationOptions after ${maxAttempts} attempts.`) + } + + console.log(`Deleting certificate ${arn}`); + + await acm.deleteCertificate({ + CertificateArn: arn + }).promise(); + + if (cleanupRecords) { + console.log(`Deleting ${records.length} DNS records from zone ${hostedZoneId}:`); + + await commitRoute53Records(route53, records, hostedZoneId, 'DELETE'); + } + + } catch (err) { + if (err.name !== 'ResourceNotFoundException') { + throw err; + } + } +}; + +/** + * Retrieve the unique domain validation options as records to be upserted (or deleted) from Route53. + * + * Returns an empty array ([]) if the domain validation options is empty or the records are not yet ready. + */ +function getDomainValidationRecords(certificate) { + const options = certificate.DomainValidationOptions || []; + // Ensure all records are ready; there is (at least a theory there's) a chance of a partial response here in rare cases. + if (options.length > 0 && options.every(opt => opt && !!opt.ResourceRecord)) { + // some alternative names will produce the same validation record + // as the main domain (eg. example.com + *.example.com) + // filtering duplicates to avoid errors with adding the same record + // to the route53 zone twice + const unique = options + .map((val) => val.ResourceRecord) + .reduce((acc, cur) => { + acc[cur.Name] = cur; + return acc; + }, {}); + return Object.keys(unique).sort().map(key => unique[key]); + } + return []; +} + +/** + * Execute Route53 ChangeResourceRecordSets for a set of records within a Hosted Zone, + * and wait for the records to commit. Defaults to an 'UPSERT' action. + */ +async function commitRoute53Records(route53, records, hostedZoneId, action = 'UPSERT') { + const changeBatch = await route53.changeResourceRecordSets({ + ChangeBatch: { + Changes: records.map((record) => { + console.log(`${record.Name} ${record.Type} ${record.Value}`); + return { + Action: action, + ResourceRecordSet: { + Name: record.Name, + Type: record.Type, + TTL: 60, + ResourceRecords: [{ + Value: record.Value + }] + } + }; + }), + }, + HostedZoneId: hostedZoneId + }).promise(); + + console.log('Waiting for DNS records to commit...'); + await route53.waitFor('resourceRecordSetsChanged', { + // Wait up to 5 minutes + $waiter: { + delay: 30, + maxAttempts: 10 + }, + Id: changeBatch.ChangeInfo.Id + }).promise(); +} + +/** + * Determines whether an update request should request a new certificate + * + * @param {map} oldParams the previously process request parameters + * @param {map} newParams the current process request parameters + * @param {string} physicalResourceId the physicalResourceId + * @returns {boolean} whether or not to request a new certificate + */ +function shouldUpdate(oldParams, newParams, physicalResourceId) { + if (!oldParams) return true; + if (oldParams.DomainName !== newParams.DomainName) return true; + if (oldParams.SubjectAlternativeNames !== newParams.SubjectAlternativeNames) return true; + if (oldParams.CertificateTransparencyLoggingPreference !== newParams.CertificateTransparencyLoggingPreference) return true; + if (oldParams.HostedZoneId !== newParams.HostedZoneId) return true; + if (oldParams.Region !== newParams.Region) return true; + if (!physicalResourceId || !physicalResourceId.startsWith('arn:')) return true; + return false; +} + +/** + * Main handler, invoked by Lambda + */ +exports.certificateRequestHandler = async function (event, context) { + var responseData = {}; + var physicalResourceId; + var certificateArn; + async function processRequest() { + certificateArn = await requestCertificate( + event.RequestId, + event.ResourceProperties.DomainName, + event.ResourceProperties.SubjectAlternativeNames, + event.ResourceProperties.CertificateTransparencyLoggingPreference, + event.ResourceProperties.HostedZoneId, + event.ResourceProperties.Region, + event.ResourceProperties.Route53Endpoint, + ); + responseData.Arn = physicalResourceId = certificateArn; + } + + try { + switch (event.RequestType) { + case 'Create': + await processRequest(); + if (event.ResourceProperties.Tags && physicalResourceId.startsWith('arn:')) { + await addTags(physicalResourceId, event.ResourceProperties.Region, event.ResourceProperties.Tags); + } + break; + case 'Update': + if (shouldUpdate(event.OldResourceProperties, event.ResourceProperties, event.PhysicalResourceId)) { + await processRequest(); + } else { + responseData.Arn = physicalResourceId = event.PhysicalResourceId; + } + if (event.ResourceProperties.Tags && physicalResourceId.startsWith('arn:')) { + await addTags(physicalResourceId, event.ResourceProperties.Region, event.ResourceProperties.Tags); + } + break; + case 'Delete': + physicalResourceId = event.PhysicalResourceId; + const removalPolicy = event.ResourceProperties.RemovalPolicy ?? 'destroy'; + // If the resource didn't create correctly, the physical resource ID won't be the + // certificate ARN, so don't try to delete it in that case. + if (physicalResourceId.startsWith('arn:') && removalPolicy === 'destroy') { + await deleteCertificate( + physicalResourceId, + event.ResourceProperties.Region, + event.ResourceProperties.HostedZoneId, + event.ResourceProperties.Route53Endpoint, + event.ResourceProperties.CleanupRecords === "true", + ); + } + break; + default: + throw new Error(`Unsupported request type ${event.RequestType}`); + } + + console.log(`Uploading SUCCESS response to S3...`); + await report(event, context, 'SUCCESS', physicalResourceId, responseData); + console.log('Done.'); + } catch (err) { + console.log(`Caught error ${err}. Uploading FAILED message to S3.`); + await report(event, context, 'FAILED', physicalResourceId, null, err.message); + } +}; + +/** + * @private + */ +exports.withReporter = function (reporter) { + report = reporter; +}; + +/** + * @private + */ +exports.withDefaultResponseURL = function (url) { + defaultResponseURL = url; +}; + +/** + * @private + */ +exports.withWaiter = function (w) { + waiter = w; +}; + +/** + * @private + */ +exports.resetWaiter = function () { + waiter = undefined; +}; + +/** + * @private + */ +exports.withSleep = function (s) { + sleep = s; +} + +/** + * @private + */ +exports.resetSleep = function () { + sleep = defaultSleep; +} + +/** + * @private + */ +exports.withRandom = function (r) { + random = r; +} + +/** + * @private + */ +exports.resetRandom = function () { + random = Math.random; +} + +/** + * @private + */ +exports.withMaxAttempts = function (ma) { + maxAttempts = ma; +} + +/** + * @private + */ +exports.resetMaxAttempts = function () { + maxAttempts = 10; +} diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/cdk.out new file mode 100644 index 0000000000000..8ecc185e9dbee --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"21.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.assets.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.assets.json new file mode 100644 index 0000000000000..5aaa44e3f2869 --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.assets.json @@ -0,0 +1,32 @@ +{ + "version": "21.0.0", + "files": { + "ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672": { + "source": { + "path": "asset.ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672", + "packaging": "zip" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672.zip", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + }, + "5b59fa131b8bdd3fda9d78a8b7a199cff546fd4f13ffe4d1a707fa21f18f6146": { + "source": { + "path": "integ-dns-validated-certificate.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "5b59fa131b8bdd3fda9d78a8b7a199cff546fd4f13ffe4d1a707fa21f18f6146.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.template.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.template.json new file mode 100644 index 0000000000000..612bb403b5d0c --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ-dns-validated-certificate.template.json @@ -0,0 +1,188 @@ +{ + "Resources": { + "CertificateCertificateRequestorFunctionServiceRoleC04C13DA": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "CertificateCertificateRequestorFunctionServiceRoleDefaultPolicy3C8845BC": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "acm:AddTagsToCertificate", + "acm:DeleteCertificate", + "acm:DescribeCertificate", + "acm:RequestCertificate", + "route53:GetChange" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "route53:changeResourceRecordSets", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":route53:::hostedzone/Z23ABC4XYZL05B" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "CertificateCertificateRequestorFunctionServiceRoleDefaultPolicy3C8845BC", + "Roles": [ + { + "Ref": "CertificateCertificateRequestorFunctionServiceRoleC04C13DA" + } + ] + } + }, + "CertificateCertificateRequestorFunction5E845413": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "S3Key": "ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672.zip" + }, + "Role": { + "Fn::GetAtt": [ + "CertificateCertificateRequestorFunctionServiceRoleC04C13DA", + "Arn" + ] + }, + "Handler": "index.certificateRequestHandler", + "Runtime": "nodejs14.x", + "Timeout": 900 + }, + "DependsOn": [ + "CertificateCertificateRequestorFunctionServiceRoleDefaultPolicy3C8845BC", + "CertificateCertificateRequestorFunctionServiceRoleC04C13DA" + ] + }, + "CertificateCertificateRequestorResource2890C6B7": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CertificateCertificateRequestorFunction5E845413", + "Arn" + ] + }, + "DomainName": "*.example.com", + "HostedZoneId": "Z23ABC4XYZL05B", + "RemovalPolicy": "retain" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Outputs": { + "CertificateArn": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "AWS::Region" + }, + ".console.aws.amazon.com/acm/home?region=", + { + "Ref": "AWS::Region" + }, + "#/certificates/", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "/", + { + "Fn::GetAtt": [ + "CertificateCertificateRequestorResource2890C6B7", + "Arn" + ] + } + ] + } + ] + } + ] + ] + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "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." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ.json new file mode 100644 index 0000000000000..11b3ba887235c --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integ.json @@ -0,0 +1,14 @@ +{ + "enableLookups": true, + "version": "21.0.0", + "testCases": { + "integ-test/DefaultTest": { + "stacks": [ + "integ-dns-validated-certificate" + ], + "diffAssets": true, + "assertionStack": "integ-test/DefaultTest/DeployAssert", + "assertionStackName": "integtestDefaultTestDeployAssert24D5C536" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json new file mode 100644 index 0000000000000..c6322e79691df --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json @@ -0,0 +1,19 @@ +{ + "version": "21.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "integtestDefaultTestDeployAssert24D5C536.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": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "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." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/manifest.json new file mode 100644 index 0000000000000..9f6568a8b811f --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/manifest.json @@ -0,0 +1,135 @@ +{ + "version": "21.0.0", + "artifacts": { + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + }, + "integ-dns-validated-certificate.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "integ-dns-validated-certificate.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "integ-dns-validated-certificate": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "integ-dns-validated-certificate.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/5b59fa131b8bdd3fda9d78a8b7a199cff546fd4f13ffe4d1a707fa21f18f6146.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integ-dns-validated-certificate.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "integ-dns-validated-certificate.assets" + ], + "metadata": { + "/integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CertificateCertificateRequestorFunctionServiceRoleC04C13DA" + } + ], + "/integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole/DefaultPolicy/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CertificateCertificateRequestorFunctionServiceRoleDefaultPolicy3C8845BC" + } + ], + "/integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CertificateCertificateRequestorFunction5E845413" + } + ], + "/integ-dns-validated-certificate/Certificate/CertificateRequestorResource/Default": [ + { + "type": "aws:cdk:logicalId", + "data": "CertificateCertificateRequestorResource2890C6B7" + } + ], + "/integ-dns-validated-certificate/CertificateArn": [ + { + "type": "aws:cdk:logicalId", + "data": "CertificateArn" + } + ], + "/integ-dns-validated-certificate/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/integ-dns-validated-certificate/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "integ-dns-validated-certificate" + }, + "integtestDefaultTestDeployAssert24D5C536.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "integtestDefaultTestDeployAssert24D5C536.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "integtestDefaultTestDeployAssert24D5C536": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "integtestDefaultTestDeployAssert24D5C536.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integtestDefaultTestDeployAssert24D5C536.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "integtestDefaultTestDeployAssert24D5C536.assets" + ], + "metadata": { + "/integ-test/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/integ-test/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "integ-test/DefaultTest/DeployAssert" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/tree.json b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/tree.json new file mode 100644 index 0000000000000..5f0cd725d482e --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.integ.snapshot/tree.json @@ -0,0 +1,285 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.102" + } + }, + "integ-dns-validated-certificate": { + "id": "integ-dns-validated-certificate", + "path": "integ-dns-validated-certificate", + "children": { + "HostedZone": { + "id": "HostedZone", + "path": "integ-dns-validated-certificate/HostedZone", + "constructInfo": { + "fqn": "@aws-cdk/core.Resource", + "version": "0.0.0" + } + }, + "Certificate": { + "id": "Certificate", + "path": "integ-dns-validated-certificate/Certificate", + "children": { + "CertificateRequestorFunction": { + "id": "CertificateRequestorFunction", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction", + "children": { + "ServiceRole": { + "id": "ServiceRole", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole", + "children": { + "Resource": { + "id": "Resource", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Role", + "aws:cdk:cloudformation:props": { + "assumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "managedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnRole", + "version": "0.0.0" + } + }, + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole/DefaultPolicy", + "children": { + "Resource": { + "id": "Resource", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/ServiceRole/DefaultPolicy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Policy", + "aws:cdk:cloudformation:props": { + "policyDocument": { + "Statement": [ + { + "Action": [ + "acm:AddTagsToCertificate", + "acm:DeleteCertificate", + "acm:DescribeCertificate", + "acm:RequestCertificate", + "route53:GetChange" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "route53:changeResourceRecordSets", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":route53:::hostedzone/Z23ABC4XYZL05B" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "policyName": "CertificateCertificateRequestorFunctionServiceRoleDefaultPolicy3C8845BC", + "roles": [ + { + "Ref": "CertificateCertificateRequestorFunctionServiceRoleC04C13DA" + } + ] + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnPolicy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.Policy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.Role", + "version": "0.0.0" + } + }, + "Code": { + "id": "Code", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/Code", + "children": { + "Stage": { + "id": "Stage", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/Code/Stage", + "constructInfo": { + "fqn": "@aws-cdk/core.AssetStaging", + "version": "0.0.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/Code/AssetBucket", + "constructInfo": { + "fqn": "@aws-cdk/aws-s3.BucketBase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-s3-assets.Asset", + "version": "0.0.0" + } + }, + "Resource": { + "id": "Resource", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorFunction/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Lambda::Function", + "aws:cdk:cloudformation:props": { + "code": { + "s3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "s3Key": "ef671dfd26b6dde1f73a4325587504813605a928622ebc466f4d0de6a0f3b672.zip" + }, + "role": { + "Fn::GetAtt": [ + "CertificateCertificateRequestorFunctionServiceRoleC04C13DA", + "Arn" + ] + }, + "handler": "index.certificateRequestHandler", + "runtime": "nodejs14.x", + "timeout": 900 + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-lambda.CfnFunction", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-lambda.Function", + "version": "0.0.0" + } + }, + "CertificateRequestorResource": { + "id": "CertificateRequestorResource", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorResource", + "children": { + "Default": { + "id": "Default", + "path": "integ-dns-validated-certificate/Certificate/CertificateRequestorResource/Default", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnResource", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.CustomResource", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-certificatemanager.DnsValidatedCertificate", + "version": "0.0.0" + } + }, + "CertificateArn": { + "id": "CertificateArn", + "path": "integ-dns-validated-certificate/CertificateArn", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnOutput", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + }, + "integ-test": { + "id": "integ-test", + "path": "integ-test", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "integ-test/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "integ-test/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.102" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "integ-test/DefaultTest/DeployAssert", + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTest", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.test.ts b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.test.ts index 5ed77764de122..688a17ef25a69 100644 --- a/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.test.ts +++ b/packages/@aws-cdk/aws-certificatemanager/test/dns-validated-certificate.test.ts @@ -1,7 +1,7 @@ import { Template } from '@aws-cdk/assertions'; import * as iam from '@aws-cdk/aws-iam'; import { HostedZone, PublicHostedZone } from '@aws-cdk/aws-route53'; -import { App, Stack, Token, Tags } from '@aws-cdk/core'; +import { App, Stack, Token, Tags, RemovalPolicy } from '@aws-cdk/core'; import { DnsValidatedCertificate } from '../lib/dns-validated-certificate'; test('creates CloudFormation Custom Resource', () => { @@ -266,4 +266,36 @@ test('test transparency logging settings is passed to the custom resource', () = }, CertificateTransparencyLoggingPreference: 'DISABLED', }); -}); \ No newline at end of file +}); + +test('can set removal policy', () => { + const stack = new Stack(); + + const exampleDotComZone = new PublicHostedZone(stack, 'ExampleDotCom', { + zoneName: 'example.com', + }); + + const cert = new DnsValidatedCertificate(stack, 'Certificate', { + domainName: 'test.example.com', + hostedZone: exampleDotComZone, + subjectAlternativeNames: ['test2.example.com'], + cleanupRoute53Records: true, + }); + cert.applyRemovalPolicy(RemovalPolicy.RETAIN); + + Template.fromStack(stack).hasResourceProperties('AWS::CloudFormation::CustomResource', { + DomainName: 'test.example.com', + SubjectAlternativeNames: ['test2.example.com'], + RemovalPolicy: 'retain', + ServiceToken: { + 'Fn::GetAtt': [ + 'CertificateCertificateRequestorFunction5E845413', + 'Arn', + ], + }, + HostedZoneId: { + Ref: 'ExampleDotCom4D1B83AA', + }, + CleanupRecords: 'true', + }); +}); diff --git a/packages/@aws-cdk/aws-certificatemanager/test/integ.dns-validated-certificate.ts b/packages/@aws-cdk/aws-certificatemanager/test/integ.dns-validated-certificate.ts new file mode 100644 index 0000000000000..b5717d16b0005 --- /dev/null +++ b/packages/@aws-cdk/aws-certificatemanager/test/integ.dns-validated-certificate.ts @@ -0,0 +1,50 @@ +import { PublicHostedZone } from '@aws-cdk/aws-route53'; +import { App, Stack, RemovalPolicy, CfnOutput, Fn } from '@aws-cdk/core'; +import { IntegTest } from '@aws-cdk/integ-tests'; +import { DnsValidatedCertificate, CertificateValidation } from '../lib'; + +/** + * In order to test this you need to have a valid public hosted zone that you can use + * to request certificates for. Currently there is not a great way to test scenarios that involve + * multiple deploys so this is what I did to test these scenarios. + * + * 1. comment out the `cert.applyRemovalPolicy` line to create the certificate + * 2. Run `yarn integ --update-on-failed --no-clean` + * 3. uncomment the line to apply the removal policy + * 4. Run `yarn integ --update-on-failed --no-clean` to validate that changing + * that property does not cause a new certificate to be created + * 5. Run `yarn integ --force` to run the test again. Since we didn't pass `--no-clean` + * the stack will be deleted + * 6. Validate that the certificate was not deleted. + * 7. Delete the certificate manually. + */ + +const hostedZoneId = process.env.CDK_INTEG_HOSTED_ZONE_ID ?? process.env.HOSTED_ZONE_ID; +if (!hostedZoneId) throw new Error('For this test you must provide your own HostedZoneId as an env var "HOSTED_ZONE_ID"'); +const hostedZoneName = process.env.CDK_INTEG_HOSTED_ZONE_NAME ?? process.env.HOSTED_ZONE_NAME; +if (!hostedZoneName) throw new Error('For this test you must provide your own HostedZoneName as an env var "HOSTED_ZONE_NAME"'); +const domainName = process.env.CDK_INTEG_DOMAIN_NAME ?? process.env.DOMAIN_NAME; +if (!domainName) throw new Error('For this test you must provide your own Domain Name as an env var "DOMAIN_NAME"'); + +const app = new App(); +const stack = new Stack(app, 'integ-dns-validated-certificate'); +const hostedZone = PublicHostedZone.fromHostedZoneAttributes(stack, 'HostedZone', { + hostedZoneId, + zoneName: hostedZoneName, +}); + +const cert = new DnsValidatedCertificate(stack, 'Certificate', { + domainName, + hostedZone, + validation: CertificateValidation.fromDns(hostedZone), +}); +cert.applyRemovalPolicy(RemovalPolicy.RETAIN); +new CfnOutput(stack, 'CertificateArn', { + value: `https://${stack.region}.console.aws.amazon.com/acm/home?region=${stack.region}#/certificates/${Fn.select(1, Fn.split('/', cert.certificateArn))}`, +}); + +new IntegTest(app, 'integ-test', { + testCases: [stack], + diffAssets: true, + enableLookups: true, +}); diff --git a/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts b/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts index c8fc073d89918..9ddaf2e7e4de4 100644 --- a/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts +++ b/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts @@ -417,5 +417,8 @@ export const DEFAULT_SYNTH_OPTIONS = { env: { CDK_INTEG_ACCOUNT: '12345678', CDK_INTEG_REGION: 'test-region', + CDK_INTEG_HOSTED_ZONE_ID: 'Z23ABC4XYZL05B', + CDK_INTEG_HOSTED_ZONE_NAME: 'example.com', + CDK_INTEG_DOMAIN_NAME: '*.example.com', }, };