From f67ab8689dc38803253067c4f9632b9bc5ea653f Mon Sep 17 00:00:00 2001 From: Thorsten Hoeger Date: Wed, 10 Feb 2021 19:04:56 +0100 Subject: [PATCH] feat(elasticsearch): add custom endpoint options (#12904) Closes #12261 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-elasticsearch/README.md | 17 +++ .../@aws-cdk/aws-elasticsearch/lib/domain.ts | 56 ++++++++ .../@aws-cdk/aws-elasticsearch/package.json | 4 + .../aws-elasticsearch/test/domain.test.ts | 130 ++++++++++++++++++ 4 files changed, 207 insertions(+) diff --git a/packages/@aws-cdk/aws-elasticsearch/README.md b/packages/@aws-cdk/aws-elasticsearch/README.md index 35d3f63c6bf55..37d117c393616 100644 --- a/packages/@aws-cdk/aws-elasticsearch/README.md +++ b/packages/@aws-cdk/aws-elasticsearch/README.md @@ -225,3 +225,20 @@ const domain = new es.Domain(this, 'Domain', { }, }); ``` + +## Custom endpoint + +Custom endpoints can be configured to reach the ES domain under a custom domain name. + +```ts +new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + customEndpoint: { + domainName: 'search.example.com', + }, +}); +``` + +It is also possible to specify a custom certificate instead of the auto-generated one. + +Additionally, an automatic CNAME-Record is created if a hosted zone is provided for the custom endpoint diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts b/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts index 603177c87c19f..28728e6cdf58f 100644 --- a/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts +++ b/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts @@ -1,10 +1,12 @@ import { URL } from 'url'; +import * as acm from '@aws-cdk/aws-certificatemanager'; import { Metric, MetricOptions, Statistic } from '@aws-cdk/aws-cloudwatch'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; import * as logs from '@aws-cdk/aws-logs'; +import * as route53 from '@aws-cdk/aws-route53'; import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; @@ -395,6 +397,28 @@ export interface AdvancedSecurityOptions { readonly masterUserPassword?: cdk.SecretValue; } +/** + * Configures a custom domain endpoint for the ES domain + */ +export interface CustomEndpointOptions { + /** + * The custom domain name to assign + */ + readonly domainName: string; + + /** + * The certificate to use + * @default - create a new one + */ + readonly certificate?: acm.ICertificate; + + /** + * The hosted zone in Route53 to create the CNAME record in + * @default - do not create a CNAME + */ + readonly hostedZone?: route53.IHostedZone; +} + /** * Properties for an AWS Elasticsearch Domain. */ @@ -545,6 +569,13 @@ export interface DomainProps { */ readonly enableVersionUpgrade?: boolean; + /** + * To configure a custom domain configure these options + * + * If you specify a Route53 hosted zone it will create a CNAME record and use DNS validation for the certificate + * @default - no custom domain endpoint will be configured + */ + readonly customEndpoint?: CustomEndpointOptions; } /** @@ -1547,6 +1578,18 @@ export class Domain extends DomainBase implements IDomain { }; } + let customEndpointCertificate: acm.ICertificate | undefined; + if (props.customEndpoint) { + if (props.customEndpoint.certificate) { + customEndpointCertificate = props.customEndpoint.certificate; + } else { + customEndpointCertificate = new acm.Certificate(this, 'CustomEndpointCertificate', { + domainName: props.customEndpoint.domainName, + validation: props.customEndpoint.hostedZone ? acm.CertificateValidation.fromDns(props.customEndpoint.hostedZone) : undefined, + }); + } + } + // Create the domain this.domain = new CfnDomain(this, 'Resource', { domainName: this.physicalName, @@ -1602,6 +1645,11 @@ export class Domain extends DomainBase implements IDomain { domainEndpointOptions: { enforceHttps, tlsSecurityPolicy: props.tlsSecurityPolicy ?? TLSSecurityPolicy.TLS_1_0, + ...props.customEndpoint && { + customEndpointEnabled: true, + customEndpoint: props.customEndpoint.domainName, + customEndpointCertificateArn: customEndpointCertificate!.certificateArn, + }, }, advancedSecurityOptions: advancedSecurityEnabled ? { @@ -1637,6 +1685,14 @@ export class Domain extends DomainBase implements IDomain { resourceName: this.physicalName, }); + if (props.customEndpoint?.hostedZone) { + new route53.CnameRecord(this, 'CnameRecord', { + recordName: props.customEndpoint.domainName, + zone: props.customEndpoint.hostedZone, + domainName: this.domainEndpoint, + }); + } + const accessPolicyStatements: iam.PolicyStatement[] | undefined = unsignedBasicAuthEnabled ? (props.accessPolicies ?? []).concat(unsignedAccessPolicy) : props.accessPolicies; diff --git a/packages/@aws-cdk/aws-elasticsearch/package.json b/packages/@aws-cdk/aws-elasticsearch/package.json index c93f1397c06b8..89fa4f885f478 100644 --- a/packages/@aws-cdk/aws-elasticsearch/package.json +++ b/packages/@aws-cdk/aws-elasticsearch/package.json @@ -78,11 +78,13 @@ "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-route53": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/custom-resources": "0.0.0", "@aws-cdk/core": "0.0.0", @@ -90,11 +92,13 @@ }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-route53": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/custom-resources": "0.0.0", "@aws-cdk/core": "0.0.0", diff --git a/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts b/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts index f01fba3c4b332..98a6f350d6f34 100644 --- a/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts +++ b/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts @@ -1,11 +1,13 @@ /* eslint-disable jest/expect-expect */ import '@aws-cdk/assert/jest'; import * as assert from '@aws-cdk/assert'; +import * as acm from '@aws-cdk/aws-certificatemanager'; import { Metric, Statistic } from '@aws-cdk/aws-cloudwatch'; import { Subnet, Vpc, EbsDeviceVolumeType } from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; import * as logs from '@aws-cdk/aws-logs'; +import * as route53 from '@aws-cdk/aws-route53'; import { App, Stack, Duration, SecretValue } from '@aws-cdk/core'; import { Domain, ElasticsearchVersion } from '../lib'; @@ -987,6 +989,134 @@ describe('advanced security options', () => { }); }); +describe('custom endpoints', () => { + const customDomainName = 'search.example.com'; + + test('custom domain without hosted zone and default cert', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + nodeToNodeEncryption: true, + enforceHttps: true, + customEndpoint: { + domainName: customDomainName, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + DomainEndpointOptions: { + EnforceHTTPS: true, + CustomEndpointEnabled: true, + CustomEndpoint: customDomainName, + CustomEndpointCertificateArn: { + Ref: 'DomainCustomEndpointCertificateD080A69E', // Auto-generated certificate + }, + }, + }); + expect(stack).toHaveResourceLike('AWS::CertificateManager::Certificate', { + DomainName: customDomainName, + ValidationMethod: 'EMAIL', + }); + }); + + test('custom domain with hosted zone and default cert', () => { + const zone = new route53.HostedZone(stack, 'DummyZone', { zoneName: 'example.com' }); + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + nodeToNodeEncryption: true, + enforceHttps: true, + customEndpoint: { + domainName: customDomainName, + hostedZone: zone, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + DomainEndpointOptions: { + EnforceHTTPS: true, + CustomEndpointEnabled: true, + CustomEndpoint: customDomainName, + CustomEndpointCertificateArn: { + Ref: 'DomainCustomEndpointCertificateD080A69E', // Auto-generated certificate + }, + }, + }); + expect(stack).toHaveResourceLike('AWS::CertificateManager::Certificate', { + DomainName: customDomainName, + DomainValidationOptions: [ + { + DomainName: customDomainName, + HostedZoneId: { + Ref: 'DummyZone03E0FE81', + }, + }, + ], + ValidationMethod: 'DNS', + }); + expect(stack).toHaveResourceLike('AWS::Route53::RecordSet', { + Name: 'search.example.com.', + Type: 'CNAME', + HostedZoneId: { + Ref: 'DummyZone03E0FE81', + }, + ResourceRecords: [ + { + 'Fn::GetAtt': [ + 'Domain66AC69E0', + 'DomainEndpoint', + ], + }, + ], + }); + }); + + test('custom domain with hosted zone and given cert', () => { + const zone = new route53.HostedZone(stack, 'DummyZone', { + zoneName: 'example.com', + }); + const certificate = new acm.Certificate(stack, 'DummyCert', { + domainName: customDomainName, + }); + + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + nodeToNodeEncryption: true, + enforceHttps: true, + customEndpoint: { + domainName: customDomainName, + hostedZone: zone, + certificate, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + DomainEndpointOptions: { + EnforceHTTPS: true, + CustomEndpointEnabled: true, + CustomEndpoint: customDomainName, + CustomEndpointCertificateArn: { + Ref: 'DummyCertFA37670B', + }, + }, + }); + expect(stack).toHaveResourceLike('AWS::Route53::RecordSet', { + Name: 'search.example.com.', + Type: 'CNAME', + HostedZoneId: { + Ref: 'DummyZone03E0FE81', + }, + ResourceRecords: [ + { + 'Fn::GetAtt': [ + 'Domain66AC69E0', + 'DomainEndpoint', + ], + }, + ], + }); + }); + +}); + describe('custom error responses', () => { test('error when availabilityZoneCount does not match vpcOptions.subnets length', () => {