Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Lambda #76

Merged
merged 1 commit into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ At the moment, this library provides the following:
- `SSM`: allows to retrieve a parameter from AWS Systems Manager
- `Kinesis`: allows to list streams, create streams, put records, list shards, get shard iterators, and get records from AWS Kinesis.
- `EventBridge`: allows to put events to AWS EventBridge.
- `Lambda`: allows to invoke functions in AWS Lambda.
- `V4 signature`: allows to sign requests to amazon AWS services
- `KinesisClient`: allows all APIs for Kinesis available by AWS.

Expand Down Expand Up @@ -375,6 +376,34 @@ export default async function () {
}
```

### Lambda

Consult the `LambdaClient` [dedicated k6 documentation page](https://k6.io/docs/javascript-api/jslib/aws/lambdaclient) for more details on its methods and how to use it.

```javascript
import { AWSConfig, LambdaClient } from 'https://jslib.k6.io/aws/0.11.0/lambda.js'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { AWSConfig, LambdaClient } from 'https://jslib.k6.io/aws/0.11.0/lambda.js'
import { AWSConfig, LambdaClient } from 'https://jslib.k6.io/aws/0.12.0/lambda.js'

I expect we need to bump, right @oleiade?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codebien So far bumping version numbering has been part of our release process, and we haven't done it in feature PRs.

Although I realize we haven't added it to our CONTRIBUTING guidelines yet, will do so 👍🏻

import { check } from 'k6';

const awsConfig = new AWSConfig({
region: __ENV.AWS_REGION,
accessKeyId: __ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: __ENV.AWS_SECRET_ACCESS_KEY,
sessionToken: __ENV.AWS_SESSION_TOKEN,
})

const lambdaClient = new LambdaClient(awsConfig)

export default async function () {
const response = await lambdaClient.invoke('add-numbers', JSON.stringify({x: 1, y: 2}))

check(response, {
'status is 200': (r) => r.statusCode === 200,
'payload is 3': (r) => r.payload === 3,
})
}

