Skip to content

feat: add ACM certificate construct #390

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 8, 2021
Merged
155 changes: 155 additions & 0 deletions src/constructs/acm/__snapshots__/certificate.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`The GuCertificate class should create a new certificate (which requires manual DNS changes) if hosted zone ids are not provided 1`] = `
Object {
"Mappings": Object {
"stagemapping": Object {
"CODE": Object {
"domainName": "code-guardian.com",
},
"PROD": Object {
"domainName": "prod-guardian.com",
},
},
},
"Parameters": Object {
"Stage": Object {
"AllowedValues": Array [
"CODE",
"PROD",
],
"Default": "CODE",
"Description": "Stage name",
"Type": "String",
},
},
"Resources": Object {
"TestCertificate6B4956B6": Object {
"DeletionPolicy": "Retain",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that adding this deletion policy will improve safety and allow us to import certificates created outside of CloudFormation 🤞

"Properties": Object {
"DomainName": Object {
"Fn::FindInMap": Array [
"stagemapping",
Object {
"Ref": "Stage",
},
"domainName",
],
},
"Tags": Array [
Object {
"Key": "App",
"Value": "testing",
},
Object {
"Key": "gu:cdk:version",
"Value": "TEST",
},
Object {
"Key": "Stack",
"Value": "test-stack",
},
Object {
"Key": "Stage",
"Value": Object {
"Ref": "Stage",
},
},
],
"ValidationMethod": "DNS",
},
"Type": "AWS::CertificateManager::Certificate",
"UpdateReplacePolicy": "Retain",
},
},
}
`;

exports[`The GuCertificate class should create a new certificate when hosted zone ids are provided 1`] = `
Object {
"Mappings": Object {
"stagemapping": Object {
"CODE": Object {
"domainName": "code-guardian.com",
"hostedZoneId": "id123",
},
"PROD": Object {
"domainName": "prod-guardian.com",
"hostedZoneId": "id124",
},
},
},
"Parameters": Object {
"Stage": Object {
"AllowedValues": Array [
"CODE",
"PROD",
],
"Default": "CODE",
"Description": "Stage name",
"Type": "String",
},
},
"Resources": Object {
"TestCertificate6B4956B6": Object {
"DeletionPolicy": "Retain",
"Properties": Object {
"DomainName": Object {
"Fn::FindInMap": Array [
"stagemapping",
Object {
"Ref": "Stage",
},
"domainName",
],
},
"DomainValidationOptions": Array [
Object {
"DomainName": Object {
"Fn::FindInMap": Array [
"stagemapping",
Object {
"Ref": "Stage",
},
"domainName",
],
},
"HostedZoneId": Object {
"Fn::FindInMap": Array [
"stagemapping",
Object {
"Ref": "Stage",
},
"hostedZoneId",
],
},
},
],
"Tags": Array [
Object {
"Key": "App",
"Value": "testing",
},
Object {
"Key": "gu:cdk:version",
"Value": "TEST",
},
Object {
"Key": "Stack",
"Value": "test-stack",
},
Object {
"Key": "Stage",
"Value": Object {
"Ref": "Stage",
},
},
],
"ValidationMethod": "DNS",
},
"Type": "AWS::CertificateManager::Certificate",
"UpdateReplacePolicy": "Retain",
},
},
}
`;
55 changes: 55 additions & 0 deletions src/constructs/acm/certificate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { SynthUtils } from "@aws-cdk/assert";
import { Stage } from "../../constants";
import type { SynthedStack } from "../../utils/test";
import { simpleGuStackForTesting } from "../../utils/test";
import { GuCertificate } from "./certificate";

