diff --git a/src/constructs/core/stack.ts b/src/constructs/core/stack.ts index 491421a3bb..03f7338f81 100644 --- a/src/constructs/core/stack.ts +++ b/src/constructs/core/stack.ts @@ -1,5 +1,6 @@ -import { Annotations, Stack, Tags } from "@aws-cdk/core"; -import type { App, StackProps } from "@aws-cdk/core"; +import { SynthUtils } from "@aws-cdk/assert"; +import { Annotations, App, Stack, Tags } from "@aws-cdk/core"; +import type { StackProps } from "@aws-cdk/core"; import execa from "execa"; import gitUrlParse from "git-url-parse"; import { StageForInfrastructure } from "../../constants"; @@ -126,6 +127,10 @@ export class GuStack extends Stack implements StackStageIdentity, GuMigratingSta return this.params.get(key) as T; } + get parameterKeys(): string[] { + return Array.from(this.params.keys()); + } + // eslint-disable-next-line custom-rules/valid-constructors -- GuStack is the exception as it must take an App constructor(app: App, id: string, props: GuStackProps) { const mergedProps = { @@ -196,3 +201,19 @@ export class GuStackForInfrastructure extends GuStack { return super.withStageDependentValue(mappingValue); } } + +/** + * A GuStack but designed for Stack Set instances. + * + * In a stack set application, `GuStackForStackSetInstance` is used to represent the infrastructure to provision in target AWS accounts. + */ +export class GuStackForStackSetInstance extends GuStackForInfrastructure { + // eslint-disable-next-line custom-rules/valid-constructors -- GuStackForStackSet should have a unique `App` + constructor(id: string, props: GuStackProps) { + super(new App(), id, props); + } + + get cfnJson(): string { + return JSON.stringify(SynthUtils.toCloudFormation(this), null, 2); + } +} diff --git a/src/constructs/stack-set/__snapshots__/stack-set.test.ts.snap b/src/constructs/stack-set/__snapshots__/stack-set.test.ts.snap new file mode 100644 index 0000000000..733bae8b29 --- /dev/null +++ b/src/constructs/stack-set/__snapshots__/stack-set.test.ts.snap @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The GuStackSet construct should correctly provision a stack with a stack set resource 1`] = ` +Object { + "Resources": Object { + "StackSet": Object { + "Properties": Object { + "AutoDeployment": Object { + "Enabled": true, + "RetainStacksOnAccountRemoval": false, + }, + "Description": "this stack set provisions some common infrastructure", + "Parameters": Array [], + "PermissionModel": "SERVICE_MANAGED", + "StackInstancesGroup": Array [ + Object { + "DeploymentTargets": Object { + "OrganizationalUnitIds": Array [ + "o-12345abcde", + ], + }, + "Regions": Array [ + Object { + "Ref": "AWS::Region", + }, + ], + }, + ], + "StackSetName": "my-stack-set", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test", + }, + Object { + "Key": "Stage", + "Value": "INFRA", + }, + ], + "TemplateBody": "{ + \\"Resources\\": { + \\"accountloggingstreamB8733874\\": { + \\"Type\\": \\"AWS::Kinesis::Stream\\", + \\"Properties\\": { + \\"ShardCount\\": 1, + \\"RetentionPeriodHours\\": 24, + \\"StreamEncryption\\": { + \\"Fn::If\\": [ + \\"AwsCdkKinesisEncryptedStreamsUnsupportedRegions\\", + { + \\"Ref\\": \\"AWS::NoValue\\" + }, + { + \\"EncryptionType\\": \\"KMS\\", + \\"KeyId\\": \\"alias/aws/kinesis\\" + } + ] + }, + \\"Tags\\": [ + { + \\"Key\\": \\"gu:cdk:version\\", + \\"Value\\": \\"TEST\\" + }, + { + \\"Key\\": \\"gu:repo\\", + \\"Value\\": \\"guardian/cdk\\" + }, + { + \\"Key\\": \\"Stack\\", + \\"Value\\": \\"test\\" + }, + { + \\"Key\\": \\"Stage\\", + \\"Value\\": \\"INFRA\\" + } + ] + } + } + }, + \\"Conditions\\": { + \\"AwsCdkKinesisEncryptedStreamsUnsupportedRegions\\": { + \\"Fn::Or\\": [ + { + \\"Fn::Equals\\": [ + { + \\"Ref\\": \\"AWS::Region\\" + }, + \\"cn-north-1\\" + ] + }, + { + \\"Fn::Equals\\": [ + { + \\"Ref\\": \\"AWS::Region\\" + }, + \\"cn-northwest-1\\" + ] + } + ] + } + } +}", + }, + "Type": "AWS::CloudFormation::StackSet", + }, + }, +} +`; + +exports[`The GuStackSet construct should support parameters in the stack set instance 1`] = ` +Object { + "Resources": Object { + "StackSet": Object { + "Properties": Object { + "AutoDeployment": Object { + "Enabled": true, + "RetainStacksOnAccountRemoval": false, + }, + "Description": "this stack set provisions some common infrastructure", + "Parameters": Array [ + Object { + "ParameterKey": "CentralSnsTopicArn", + "ParameterValue": Object { + "Ref": "accountalertsD9982E6F", + }, + }, + ], + "PermissionModel": "SERVICE_MANAGED", + "StackInstancesGroup": Array [ + Object { + "DeploymentTargets": Object { + "OrganizationalUnitIds": Array [ + "o-12345abcde", + ], + }, + "Regions": Array [ + Object { + "Ref": "AWS::Region", + }, + ], + }, + ], + "StackSetName": "my-stack-set", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test", + }, + Object { + "Key": "Stage", + "Value": "INFRA", + }, + ], + "TemplateBody": "{ + \\"Parameters\\": { + \\"CentralSnsTopicArn\\": { + \\"Type\\": \\"String\\", + \\"AllowedPattern\\": \\"arn:aws:[a-z0-9]*:[a-z0-9\\\\\\\\-]*:[0-9]{12}:.*\\" + } + } +}", + }, + "Type": "AWS::CloudFormation::StackSet", + }, + "accountalertsD9982E6F": Object { + "Properties": Object { + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test", + }, + Object { + "Key": "Stage", + "Value": "INFRA", + }, + ], + }, + "Type": "AWS::SNS::Topic", + }, + "accountalertsPolicy8E37A8FA": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sns:Publish", + "Condition": Object { + "StringEquals": Object { + "aws:PrincipalOrgID": "o-12345abcde", + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": Object { + "Ref": "accountalertsD9982E6F", + }, + "Sid": "0", + }, + ], + "Version": "2012-10-17", + }, + "Topics": Array [ + Object { + "Ref": "accountalertsD9982E6F", + }, + ], + }, + "Type": "AWS::SNS::TopicPolicy", + }, + }, +} +`; diff --git a/src/constructs/stack-set/index.ts b/src/constructs/stack-set/index.ts new file mode 100644 index 0000000000..e3222464c1 --- /dev/null +++ b/src/constructs/stack-set/index.ts @@ -0,0 +1 @@ +export * from "./stack-set"; diff --git a/src/constructs/stack-set/stack-set.test.ts b/src/constructs/stack-set/stack-set.test.ts new file mode 100644 index 0000000000..4b697a76da --- /dev/null +++ b/src/constructs/stack-set/stack-set.test.ts @@ -0,0 +1,70 @@ +import { SynthUtils } from "@aws-cdk/assert"; +import { OrganizationPrincipal } from "@aws-cdk/aws-iam"; +import { Topic } from "@aws-cdk/aws-sns"; +import { App } from "@aws-cdk/core"; +import { RegexPattern } from "../../constants"; +import { GuStackForInfrastructure, GuStackForStackSetInstance, GuStringParameter } from "../core"; +import { GuKinesisStream } from "../kinesis"; +import { GuSnsTopic } from "../sns"; +import { GuStackSet } from "./stack-set"; + +describe("The GuStackSet construct", () => { + it("should correctly provision a stack with a stack set resource", () => { + const theStackSetInstance = new GuStackForStackSetInstance("the-stack-set", { stack: "test" }); + new GuKinesisStream(theStackSetInstance, "account-logging-stream"); + + const parentStack = new GuStackForInfrastructure(new App(), "Test", { stack: "test" }); + + new GuStackSet(parentStack, "StackSet", { + name: "my-stack-set", + description: "this stack set provisions some common infrastructure", + organisationUnitTargets: ["o-12345abcde"], + stackSetInstance: theStackSetInstance, + }); + + expect(SynthUtils.toCloudFormation(parentStack)).toMatchSnapshot(); + }); + + it("should support parameters in the stack set instance", () => { + const theStackSetInstance = new GuStackForStackSetInstance("the-stack-set", { stack: "test" }); + Topic.fromTopicArn( + theStackSetInstance, + "central-topic", + new GuStringParameter(theStackSetInstance, "CentralSnsTopicArn", { allowedPattern: RegexPattern.ARN }) + .valueAsString + ); + + const awsOrgId = "o-12345abcde"; + const parentStack = new GuStackForInfrastructure(new App(), "Test", { stack: "test" }); + const centralTopic = new GuSnsTopic(parentStack, "account-alerts"); + centralTopic.grantPublish(new OrganizationPrincipal(awsOrgId)); + + new GuStackSet(parentStack, "StackSet", { + name: "my-stack-set", + description: "this stack set provisions some common infrastructure", + organisationUnitTargets: [awsOrgId], + stackSetInstance: theStackSetInstance, + stackSetInstanceParameters: { + CentralSnsTopicArn: centralTopic.topicArn, + }, + }); + + expect(SynthUtils.toCloudFormation(parentStack)).toMatchSnapshot(); + }); + + it("should error if the parent stack does not specify all parameters for the stack set instance template", () => { + const theStackSetInstance = new GuStackForStackSetInstance("the-stack-set", { stack: "test" }); + new GuStringParameter(theStackSetInstance, "CentralSnsTopic", { allowedPattern: RegexPattern.ARN }); + + const parentStack = new GuStackForInfrastructure(new App(), "Test", { stack: "test" }); + + expect(() => { + new GuStackSet(parentStack, "StackSet", { + name: "my-stack-set", + description: "this stack set provisions some common infrastructure", + organisationUnitTargets: ["o-12345abcde"], + stackSetInstance: theStackSetInstance, + }); + }).toThrow("There are undefined stack set parameters: CentralSnsTopic"); + }); +}); diff --git a/src/constructs/stack-set/stack-set.ts b/src/constructs/stack-set/stack-set.ts new file mode 100644 index 0000000000..0dd4ef0d32 --- /dev/null +++ b/src/constructs/stack-set/stack-set.ts @@ -0,0 +1,173 @@ +import { CfnStackSet } from "@aws-cdk/core"; +import type { GuStackForInfrastructure, GuStackForStackSetInstance } from "../core"; + +interface GuStackSetProps { + /** + * The contents for `TemplateBody` within a `AWS::CloudFormation::StackSet`. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-stackset.html#cfn-cloudformation-stackset-templatebody + */ + stackSetInstance: GuStackForStackSetInstance; + + /** + * The name of the stack set. This will appear in the target account. + */ + name: string; + + /** + * A description of the stack set. This will appear in the target account. + */ + description: string; + + /** + * AWS Organisation Unit IDs to deploy the stack set into. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudformation-stackset-deploymenttargets.html#cfn-cloudformation-stackset-deploymenttargets-organizationalunitids + */ + organisationUnitTargets: string[]; + + /** + * The contents for`Parameters` within a `AWS::CloudFormation::StackSet`. + * + * Typically, you'll only need parameters if the parent stack creates resources for use in the stack set instances. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-stackset.html#cfn-cloudformation-stackset-parameters + */ + stackSetInstanceParameters?: Record; + + /** + * Which regions to run the stack set in. + * + * @default The region of the parent stack. + */ + regions?: string[]; +} + +/** + * A construct to create a `AWS::CloudFormation::StackSet`. + * + * The stack set will be configured to automatically deploy into accounts when they join an AWS Organisation Unit. + * It's assumed stack sets are only provisioned for infrastructure, therefore the `STAGE` value will always be `INFRA`. + * + * Usage: + * ```typescript + * // the infrastructure to create in target accounts + * class AccountAlertTopic extends GuStackForStackSetInstance { + * constructor(id: string, props: GuStackProps) { + * super(id, props); + * + * new GuSnsTopic(this, "topic-for-alerts"); + * } + * } + * + * class ParentStack extends GuStackForInfrastructure { + * constructor(scope: App, id: string, props: GuStackProps) { + * super(scope, id, props); + * + * new GuStackSet(this, `${id}StackSet`, { + * stackSetInstance: new AccountAlertTopic("Alerts", { stack: props.stack }), + * name: "centralised-alarms", + * description: "Provisioning of standard account alerting resources", + * organisationUnitTargets: [ "o-abcde12345" ] + * }); + * } + * } + * + * // contents of `bin/cdk.ts` + * new ParentStack(new App(), "AccountAlarmResources", { stack: "alarms" }) + * ``` + * + * This will produce a CloudFormation template like this: + * ```yaml + * Resources: + * AccountAlarmResourcesStackSet: + * Type: AWS::CloudFormation::StackSet + * Properties: + * PermissionModel: SERVICE_MANAGED + * StackSetName: centralised-alarms + * AutoDeployment: + * Enabled: true + * RetainStacksOnAccountRemoval: false + * Description: Provisioning of standard account alerting resources + * Parameters: [] + * StackInstancesGroup: + * - DeploymentTargets: + * OrganizationalUnitIds: + * - o-abcde12345 + * Regions: + * - Ref: AWS::Region + * Tags: + * - Key: gu:cdk:version + * Value: TEST + * - Key: gu:repo + * Value: guardian/cdk + * - Key: Stack + * Value: alarms + * - Key: Stage + * Value: INFRA + * TemplateBody: |- + * { + * "Resources": { + * "topicforalerts57330FBE": { + * "Type": "AWS::SNS::Topic", + * "Properties": { + * "Tags": [ + * { + * "Key": "gu:cdk:version", + * "Value": "TEST" + * }, + * { + * "Key": "gu:repo", + * "Value": "guardian/cdk" + * }, + * { + * "Key": "Stack", + * "Value": "alarms" + * }, + * { + * "Key": "Stage", + * "Value": "INFRA" + * } + * ] + * } + * } + * } + * } + * ``` + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-stackset.html + */ +export class GuStackSet extends CfnStackSet { + constructor(scope: GuStackForInfrastructure, id: string, props: GuStackSetProps) { + const stackSetInstanceParameters = props.stackSetInstanceParameters ?? {}; + + const params = Object.keys(stackSetInstanceParameters); + const undefinedStackSetParams = props.stackSetInstance.parameterKeys.filter((_) => !params.includes(_)); + + if (undefinedStackSetParams.length !== 0) { + throw new Error(`There are undefined stack set parameters: ${undefinedStackSetParams.join(", ")}`); + } + + super(scope, id, { + stackSetName: props.name, + description: props.description, + permissionModel: "SERVICE_MANAGED", + autoDeployment: { + enabled: true, + retainStacksOnAccountRemoval: false, + }, + stackInstancesGroup: [ + { + regions: props.regions ?? [scope.region], + deploymentTargets: { + organizationalUnitIds: props.organisationUnitTargets, + }, + }, + ], + templateBody: props.stackSetInstance.cfnJson, + parameters: Object.entries(stackSetInstanceParameters).map(([key, value]) => { + return { parameterKey: key, parameterValue: value }; + }), + }); + } +}