Skip to content

Commit 33e4dfa

Browse files
authored
Opt-in region State Machine enhancements (#1231)
* initial optin work * initial optin work * initial optin work * initial optin work * initial optin work * initial optin work * initial optin work discard * initial optin work discard * initial optin work fix run all accounts * initial optin work exclude control tower * initial optin work enable bug fix * initial optin work enable bug fix * initial optin work enable bug fix error check * initial optin work enable add check * fix eslint
1 parent 3bb5b7f commit 33e4dfa

File tree

8 files changed

+426
-0
lines changed

8 files changed

+426
-0
lines changed

src/core/cdk/src/initial-setup.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { CreateControlTowerAccountTask } from './tasks/create-control-tower-acco
3434
import { CreateOrganizationAccountTask } from './tasks/create-organization-account-task';
3535
import { CreateStackTask } from './tasks/create-stack-task';
3636
import { RunAcrossAccountsTask } from './tasks/run-across-accounts-task';
37+
import { EnableOptinRegionTask } from './tasks/enable-optin-region-task';
3738
import { Construct } from 'constructs';
3839
import * as fs from 'fs';
3940
import * as sns from 'aws-cdk-lib/aws-sns';
@@ -623,6 +624,33 @@ export namespace InitialSetup {
623624

624625
installExecRolesInAccounts.iterator(installRolesTask);
625626

627+
// Opt in Region - Begin
628+
const optinRegionsStateMachine = new sfn.StateMachine(this, `${props.acceleratorPrefix}OptinRegions_sm`, {
629+
stateMachineName: `${props.acceleratorPrefix}OptinRegions_sm`,
630+
definition: new EnableOptinRegionTask(this, 'OptinRegions', {
631+
lambdaCode,
632+
role: pipelineRole,
633+
}),
634+
});
635+
636+
const optinRegionTask = new tasks.StepFunctionsStartExecution(this, 'Enable Opt-in Regions', {
637+
stateMachine: optinRegionsStateMachine,
638+
integrationPattern: sfn.IntegrationPattern.RUN_JOB,
639+
resultPath: sfn.JsonPath.DISCARD,
640+
input: sfn.TaskInput.fromObject({
641+
'accounts.$': '$.accounts',
642+
'regions.$': '$.regions',
643+
configRepositoryName: props.configRepositoryName,
644+
'configFilePath.$': '$.configFilePath',
645+
'configCommitId.$': '$.configCommitId',
646+
'baseline.$': '$.baseline',
647+
acceleratorPrefix: props.acceleratorPrefix,
648+
assumeRoleName: props.stateMachineExecutionRole,
649+
}),
650+
});
651+
652+
// Opt in Region - End
653+
626654
const deleteVpcSfn = new sfn.StateMachine(this, 'Delete Default Vpcs Sfn', {
627655
stateMachineName: `${props.acceleratorPrefix}DeleteDefaultVpcs_sfn`,
628656
definition: new RunAcrossAccountsTask(this, 'DeleteDefaultVPCs', {
@@ -1131,6 +1159,7 @@ export namespace InitialSetup {
11311159
const commonDefinition = loadOrganizationsTask.startState
11321160
.next(loadAccountsTask)
11331161
.next(installExecRolesInAccounts)
1162+
.next(optinRegionTask)
11341163
.next(cdkBootstrapTask)
11351164
.next(deleteVpcTask)
11361165
.next(loadLimitsTask)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
5+
* with the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
10+
* OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
14+
import * as cdk from 'aws-cdk-lib';
15+
import * as iam from 'aws-cdk-lib/aws-iam';
16+
import * as lambda from 'aws-cdk-lib/aws-lambda';
17+
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
18+
import { CodeTask } from '@aws-accelerator/cdk-accelerator/src/stepfunction-tasks';
19+
import { Construct } from 'constructs';
20+
21+
export namespace EnableOptinRegionTask {
22+
export interface Props {
23+
role: iam.IRole;
24+
lambdaCode: lambda.Code;
25+
waitSeconds?: number;
26+
}
27+
}
28+
29+
export class EnableOptinRegionTask extends sfn.StateMachineFragment {
30+
readonly startState: sfn.State;
31+
readonly endStates: sfn.INextable[];
32+
33+
constructor(scope: Construct, id: string, props: EnableOptinRegionTask.Props) {
34+
super(scope, id);
35+
36+
const { role, lambdaCode, waitSeconds = 60 } = props;
37+
38+
role.addToPrincipalPolicy(
39+
new iam.PolicyStatement({
40+
effect: iam.Effect.ALLOW,
41+
resources: ['*'],
42+
actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
43+
}),
44+
);
45+
role.addToPrincipalPolicy(
46+
new iam.PolicyStatement({
47+
effect: iam.Effect.ALLOW,
48+
resources: ['*'],
49+
actions: ['codepipeline:PutJobSuccessResult', 'codepipeline:PutJobFailureResult'],
50+
}),
51+
);
52+
53+
const createTaskResultPath = '$.enableOutput';
54+
const createTaskResultLength = `${createTaskResultPath}.outputCount`;
55+
const createTaskErrorCount = `${createTaskResultPath}.errorCount`;
56+
57+
const enableTask = new CodeTask(scope, `Start Optin Region`, {
58+
resultPath: createTaskResultPath,
59+
functionProps: {
60+
role,
61+
code: lambdaCode,
62+
handler: 'index.enableOptinRegions.enable',
63+
},
64+
});
65+
66+
// Create Map task to iterate
67+
const mapTask = new sfn.Map(this, `Enable Optin Region Map`, {
68+
itemsPath: '$.accounts',
69+
resultPath: sfn.JsonPath.DISCARD,
70+
maxConcurrency: 15,
71+
parameters: {
72+
'accountId.$': '$$.Map.Item.Value',
73+
'assumeRoleName.$': '$.assumeRoleName',
74+
'configRepositoryName.$': '$.configRepositoryName',
75+
'configFilePath.$': '$.configFilePath',
76+
'configCommitId.$': '$.configCommitId',
77+
'acceleratorPrefix.$': '$.acceleratorPrefix',
78+
'baseline.$': '$.baseline',
79+
},
80+
});
81+
mapTask.iterator(enableTask);
82+
83+
const verifyTaskResultPath = '$.verifyOutput';
84+
const verifyTask = new CodeTask(scope, 'Verify Optin Region', {
85+
resultPath: verifyTaskResultPath,
86+
functionProps: {
87+
role,
88+
code: lambdaCode,
89+
handler: 'index.enableOptinRegions.verify',
90+
},
91+
});
92+
93+
const waitTask = new sfn.Wait(scope, 'Wait for Optin Region Enabling', {
94+
time: sfn.WaitTime.duration(cdk.Duration.seconds(waitSeconds)),
95+
});
96+
97+
const pass = new sfn.Pass(this, 'Optin Region Enablement Succeeded');
98+
99+
const fail = new sfn.Fail(this, 'Optin Region Enablement Failed');
100+
101+
waitTask
102+
.next(verifyTask)
103+
.next(
104+
new sfn.Choice(scope, 'Optin Region Enablement Done?')
105+
.when(sfn.Condition.stringEquals(verifyTaskResultPath, 'SUCCESS'), pass)
106+
.when(sfn.Condition.stringEquals(verifyTaskResultPath, 'IN_PROGRESS'), waitTask)
107+
.otherwise(fail)
108+
.afterwards(),
109+
);
110+
111+
enableTask.next(
112+
new sfn.Choice(scope, 'Optin Region Enablement Started?')
113+
.when(
114+
sfn.Condition.and(
115+
sfn.Condition.numberEquals(createTaskResultLength, 0),
116+
sfn.Condition.numberEquals(createTaskErrorCount, 0),
117+
),
118+
pass,
119+
) // already enabled or skipped
120+
.when(sfn.Condition.numberGreaterThan(createTaskResultLength, 0), waitTask) // processing
121+
.when(sfn.Condition.numberGreaterThan(createTaskErrorCount, 0), fail)
122+
.otherwise(fail)
123+
.afterwards(),
124+
);
125+
126+
this.startState = mapTask.startState;
127+
this.endStates = fail.endStates;
128+
}
129+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
5+
* with the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
10+
* OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
14+
import { Account } from '@aws-accelerator/common/src/aws/account';
15+
import { EC2 } from '@aws-accelerator/common/src/aws/ec2';
16+
import { LoadConfigurationInput } from '../load-configuration-step';
17+
import { STS } from '@aws-accelerator/common/src/aws/sts';
18+
import { loadAcceleratorConfig } from '@aws-accelerator/common-config/src/load';
19+
import { Organizations } from '@aws-accelerator/common/src/aws/organizations';
20+
import { equalIgnoreCase } from '@aws-accelerator/common/src/util/common';
21+
22+
interface EnableOptinRegionInput extends LoadConfigurationInput {
23+
accountId: string;
24+
assumeRoleName: string;
25+
}
26+
27+
export interface EnableOptinRegionOutput {
28+
accountId: string;
29+
optinRegionName: string;
30+
assumeRoleName: string;
31+
}
32+
33+
const CustomErrorMessage = [
34+
{
35+
code: 'AuthFailure',
36+
message: 'Region Not Enabled',
37+
},
38+
{
39+
code: 'OptInRequired',
40+
message: 'Region not Opted-in',
41+
},
42+
];
43+
44+
const sts = new STS();
45+
const organizations = new Organizations();
46+
export const handler = async (input: EnableOptinRegionInput) => {
47+
console.log(`Enabling Opt-in Region in account ...`);
48+
console.log(JSON.stringify(input, null, 2));
49+
const { accountId, assumeRoleName, configRepositoryName, configFilePath, configCommitId } = input;
50+
51+
// Retrieve Configuration from Code Commit with specific commitId
52+
const acceleratorConfig = await loadAcceleratorConfig({
53+
repositoryName: configRepositoryName,
54+
filePath: configFilePath,
55+
commitId: configCommitId,
56+
});
57+
const awsAccount = await organizations.getAccount(accountId);
58+
if (!awsAccount) {
59+
// This will never happen unless it is called explicitly with invalid AccountId
60+
throw new Error(`Unable to retrieve account info from Organizations API for "${accountId}"`);
61+
}
62+
63+
const supportedRegions = acceleratorConfig['global-options']['supported-regions'];
64+
65+
console.log(`${accountId}: ${JSON.stringify(supportedRegions, null, 2)}`);
66+
const errors: string[] = [];
67+
const credentials = await sts.getCredentialsForAccountAndRole(accountId, assumeRoleName);
68+
const account = new Account(credentials, 'us-east-1');
69+
const ec2 = new EC2(credentials, 'us-east-1');
70+
const isControlTower = acceleratorConfig['global-options']['ct-baseline'];
71+
const enabledRegions = await ec2.describeAllRegions();
72+
const enabledOptinRegionList: EnableOptinRegionOutput[] = [];
73+
74+
if (!isControlTower) {
75+
if (enabledRegions) {
76+
const enabledRegionLookup = Object.fromEntries(enabledRegions.map(obj => [obj.RegionName, obj.OptInStatus]));
77+
78+
for (const region of supportedRegions) {
79+
const enabledRegionStatus = enabledRegionLookup[region];
80+
81+
// If region is an opt-in region
82+
if (enabledRegionStatus === 'not-opted-in') {
83+
// Check to see if it is Enabling state. This could happen during a SM restart.
84+
const optInRegionStatus = await account.getRegionOptinStatus(region);
85+
if (optInRegionStatus.RegionOptStatus! === 'ENABLING') {
86+
console.log(`Opt-in region '${region}' is already being enabled. Skipping.`);
87+
enabledOptinRegionList.push({
88+
accountId,
89+
optinRegionName: region,
90+
assumeRoleName,
91+
});
92+
continue;
93+
}
94+
95+
console.log(`Enabling Opt-in region '${region}'`);
96+
try {
97+
await account.enableOptinRegion(region);
98+
enabledOptinRegionList.push({
99+
accountId,
100+
optinRegionName: region,
101+
assumeRoleName,
102+
});
103+
} catch (error: any) {
104+
errors.push(
105+
`${accountId}:${region}: ${error.code}: ${
106+
CustomErrorMessage.find(cm => cm.code === error.code)?.message || error.message
107+
}`,
108+
);
109+
continue;
110+
}
111+
} else if (enabledRegionStatus === 'opted-in') {
112+
console.log(`${region} already opted-in`);
113+
} else {
114+
// opt-in-not-required
115+
console.log(`${region} opt-in-not required`);
116+
}
117+
}
118+
}
119+
} else {
120+
console.log(`Control Tower is enabled. Skipping Opt-in enablement.`);
121+
}
122+
123+
return { enabledOptinRegionList, outputCount: enabledOptinRegionList.length, errors, errorCount: errors.length };
124+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
5+
* with the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
10+
* OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
14+
export { handler as enable } from './enable';
15+
export { handler as verify } from './verify';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
5+
* with the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
10+
* OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
14+
import { Account } from '@aws-accelerator/common/src/aws/account';
15+
import { STS } from '@aws-accelerator/common/src/aws/sts';
16+
import { EnableOptinRegionOutput } from './enable';
17+
18+
interface StepInput {
19+
enableOutput: OptinRegionList;
20+
}
21+
22+
interface OptinRegionList {
23+
enabledOptinRegionList: EnableOptinRegionOutput[];
24+
}
25+
26+
export const handler = async (input: StepInput): Promise<string> => {
27+
console.log(`Verifying status of enabled Optin Regions`);
28+
console.log(JSON.stringify(input, null, 2));
29+
30+
const status: string[] = [];
31+
const sts = new STS();
32+
33+
for (const enabledOptinRegion of input.enableOutput.enabledOptinRegionList) {
34+
const credentials = await sts.getCredentialsForAccountAndRole(
35+
enabledOptinRegion.accountId,
36+
enabledOptinRegion.assumeRoleName,
37+
);
38+
39+
const account = new Account(credentials, 'us-east-1');
40+
41+
const optInRegionStatus = await account.getRegionOptinStatus(enabledOptinRegion.optinRegionName);
42+
43+
status.push(optInRegionStatus.RegionOptStatus!);
44+
}
45+
46+
// "ENABLED"|"ENABLING"|"DISABLING"|"DISABLED"|"ENABLED_BY_DEFAULT"|string;
47+
48+
const statusEnabling = status.filter(s => s === 'ENABLING');
49+
if (statusEnabling && statusEnabling.length > 0) {
50+
return 'IN_PROGRESS';
51+
}
52+
53+
const statusDisabling = status.filter(s => s === 'DISABLING');
54+
if (statusDisabling && statusDisabling.length > 0) {
55+
return 'IN_PROGRESS';
56+
}
57+
58+
return 'SUCCESS';
59+
};

src/core/runtime/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import * as createOrganizationAccount from './create-organization-account';
4949
import * as createStack from './create-stack';
5050
import * as createStackSet from './create-stack-set';
5151
import * as deleteDefaultVpcs from './delete-default-vpc';
52+
import * as enableOptinRegions from './enable-optin-regions';
5253
export {
5354
createAccount,
5455
createStack,
@@ -58,4 +59,5 @@ export {
5859
deleteDefaultVpcs,
5960
createConfigRecorder,
6061
addTagsToSharedResources,
62+
enableOptinRegions,
6163
};

0 commit comments

Comments
 (0)