describe("The GuCertificate class", () => {
it("should create a new certificate when hosted zone ids are provided", () => {
const stack = simpleGuStackForTesting();
new GuCertificate(stack, "TestCertificate", {
app: "testing",
[Stage.CODE]: {
domainName: "code-guardian.com",
hostedZoneId: "id123",
},
[Stage.PROD]: {
domainName: "prod-guardian.com",
hostedZoneId: "id124",
},
});
expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
});

it("should create a new certificate (which requires manual DNS changes) if hosted zone ids are not provided", () => {
const stack = simpleGuStackForTesting();
new GuCertificate(stack, "TestCertificate", {
app: "testing",
[Stage.CODE]: {
domainName: "code-guardian.com",
},
[Stage.PROD]: {
domainName: "prod-guardian.com",
},
});
expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
});

it("should inherit a CloudFormed certificate correctly", () => {
const stack = simpleGuStackForTesting({ migratedFromCloudFormation: true });
new GuCertificate(stack, "TestCertificate", {
app: "testing",
existingLogicalId: "MyCloudFormedCertificate",
[Stage.CODE]: {
domainName: "code-guardian.com",
hostedZoneId: "id123",
},
[Stage.PROD]: {
domainName: "prod-guardian.com",
hostedZoneId: "id124",
},
});
const json = SynthUtils.toCloudFormation(stack) as SynthedStack;
expect(Object.keys(json.Resources)).toContain("MyCloudFormedCertificate");
});
});
87 changes: 87 additions & 0 deletions src/constructs/acm/certificate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Certificate, CertificateValidation } from "@aws-cdk/aws-certificatemanager";
import type { CertificateProps } from "@aws-cdk/aws-certificatemanager/lib/certificate";
import { HostedZone } from "@aws-cdk/aws-route53";
import { RemovalPolicy } from "@aws-cdk/core";
import { Stage } from "../../constants";
import type { GuStack } from "../core";
import { AppIdentity } from "../core/identity";
import { GuMigratingResource } from "../core/migrating";
import type { GuStatefulConstruct } from "../core/migrating";

export type GuCertificateProps = Record<Stage, GuDnsValidatedCertificateProps> & GuMigratingResource & AppIdentity;

export interface GuDnsValidatedCertificateProps {
domainName: string;
hostedZoneId?: string;
}

/**
* Construct which creates an ACM Certificate.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: Should we make it explicit that this is a DNS validated cert? Either by mentioning it in this comment or in the naming of the construct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I've updated the docs.

*
* If your DNS is managed via Route 53, then supplying `hostedZoneId` props will allow AWS to automatically
* validate your certificate.
*
* If your DNS is not managed via Route 53, or you omit the `hostedZoneId` props, then the CloudFormation
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This advice is based on https://github.com/aws/aws-cdk/blob/7966f8d48c4bff26beb22856d289f9d0c7e7081d/packages/%40aws-cdk/aws-certificatemanager/lib/certificate.ts#L101-L116. In the future, perhaps we can use a custom resource to create the appropriate records elsewhere (NS1?) automatically?

* operation which adds this construct will pause until the relevant DNS record has been added manually.
*
* Example usage for creating a new certificate:
*
* ```typescript
* new GuCertificate(stack, "TestCertificate", {
* app: "testing",
* [Stage.CODE]: {
* domainName: "code-guardian.com",
* hostedZoneId: "id123",
* },
* [Stage.PROD]: {
* domainName: "prod-guardian.com",
* hostedZoneId: "id124",
* },
* });
*```
*
* Example usage for inheriting a certificate which was created via CloudFormation:
*
* ```typescript
* new GuCertificate(stack, "TestCertificate", {
* app: "testing",
* existingLogicalId: "MyCloudFormedCertificate",
* [Stage.CODE]: {
* domainName: "code-guardian.com",
* hostedZoneId: "id123",
* },
* [Stage.PROD]: {
* domainName: "prod-guardian.com",
* hostedZoneId: "id124",
* },
* });
*```
*/
export class GuCertificate extends Certificate implements GuStatefulConstruct {
isStatefulConstruct: true;
constructor(scope: GuStack, id: string, props: GuCertificateProps) {
const maybeHostedZone =
props.CODE.hostedZoneId && props.PROD.hostedZoneId
? HostedZone.fromHostedZoneId(
scope,
"HostedZone",
scope.withStageDependentValue({
variableName: "hostedZoneId",
stageValues: { [Stage.CODE]: props.CODE.hostedZoneId, [Stage.PROD]: props.PROD.hostedZoneId },
})
)
: undefined;
const awsCertificateProps: CertificateProps = {
domainName: scope.withStageDependentValue({
variableName: "domainName",
stageValues: { [Stage.CODE]: props.CODE.domainName, [Stage.PROD]: props.PROD.domainName },
}),
validation: CertificateValidation.fromDns(maybeHostedZone),
};
super(scope, id, awsCertificateProps);
this.applyRemovalPolicy(RemovalPolicy.RETAIN);
this.isStatefulConstruct = true;
GuMigratingResource.setLogicalId(this, scope, { existingLogicalId: props.existingLogicalId });
AppIdentity.taggedConstruct({ app: props.app }, this);
}
}
1 change: 1 addition & 0 deletions src/constructs/acm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./certificate";