diff --git a/src/commands/invoke/index.js b/src/commands/invoke/index.js new file mode 100644 index 0000000..5c258e9 --- /dev/null +++ b/src/commands/invoke/index.js @@ -0,0 +1,13 @@ +const program = require("commander"); +const cons = require("./invoke"); +program + .command("invoke") + .alias("in") + .description("Invokes a Lambda function or a StepFunctions state machine") + .option("-s, --stack-name [stackName]", "The name of the deployed stack") + .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("-p, --profile [profile]", "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) => { + await cons.run(cmd); + }); diff --git a/src/commands/invoke/invoke.js b/src/commands/invoke/invoke.js new file mode 100644 index 0000000..8f6d327 --- /dev/null +++ b/src/commands/invoke/invoke.js @@ -0,0 +1,136 @@ +const { CloudFormationClient, DescribeStackResourcesCommand } = require("@aws-sdk/client-cloudformation"); +const { fromSSO } = require("@aws-sdk/credential-provider-sso"); +const lambdaInvoker = require("./lambdaInvoker"); +const stepFunctionsInvoker = require("./stepFunctionsInvoker"); +const inputUtil = require('../../shared/inputUtil'); +const parser = require("../../shared/parser"); +const fs = require("fs"); +const ini = require('ini'); +const link2aws = require('link2aws'); +const open = import('open'); +let region; +async function run(cmd) { + if (fs.existsSync("samconfig.toml")) { + const config = ini.parse(fs.readFileSync("samconfig.toml", "utf8")); + const params = config?.default?.deploy?.parameters; + if (!cmd.stackName && params.stack_name) { + console.log("Using stack name from config:", params.stack_name); + cmd.stackName = params.stack_name; + } + if (!cmd.profile && params.profile) { + console.log("Using AWS profile from config:", params.profile); + cmd.profile = params.profile; + } + if (!cmd.region && params.region) { + console.log("Using AWS region from config:", params.region); + cmd.region = params.region; + region = params.region; + } + } + const credentials = await fromSSO({ profile: cmd.profile })(); + if (!cmd.stackName) { + console.error("Missing required option: --stack-name"); + process.exit(1); + } + + const cloudFormation = new CloudFormationClient({ credentials: credentials, region: cmd.region }); + let resources; + try { + resources = await cloudFormation.send(new DescribeStackResourcesCommand({ StackName: cmd.stackName })); + } + catch (e) { + console.log(`Failed to describe stack resources: ${e.message}`); + process.exit(1); + } + const targets = resources.StackResources.filter(r => ["AWS::Lambda::Function", "AWS::StepFunctions::StateMachine"].includes(r.ResourceType)).map(r => { return { name: `${r.LogicalResourceId} [${r.ResourceType.split("::")[1]}]`, value: r } }); + + if (targets.length === 0) { + console.log("No compatible resources found in stack"); + return; + } + let resource; + + if (targets.length === 1) { + resource = targets[0].value; + } + else { + resource = await inputUtil.autocomplete("Select a resource", targets); + } + if (resource.ResourceType === "AWS::StepFunctions::StateMachine") { + await stepFunctionsInvoker.invoke(cmd, resource.PhysicalResourceId); + } + else { + await lambdaInvoker.invoke(cmd, resource.PhysicalResourceId); + } +} + +async function getPhysicalId(cloudFormation, stackName, logicalId) { + const params = { + StackName: stackName, + LogicalResourceId: logicalId + }; + if (logicalId.endsWith(" Logs")) { + const logGroupParentResource = logicalId.split(" ")[0]; + params.LogicalResourceId = logGroupParentResource; + } + + const response = await cloudFormation.send(new DescribeStackResourcesCommand(params)); + if (response.StackResources.length === 0) { + console.log(`No stack resource found for ${logicalId}`); + process.exit(1); + } + if (logicalId.endsWith(" Logs")) { + const logGroup = `/aws/lambda/${response.StackResources[0].PhysicalResourceId}`; + const source = `$257E$2527${logGroup}`.replace(/\//g, "*2f") + const query = `fields*20*40timestamp*2c*20*40message*2c*20*40logStream*2c*20*40log*0a*7c*20filter*20*40message*20like*20*2f*2f*0a*7c*20sort*20*40timestamp*20desc*0a*7c*20limit*2020`; + return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:logs-insights$3FqueryDetail$3D$257E$2528end$257E0$257Estart$257E-3600$257EtimeType$257E$2527RELATIVE$257Eunit$257E$2527seconds$257EeditorString$257E$2527${query}$257EisLiveTail$257Efalse$257Esource$257E$2528${source}$2529$2529` + } + + return response.StackResources[0].PhysicalResourceId; +} + +function createARN(resourceType, resourceName) { + if (!resourceType.includes("::")) { + console.log(`Can't create ARN for ${resourceType}`); + return; + } + if (resourceName.startsWith("arn:")) { + return resourceName; + } + let service = resourceType.split("::")[1].toLowerCase(); + const noResourceTypeArns = [ + "s3", + "sqs", + "sns", + ] + const type = noResourceTypeArns.includes(service) ? "" : resourceType.split("::")[2].toLowerCase(); + + if (service === "sqs") { + resourceName = resourceName.split("/").pop(); + } + + //map sam to cloudformation + if (service === "serverless") { + switch (type) { + case "function": + service = "lambda"; + break; + case "api": + service = "apigateway"; + break; + case "table": + service = "dynamodb"; + break; + case "statemachine": + service = "states"; + break; + } + } + + return `arn:aws:${service}:${region}::${type}:${resourceName}`; +} + +module.exports = { + run +}; + diff --git a/src/commands/invoke/lambdaInvoker.js b/src/commands/invoke/lambdaInvoker.js new file mode 100644 index 0000000..f5f8530 --- /dev/null +++ b/src/commands/invoke/lambdaInvoker.js @@ -0,0 +1,134 @@ +const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda'); +const { SchemasClient, DescribeSchemaCommand, UpdateSchemaCommand, CreateSchemaCommand, CreateRegistryCommand } = require('@aws-sdk/client-schemas'); +const { fromSSO } = require("@aws-sdk/credential-provider-sso"); +const fs = require('fs'); +const inputUtil = require('../../shared/inputUtil'); + +async function invoke(cmd, resourceName) { + const lambdaClient = new LambdaClient({ credentials: await fromSSO({ profile: cmd.profile }) }); + const schemasClient = new SchemasClient({ credentials: await fromSSO({ profile: cmd.profile }) }); + if (!cmd.payload) { + const payloadSource = await inputUtil.list("Select a payload source", ["Local JSON file", "Shared test event", "Input JSON"]); + if (payloadSource === "Local JSON file") { + cmd.payload = await inputUtil.file("Select file(s) to use as payload", "json"); + } else if (payloadSource === "Shared test event") { + try { + const sharedEvents = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: "lambda-testevent-schemas", SchemaName: `_${resourceName}-schema` })); + const schema = JSON.parse(sharedEvents.Content); + const savedEvents = Object.keys(schema.components.examples); + const event = await inputUtil.autocomplete("Select an event", savedEvents); + cmd.payload = JSON.stringify(schema.components.examples[event].value); + } catch (e) { + console.log("Failed to fetch shared test events", e.message); + process.exit(1); + } + } else if (payloadSource === "Input JSON") { + do { + cmd.payload = await inputUtil.text("Enter payload JSON"); + } while (!isValidJson(cmd.payload, true)); + const save = await inputUtil.prompt("Save as shared test event?", "No"); + if (save) { + const name = await inputUtil.text("Enter a name for the event"); + try { + try { + await schemasClient.send(new CreateRegistryCommand({ RegistryName: registryName })); + } catch (e) { + // do nothing + } + + const schema = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: "lambda-testevent-schemas", SchemaName: `_${resourceName}-schema` })); + const schemaContent = JSON.parse(schema.Content); + schemaContent.components.examples[name] = { value: JSON.parse(cmd.payload) }; + await schemasClient.send(new UpdateSchemaCommand({ RegistryName: "lambda-testevent-schemas", SchemaName: `_${resourceName}-schema`, Type: "OpenApi3", Content: JSON.stringify(schemaContent) })); + } catch (e) { + if (e.message.includes("does not exist")) { + console.log("Creating new schema"); + const schemaContent = { + openapi: "3.0.0", + info: { + title: `Event`, + version: "1.0.0" + }, + paths: {}, + components: { + examples: { + [name]: { + value: JSON.parse(cmd.payload) + } + } + } + }; + await schemasClient.send(new CreateSchemaCommand({ RegistryName: "lambda-testevent-schemas", SchemaName: `_${resourceName}-schema`, Type: "OpenApi3", Content: JSON.stringify(schemaContent) })); + } else { + + console.log("Failed to save shared test event", e.message); + process.exit(1); + } + } + console.log(`Saved event '${name}'`); + } + } + } + + if (isFilePath(cmd.payload)) { + cmd.payload = fs.readFileSync(cmd.payload).toString(); + } + + if (!isValidJson(cmd.payload)) { + try { + const sharedEvents = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: "lambda-testevent-schemas", SchemaName: `_${resourceName}-schema` })); + const schema = JSON.parse(sharedEvents.Content); + cmd.payload = JSON.stringify(schema.components.examples[cmd.payload].value); + } catch (e) { + console.log("Failed to fetch shared test events", e.message); + process.exit(1); + } + } + + if (isValidJson(cmd.payload)) { + const params = new InvokeCommand({ + FunctionName: resourceName, + Payload: cmd.payload + }); + try { + console.log("Invoking function with payload:", concatenateAndAddDots(cmd.payload, 100)) + const data = await lambdaClient.send(params); + const response = JSON.parse(Buffer.from(data.Payload).toString()); + try { + console.log("Response:", JSON.stringify(JSON.parse(response), null, 2)); + } catch (e) { + console.log("Response:", response); + } + } + catch (err) { + console.log("Error", err); + } + } else { + console.log("Invalid JSON, please try again"); + } +} + +function concatenateAndAddDots(str, maxLength) { + if (str.length <= maxLength) { + return str; + } + return str.substring(0, maxLength - 3) + "..."; +} + +function isFilePath(str) { + return str.startsWith("./") || str.startsWith("../") || str.startsWith("/") || str.startsWith("~") || str.startsWith("file://") + && fs.existsSync(str); +} + +function isValidJson(str, logInfo) { + try { + JSON.parse(str); + } catch (e) { + if (logInfo) + console.log("Invalid JSON, please try again"); + return false; + } + return true; +} + +exports.invoke = invoke; \ No newline at end of file diff --git a/src/commands/invoke/stepFunctionsInvoker.js b/src/commands/invoke/stepFunctionsInvoker.js new file mode 100644 index 0000000..b76ed48 --- /dev/null +++ b/src/commands/invoke/stepFunctionsInvoker.js @@ -0,0 +1,148 @@ +const { SFNClient, 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 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 schemasClient = new SchemasClient({ credentials: await fromSSO({ profile: cmd.profile }) }); + const stateMachineName = sfnArn.split(":").pop(); + if (!cmd.payload) { + const payloadSource = await inputUtil.list("Select a payload source", ["Local JSON file", "Shared test event", "Recent execution history", "Input JSON"]); + if (payloadSource === "Local JSON file") { + cmd.payload = await inputUtil.file("Select file(s) to use as payload", "json"); + } else if (payloadSource === "Shared test event") { + try { + const sharedEvents = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: registryName, SchemaName: `_${stateMachineName}-schema` })); + const schema = JSON.parse(sharedEvents.Content); + const savedEvents = Object.keys(schema.components.examples); + const event = await inputUtil.autocomplete("Select an event", savedEvents); + cmd.payload = JSON.stringify(schema.components.examples[event].value); + } catch (e) { + console.log("Failed to fetch shared test events", e.message); + process.exit(1); + } + } else if (payloadSource === "Recent execution history") { + try { + const executions = await sfnClient.send(new ListExecutionsCommand({ stateMachineArn: sfnArn })); + const executionNames = executions.executions.map(e => {return {name: `${e.name} (${e.startDate.toISOString()}) - ${e.status}` , value: e.executionArn}}); + const executionArn = await inputUtil.autocomplete("Select an execution", executionNames); + const execution = await sfnClient.send(new DescribeExecutionCommand({ executionArn })); + + cmd.payload = execution.input + } catch (e) { + console.log("Failed to fetch shared test events", e.message); + process.exit(1); + } + } else if (payloadSource === "Input JSON") { + do { + cmd.payload = await inputUtil.text("Enter payload JSON"); + } while (!isValidJson(cmd.payload, true)); + const save = await inputUtil.prompt("Save as shared test event?", "No"); + if (save) { + const name = await inputUtil.text("Enter a name for the event"); + try { + try { + await schemasClient.send(new CreateRegistryCommand({ RegistryName: registryName })); + } catch (e) { + // do nothing + } + const schema = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: registryName, SchemaName: `_${stateMachineName}-schema` })); + const schemaContent = JSON.parse(schema.Content); + schemaContent.components.examples[name] = { value: JSON.parse(cmd.payload) }; + await schemasClient.send(new UpdateSchemaCommand({ RegistryName: registryName, SchemaName: `_${stateMachineName}-schema`, Type: "OpenApi3", Content: JSON.stringify(schemaContent) })); + } catch (e) { + if (e.message.includes("does not exist")) { + console.log("Creating new schema"); + const schemaContent = { + openapi: "3.0.0", + info: { + title: `Event`, + version: "1.0.0" + }, + paths: {}, + components: { + examples: { + [name]: { + value: JSON.parse(cmd.payload) + } + } + } + }; + await schemasClient.send(new CreateSchemaCommand({ RegistryName: registryName, SchemaName: `_${stateMachineName}-schema`, Type: "OpenApi3", Content: JSON.stringify(schemaContent) })); + } else { + + console.log("Failed to save shared test event", e.message); + process.exit(1); + } + } + console.log(`Saved event '${name}'`); + } + } + } + + if (isFilePath(cmd.payload)) { + cmd.payload = fs.readFileSync(cmd.payload).toString(); + } + + if (!isValidJson(cmd.payload)) { + try { + const sharedEvents = await schemasClient.send(new DescribeSchemaCommand({ RegistryName: registryName, SchemaName: `_${stateMachineName}-schema` })); + const schema = JSON.parse(sharedEvents.Content); + cmd.payload = JSON.stringify(schema.components.examples[cmd.payload].value); + } catch (e) { + console.log("Failed to fetch shared test events", e.message); + process.exit(1); + } + } + + if (isValidJson(cmd.payload)) { + const executionName = await inputUtil.text("Enter a name for the execution", "test-execution"); + const params = new StartExecutionCommand({ + stateMachineArn: sfnArn, + input: cmd.payload, + name: `${executionName}-${Date.now()}` + }); + try { + console.log("Invoking state machine with payload:", concatenateAndAddDots(cmd.payload, 100)) + const data = await sfnClient.send(params); + const response = data.executionArn; + if (response.includes(":express:")) { + const url = `https://${cmd.region}.console.aws.amazon.com/states/home?region=${cmd.region}#/express-executions/details/${response}?startDate=${data.startDate.getTime()}` + console.log("Started:", url); + } + } + catch (err) { + console.log("Error", err); + } + } else { + console.log("Invalid JSON, please try again"); + } +} + +function concatenateAndAddDots(str, maxLength) { + if (str.length <= maxLength) { + return str; + } + return str.substring(0, maxLength - 3) + "..."; +} + +function isFilePath(str) { + return str.startsWith("./") || str.startsWith("../") || str.startsWith("/") || str.startsWith("~") || str.startsWith("file://") + && fs.existsSync(str); +} + +function isValidJson(str, logInfo) { + try { + JSON.parse(str); + } catch (e) { + if (logInfo) + console.log("Invalid JSON, please try again"); + return false; + } + return true; +} + +exports.invoke = invoke; \ No newline at end of file