Skip to content

Commit

Permalink
feat(elasticsearch): add custom endpoint options (#12904)
Browse files Browse the repository at this point in the history
Closes #12261 

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
hoegertn authored Feb 10, 2021
1 parent f864e46 commit f67ab86
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 0 deletions.
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-elasticsearch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 56 additions & 0 deletions packages/@aws-cdk/aws-elasticsearch/lib/domain.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
? {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/@aws-cdk/aws-elasticsearch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,27 @@
"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",
"constructs": "^3.2.0"
},
"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",
Expand Down
130 changes: 130 additions & 0 deletions packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit f67ab86

Please sign in to comment.