Skip to content

Commit 40c8cb4

Browse files
committed
feat(custom-resources): state machine provider
Add a state machine custom resource provider. It uses the nested workflow pattern where the "user" state machine is started as a task in the provder state machine. This allows to set a timeout for the custom resource. This could be used as a base for a Fargate task custom resource provider. Closes aws#6505
1 parent a48767c commit 40c8cb4

File tree

6 files changed

+914
-1
lines changed

6 files changed

+914
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './aws-custom-resource';
2-
export * from './provider-framework';
2+
export * from './provider-framework';
3+
export * from './state-machine-provider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './state-machine-provider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// tslint:disable no-console
2+
import { StepFunctions } from 'aws-sdk'; // eslint-disable-line import/no-extraneous-dependencies
3+
import * as https from 'https';
4+
import * as url from 'url';
5+
6+
const CREATE_FAILED_PHYSICAL_ID_MARKER = 'AWSCDK::StateMachineProvider::CREATE_FAILED';
7+
const MISSING_PHYSICAL_ID_MARKER = 'AWSCDK::StateMachineProvider::MISSING_PHYSICAL_ID';
8+
9+
interface ExecutionResult {
10+
ExecutionArn: string;
11+
Input: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string };
12+
Name: string;
13+
Output?: AWSLambda.CloudFormationCustomResourceResponse;
14+
StartDate: number;
15+
StateMachineArn: string;
16+
Status: 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'TIMED_OUT' | 'ABORTED';
17+
StopDate: number;
18+
}
19+
20+
interface FailedExecutionEvent {
21+
Error: string;
22+
Cause: string;
23+
}
24+
25+
interface CloudFormationResponse {
26+
StackId: string;
27+
RequestId: string;
28+
PhysicalResourceId?: string;
29+
LogicalResourceId: string;
30+
ResponseURL: string;
31+
Data?: any;
32+
NoEcho?: boolean;
33+
Reason?: string;
34+
}
35+
36+
export async function cfnResponseSuccess(event: ExecutionResult, _context: AWSLambda.Context) {
37+
console.log('Event: %j', event);
38+
await respond('SUCCESS', {
39+
...event.Input,
40+
PhysicalResourceId: event.Output?.PhysicalResourceId ?? event.Input.PhysicalResourceId ?? event.Input.RequestId,
41+
Data: event.Output?.Data ?? {},
42+
NoEcho: event.Output?.NoEcho,
43+
});
44+
}
45+
46+
export async function cfnResponseFailed(event: FailedExecutionEvent, _context: AWSLambda.Context) {
47+
console.log('Event: %j', event);
48+
49+
const parsedCause = JSON.parse(event.Cause);
50+
const executionResult: ExecutionResult = {
51+
...parsedCause,
52+
Input: JSON.parse(parsedCause.Input),
53+
};
54+
console.log('Execution result: %j', executionResult);
55+
56+
let physicalResourceId = executionResult.Output?.PhysicalResourceId;
57+
if (!physicalResourceId) {
58+
// special case: if CREATE fails, which usually implies, we usually don't
59+
// have a physical resource id. in this case, the subsequent DELETE
60+
// operation does not have any meaning, and will likely fail as well. to
61+
// address this, we use a marker so the provider framework can simply
62+
// ignore the subsequent DELETE.
63+
if (executionResult.Input.RequestType === 'Create') {
64+
console.log('CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored');
65+
physicalResourceId = CREATE_FAILED_PHYSICAL_ID_MARKER;
66+
} else {
67+
console.log(`ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(event)}`);
68+
}
69+
}
70+
71+
await respond('FAILED', {
72+
...executionResult.Input,
73+
Reason: `${event.Error}: ${event.Cause}`,
74+
PhysicalResourceId: physicalResourceId,
75+
});
76+
}
77+
78+
export async function startExecution(event: AWSLambda.CloudFormationCustomResourceEvent, _context: AWSLambda.Context) {
79+
try {
80+
console.log('Event: %j', event);
81+
82+
if (!process.env.STATE_MACHINE_ARN) {
83+
throw new Error('Missing STATE_MACHINE_ARN.');
84+
}
85+
86+
// ignore DELETE event when the physical resource ID is the marker that
87+
// indicates that this DELETE is a subsequent DELETE to a failed CREATE
88+
// operation.
89+
if (event.RequestType === 'Delete' && event.PhysicalResourceId === CREATE_FAILED_PHYSICAL_ID_MARKER) {
90+
console.log('ignoring DELETE event caused by a failed CREATE event');
91+
await respond('SUCCESS', event);
92+
return;
93+
}
94+
95+
const stepFunctions = new StepFunctions();
96+
await stepFunctions.startExecution({
97+
stateMachineArn: process.env.STATE_MACHINE_ARN,
98+
input: JSON.stringify(event),
99+
}).promise();
100+
} catch (err) {
101+
console.log(err);
102+
await respond('FAILED', {
103+
...event,
104+
Reason: err.message,
105+
});
106+
}
107+
}
108+
109+
function respond(status: 'SUCCESS' | 'FAILED', event: CloudFormationResponse) {
110+
const json: AWSLambda.CloudFormationCustomResourceResponse = {
111+
Status: status,
112+
Reason: event.Reason ?? status,
113+
PhysicalResourceId: event.PhysicalResourceId || MISSING_PHYSICAL_ID_MARKER,
114+
StackId: event.StackId,
115+
RequestId: event.RequestId,
116+
LogicalResourceId: event.LogicalResourceId,
117+
NoEcho: event.NoEcho ?? false,
118+
Data: event.Data,
119+
};
120+
121+
console.log('Responding: %j', json);
122+
123+
const responseBody = JSON.stringify(json);
124+
125+
const parsedUrl = url.parse(event.ResponseURL);
126+
const requestOptions = {
127+
hostname: parsedUrl.hostname,
128+
path: parsedUrl.path,
129+
method: 'PUT',
130+
headers: { 'content-type': '', 'content-length': responseBody.length },
131+
};
132+
133+
return new Promise((resolve, reject) => {
134+
try {
135+
const request = https.request(requestOptions, resolve);
136+
request.on('error', reject);
137+
request.write(responseBody);
138+
request.end();
139+
} catch (e) {
140+
reject(e);
141+
}
142+
});
143+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as iam from '@aws-cdk/aws-iam';
2+
import * as lambda from '@aws-cdk/aws-lambda';
3+
import { CfnResource, Construct, Duration, Stack } from '@aws-cdk/core';
4+
import * as path from 'path';
5+
6+
/**
7+
* A State Machine
8+
*/
9+
export interface IStateMachine {
10+
/**
11+
* The ARN of the state machine
12+
*/
13+
readonly stateMachineArn: string;
14+
}
15+
16+
/**
17+
* Properties for a StateMachineProvider
18+
*/
19+
export interface StateMachineProviderProps {
20+
/**
21+
* The state machine
22+
*/
23+
readonly stateMachine: IStateMachine;
24+
25+
/**
26+
* Timeout
27+
*
28+
* @default Duration.minutes(30)
29+
*/
30+
readonly timeout?: Duration;
31+
}
32+
33+
/**
34+
* A state machine custom resource provider
35+
*/
36+
export class StateMachineProvider extends Construct {
37+
/**
38+
* The service token
39+
*/
40+
public readonly serviceToken: string;
41+
42+
constructor(scope: Construct, id: string, props: StateMachineProviderProps) {
43+
super(scope, id);
44+
45+
const cfnResponseSuccessFn = this.createCfnResponseFn('Success');
46+
const cfnResponseFailedFn = this.createCfnResponseFn('Failed');
47+
48+
const role = new iam.Role(this, 'Role', {
49+
assumedBy: new iam.ServicePrincipal('states.amazonaws.com'),
50+
});
51+
role.addToPolicy(new iam.PolicyStatement({
52+
actions: ['lambda:InvokeFunction'],
53+
resources: [cfnResponseSuccessFn.functionArn, cfnResponseFailedFn.functionArn],
54+
}));
55+
// https://docs.aws.amazon.com/step-functions/latest/dg/stepfunctions-iam.html
56+
// https://docs.aws.amazon.com/step-functions/latest/dg/concept-create-iam-advanced.html#concept-create-iam-advanced-execution
57+
role.addToPolicy(new iam.PolicyStatement({
58+
actions: ['states:StartExecution'],
59+
resources: [props.stateMachine.stateMachineArn],
60+
}));
61+
role.addToPolicy(new iam.PolicyStatement({
62+
actions: ['states:DescribeExecution', 'states:StopExecution'],
63+
resources: [Stack.of(this).formatArn({
64+
service: 'states',
65+
resource: 'execution',
66+
sep: ':',
67+
resourceName: `${Stack.of(this).parseArn(props.stateMachine.stateMachineArn, ':').resourceName}*`,
68+
})],
69+
}));
70+
role.addToPolicy(new iam.PolicyStatement({
71+
actions: ['events:PutTargets', 'events:PutRule', 'events:DescribeRule'],
72+
resources: [Stack.of(this).formatArn({
73+
service: 'events',
74+
resource: 'rule',
75+
resourceName: 'StepFunctionsGetEventsForStepFunctionsExecutionRule',
76+
})],
77+
}));
78+
79+
const definition = Stack.of(this).toJsonString({
80+
StartAt: 'StartExecution',
81+
States: {
82+
StartExecution: {
83+
Type: 'Task',
84+
Resource: 'arn:aws:states:::states:startExecution.sync:2',
85+
Parameters: {
86+
'Input.$': '$',
87+
'StateMachineArn': props.stateMachine.stateMachineArn,
88+
},
89+
TimeoutSeconds: (props.timeout ?? Duration.minutes(30)).toSeconds(),
90+
Next: 'CfnResponseSuccess',
91+
Catch: [{
92+
ErrorEquals: [ 'States.ALL' ],
93+
Next: 'CfnResponseFailed',
94+
}],
95+
},
96+
CfnResponseSuccess: {
97+
Type: 'Task',
98+
Resource: cfnResponseSuccessFn.functionArn,
99+
End: true,
100+
},
101+
CfnResponseFailed: {
102+
Type: 'Task',
103+
Resource: cfnResponseFailedFn.functionArn,
104+
End: true,
105+
},
106+
},
107+
}, 2);
108+
109+
const stateMachine = new CfnResource(this, 'StateMachine', {
110+
type: 'AWS::StepFunctions::StateMachine',
111+
properties: {
112+
DefinitionString: definition,
113+
RoleArn: role.roleArn,
114+
},
115+
});
116+
stateMachine.node.addDependency(role);
117+
118+
const startExecution = new lambda.Function(this, 'StartExecution', {
119+
code: lambda.Code.fromAsset(path.join(__dirname, 'runtime')),
120+
handler: 'index.startExecution',
121+
runtime: lambda.Runtime.NODEJS_12_X,
122+
});
123+
startExecution.addToRolePolicy(new iam.PolicyStatement({
124+
actions: ['states:StartExecution'],
125+
resources: [stateMachine.ref],
126+
}));
127+
startExecution.addEnvironment('STATE_MACHINE_ARN', stateMachine.ref);
128+
129+
this.serviceToken = startExecution.functionArn;
130+
}
131+
132+
private createCfnResponseFn(status: string) {
133+
return new lambda.Function(this, `CfnResponse${status}`, {
134+
code: lambda.Code.fromAsset(path.join(__dirname, 'runtime')),
135+
handler: `index.cfnResponse${status}`,
136+
runtime: lambda.Runtime.NODEJS_12_X,
137+
});
138+
}
139+
}

0 commit comments

Comments
 (0)