Skip to content
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

feat: Introduce mixins for migratable constructs #418

Merged
merged 3 commits into from
Apr 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint/rules/valid-constructors.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const lintParameter = (param, node, context, { name, type, allowOptional, allowD
});
}

const currentName = param.name ?? param.left.name;
const currentName = param.name ?? param.left?.name ?? param.argument.name;

if (currentName !== name) {
return context.report({
Expand Down
12 changes: 5 additions & 7 deletions src/constructs/acm/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type { CertificateProps } from "@aws-cdk/aws-certificatemanager/lib/certi
import { HostedZone } from "@aws-cdk/aws-route53";
import { RemovalPolicy } from "@aws-cdk/core";
import { Stage } from "../../constants";
import { GuStatefulMigratableConstruct } from "../../utils/mixin";
import type { GuStack } from "../core";
import { AppIdentity } from "../core/identity";
import { GuMigratingResource } from "../core/migrating";
import type { GuStatefulConstruct } from "../core/migrating";
import type { GuMigratingResource } from "../core/migrating";

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

Expand Down Expand Up @@ -57,8 +57,7 @@ export interface GuDnsValidatedCertificateProps {
* });
*```
*/
export class GuCertificate extends Certificate implements GuStatefulConstruct {
isStatefulConstruct: true;
export class GuCertificate extends GuStatefulMigratableConstruct(Certificate) {
constructor(scope: GuStack, id: string, props: GuCertificateProps) {
const maybeHostedZone =
props.CODE.hostedZoneId && props.PROD.hostedZoneId
Expand All @@ -71,17 +70,16 @@ export class GuCertificate extends Certificate implements GuStatefulConstruct {
})
)
: undefined;
const awsCertificateProps: CertificateProps = {
const awsCertificateProps: CertificateProps & GuMigratingResource = {
domainName: scope.withStageDependentValue({
variableName: "domainName",
stageValues: { [Stage.CODE]: props.CODE.domainName, [Stage.PROD]: props.PROD.domainName },
}),
validation: CertificateValidation.fromDns(maybeHostedZone),
existingLogicalId: props.existingLogicalId,
};
super(scope, id, awsCertificateProps);
this.applyRemovalPolicy(RemovalPolicy.RETAIN);
this.isStatefulConstruct = true;
GuMigratingResource.setLogicalId(this, scope, { existingLogicalId: props.existingLogicalId });
AppIdentity.taggedConstruct({ app: props.app }, this);
}
}
2 changes: 1 addition & 1 deletion src/constructs/core/migrating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { SynthUtils } from "@aws-cdk/assert";
import type { BucketProps } from "@aws-cdk/aws-s3";
import { Bucket } from "@aws-cdk/aws-s3";
import { Logger } from "../../utils/logger";
import type { GuStatefulConstruct } from "../../utils/mixin";
import type { SynthedStack } from "../../utils/test";
import { simpleGuStackForTesting } from "../../utils/test";
import type { GuStatefulConstruct } from "./migrating";
import { GuMigratingResource } from "./migrating";
import type { GuStack } from "./stack";

Expand Down
19 changes: 8 additions & 11 deletions src/constructs/core/migrating.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CfnElement, IConstruct } from "@aws-cdk/core";
import { Logger } from "../../utils/logger";
import { isGuStatefulConstruct } from "../../utils/mixin";

export interface GuMigratingStack {
/**
Expand All @@ -10,7 +11,12 @@ export interface GuMigratingStack {
* @see GuMigratingResource
* @see GuStack
*/
migratedFromCloudFormation?: boolean;
migratedFromCloudFormation: boolean;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -- user defined type guard
export function isGuMigratingStack(construct: any): construct is GuMigratingStack {
return "migratedFromCloudFormation" in construct;
}

export interface GuMigratingResource {
Expand All @@ -29,17 +35,8 @@ export interface GuMigratingResource {
existingLogicalId?: string;
}

export interface GuStatefulConstruct extends IConstruct {
isStatefulConstruct: true;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- user defined type guard
function isGuStatefulConstruct(construct: any): construct is GuStatefulConstruct {
return "isStatefulConstruct" in construct;
}

export const GuMigratingResource = {
setLogicalId<T extends GuStatefulConstruct | IConstruct>(
setLogicalId<T extends IConstruct>(
construct: T,
{ migratedFromCloudFormation }: GuMigratingStack,
{ existingLogicalId }: GuMigratingResource
Expand Down
4 changes: 2 additions & 2 deletions src/constructs/core/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { GuMigratingStack } from "./migrating";
import type { GuParameter } from "./parameters";
import { GuStageParameter } from "./parameters";

export interface GuStackProps extends StackProps, GuMigratingStack {
export interface GuStackProps extends StackProps, Partial<GuMigratingStack> {
stack: string;
}

Expand Down Expand Up @@ -40,7 +40,7 @@ export interface GuStackProps extends StackProps, GuMigratingStack {
* }
* ```
*/
export class GuStack extends Stack implements StackStageIdentity {
export class GuStack extends Stack implements StackStageIdentity, GuMigratingStack {
private readonly _stack: string;
private readonly _stage: string;

Expand Down
2 changes: 2 additions & 0 deletions src/utils/mixin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./migratable-construct";
export * from "./migratable-construct-stateful";
42 changes: 42 additions & 0 deletions src/utils/mixin/migratable-construct-stateful.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { BucketProps } from "@aws-cdk/aws-s3";
import "../test/jest";
import { Bucket } from "@aws-cdk/aws-s3";
import type { GuStack } from "../../constructs/core";
import type { GuMigratingResource } from "../../constructs/core/migrating";
import { Logger } from "../logger";
import { simpleGuStackForTesting } from "../test";
import { GuStatefulMigratableConstruct } from "./migratable-construct-stateful";

interface TestGuMigratableConstructProps extends BucketProps, GuMigratingResource {}

class StatefulTestGuMigratableConstruct extends GuStatefulMigratableConstruct(Bucket) {
constructor(scope: GuStack, id: string, props: TestGuMigratableConstructProps) {
super(scope, id, props);
}
}

describe("The GuStatefulMigratableConstruct mixin", () => {
const info = jest.spyOn(Logger, "info");

beforeEach(() => {
info.mockReset();
});

it("should add the `isStatefulConstruct` property when used", () => {
const stack = simpleGuStackForTesting();
const bucket = new StatefulTestGuMigratableConstruct(stack, "MyBucket", {});

expect(bucket.isStatefulConstruct).toBe(true);
});

it("should trigger a warning when creating a stateful construct in a new stack", () => {
const stack = simpleGuStackForTesting();
new StatefulTestGuMigratableConstruct(stack, "MyBucket", {});

expect(info).toHaveBeenCalledTimes(1);
expect(info).toHaveBeenCalledWith(
"GuStack has 'migratedFromCloudFormation' set to false. MyBucket is a stateful construct, it's logicalId will be auto-generated and AWS will create a new resource."
);
expect(stack).toHaveResourceOfTypeAndLogicalId("AWS::S3::Bucket", /^MyBucket.+/);
});
});
57 changes: 57 additions & 0 deletions src/utils/mixin/migratable-construct-stateful.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { GuMigratableConstruct } from "./migratable-construct";
import type { AnyConstructor } from "./types";

export interface GuStatefulConstruct {
/**
* A flag to signal to `GuMigratingResource` that a construct is stateful and care should be taken when migrating to GuCDK.
* If one accidentally replaces a stateful resource, downstream services such as DNS may be impacted.
*/
isStatefulConstruct: true;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -- user defined type guard
export function isGuStatefulConstruct(construct: any): construct is GuStatefulConstruct {
return "isStatefulConstruct" in construct;
}

/**
* A mixin to add the property `isStatefulConstruct` to a class and execute logic to conditionally override a construct's logicalId when synthesised.
*
* If one accidentally replaces a stateful resource, downstream services such as DNS may be impacted.
*
* Usage:
* ```typescript
* class MyClass extends GuStatefulMigratableConstruct(SomeAwsConstruct) { }
* ```
*
* This results in any new instance of `MyClass` having an `isStatefulConstruct` accessor field:
*
* ```typescript
* const resource = new MyClass();
* console.log(resource.isStatefulConstruct) // true
* ```
*
* Note, if you have `experimentalDecorators` enabled, you can use it as such:
*
* ```typescript
* @GuStatefulMigratableConstruct
* class MyClass extends SomeAwsConstruct { }
* ```
* As the name suggest, decorators are experimental.
*
* @param BaseClass the class to apply the mixin to
* @constructor
*
* @see GuMigratableConstruct
* @see GuMigratingResource
* @see https://www.typescriptlang.org/docs/handbook/mixins.html
* @see https://www.typescriptlang.org/docs/handbook/decorators.html
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -- mixin
export function GuStatefulMigratableConstruct<TBase extends AnyConstructor>(BaseClass: TBase) {
return class extends GuMigratableConstruct(BaseClass) implements GuStatefulConstruct {
get isStatefulConstruct(): true {
return true;
}
};
}
47 changes: 47 additions & 0 deletions src/utils/mixin/migratable-construct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { BucketProps } from "@aws-cdk/aws-s3";
import "../test/jest";
import { Bucket } from "@aws-cdk/aws-s3";
import type { GuStack } from "../../constructs/core";
import { GuMigratingResource } from "../../constructs/core/migrating";
import { simpleGuStackForTesting } from "../test";
import { GuMigratableConstruct } from "./migratable-construct";

interface TestGuMigratableConstructProps extends BucketProps, GuMigratingResource {}

class TestGuMigratableConstruct extends GuMigratableConstruct(Bucket) {
constructor(scope: GuStack, id: string, props: TestGuMigratableConstructProps) {
super(scope, id, props);
}
}

describe("The GuMigratableConstruct mixin", () => {
const spy = jest.spyOn(GuMigratingResource, "setLogicalId");

afterEach(() => {
spy.mockReset();
});

it("should call GuMigratingResource.setLogicalId when the stack is being migrated and existingLogicalId is set", () => {
const stack = simpleGuStackForTesting({ migratedFromCloudFormation: true });
new TestGuMigratableConstruct(stack, "MyBucket", { existingLogicalId: "Hello" });

expect(spy).toHaveBeenCalledTimes(1);
expect(stack).toHaveResourceOfTypeAndLogicalId("AWS::S3::Bucket", "Hello");
});

it("should call GuMigratingResource.setLogicalId when the stack is not being migrated and existingLogicalId is set", () => {
const stack = simpleGuStackForTesting({ migratedFromCloudFormation: false });
new TestGuMigratableConstruct(stack, "MyBucket", { existingLogicalId: "Hello" });

expect(spy).toHaveBeenCalledTimes(1);
expect(stack).toHaveResourceOfTypeAndLogicalId("AWS::S3::Bucket", /^MyBucket.+/);
});

it("should call GuMigratingResource.setLogicalId even when existingLogicalId is undefined", () => {
const stack = simpleGuStackForTesting();
new TestGuMigratableConstruct(stack, "MyBucket", {});

expect(spy).toHaveBeenCalledTimes(1);
expect(stack).toHaveResourceOfTypeAndLogicalId("AWS::S3::Bucket", /^MyBucket.+/);
});
});
90 changes: 90 additions & 0 deletions src/utils/mixin/migratable-construct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Construct } from "@aws-cdk/core";
import type { GuMigratingStack } from "../../constructs/core/migrating";
import { GuMigratingResource, isGuMigratingStack } from "../../constructs/core/migrating";
import type { AnyConstructor } from "./types";

/**
* A mixin to add the property `isStatefulConstruct` to a class and execute logic to conditionally override a construct's logicalId when synthesised.
*
* Overriding the logicalId makes a migration easier as the resulting CloudFormation template has a smaller difference from that of a running stack.
*
* For example, say you have a stack defined in YAML that creates a bucket:
*
* ```yaml
* MyBucket:
* Type: AWS::S3::Bucket
* ```
*
* By default, the CDK library will auto-generate the logicalId of resources. That is, by default a bucket would look like:
*
* ```yaml
* MyBucketA0B1C2D:
* Type: AWS::S3::Bucket
* ```
*
* Override the logicalId to keep it to `MyBucket`.
*
* Usage:
* ```typescript
* class MyClass extends GuMigratableConstruct(SomeAwsConstruct) { }
*```
*
* Note, if you have `experimentalDecorators` enabled, you can use it as such:
*
* ```typescript
* @GuMigratableConstruct
* class MyClass extends SomeAwsConstruct {
*
* }
* ```
* As the name suggest, decorators are experimental.
*
* @param BaseClass the class to apply the mixin to
* @constructor
*
* @see GuStatefulMigratableConstruct
* @see GuMigratingResource
* @see https://www.typescriptlang.org/docs/handbook/mixins.html
* @see https://www.typescriptlang.org/docs/handbook/decorators.html
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -- mixin
export function GuMigratableConstruct<TBase extends AnyConstructor>(BaseClass: TBase) {
return class extends BaseClass {
// eslint-disable-next-line custom-rules/valid-constructors, @typescript-eslint/no-explicit-any -- mixin
constructor(...args: any[]) {
// `super` is the parent AWS constructor here
super(...args);

// Constrained mixins could be used here but couldn't get it to work
// See https://www.typescriptlang.org/docs/handbook/mixins.html#constrained-mixins
if (Construct.isConstruct(this)) {
// the parent AWS constructor presents a common signature of `scope`, `id`, `props`
if (args.length === 3) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- mixin
const [scope, id, props] = args;

const isAMigratingStack = isGuMigratingStack(scope);
const isIdAString = typeof id === "string";

/*
A test for `props` would be to check if it's a `GuMigratingResource`.
That is, is `existingLogicalId` present.
However, this would be `false` if `existingLogicalId` is `undefined`
and `GuMigratingResource.setLogicalId` won't get called.
*/
const looksLikeAConstruct = isAMigratingStack && isIdAString;

if (looksLikeAConstruct) {
GuMigratingResource.setLogicalId(
this,
{
migratedFromCloudFormation: (scope as GuMigratingStack).migratedFromCloudFormation,
},
props
);
}
}
}
}
};
}
2 changes: 2 additions & 0 deletions src/utils/mixin/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types -- mixin
export type AnyConstructor = new (...args: any[]) => {};