From bfa931c48aac5a12d110599e6b9a9eb44c5fc71f Mon Sep 17 00:00:00 2001 From: Wanxian Yang <79273084+Lou1415926@users.noreply.github.com> Date: Wed, 24 Nov 2021 10:43:25 -0800 Subject: [PATCH] chore(custom-resource): extend alias to app-level and domain-level for NLB (#3070) The previous PR #3075 takes into consideration only the case where the aliases are environment-level (e.g. `a.env.app.domain.com`); however, they could also be app-level (e.g. `a.app.domain.com`) or root-level (e.g. `a.domain.com`). This PR extends the cases considered as such. In addition, the PR adds lazy-loading into the script. Previous PR: #3057 By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License. --- .../lib/nlb-cert-validator-updater.js | 160 ++++++++++++++++-- .../test/nlb-cert-validator-updater-test.js | 121 +++++++++++-- 2 files changed, 254 insertions(+), 27 deletions(-) diff --git a/cf-custom-resources/lib/nlb-cert-validator-updater.js b/cf-custom-resources/lib/nlb-cert-validator-updater.js index d6be5408869..2af5455b819 100644 --- a/cf-custom-resources/lib/nlb-cert-validator-updater.js +++ b/cf-custom-resources/lib/nlb-cert-validator-updater.js @@ -9,13 +9,87 @@ const DELAY_RECORD_SETS_CHANGE_IN_S = 30; const ATTEMPTS_CERTIFICATE_VALIDATED = 19; const DELAY_CERTIFICATE_VALIDATED_IN_S = 30; -let acm, envRoute53, envHostedZoneID, appName, envName, serviceName, certificateDomain; +let envHostedZoneID; +let appName, envName, serviceName, certificateDomain, domainTypes, rootDNSRole, domainName; let defaultSleep = function (ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; let sleep = defaultSleep; let random = Math.random; +const appRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53({ + credentials: new AWS.ChainableTemporaryCredentials({ + params: { RoleArn: rootDNSRole, }, + masterCredentials: new AWS.EnvironmentCredentials("AWS"), + }), + }); + } + return client; + }; +} + +const envRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53(); + } + return client; + }; +} + +const acmContext = () => { + let client; + return () => { + if (!client) { + client = new AWS.ACM(); + } + return client; + }; +} + +const clients = { + app: { + route53: appRoute53Context(), + }, + root: { + route53: appRoute53Context(), + }, + env: { + route53:envRoute53Context(), + }, + acm: acmContext(), +} + +const appHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${appName}.${domainName}`); + } + return id + }; +} + +const rootHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${domainName}`); + } + return id + }; +} + +let hostedZoneID = { + app: appHostedZoneIDContext(), + root: rootHostedZoneIDContext(), +} + /** * Upload a CloudFormation response object to S3. * @@ -78,21 +152,35 @@ function report ( } exports.handler = async function (event, context) { + // Destruct resource properties into local variables. const props = event.ResourceProperties; - let {LoadBalancerDNS: loadBalancerDNS, LoadBalancerHostedZoneID: loadBalancerHostedZoneID, - DomainName: domainName, } = props; const aliases = new Set(props.Aliases); - acm = new AWS.ACM(); - envRoute53 = new AWS.Route53(); + // Initialize global variables. envHostedZoneID = props.EnvHostedZoneId; envName = props.EnvName; appName = props.AppName; serviceName = props.ServiceName; + domainName = props.DomainName; + rootDNSRole = props.RootDNSRole; certificateDomain = `${serviceName}-nlb.${envName}.${appName}.${domainName}`; + domainTypes = { + EnvDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${envName}.${appName}.${domainName}`), + domain: `${envName}.${appName}.${domainName}`, + }, + AppDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${appName}.${domainName}`), + domain: `${appName}.${domainName}`, + }, + RootDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${domainName}`), + domain: `${domainName}`, + }, + }; // NOTE: If the aliases have changed, then we need to replace the certificate being used, as well as deleting/adding // validation records and A records. In general, any change in aliases indicate a "replacement" of the resources @@ -141,8 +229,9 @@ async function validateAliases(aliases, loadBalancerDNS) { let promises = []; for (let alias of aliases) { - const promise = envRoute53.listResourceRecordSets({ - HostedZoneId: envHostedZoneID, + let {hostedZoneID, route53Client } = await domainResources(alias); + const promise = route53Client.listResourceRecordSets({ + HostedZoneId: hostedZoneID, MaxItems: "1", StartRecordName: alias, }).promise().then((data) => { @@ -150,11 +239,13 @@ async function validateAliases(aliases, loadBalancerDNS) { if (!recordSet || recordSet.length === 0) { return; } + if (recordSet[0].Name !== alias) { + return; + } let aliasTarget = recordSet[0].AliasTarget; if (aliasTarget && aliasTarget.DNSName === `${loadBalancerDNS}.`) { return; // The record is an alias record and is in use by myself, hence valid. } - if (aliasTarget) { throw new Error(`Alias ${alias} is already in use by ${aliasTarget.DNSName}. This could be another load balancer of a different service.`); } @@ -172,7 +263,7 @@ async function validateAliases(aliases, loadBalancerDNS) { * @return {String} The ARN of the requested certificate. */ async function requestCertificate({ aliases, idempotencyToken }) { - const { CertificateArn } = await acm.requestCertificate({ + const { CertificateArn } = await clients.acm().requestCertificate({ DomainName: certificateDomain, IdempotencyToken: idempotencyToken, SubjectAlternativeNames: aliases.size === 0? null: [...aliases], @@ -207,7 +298,7 @@ async function waitForValidationOptionsToBeReady(certificateARN, aliases) { let attempt; // TODO: This wait loops could be further abstracted. for (attempt = 0; attempt < ATTEMPTS_VALIDATION_OPTIONS_READY; attempt++) { let readyCount = 0; - const { Certificate } = await acm.describeCertificate({ + const { Certificate } = await clients.acm().describeCertificate({ CertificateArn: certificateARN, }).promise(); const options = Certificate.DomainValidationOptions || []; @@ -243,8 +334,7 @@ async function activate(validationOptions, certificateARN, loadBalancerDNS, load promises.push(activateOption(option, loadBalancerDNS, loadBalancerHostedZone)); } await Promise.all(promises); - - await acm.waitFor("certificateValidated", { + await clients.acm().waitFor("certificateValidated", { // Wait up to 9 minutes and 30 seconds $waiter: { delay: DELAY_CERTIFICATE_VALIDATED_IN_S, @@ -291,15 +381,16 @@ async function activateOption(option, loadBalancerDNS, loadBalancerHostedZone) { }); } - let { ChangeInfo } = await envRoute53.changeResourceRecordSets({ + let {hostedZoneID, route53Client} = await domainResources(option.DomainName); + let { ChangeInfo } = await route53Client.changeResourceRecordSets({ ChangeBatch: { Comment: "Validate the certificate and create A record for the alias", Changes: changes, }, - HostedZoneId: envHostedZoneID, + HostedZoneId: hostedZoneID, }).promise(); - await envRoute53.waitFor('resourceRecordSetsChanged', { + await route53Client.waitFor('resourceRecordSetsChanged', { // Wait up to 5 minutes $waiter: { delay: DELAY_RECORD_SETS_CHANGE_IN_S, @@ -319,6 +410,43 @@ exports.deadlineExpired = function () { }); }; +async function hostedZoneIDByName(domain) { + const { HostedZones } = await clients.app.route53() + .listHostedZonesByName({ + DNSName: domain, + MaxItems: "1", + }).promise(); + if (!HostedZones || HostedZones.length === 0) { + throw new Error( `Couldn't find any Hosted Zone with DNS name ${domainName}.`); + } + return HostedZones[0].Id.split("/").pop(); +} + +async function domainResources (alias) { + if (domainTypes.EnvDomainZone.regex.test(alias)) { + return { + domain: domainTypes.EnvDomainZone.domain, + route53Client: clients.env.route53(), + hostedZoneID: envHostedZoneID, + }; + } + if (domainTypes.AppDomainZone.regex.test(alias)) { + return { + domain: domainTypes.AppDomainZone.domain, + route53Client: clients.app.route53(), + hostedZoneID: await hostedZoneID.app(), + }; + } + if (domainTypes.RootDomainZone.regex.test(alias)) { + return { + domain: domainTypes.RootDomainZone.domain, + route53Client: clients.root.route53(), + hostedZoneID: await hostedZoneID.root(), + }; + } + throw new Error(`unrecognized domain type for ${alias}`); +} + exports.withSleep = function (s) { sleep = s; }; @@ -328,4 +456,4 @@ exports.reset = function () { exports.withDeadlineExpired = function (d) { exports.deadlineExpired = d; }; -exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; \ No newline at end of file +exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; diff --git a/cf-custom-resources/test/nlb-cert-validator-updater-test.js b/cf-custom-resources/test/nlb-cert-validator-updater-test.js index 75df13dd45e..03ea8b8f6b3 100644 --- a/cf-custom-resources/test/nlb-cert-validator-updater-test.js +++ b/cf-custom-resources/test/nlb-cert-validator-updater-test.js @@ -8,7 +8,7 @@ const sinon = require("sinon"); const nock = require("nock"); let origLog = console.log; -const {handler, withSleep, withDeadlineExpired, reset, attemptsValidationOptionsReady} = require("../lib/nlb-cert-validator-updater"); +const { attemptsValidationOptionsReady } = require("../lib/nlb-cert-validator-updater"); describe("DNS Certificate Validation And Custom Domains for NLB", () => { // Mock requests. @@ -20,17 +20,19 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { const mockLBDNS = "mockLBDNS"; const mockLBHostedZoneID = "mockLBHostedZoneID" const mockResponseURL = "https://mock.com/"; + const mockRootDNSRole = "mockRootDNSRole" const mockRequest = { ResponseURL: mockResponseURL, ResourceProperties: { ServiceName: mockServiceName, - Aliases: ["dash-test.mockDomain.com", "frontend.mockDomain.com", "frontend.v2.mockDomain.com"], + Aliases: ["dash-test.mockDomain.com", "a.mockApp.mockDomain.com", "b.mockEnv.mockApp.mockDomain.com"], EnvName: mockEnvName, AppName: mockAppName, DomainName: mockDomainName, LoadBalancerDNS: mockLBDNS, LoadBalancerHostedZoneID: mockLBHostedZoneID, EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, }, RequestType: "Create", LogicalResourceId: "mockID", @@ -49,14 +51,31 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { } // API call mocks. + const mockListHostedZonesByName = sinon.stub(); const mockListResourceRecordSets = sinon.stub(); const mockRequestCertificate = sinon.stub(); const mockDescribeCertificate = sinon.stub(); const mockChangeResourceRecordSets = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + + let handler, reset, withDeadlineExpired ; beforeEach(() => { // Prevent logging. console.log = function () {}; - withSleep(_ => { + + // Reimport handlers so that the lazy loading does not fail the mocks. + // A description of the issue can be found here: https://github.com/dwyl/aws-sdk-mock/issues/206. + // This workaround follows the comment here: https://github.com/dwyl/aws-sdk-mock/issues/206#issuecomment-640418772. + jest.resetModules(); + AWS.setSDKInstance(require('aws-sdk')); + const imported = require("../lib/nlb-cert-validator-updater"); + handler = imported.handler; + reset = imported.reset; + withDeadlineExpired = imported.withDeadlineExpired; + + // Mocks wait functions. + imported.withSleep(_ => { return Promise.resolve(); }); withDeadlineExpired(_ => { @@ -92,18 +111,28 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { Value: "mock-validate-alias-2-value", Type: "mock-validate-alias-2-type" }, - "DomainName": "frontend.mockDomain.com", + "DomainName": "a.mockApp.mockDomain.com", },{ "ResourceRecord": { Name: "mock-validate-alias-3", Value: "mock-validate-alias-3-value", Type: "mock-validate-alias-3-type" }, - "DomainName": "frontend.v2.mockDomain.com", + "DomainName": "b.mockEnv.mockApp.mockDomain.com", }], }, }); mockChangeResourceRecordSets.resolves({ ChangeInfo: {Id: "mockChangeID", }, }) + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); }); afterEach(() => { @@ -113,6 +142,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { reset(); // Reset mocks call count. + mockListHostedZonesByName.reset(); mockListResourceRecordSets.reset(); mockRequestCertificate.reset(); mockDescribeCertificate.reset(); @@ -138,7 +168,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { const request = nock(mockResponseURL) .put("/", (body) => { return ( - body.PhysicalResourceId === "/web/dash-test.mockDomain.com,frontend.mockDomain.com,frontend.v2.mockDomain.com" + body.PhysicalResourceId === "/web/a.mockApp.mockDomain.com,b.mockEnv.mockApp.mockDomain.com,dash-test.mockDomain.com" ); }).reply(200); return LambdaTester(handler) @@ -148,10 +178,62 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { }); }); + test("error if an alias is not valid", () => { + let request = mockFailedRequest(/^unrecognized domain type for Wow-this-domain-is-so-weird-that-it-does-not-work-at-all \(Log: .*\)$/); + return LambdaTester(handler) + .event({ + ResponseURL: mockResponseURL, + ResourceProperties: { + Aliases: ["Wow-this-domain-is-so-weird-that-it-does-not-work-at-all"], + }, + RequestType: "Create", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error fetching app-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).rejects(new Error("some error")); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + }); + }); + + test("error fetching root-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID, + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + test("error validating aliases", () => { const mockListResourceRecordSets = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); - let request = mockFailedRequest(/^some error \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) @@ -166,29 +248,36 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { "ResourceRecordSets": [{ "AliasTarget": { "DNSName": "other-lb-DNS", - } + }, + Name: "dash-test.mockDomain.com", }] }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^Alias dash-test.mockDomain.com is already in use by other-lb-DNS. This could be another load balancer of a different service. \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); }); }); test("fail to request a certificate", () => { const mockRequestCertificate =sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); }); @@ -203,16 +292,18 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { }], }, }); - + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); AWS.mock("ACM", "requestCertificate", mockRequestCertificate); AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + let request = mockFailedRequest(/^resource validation records are not ready after 10 tries \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); sinon.assert.callCount(mockDescribeCertificate, attemptsValidationOptionsReady); @@ -221,7 +312,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { test("error while waiting for validation options to be ready", () => { const mockDescribeCertificate = sinon.fake.rejects(new Error("some error")); - + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); AWS.mock("ACM", "requestCertificate", mockRequestCertificate); AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); @@ -231,6 +322,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); sinon.assert.callCount(mockDescribeCertificate, 1); @@ -246,12 +338,15 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { AWS.mock("ACM", "requestCertificate", mockRequestCertificate); AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); sinon.assert.callCount(mockDescribeCertificate, 1); @@ -261,18 +356,20 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { test("fail to wait for resource record sets change to be finished", () => { const mockWaitFor = sinon.fake.rejects(new Error("some error")); - + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); AWS.mock("ACM", "requestCertificate", mockRequestCertificate); AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); AWS.mock("Route53", "waitFor", mockWaitFor); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); return LambdaTester(handler) .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); sinon.assert.callCount(mockDescribeCertificate, 1); @@ -287,6 +384,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { const mockWaitForCertificateValidation = sinon.stub(); mockWaitForCertificateValidation.withArgs('certificateValidated', sinon.match.has("CertificateArn", "mockCertArn")).rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); AWS.mock("ACM", "requestCertificate", mockRequestCertificate); AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); @@ -299,6 +397,7 @@ describe("DNS Certificate Validation And Custom Domains for NLB", () => { .event(mockRequest) .expectResolve(() => { expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); sinon.assert.callCount(mockListResourceRecordSets, 3); sinon.assert.callCount(mockRequestCertificate, 1); sinon.assert.callCount(mockDescribeCertificate, 1);