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', }, };