diff --git a/src/core/cdk/src/initial-setup.ts b/src/core/cdk/src/initial-setup.ts index 743148f7a..771b309a9 100644 --- a/src/core/cdk/src/initial-setup.ts +++ b/src/core/cdk/src/initial-setup.ts @@ -34,6 +34,7 @@ import { CreateControlTowerAccountTask } from './tasks/create-control-tower-acco import { CreateOrganizationAccountTask } from './tasks/create-organization-account-task'; import { CreateStackTask } from './tasks/create-stack-task'; import { RunAcrossAccountsTask } from './tasks/run-across-accounts-task'; +import { EnableOptinRegionTask } from './tasks/enable-optin-region-task'; import { Construct } from 'constructs'; import * as fs from 'fs'; import * as sns from 'aws-cdk-lib/aws-sns'; @@ -623,6 +624,33 @@ export namespace InitialSetup { installExecRolesInAccounts.iterator(installRolesTask); + // Opt in Region - Begin + const optinRegionsStateMachine = new sfn.StateMachine(this, `${props.acceleratorPrefix}OptinRegions_sm`, { + stateMachineName: `${props.acceleratorPrefix}OptinRegions_sm`, + definition: new EnableOptinRegionTask(this, 'OptinRegions', { + lambdaCode, + role: pipelineRole, + }), + }); + + const optinRegionTask = new tasks.StepFunctionsStartExecution(this, 'Enable Opt-in Regions', { + stateMachine: optinRegionsStateMachine, + integrationPattern: sfn.IntegrationPattern.RUN_JOB, + resultPath: sfn.JsonPath.DISCARD, + input: sfn.TaskInput.fromObject({ + 'accounts.$': '$.accounts', + 'regions.$': '$.regions', + configRepositoryName: props.configRepositoryName, + 'configFilePath.$': '$.configFilePath', + 'configCommitId.$': '$.configCommitId', + 'baseline.$': '$.baseline', + acceleratorPrefix: props.acceleratorPrefix, + assumeRoleName: props.stateMachineExecutionRole, + }), + }); + + // Opt in Region - End + const deleteVpcSfn = new sfn.StateMachine(this, 'Delete Default Vpcs Sfn', { stateMachineName: `${props.acceleratorPrefix}DeleteDefaultVpcs_sfn`, definition: new RunAcrossAccountsTask(this, 'DeleteDefaultVPCs', { @@ -1131,6 +1159,7 @@ export namespace InitialSetup { const commonDefinition = loadOrganizationsTask.startState .next(loadAccountsTask) .next(installExecRolesInAccounts) + .next(optinRegionTask) .next(cdkBootstrapTask) .next(deleteVpcTask) .next(loadLimitsTask) diff --git a/src/core/cdk/src/tasks/enable-optin-region-task.ts b/src/core/cdk/src/tasks/enable-optin-region-task.ts new file mode 100644 index 000000000..ce5306253 --- /dev/null +++ b/src/core/cdk/src/tasks/enable-optin-region-task.ts @@ -0,0 +1,129 @@ +/** + * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import * as cdk from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; +import { CodeTask } from '@aws-accelerator/cdk-accelerator/src/stepfunction-tasks'; +import { Construct } from 'constructs'; + +export namespace EnableOptinRegionTask { + export interface Props { + role: iam.IRole; + lambdaCode: lambda.Code; + waitSeconds?: number; + } +} + +export class EnableOptinRegionTask extends sfn.StateMachineFragment { + readonly startState: sfn.State; + readonly endStates: sfn.INextable[]; + + constructor(scope: Construct, id: string, props: EnableOptinRegionTask.Props) { + super(scope, id); + + const { role, lambdaCode, waitSeconds = 60 } = props; + + role.addToPrincipalPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + resources: ['*'], + actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + }), + ); + role.addToPrincipalPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + resources: ['*'], + actions: ['codepipeline:PutJobSuccessResult', 'codepipeline:PutJobFailureResult'], + }), + ); + + const createTaskResultPath = '$.enableOutput'; + const createTaskResultLength = `${createTaskResultPath}.outputCount`; + const createTaskErrorCount = `${createTaskResultPath}.errorCount`; + + const enableTask = new CodeTask(scope, `Start Optin Region`, { + resultPath: createTaskResultPath, + functionProps: { + role, + code: lambdaCode, + handler: 'index.enableOptinRegions.enable', + }, + }); + + // Create Map task to iterate + const mapTask = new sfn.Map(this, `Enable Optin Region Map`, { + itemsPath: '$.accounts', + resultPath: sfn.JsonPath.DISCARD, + maxConcurrency: 15, + parameters: { + 'accountId.$': '$$.Map.Item.Value', + 'assumeRoleName.$': '$.assumeRoleName', + 'configRepositoryName.$': '$.configRepositoryName', + 'configFilePath.$': '$.configFilePath', + 'configCommitId.$': '$.configCommitId', + 'acceleratorPrefix.$': '$.acceleratorPrefix', + 'baseline.$': '$.baseline', + }, + }); + mapTask.iterator(enableTask); + + const verifyTaskResultPath = '$.verifyOutput'; + const verifyTask = new CodeTask(scope, 'Verify Optin Region', { + resultPath: verifyTaskResultPath, + functionProps: { + role, + code: lambdaCode, + handler: 'index.enableOptinRegions.verify', + }, + }); + + const waitTask = new sfn.Wait(scope, 'Wait for Optin Region Enabling', { + time: sfn.WaitTime.duration(cdk.Duration.seconds(waitSeconds)), + }); + + const pass = new sfn.Pass(this, 'Optin Region Enablement Succeeded'); + + const fail = new sfn.Fail(this, 'Optin Region Enablement Failed'); + + waitTask + .next(verifyTask) + .next( + new sfn.Choice(scope, 'Optin Region Enablement Done?') + .when(sfn.Condition.stringEquals(verifyTaskResultPath, 'SUCCESS'), pass) + .when(sfn.Condition.stringEquals(verifyTaskResultPath, 'IN_PROGRESS'), waitTask) + .otherwise(fail) + .afterwards(), + ); + + enableTask.next( + new sfn.Choice(scope, 'Optin Region Enablement Started?') + .when( + sfn.Condition.and( + sfn.Condition.numberEquals(createTaskResultLength, 0), + sfn.Condition.numberEquals(createTaskErrorCount, 0), + ), + pass, + ) // already enabled or skipped + .when(sfn.Condition.numberGreaterThan(createTaskResultLength, 0), waitTask) // processing + .when(sfn.Condition.numberGreaterThan(createTaskErrorCount, 0), fail) + .otherwise(fail) + .afterwards(), + ); + + this.startState = mapTask.startState; + this.endStates = fail.endStates; + } +} diff --git a/src/core/runtime/src/enable-optin-regions/enable.ts b/src/core/runtime/src/enable-optin-regions/enable.ts new file mode 100644 index 000000000..71f4ac49b --- /dev/null +++ b/src/core/runtime/src/enable-optin-regions/enable.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { Account } from '@aws-accelerator/common/src/aws/account'; +import { EC2 } from '@aws-accelerator/common/src/aws/ec2'; +import { LoadConfigurationInput } from '../load-configuration-step'; +import { STS } from '@aws-accelerator/common/src/aws/sts'; +import { loadAcceleratorConfig } from '@aws-accelerator/common-config/src/load'; +import { Organizations } from '@aws-accelerator/common/src/aws/organizations'; +import { equalIgnoreCase } from '@aws-accelerator/common/src/util/common'; + +interface EnableOptinRegionInput extends LoadConfigurationInput { + accountId: string; + assumeRoleName: string; +} + +export interface EnableOptinRegionOutput { + accountId: string; + optinRegionName: string; + assumeRoleName: string; +} + +const CustomErrorMessage = [ + { + code: 'AuthFailure', + message: 'Region Not Enabled', + }, + { + code: 'OptInRequired', + message: 'Region not Opted-in', + }, +]; + +const sts = new STS(); +const organizations = new Organizations(); +export const handler = async (input: EnableOptinRegionInput) => { + console.log(`Enabling Opt-in Region in account ...`); + console.log(JSON.stringify(input, null, 2)); + const { accountId, assumeRoleName, configRepositoryName, configFilePath, configCommitId } = input; + + // Retrieve Configuration from Code Commit with specific commitId + const acceleratorConfig = await loadAcceleratorConfig({ + repositoryName: configRepositoryName, + filePath: configFilePath, + commitId: configCommitId, + }); + const awsAccount = await organizations.getAccount(accountId); + if (!awsAccount) { + // This will never happen unless it is called explicitly with invalid AccountId + throw new Error(`Unable to retrieve account info from Organizations API for "${accountId}"`); + } + + const supportedRegions = acceleratorConfig['global-options']['supported-regions']; + + console.log(`${accountId}: ${JSON.stringify(supportedRegions, null, 2)}`); + const errors: string[] = []; + const credentials = await sts.getCredentialsForAccountAndRole(accountId, assumeRoleName); + const account = new Account(credentials, 'us-east-1'); + const ec2 = new EC2(credentials, 'us-east-1'); + const isControlTower = acceleratorConfig['global-options']['ct-baseline']; + const enabledRegions = await ec2.describeAllRegions(); + const enabledOptinRegionList: EnableOptinRegionOutput[] = []; + + if (!isControlTower) { + if (enabledRegions) { + const enabledRegionLookup = Object.fromEntries(enabledRegions.map(obj => [obj.RegionName, obj.OptInStatus])); + + for (const region of supportedRegions) { + const enabledRegionStatus = enabledRegionLookup[region]; + + // If region is an opt-in region + if (enabledRegionStatus === 'not-opted-in') { + // Check to see if it is Enabling state. This could happen during a SM restart. + const optInRegionStatus = await account.getRegionOptinStatus(region); + if (optInRegionStatus.RegionOptStatus! === 'ENABLING') { + console.log(`Opt-in region '${region}' is already being enabled. Skipping.`); + enabledOptinRegionList.push({ + accountId, + optinRegionName: region, + assumeRoleName, + }); + continue; + } + + console.log(`Enabling Opt-in region '${region}'`); + try { + await account.enableOptinRegion(region); + enabledOptinRegionList.push({ + accountId, + optinRegionName: region, + assumeRoleName, + }); + } catch (error: any) { + errors.push( + `${accountId}:${region}: ${error.code}: ${ + CustomErrorMessage.find(cm => cm.code === error.code)?.message || error.message + }`, + ); + continue; + } + } else if (enabledRegionStatus === 'opted-in') { + console.log(`${region} already opted-in`); + } else { + // opt-in-not-required + console.log(`${region} opt-in-not required`); + } + } + } + } else { + console.log(`Control Tower is enabled. Skipping Opt-in enablement.`); + } + + return { enabledOptinRegionList, outputCount: enabledOptinRegionList.length, errors, errorCount: errors.length }; +}; diff --git a/src/core/runtime/src/enable-optin-regions/index.ts b/src/core/runtime/src/enable-optin-regions/index.ts new file mode 100644 index 000000000..0747ae86d --- /dev/null +++ b/src/core/runtime/src/enable-optin-regions/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +export { handler as enable } from './enable'; +export { handler as verify } from './verify'; diff --git a/src/core/runtime/src/enable-optin-regions/verify.ts b/src/core/runtime/src/enable-optin-regions/verify.ts new file mode 100644 index 000000000..b47d2e651 --- /dev/null +++ b/src/core/runtime/src/enable-optin-regions/verify.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { Account } from '@aws-accelerator/common/src/aws/account'; +import { STS } from '@aws-accelerator/common/src/aws/sts'; +import { EnableOptinRegionOutput } from './enable'; + +interface StepInput { + enableOutput: OptinRegionList; +} + +interface OptinRegionList { + enabledOptinRegionList: EnableOptinRegionOutput[]; +} + +export const handler = async (input: StepInput): Promise => { + console.log(`Verifying status of enabled Optin Regions`); + console.log(JSON.stringify(input, null, 2)); + + const status: string[] = []; + const sts = new STS(); + + for (const enabledOptinRegion of input.enableOutput.enabledOptinRegionList) { + const credentials = await sts.getCredentialsForAccountAndRole( + enabledOptinRegion.accountId, + enabledOptinRegion.assumeRoleName, + ); + + const account = new Account(credentials, 'us-east-1'); + + const optInRegionStatus = await account.getRegionOptinStatus(enabledOptinRegion.optinRegionName); + + status.push(optInRegionStatus.RegionOptStatus!); + } + + // "ENABLED"|"ENABLING"|"DISABLING"|"DISABLED"|"ENABLED_BY_DEFAULT"|string; + + const statusEnabling = status.filter(s => s === 'ENABLING'); + if (statusEnabling && statusEnabling.length > 0) { + return 'IN_PROGRESS'; + } + + const statusDisabling = status.filter(s => s === 'DISABLING'); + if (statusDisabling && statusDisabling.length > 0) { + return 'IN_PROGRESS'; + } + + return 'SUCCESS'; +}; diff --git a/src/core/runtime/src/index.ts b/src/core/runtime/src/index.ts index 28eb19170..ff5412ade 100644 --- a/src/core/runtime/src/index.ts +++ b/src/core/runtime/src/index.ts @@ -49,6 +49,7 @@ import * as createOrganizationAccount from './create-organization-account'; import * as createStack from './create-stack'; import * as createStackSet from './create-stack-set'; import * as deleteDefaultVpcs from './delete-default-vpc'; +import * as enableOptinRegions from './enable-optin-regions'; export { createAccount, createStack, @@ -58,4 +59,5 @@ export { deleteDefaultVpcs, createConfigRecorder, addTagsToSharedResources, + enableOptinRegions, }; diff --git a/src/lib/common/src/aws/account.ts b/src/lib/common/src/aws/account.ts new file mode 100644 index 000000000..6247eb48d --- /dev/null +++ b/src/lib/common/src/aws/account.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import aws from './aws-client'; +import { EnableRegionRequest, GetRegionOptStatusRequest, GetRegionOptStatusResponse } from 'aws-sdk/clients/account'; +import { throttlingBackOff } from './backoff'; +import { listWithNextToken, listWithNextTokenGenerator } from './next-token'; +import { collectAsync } from '../util/generator'; + +export class Account { + private readonly client: aws.Account; + + public constructor(credentials?: aws.Credentials, region?: string) { + this.client = new aws.Account({ + region, + credentials, + }); + } + + /** + * enables opt-in region + * @param accountId + * @param regionName + */ + async enableOptinRegion(regionName: string): Promise { + const params: EnableRegionRequest = { + RegionName: regionName, + }; + await throttlingBackOff(() => this.client.enableRegion(params).promise()); + } + + /** + * gets Region Optin Status + * @param accountId + * @param regionName + */ + async getRegionOptinStatus(regionName: string): Promise { + const params: GetRegionOptStatusRequest = { + RegionName: regionName, + }; + const result = await throttlingBackOff(() => this.client.getRegionOptStatus(params).promise()); + return result; + } +} diff --git a/src/lib/common/src/aws/ec2.ts b/src/lib/common/src/aws/ec2.ts index 8d6124585..73d4ce8a2 100644 --- a/src/lib/common/src/aws/ec2.ts +++ b/src/lib/common/src/aws/ec2.ts @@ -17,12 +17,15 @@ import { EnableEbsEncryptionByDefaultResult, ModifyEbsDefaultKmsKeyIdRequest, ModifyEbsDefaultKmsKeyIdResult, + DescribeRegionsRequest, + DescribeRegionsResult, InternetGatewayList, VpcList, DescribeSubnetsRequest, SubnetList, DescribeSubnetsResult, Subnet, + RegionList, } from 'aws-sdk/clients/ec2'; import { throttlingBackOff } from './backoff'; import { listWithNextToken, listWithNextTokenGenerator } from './next-token'; @@ -163,4 +166,15 @@ export class EC2 { input, ); } + + /** + * Wrapper around AWS.EC2.describeRegions. + */ + async describeAllRegions(): Promise { + const params: DescribeRegionsRequest = { + AllRegions: true, + }; + const regions = await throttlingBackOff(() => this.client.describeRegions(params).promise()); + return regions.Regions; + } }