aspect-build / rules_aws

EXPERIMENTAL: Bazel Integration for Amazon Web Services
Other
10 stars 1 forks source link

[FR]: support for nodejs lambda #23

Open alexeagle opened 1 year ago

alexeagle commented 1 year ago

What is the current behavior?

AWS documents how to build, test, and release NodeJS lambdas.

They can be released as container images or zip files.

Describe the feature

Provide easy support in Bazel for doing all the build/test/release tasks related to NodeJS lambda.

alexeagle commented 1 year ago

Rough design notes:

Fetch RIE

We need the AWS provided "Runtime Interface Emulator" to be able to run lambda logic locally. See https://github.com/aws/aws-lambda-runtime-interface-emulator

WORKSPACE something like

oci_pull(
    name = "aws_lambda_nodejs",
    digest = "sha256:715a39e5d7eb88bca7b25617b734087c12724178978401e038bd8e4964757dc0",
    image = "public.ecr.aws/lambda/nodejs",
    platforms = [
        "linux/amd64",
        "linux/arm64/v8",
    ],
)

Build the image

# Bundle the function with its dependencies because AWS Lambda requires that
# external dependencies be bundled in a flat node_modules structure without
# symlinks, which rules_js (which uses a pnpm dependency layout) cannot produce.
# TODO: Look into layering dependencies into a separate archive as an optimization
# if the bundle file becomes too large.
# https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html
esbuild(
    name = "bundle",
    srcs = [":revert"],
    config = {
        "resolveExtensions": [".js"],
        "banner": {
            "js": "// Copyright 2022 Aspect Build Systems, Inc. All rights reserved.",
        },
    },
    entry_point = "index.js",
    minify = True,
    output = "bundle.js",
    platform = "node",
    sourcemap = "inline",
)

# Copy bundle.js into a new directory so that we can rename it to index.js and not
# conflict with the application's transpiled index.js. AWS Lambda requires an index.js
# file in the root of the zip archive.
copy_to_directory(
    name = "package",
    srcs = [
        "bundle.js",
    ],
    replace_prefixes = {
        "bundle.js": "index.js",
    },
)

pkg_tar(
    name = "tar",
    srcs = [":package"],
    package_dir = "/var/task",
    strip_prefix = "package",
)

# See https://docs.aws.amazon.com/lambda/latest/dg/nodejs-image.html
oci_image(
    name = "image",
    base = "@aws_lambda_nodejs",
    cmd = ["index.handler"],
    tars = [":tar"],
)

We could probably provide a macro to make this convenient.

Testing


# Allow the image to be run locally for integration testing.
# See https://docs.aws.amazon.com/lambda/latest/dg/images-test.html#images-test-AWSbase
#
# bazel run //path/to:tarball
# docker run -p 9000:8080 --env ENVIRONMENT=INTEGRATION_TEST --env APP_ID=test-123 --env APP_WEBHOOK_SECRET=secretX --env APP_PRIVATE_KEY=keyX --rm revert:latest
#
# In another terminal:
# curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
# -> {"statusCode":418,"body":""}
oci_tarball(
    name = "tarball",
    image = ":image",
    repo_tags = ["revert:latest"],
)

And the user would then write a normal testcontainers-style test like this:

// Copyright 2022 Aspect Build Systems, Inc. All rights reserved.

const { spawnSync } = require('node:child_process');
import { join } from 'node:path';
import axios from 'axios';

import { GenericContainer, StartedTestContainer } from 'testcontainers';

const IMAGE_TARBALL = join(
    process.env['TEST_SRCDIR']!,
    process.env['TEST_WORKSPACE']!,
    'path/to/tarball/tarball.tar'
);

describe('Revert lambda', () => {
    let container: StartedTestContainer | undefined;
    let endpoint: string;

    beforeAll(async () => {
        const res = spawnSync('docker', ['load', '-i', IMAGE_TARBALL]);
        if (res.status) {
            process.stderr.write(res.stderr);
            throw new Error('failed to load docker image' + res.status);
        }
        container = await new GenericContainer('revert:latest')
            .withExposedPorts(8080)
            .withEnvironment({
                // See comment in src/entrypoint.ts
                ENVIRONMENT: 'INTEGRATION_TEST',
                APP_ID: 'test-123',
                APP_WEBHOOK_SECRET: 'secretX',
                APP_PRIVATE_KEY: 'keyX',
            })
            .start();
        const port = container.getMappedPort(8080);
        endpoint = `http://localhost:${port}/2015-03-31/functions/function/invocations`;
    }, /* On a busy machine, the docker operations can take longer than 5sec */ 50000);

    afterAll(async () => {
        await container?.stop();
    });

    it('Handles a request', async () => {
        const resp = await axios.post(endpoint, {
            /* empty post data */
        });
        // We return this code when INTEGRATION_TEST is in the environment.
        expect(resp.data.statusCode).toBe(418);
    });
});

Deploying

Either deploy the same image we tested:


# Push the image to ECR.
# See "Deploying the image": https://docs.aws.amazon.com/lambda/latest/dg/typescript-image.html
oci_push(
    name = "push",
    image = ":image",
    remote_tags = ":tags",
    repository = "12345.dkr.ecr.us-west-2.amazonaws.com/revert-lambda",
)

or deploy a zip file:

# Production release is a zip-package.
# See https://docs.aws.amazon.com/lambda/latest/dg/nodejs-package.html
pkg_zip(
    name = "zip",
    srcs = [":index.js"],
    out = "my-lambda.zip",
)

Then refer to that ZIP from a terraform deploy rule.