Skip to content

Commit

Permalink
EDSC-4351: Implements a mock step function workflow, uppercase env va…
Browse files Browse the repository at this point in the history
…riable
  • Loading branch information
macrouch committed Jan 28, 2025
1 parent 5a8914e commit 11bdf8d
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 14 deletions.
8 changes: 6 additions & 2 deletions bin/receiveMessages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ const receiveMessages = async ({

const { default: handler } = (await import(handlerPath)).default

console.log(`Calling Lambda: ${lambdaFunctionName}`)
await handler(sqsMessage, {})
try {
console.log(`Calling Lambda: ${lambdaFunctionName}`)
await handler(sqsMessage, {})
} catch (error) {
console.log(`Error calling Lambda: ${lambdaFunctionName}`, error)
}

// Delete the message after processing
const deleteParams = {
Expand Down
9 changes: 5 additions & 4 deletions cdk/earthdata-search/lib/earthdata-search-step-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export class StepFunctions extends Construct {
StringEquals: 'in_progress',
Next: 'WaitForRetry'
}],
Default: 'OrderFailed'
},
WaitForRetry: {
Type: 'Wait',
Expand All @@ -192,7 +193,7 @@ export class StepFunctions extends Construct {
...defaultLambdaConfig,
environment: {
...defaultLambdaConfig.environment,
updateOrderStatusStateMachineArn: updateOrderStatusStateMachine.stateMachineArn
UPDATE_ORDER_STATUS_STATE_MACHINE_ARN: updateOrderStatusStateMachine.stateMachineArn
},
entry: '../../serverless/src/submitCatalogRestOrder/handler.js',
functionName: 'submitCatalogRestOrder',
Expand All @@ -212,7 +213,7 @@ export class StepFunctions extends Construct {
...defaultLambdaConfig,
environment: {
...defaultLambdaConfig.environment,
updateOrderStatusStateMachineArn: updateOrderStatusStateMachine.stateMachineArn
UPDATE_ORDER_STATUS_STATE_MACHINE_ARN: updateOrderStatusStateMachine.stateMachineArn
},
entry: '../../serverless/src/submitCmrOrderingOrder/handler.js',
functionName: 'submitCmrOrderingOrder',
Expand All @@ -231,7 +232,7 @@ export class StepFunctions extends Construct {
...defaultLambdaConfig,
environment: {
...defaultLambdaConfig.environment,
updateOrderStatusStateMachineArn: updateOrderStatusStateMachine.stateMachineArn
UPDATE_ORDER_STATUS_STATE_MACHINE_ARN: updateOrderStatusStateMachine.stateMachineArn
},
entry: '../../serverless/src/submitHarmonyOrder/handler.js',
functionName: 'submitHarmonyOrder',
Expand All @@ -251,7 +252,7 @@ export class StepFunctions extends Construct {
...defaultLambdaConfig,
environment: {
...defaultLambdaConfig.environment,
updateOrderStatusStateMachineArn: updateOrderStatusStateMachine.stateMachineArn
UPDATE_ORDER_STATUS_STATE_MACHINE_ARN: updateOrderStatusStateMachine.stateMachineArn
},
entry: '../../serverless/src/submitSwodlrOrder/handler.js',
functionName: 'submitSwodlrOrder',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SFNClient } from '@aws-sdk/client-sfn'

import { startOrderStatusUpdateWorkflow } from '../startOrderStatusUpdateWorkflow'
import { getStepFunctionsConfig } from '../aws/getStepFunctionsConfig'
import * as mockStepFunction from '../mockStepFunction'

jest.mock('@aws-sdk/client-sfn', () => {
const original = jest.requireActual('@aws-sdk/client-sfn')
Expand All @@ -28,6 +29,7 @@ beforeEach(() => {
jest.resetModules()
process.env = { ...OLD_ENV }
delete process.env.NODE_ENV
process.env.UPDATE_ORDER_STATUS_STATE_MACHINE_ARN = 'order-status-arn'
})

afterEach(() => {
Expand All @@ -39,8 +41,6 @@ describe('startOrderStatusUpdateWorkflow', () => {
test('starts the order status workflow', async () => {
const consoleMock = jest.spyOn(console, 'log')

process.env.updateOrderStatusStateMachineArn = 'order-status-arn'

await startOrderStatusUpdateWorkflow(1, 'access-token', 'ESI')

expect(client.send).toHaveBeenCalledTimes(1)
Expand All @@ -61,4 +61,60 @@ describe('startOrderStatusUpdateWorkflow', () => {
startDate: 12345
})
})

describe('when in development mode running SQS', () => {
test('mocks the step function', async () => {
process.env.NODE_ENV = 'development'
process.env.SKIP_SQS = 'false'
const mockStepFunctionMock = jest.spyOn(mockStepFunction, 'mockStepFunction').mockImplementationOnce(() => jest.fn())

const consoleMock = jest.spyOn(console, 'log')

await startOrderStatusUpdateWorkflow(1, 'access-token', 'ESI')

expect(consoleMock).toHaveBeenCalledTimes(1)
expect(consoleMock).toHaveBeenCalledWith('Starting order status update workflow for order ID: 1')

expect(mockStepFunctionMock).toHaveBeenCalledTimes(1)
expect(mockStepFunctionMock).toHaveBeenCalledWith('UpdateOrderStatus', {
accessToken: 'access-token',
id: 1,
orderType: 'ESI'
})

expect(client.send).toHaveBeenCalledTimes(0)
})
})

describe('when in development mode and skipping SQS', () => {
test('does not mock the step function', async () => {
process.env.NODE_ENV = 'development'
process.env.SKIP_SQS = 'true'
const mockStepFunctionMock = jest.spyOn(mockStepFunction, 'mockStepFunction').mockImplementationOnce(() => jest.fn())

const consoleMock = jest.spyOn(console, 'log')

await startOrderStatusUpdateWorkflow(1, 'access-token', 'ESI')

expect(consoleMock).toHaveBeenCalledTimes(1)
expect(consoleMock).toHaveBeenCalledWith('State Machine Invocation (Order ID: 1): ', {
executionArn: 'mockArn',
startDate: 12345
})

expect(client.send).toHaveBeenCalledTimes(1)
expect(client.send).toHaveBeenCalledWith(expect.objectContaining({
input: {
stateMachineArn: 'order-status-arn',
input: JSON.stringify({
id: 1,
accessToken: 'access-token',
orderType: 'ESI'
})
}
}))

expect(mockStepFunctionMock).toHaveBeenCalledTimes(0)
})
})
})
220 changes: 220 additions & 0 deletions serverless/src/util/mockStepFunction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* This file is only used in the development environment.
* It mocks a AWS Step Function workflow.
* It can be used to update the status of an order.
*/

import fs from 'fs'
import { get } from 'lodash-es'
import path from 'path'

// Read the nested stack file and return the contents
const getNestedStack = (file) => JSON.parse(fs.readFileSync(file, 'utf8'))

// Get the state machines from the template
const getStateMachines = (filepath) => {
const file = fs.readFileSync(filepath, 'utf8')
const template = JSON.parse(file)

// Combine all the nested stack template resources into the main template
const combinedTemplate = template
Object.keys(template.Resources).forEach((resourceId) => {
const resource = template.Resources[resourceId]

if (resource.Type === 'AWS::CloudFormation::Stack') {
const nestedStackPath = resource.Metadata['aws:asset:path']
const nestedTemplatePath = path.resolve(path.dirname(filepath), nestedStackPath)
const nestedTemplate = getNestedStack(nestedTemplatePath)

combinedTemplate.Resources = {
...combinedTemplate.Resources,
...nestedTemplate.Resources
}
}
})

// Find all the state machines in the template
const resources = template.Resources
const stateMachines = Object.keys(resources).filter((key) => resources[key].Type === 'AWS::StepFunctions::StateMachine')

// Parse the state machines
const configs = {}
stateMachines.forEach((key) => {
const definitionString = resources[key].Properties.DefinitionString
const { 'Fn::Join': fnJoin } = definitionString
const stringParts = fnJoin[1]
const string = stringParts.map((part) => {
if (typeof part === 'object') {
return part['Fn::GetAtt'][0]
}

return part
}).join('')

// Replace the GetAtt function with the actual value
const regex = /,\s*{\s*"Fn::GetAtt":\s*\[\s*"([^"]+)",\s*"[^"]+"\s*\]\s*},\s*"/g
const replacedString = string.replaceAll(regex, '": "$1", "')

const definition = JSON.parse(replacedString)

// Remove the 8 character has from the end of the `key`
const stateMachineName = key.slice(0, -8)

configs[stateMachineName] = definition
})

return configs
}

// Do the `Wait` state
const doWait = ({
callback,
next,
payload,
seconds,
stateMachine
}) => {
const waitTime = parseInt(seconds, 10)

console.log(`Waiting for ${seconds} seconds...`)
setTimeout(() => {
callback(stateMachine, next, payload)
}, waitTime * 1000)
}

// Do the `Task` state
const doTask = async ({
callback,
next,
payload,
resource,
stateMachine
}) => {
// Get the resource from the template
const [functionName] = resource.split('NestedStack')

// Lowercase the first letter of the function name
const lambdaName = functionName.charAt(0).toLowerCase() + functionName.slice(1)
console.log(`Running task: ${lambdaName}`)

// Import the handler
const handlerPath = `../${lambdaName}/handler.js`
const { default: handler } = (await import(handlerPath)).default

// Call the handler
const result = await handler(payload, {})

// Call the next state with the result of the task
callback(stateMachine, next, result)
}

// Do the `Choice` state
const doChoice = ({
callback,
choices,
defaultState,
payload,
stateMachine
}) => {
// Loop through the choices
const result = choices.some((choice) => {
const {
Variable: variable,
StringEquals: stringEquals,
Next: next
} = choice

// Find the variable within the payload
// `variable` uses JSONPath, and lodash.get doesn't, so remove the `$.`.
// This works for our limited usecase, but if we add more complex JSONPath variables in the future
// we'll need to use a proper JSONPath library
const value = get(payload, variable.replace('$.', ''))

// Check if the value matches the expected value
if (value === stringEquals) {
console.log(`Choice matched: ${variable} === ${stringEquals}, calling next state: ${next}`)

callback(stateMachine, next, payload)

return true
}

return false
})

// If no choice matched, call the `Default` state
if (!result) {
if (!defaultState) {
console.log('No choice matched, and no default state. Returning')

return
}

console.log(`No choice matched, calling default state: ${defaultState}`)
callback(stateMachine, defaultState, payload)
}
}

// Run the state machine
const runStateMachine = (stateMachine, stateName = undefined, payload = {}) => {
// Find the state in the state machine
const state = stateMachine.States[stateName || stateMachine.StartAt]

// Run the state
const {
Choices: choices,
Default: defaultState,
Next: next,
Resource: resource,
Seconds: seconds,
Type: type
} = state

switch (type) {
case 'Wait':
doWait({
callback: runStateMachine,
next,
payload,
seconds,
stateMachine
})

break
case 'Task':
doTask({
callback: runStateMachine,
next,
payload,
resource,
stateMachine
})

break
case 'Choice':
doChoice({
callback: runStateMachine,
defaultState,
choices,
payload,
stateMachine
})

break
case 'Succeed':
console.log('State machine completed successfully')
break
case 'Fail':
console.log('State machine failed')
break
default:
break
}
}

export const mockStepFunction = (stateMachineName, payload) => {
const templateFilePath = 'cdk/earthdata-search/cdk.out/earthdata-search-dev.template.json'
const stateMachines = getStateMachines(templateFilePath)

runStateMachine(stateMachines[stateMachineName], undefined, payload)
}
Loading

0 comments on commit 11bdf8d

Please sign in to comment.