```

## Development

### Contributing
Expand Down
2 changes: 1 addition & 1 deletion build/aws.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/aws.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/event-bridge.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/event-bridge.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kinesis.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kinesis.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kms.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kms.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions build/lambda.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build/lambda.js.LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
1 change: 1 addition & 0 deletions build/lambda.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/s3.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/s3.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/secrets-manager.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/secrets-manager.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/signature.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/signature.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/sqs.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/sqs.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/ssm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/ssm.js.map

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions examples/lambda.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AWSConfig, LambdaClient } from '../build/lambda.js'
import { check } from 'k6';

const awsConfig = new AWSConfig({
region: __ENV.AWS_REGION,
accessKeyId: __ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: __ENV.AWS_SECRET_ACCESS_KEY,
sessionToken: __ENV.AWS_SESSION_TOKEN,
})

const lambdaClient = new LambdaClient(awsConfig)

export default async function () {
const response = await lambdaClient.invoke('add-numbers', JSON.stringify({x: 1, y: 2}))

check(response, {
'status is 200': (r) => r.statusCode === 200,
'payload is 3': (r) => r.payload === 3,
})
}
31 changes: 14 additions & 17 deletions localstack/init/ready.d/lambda.sh
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
#!/bin/bash

FUNCTION_NAME="test-jslib-aws-lambda"
testdata_folder="/etc/localstack/init/testdata/lambda"
zip_dir=/tmp/lambda
mkdir -p "$zip_dir"

# Create a dummy lambda function responding with a static string "Hello World!"
cat >index.js <<EOF
exports.handler = async function(event, context) {
return "Hello World!";
}
EOF
for file in "$testdata_folder"/*; do
function_name=$(basename "$file")
function_zip="$zip_dir/$function_name.zip"
(cd "$file" || exit; zip "$function_zip" ./*)
Comment on lines +3 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nickcaballero do we need to have them nested in folders? Could we have direct a list of files?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file structure matches how the zips are created. We can flatten the folders, but it'll make the script a bit more complicated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to have a simpler script, rather than a simpler folder structure 👍🏻


# Create a zip file containing the lambda function
zip lambda.zip index.js

# Create a dummy lambda function responding with a static string "Hello World!"
awslocal lambda create-function \
--function-name "$FUNCTION_NAME" \
--runtime nodejs18.x \
--handler index.handler \
--zip-file fileb://lambda.zip \
--role arn:aws:iam::123456789012:role/irrelevant
awslocal lambda create-function \
--function-name "$function_name" \
--runtime nodejs18.x \
--zip-file "fileb://$function_zip" \
--handler index.handler \
--role arn:aws:iam::000000000000:role/lambda-role
done
3 changes: 3 additions & 0 deletions localstack/init/testdata/lambda/test-fail/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
exports.handler = async (event) => {
throw new Error(event)
};
4 changes: 4 additions & 0 deletions localstack/init/testdata/lambda/test-product/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
exports.handler = async (event) => {
console.log('received event:', JSON.stringify(event));
return event.a * event.b
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export {
export { SQSClient } from './sqs'
export { KinesisClient } from './internal/kinesis'
export { EventBridgeClient } from './internal/event-bridge'
export { LambdaClient, LambdaInvocationError } from './lambda'
12 changes: 12 additions & 0 deletions src/internal/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseHTML } from 'k6/html'
import { Response } from 'k6/http'

/**
* Base class to derive errors from
Expand Down Expand Up @@ -35,4 +36,15 @@ export class AWSError extends Error {
const doc = parseHTML(xmlDocument)
return new AWSError(doc.find('Message').text(), doc.find('Code').text())
}

static parse(response: Response): AWSError {
if (response.headers['Content-Type'] === 'application/json') {
const error = response.json() as any;
const message = error.Message || error.message || error.__type || 'An error occurred on the server side';
const code = response.headers['X-Amzn-Errortype'] || error.__type
return new AWSError(message, code)
Comment on lines +42 to +45
Copy link
Contributor Author

@nickcaballero nickcaballero Nov 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled some of the error handling logic that @jakub-qg added into here to make it more reusable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really neat 🙇🏻

} else {
return AWSError.parseXML(response.body as string);
}
}
}
163 changes: 74 additions & 89 deletions src/internal/lambda.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import http, { RefinedResponse, ResponseType } from 'k6/http'
import encoding from 'k6/encoding';

import { AWSClient } from './client'
import { AWSConfig } from './config'
import { AWSError } from './error'
import { JSONObject } from './json'
import { InvalidSignatureError, SignatureV4 } from './signature'
import { AMZ_TARGET_HEADER } from './constants'
import { HTTPHeaders, HTTPMethod } from './http'
import { HTTPHeaders, HTTPMethod, QueryParameterBag } from './http'


/**
* Class allowing to interact with Amazon AWS's Lambda service
*/
export class LambdaClient extends AWSClient {
method: HTTPMethod

commonHeaders: HTTPHeaders

signature: SignatureV4
private readonly signature: SignatureV4
private readonly commonHeaders: HTTPHeaders
private readonly method: HTTPMethod

constructor(awsConfig: AWSConfig) {
super(awsConfig, 'lambda')
Expand All @@ -42,134 +41,120 @@ export class LambdaClient extends AWSClient {
/**
* Invoke an AWS Lambda function
*
* @param {InvokeInput} input - The input for the PutEvents operation.
* @throws {LambdaServiceError}
* @throws {InvalidSignatureError}
* @param {string} name - The name of the function
* @param {string} payload - The payload to send to function
* @param {InvocationOptions} options - Additional options to customize invocation
*
* @throws {LambdaInvocationError}
*/
async invoke(input: InvokeInput) {
const qualifier = input.Qualifier ? `?Qualifier=${input.Qualifier}` : ''
async invoke(
name: string,
payload: string,
options: InvocationOptions = {}
): Promise<InvocationResponse> {
const query: QueryParameterBag = {};
const invocationType = options.invocationType || 'RequestResponse'
const headers = {
...this.commonHeaders,
[AMZ_TARGET_HEADER]: `AWSLambda.${input.InvocationType}`,
'X-Amz-Invocation-Type': input.InvocationType,
'X-Amz-Log-Type': input.LogType || 'None',
};

if (input.ClientContext) {
headers['X-Amz-Client-Context'] = input.ClientContext
[AMZ_TARGET_HEADER]: `AWSLambda.${invocationType}`,
'X-Amz-Invocation-Type': invocationType,
'X-Amz-Log-Type': options.logType || 'None'
}
if (options.clientContext) {
headers['X-Amz-Client-Context'] = options.clientContext
}
if (options.qualifier) {
query['Qualifier'] = options.qualifier;
}

const signedRequest = this.signature.sign(
{
method: this.method,
endpoint: this.endpoint,
path: `/2015-03-31/functions/${input.FunctionName}/invocations${qualifier}`,
path: `/2015-03-31/functions/${name}/invocations`,
query,
headers,
body: JSON.stringify(input.Payload ?? ''),
body: payload || ''
},
{}
)

const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, {
headers: signedRequest.headers,
})
this._handle_error(LambdaOperation.Invoke, res)

if(input.InvocationType === 'Event') {
return
this._handle_error(res)

const logResult = res.headers['X-Amz-Log-Result']
const response = {
executedVersion: res.headers['X-Amz-Executed-Version'],
logResult: logResult ? encoding.b64decode(logResult, 'std', 's') : undefined,
statusCode: res.status,
payload: res.body as string
}

return res.json()
const functionError = res.headers['X-Amz-Function-Error']
if (functionError) {
throw new LambdaInvocationError(functionError, response)
} else {
return response
}
}

_handle_error(
operation: LambdaOperation,
private _handle_error(
response: RefinedResponse<ResponseType | undefined>
) {
const errorCode = response.error_code
if (errorCode === 0) {
return
}
const errorCode: number = response.error_code
const errorMessage: string = response.error

const error = response.json() as JSONObject
if (errorCode >= 1400 && errorCode <= 1499) {
// In the event of certain errors, the message is not set.
// Also, note the inconsistency in casing...
const errorMessage: string =
(error.Message as string) || (error.message as string) || (error.__type as string)

// Handle specifically the case of an invalid signature
if (error.__type === 'InvalidSignatureException') {
throw new InvalidSignatureError(errorMessage, error.__type)
}

// Otherwise throw a standard service error
throw new LambdaServiceError(errorMessage, error.__type as string, operation)
if (errorMessage == '' && errorCode === 0) {
return
}

if (errorCode === 1500) {
throw new LambdaServiceError(
'An error occured on the server side',
'InternalServiceError',
operation
)
const awsError = AWSError.parse(response)
switch (awsError.code) {
case 'AuthorizationHeaderMalformed':
case 'InvalidSignatureException':
throw new InvalidSignatureError(awsError.message, awsError.code)
default:
throw awsError
}
}
}

enum LambdaOperation {
Invoke = 'Invoke',
}
export class LambdaInvocationError extends Error {
response: InvocationResponse

constructor(message: string, response: InvocationResponse) {
super(`${message}: ${response.payload}`)
this.response = response
}
}

/**
* Represents the input for an Invoke operation.
*/
interface InvokeInput {
/**
* The name of the Lambda function, version, or alias.
*
* Supported names formats:
* - Function name: `my-function` (name-only), `my-function:v1` (with alias).
* - Function ARM: `arn:aws:lambda:us-west-2:123456789012:function:my-function`.
* - Partial ARN: `123456789012:function:my-function`.
*/
FunctionName: string
interface InvocationOptions {
/**
* Defines whether the function is invoked synchronously or asynchronously.
* - `RequestResponse` (default): Invoke the function synchronously.
* - `Event`: Invoke the function asynchronously.
* - `DryRun`: Validate parameter values and verify that the user or role has permission to invoke the function.
*/
InvocationType: 'RequestResponse' | 'Event' | 'DryRun'
invocationType?: 'RequestResponse' | 'Event' | 'DryRun';
/**
* Set to `Tail` to include the execution log in the response. Applies to synchronously invoked functions only.
*/
LogType?: 'None' | 'Tail'
logType?: 'None' | 'Tail';
/**
* Up to 3,583 bytes of base64-encoded data about the invoking client to pass to the function in the context object.
*/
ClientContext?: string
clientContext?: string;
/**
* Specify a version or alias to invoke a published version of the function.
*/
Qualifier?: string
Payload?: string
qualifier?: string;
}

export class LambdaServiceError extends AWSError {
operation: LambdaOperation

/**
* Constructs a LambdaServiceError
*
* @param {string} message - human readable error message
* @param {string} code - A unique short code representing the error that was emitted
* @param {string} operation - Name of the failed Operation
*/
constructor(message: string, code: string, operation: LambdaOperation) {
super(message, code)
this.name = 'LambdaServiceError'
this.operation = operation
}
}
interface InvocationResponse {
statusCode: number;
executedVersion?: string;
logResult?: string;
payload?: string;
}
2 changes: 1 addition & 1 deletion src/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
export { AWSConfig, InvalidAWSConfigError } from './internal/config'
export { InvalidSignatureError } from './internal/signature'
export {
LambdaServiceError,
LambdaInvocationError,
LambdaClient
} from './internal/lambda'
Loading