Skip to content

Commit

Permalink
feat: StepFunctions test state command 🎉
Browse files Browse the repository at this point in the history
  • Loading branch information
ljacobsson committed Dec 6, 2023
1 parent 0850d09 commit c60c3df
Show file tree
Hide file tree
Showing 7 changed files with 8,083 additions and 18,179 deletions.
26,039 changes: 7,873 additions & 18,166 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 5 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "samp-cli",
"version": "1.0.64",
"version": "1.0.65",
"description": "CLI tool for extended productivity with AWS Serverless Application Model (SAM)",
"main": "index.js",
"scripts": {
Expand Down Expand Up @@ -34,19 +34,19 @@
"@aws-sdk/client-iot": "^3.345.0",
"@aws-sdk/client-lambda": "^3.358.0",
"@aws-sdk/client-schemas": "^3.358.0",
"@aws-sdk/client-sfn": "^3.359.0",
"@aws-sdk/client-sfn": "^3.465.0",
"@aws-sdk/client-sts": "^3.379.1",
"@aws-sdk/credential-provider-sso": "^3.319.0",
"@aws-sdk/shared-ini-file-loader": "^3.374.0",
"@mhlabs/iam-policies-cli": "^1.0.5",
"@mhlabs/sfn-cli": "^1.0.2",
"@mhlabs/iam-policies-cli": "^1.0.7",
"@mhlabs/sfn-cli": "^1.0.3",
"@mhlabs/xray-cli": "^1.0.8",
"@octokit/rest": "^18.5.2",
"archiver": "^6.0.0",
"ascii-table3": "^0.9.0",
"aws-cdk-lib": "^2.87.0",
"axios": "^1.4.0",
"chokidar": "^3.5.3",
"cli-color": "^2.0.3",
"cli-spinner": "^0.2.10",
"clipboardy": "^2.2.0",
"commander": "^7.2.0",
Expand Down Expand Up @@ -75,9 +75,6 @@
"yaml": "^2.3.1",
"yaml-cfn": "^0.3.2"
},
"devDependencies": {
"jest": "^26.6.3"
},
"engines": {
"node": ">=15.0.0"
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/invoke/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ program
.option("-r, --resource [resource]", "The resource (function name or state machine ARN) to invoke. If not specified, you will be prompted to select one")
.option("-pl, --payload [payload]", "The payload to send to the function. Could be stringified JSON, a file path to a JSON file or the name of a shared test event")
.option("-l, --latest", "Invokes the latest request that was sent to the function", false)
.option("-p, --profile [profile]", "AWS profile to use", "default")
.option("-p, --profile [profile]", "AWS profile to use")
.option("-sync", "--synchronous", "StepFuncitons only - wait for the state machine to finish and print the output", false)
.option("--region [region]", "The AWS region to use. Falls back on AWS_REGION environment variable if not specified")
.action(async (cmd) => {
Expand Down
4 changes: 3 additions & 1 deletion src/commands/invoke/stepFunctionsInvoker.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
const { SFNClient, DescribeStateMachineForExecutionCommand, StartExecutionCommand, ListExecutionsCommand, DescribeExecutionCommand } = require('@aws-sdk/client-sfn');
const { SchemasClient, DescribeSchemaCommand, UpdateSchemaCommand, CreateSchemaCommand, CreateRegistryCommand } = require('@aws-sdk/client-schemas');
const { fromSSO } = require("@aws-sdk/credential-provider-sso");
const samConfigParser = require('../../shared/samConfigParser');
const link2aws = require('link2aws');
const fs = require('fs');
const inputUtil = require('../../shared/inputUtil');
const registryName = "sfn-testevent-schemas";
async function invoke(cmd, sfnArn) {
const sfnClient = new SFNClient({ credentials: await fromSSO({ profile: cmd.profile }) });
const config = await samConfigParser.parse();
const sfnClient = new SFNClient({ credentials: await fromSSO({ profile: cmd.profile || config.profile }), region: cmd.region || config.region });
const schemasClient = new SchemasClient({ credentials: await fromSSO({ profile: cmd.profile }) });
const stateMachineName = sfnArn.split(":").pop();
if (!cmd.payload) {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/stepfunctions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const program = require("commander");
const sync = require("@mhlabs/sfn-cli/src/commands/sync/sync");
const init = require("@mhlabs/sfn-cli/src/commands/init/init");
const inputUtil = require("../../shared/inputUtil");
const testState = require("./test-state");
program
.command("stepfunctions")
.alias("sfn")
Expand All @@ -10,7 +11,7 @@ program
.description("Initiates a state machine or sets up a live sync between your local ASL and the cloud")
.option("-t, --template-file [templateFile]", "Path to SAM template file", "template.yaml")
.option("-s, --stack-name [stackName]", "[Only applicable when syncing] The name of the deployed stack")
.option("-p, --profile [profile]", "[Only applicable when syncing] AWS profile to use", "default")
.option("-p, --profile [profile]", "[Only applicable when syncing] AWS profile to use")
.option("--region [region]", "The AWS region to use. Falls back on AWS_REGION environment variable if not specified")

.action(async (cmd, opts) => {
Expand All @@ -21,8 +22,10 @@ program
await init.run(opts);
} else if (cmd === "sync") {
await sync.run(opts);
} else if (cmd === "test-state") {
await testState.run(opts);
} else {
console.log("Unknown command. Valid commands are: init, sync");
console.log("Unknown command. Valid commands are: init, sync, test-state");
}
return;
});
190 changes: 190 additions & 0 deletions src/commands/stepfunctions/test-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
const { SFNClient, TestStateCommand, DescribeStateMachineCommand, ListExecutionsCommand, GetExecutionHistoryCommand } = require('@aws-sdk/client-sfn');
const { CloudFormationClient, ListStackResourcesCommand } = require('@aws-sdk/client-cloudformation');
const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts');
const { fromSSO } = require("@aws-sdk/credential-provider-sso");
const samConfigParser = require('../../shared/samConfigParser');
const parser = require('../../shared/parser');
const fs = require('fs');
const inputUtil = require('../../shared/inputUtil');
const clc = require("cli-color");
const path = require('path');
const { Spinner } = require('cli-spinner');

const os = require('os');
let clientParams;
async function run(cmd) {
const config = await samConfigParser.parse();
const credentials = await fromSSO({ profile: cmd.profile || config.profile || 'default' });
clientParams = { credentials, region: cmd.region || config.region }
const sfnClient = new SFNClient(clientParams);
const cloudFormation = new CloudFormationClient(clientParams);
const sts = new STSClient(clientParams);
const template = await parser.findSAMTemplateFile(process.cwd());
const templateContent = fs.readFileSync(template, 'utf8');
const templateObj = parser.parse("template", templateContent);
const stateMachines = findAllStateMachines(templateObj);
const stateMachine = stateMachines.length === 1 ? stateMachines[0] : await inputUtil.list("Select state machine", stateMachines);

const spinner = new Spinner(`Fetching state machine ${stateMachine}... %s`);
spinner.setSpinnerString(30);
spinner.start();

const stackResources = await listAllStackResourcesWithPagination(cloudFormation, cmd.stackName || config.stack_name);

const stateMachineArn = stackResources.find(r => r.LogicalResourceId === stateMachine).PhysicalResourceId;
const stateMachineRoleName = stackResources.find(r => r.LogicalResourceId === `${stateMachine}Role`).PhysicalResourceId;

const describedStateMachine = await sfnClient.send(new DescribeStateMachineCommand({ stateMachineArn }));
const definition = JSON.parse(describedStateMachine.definition);

spinner.stop(true);
const states = findStates(definition);
const state = await inputUtil.autocomplete("Select state", states.map(s => { return { name: s.key, value: { name: s.key, state: s.state } } }));

const input = await getInput(stateMachineArn, state.name, describedStateMachine.type);

const accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
console.log(`Invoking state ${clc.green(state.name)} with input:\n${clc.green(input)}\n`);
const testResult = await sfnClient.send(new TestStateCommand(
{
definition: JSON.stringify(state.state),
roleArn: `arn:aws:iam::${accountId}:role/${stateMachineRoleName}`,
input: input
}
));
delete testResult.$metadata;
let color = "green";
if (testResult.error) {
color = "red";
}
for (const key in testResult) {
console.log(`${clc[color](key.charAt(0).toUpperCase() + key.slice(1))}: ${testResult[key]}`);
}
}

async function getInput(stateMachineArn, state, stateMachineType) {
let types = [
"Empty JSON",
"Manual input",
"From file"];

if (stateMachineType === "STANDARD") {
types.push("From recent execution");
}

const configDirExists = fs.existsSync(path.join(os.homedir(), '.samp-cli', 'state-tests'));
if (!configDirExists) {
fs.mkdirSync(path.join(os.homedir(), '.samp-cli', 'state-tests'), { recursive: true });
}

const stateMachineStateFileExists = fs.existsSync(path.join(os.homedir(), '.samp-cli', 'state-tests', stateMachineArn));

if (!stateMachineStateFileExists) {
fs.writeFileSync(path.join(os.homedir(), '.samp-cli', 'state-tests', stateMachineArn), "{}");
}

const storedState = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.samp-cli', 'state-tests', stateMachineArn), "utf8"));
if (Object.keys(storedState).length > 0) {
types = ["Latest input", ...types];
}

const type = await inputUtil.list("Select input type", types);

if (type === "Empty JSON") {
return "{}";
}

if (type === "Manual input") {
return inputUtil.text("Enter input JSON", "{}");
}

if (type === "From file") {
const file = await inputUtil.file("Select input file", "json");
return fs.readFileSync(file, "utf8");
}

if (type === "Latest input") {
return JSON.stringify(storedState[state]);
}

if (type === "From recent execution") {
const sfnClient = new SFNClient(clientParams);

const executions = await sfnClient.send(new ListExecutionsCommand({ stateMachineArn }));
const execution = await inputUtil.autocomplete("Select execution", executions.executions.map(e => { return { name: `[${e.startDate.toLocaleTimeString()}] ${e.name}`, value: e.executionArn } }));
const executionHistory = await sfnClient.send(new GetExecutionHistoryCommand({ executionArn: execution }));
const input = findFirstTaskEnteredEvent(executionHistory, state);
if (!input) {
console.log("No input found for state. Did it execute in the chosen execution?");
process.exit(1);
}
return input.stateEnteredEventDetails.input;
}
}

function findFirstTaskEnteredEvent(jsonData, state) {
console.log("state", state);
for (const event of jsonData.events) {
if (event.type.endsWith("StateEntered") && event.stateEnteredEventDetails.name === state) {
return event;
}
}
return null; // or any appropriate default value
}


function findStates(aslDefinition) {
const result = [];

function traverseStates(states) {
Object.keys(states).forEach(key => {
const state = states[key];
if (state.Type === 'Task' || state.Type === 'Pass' || state.Type === 'Choice') {
result.push({ key, state });
}
// Recursively search in Parallel and Map structures
if (state.Type === 'Parallel' && state.Branches) {
state.Branches.forEach(branch => {
traverseStates(branch.States);
});
}
if (state.Type === 'Map' && state.ItemProcessor && state.ItemProcessor.States) {
traverseStates(state.ItemProcessor.States);
}
});
}

traverseStates(aslDefinition.States);
return result;
}

function listAllStackResourcesWithPagination(cloudFormation, stackName) {
const params = {
StackName: stackName
};
const resources = [];
const listStackResources = async (params) => {
const response = await cloudFormation.send(new ListStackResourcesCommand(params));
resources.push(...response.StackResourceSummaries);
if (response.NextToken) {
params.NextToken = response.NextToken;
await listStackResources(params);
}
};

return listStackResources(params).then(() => resources);
}

function findAllStateMachines(templateObj) {
const stateMachines = Object.keys(templateObj.Resources).filter(r => templateObj.Resources[r].Type === "AWS::Serverless::StateMachine");
if (stateMachines.length === 0) {
console.log("No state machines found in template");
process.exit(0);
}

return stateMachines;
}

module.exports = {
run
}
7 changes: 6 additions & 1 deletion src/commands/traces/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const program = require("commander");
const traces = require("@mhlabs/xray-cli/src/commands/traces/traces");
const samConfigParser = require("../../shared/samConfigParser");
program
.command("traces")
.alias("t")
Expand All @@ -9,8 +10,12 @@ program
.option("-as, --absolute-start <start>", "Start time (ISO 8601)")
.option("-ae, --absolute-end <end>", "End time (ISO 8601)")
.option("-f, --filter-expression <filter>", "Filter expression. Must be inside double or single quotes (\"/')")
.option("-p, --profile <profile>", "AWS profile to use", "default")
.option("-p, --profile <profile>", "AWS profile to use")
.option("-r, --region <region>", "AWS region to use")
.action(async (cmd) => {
const config = await samConfigParser.parse();
cmd.region = cmd.region || config.region;
cmd.profile = cmd.profile || config.profile || 'default';

await traces.run(cmd);
});

0 comments on commit c60c3df

Please sign in to comment.