diff --git a/src/constructs/core/migrating.test.ts b/src/constructs/core/migrating.test.ts new file mode 100644 index 0000000000..5949d807ca --- /dev/null +++ b/src/constructs/core/migrating.test.ts @@ -0,0 +1,122 @@ +import "@aws-cdk/assert/jest"; +import { SynthUtils } from "@aws-cdk/assert"; +import type { BucketProps } from "@aws-cdk/aws-s3"; +import { Bucket } from "@aws-cdk/aws-s3"; +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"; + +class TestGuStatefulConstruct extends Bucket implements GuStatefulConstruct { + isStatefulConstruct: true; + + constructor(scope: GuStack, id: string, props?: BucketProps) { + super(scope, id, props); + this.isStatefulConstruct = true; + } +} + +/* +NOTE: In reality, we'd never directly call `GuMigratingResource.setLogicalId` as the GuConstruct will do that. +We're calling it here to test the function in isolation. + */ + +describe("GuMigratingResource", () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function -- we are testing `console.info` being called, we don't need to see the message + const info = jest.spyOn(console, "info").mockImplementation(() => {}); + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- we are testing `console.warn` being called, we don't need to see the message + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + afterEach(() => { + warn.mockReset(); + info.mockReset(); + }); + + test("Creating a new resource in a new stack produces a new auto-generated ID", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: false }); + const construct = new Bucket(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, {}); + + expect(warn).toHaveBeenCalledTimes(0); + expect(info).toHaveBeenCalledTimes(0); + + const json = SynthUtils.toCloudFormation(stack) as SynthedStack; + const resourceKeys = Object.keys(json.Resources); + + expect(resourceKeys).toHaveLength(1); + expect(resourceKeys[0]).toMatch(/^MyBucket[A-Z0-9]+$/); + }); + + test("Keeping a resource's logicalId when migrating a stack", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: true }); + const construct = new Bucket(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, { existingLogicalId: "my-pre-existing-bucket" }); + + expect(warn).toHaveBeenCalledTimes(0); + expect(info).toHaveBeenCalledTimes(0); + + const json = SynthUtils.toCloudFormation(stack) as SynthedStack; + expect(Object.keys(json.Resources)).toContain("my-pre-existing-bucket"); + }); + + test("Creating a construct in a migrating stack, w/out setting existingLogicalId", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: true }); + const construct = new Bucket(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, {}); + + expect(info).toHaveBeenCalledTimes(0); + expect(warn).toHaveBeenCalledTimes(0); + + const json = SynthUtils.toCloudFormation(stack) as SynthedStack; + const resourceKeys = Object.keys(json.Resources); + + expect(resourceKeys).toHaveLength(1); + expect(resourceKeys[0]).toMatch(/^MyBucket[A-Z0-9]+$/); + }); + + test("Specifying a construct's existingLogicalId in a new stack", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: false }); + const construct = new Bucket(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, { existingLogicalId: "my-pre-existing-bucket" }); + + expect(info).toHaveBeenCalledTimes(0); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "GuStack has 'migratedFromCloudFormation' set to false. MyBucket has an 'existingLogicalId' set to my-pre-existing-bucket. This will have no effect - the logicalId will be auto-generated. Set 'migratedFromCloudFormation' to true for 'existingLogicalId' to be observed." + ); + + const json = SynthUtils.toCloudFormation(stack) as SynthedStack; + const resourceKeys = Object.keys(json.Resources); + + expect(resourceKeys).toHaveLength(1); + expect(resourceKeys[0]).toMatch(/^MyBucket[A-Z0-9]+$/); + }); + + test("Creating a stateful construct in a migrating stack, w/out setting existingLogicalId", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: true }); + const construct = new TestGuStatefulConstruct(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, {}); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "GuStack has 'migratedFromCloudFormation' set to true. MyBucket is a stateful construct and 'existingLogicalId' has not been set. MyBucket's logicalId will be auto-generated and consequently AWS will create a new resource rather than inheriting an existing one. This is not advised as downstream services, such as DNS, will likely need updating." + ); + }); + + test("Creating a stateful construct in a new stack, w/out setting existingLogicalId", () => { + const stack = simpleGuStackForTesting({ migratedFromCloudFormation: false }); + const construct = new TestGuStatefulConstruct(stack, "MyBucket"); + + GuMigratingResource.setLogicalId(construct, stack, {}); + 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." + ); + }); +}); diff --git a/src/constructs/core/migrating.ts b/src/constructs/core/migrating.ts new file mode 100644 index 0000000000..e7d2f202a5 --- /dev/null +++ b/src/constructs/core/migrating.ts @@ -0,0 +1,67 @@ +import type { CfnElement, IConstruct } from "@aws-cdk/core"; +import type { GuStack } from "./stack"; + +export interface GuMigratingResource { + /** + * A string to use to override the logicalId AWS CDK auto-generates for a resource. + * This is useful when migrating a pre-existing stack into guardian/cdk, + * as it ensures resources are kept rather than recreated. + * For example, imagine a YAML stack that creates a load balancer with logicalId `DotcomLoadbalancer`. + * We would want to set `existingLogicalId` for the GuLoadBalancer in guardian/cdk to ensure it is preserved when moving to guardian/cdk. + * Otherwise it will be created as something like `DotcomLoadbalancerABCDEF`, + * is a new resource, and require any DNS entries to be updated accordingly. + * + * @requires `migratedFromCloudFormation` to be true in [[ GuStack ]] + * @see GuStackProps + */ + 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( + construct: T, + { migratedFromCloudFormation }: GuStack, + { existingLogicalId }: GuMigratingResource + ): void { + const overrideLogicalId = (logicalId: string) => { + const defaultChild = construct.node.defaultChild as CfnElement; + defaultChild.overrideLogicalId(logicalId); + }; + + const id = construct.node.id; + const isStateful = isGuStatefulConstruct(construct); + + if (migratedFromCloudFormation) { + if (existingLogicalId) { + return overrideLogicalId(existingLogicalId); + } + + if (isStateful) { + console.warn( + `GuStack has 'migratedFromCloudFormation' set to true. ${id} is a stateful construct and 'existingLogicalId' has not been set. ${id}'s logicalId will be auto-generated and consequently AWS will create a new resource rather than inheriting an existing one. This is not advised as downstream services, such as DNS, will likely need updating.` + ); + } + } else { + if (existingLogicalId) { + console.warn( + `GuStack has 'migratedFromCloudFormation' set to false. ${id} has an 'existingLogicalId' set to ${existingLogicalId}. This will have no effect - the logicalId will be auto-generated. Set 'migratedFromCloudFormation' to true for 'existingLogicalId' to be observed.` + ); + } + + if (isStateful) { + console.info( + `GuStack has 'migratedFromCloudFormation' set to false. ${id} is a stateful construct, it's logicalId will be auto-generated and AWS will create a new resource.` + ); + } + } + }, +}; diff --git a/src/constructs/core/stack.ts b/src/constructs/core/stack.ts index d5372f0959..9ab4b989e1 100644 --- a/src/constructs/core/stack.ts +++ b/src/constructs/core/stack.ts @@ -10,6 +10,14 @@ import { GuStageParameter } from "./parameters"; export interface GuStackProps extends StackProps { stack: string; + + /** + * A flag to symbolise if a stack is being migrated from a previous format (eg YAML) into guardian/cdk. + * A value of `true` means resources in the stack can have custom logicalIds set using the property `existingLogicalId` (where available). + * A value of `false` or `undefined` means the stack is brand new. Any resource that gets created will have an auto-generated logicalId. + * + * @see GuMigratingResource + */ migratedFromCloudFormation?: boolean; }