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

Support for runtime: provided without requiring useDocker #1792

Closed
wants to merge 10 commits into from
Closed
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
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"desm": "^1.3.1",
"execa": "^8.0.1",
"fs-extra": "^11.2.0",
"get-port": "^7.1.0",
"is-wsl": "^3.1.0",
"java-invoke-local": "0.0.6",
"jose": "^5.2.1",
Expand Down
158 changes: 158 additions & 0 deletions src/lambda/RuntimeServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import crypto from "node:crypto"
import { Server } from "@hapi/hapi"
import getPort from "get-port"
import { log } from "../utils/log.js"

/**
* Lightweight implementation of the AWS Lambda Runtimes API
*
* https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
*/
export default class RuntimeServer {
#server = null

#runtimeApi = null

#requestId = null

#event = null

#context = null

#timeout = null

#callback = null

constructor(event, context, timeout) {
this.#event = event
this.#context = context
this.#timeout = timeout
}

async start(startCb, payloadCb) {
this.#callback = payloadCb
this.#requestId = crypto.randomUUID()

// add routes
const nextRoute = this.nextRoute()
const responseRoute = this.responseRoute()

// DEVNOTE: Each invocation gets a s short-lived server on a random port
this.#server = new Server({
host: "127.0.0.1",
port: await getPort(),
})

// TODO: error route
this.#server.route([nextRoute, responseRoute])

try {
await this.#server.start()
} catch (err) {
throw new Error(
`Unexpected error while starting serverless-offline lambda runtime server: ${err}`,
)
}

this.#runtimeApi = `${this.#server.info.host}:${this.#server.info.port}`

log.verbose(
`Offline [http for lambda runtime] listening on http://${this.#runtimeApi}`,
)

startCb(this.#runtimeApi)
}

stop(timeout) {
if (!this.#server) {
return Promise.resolve()
}

return this.#server
.stop({
timeout,
})
.finally(() => {
log.verbose(
`Offline [http for lambda rie] stopped listening on http://${this.#runtimeApi}`,
)
})
}

nextRoute() {
const requestId = this.#requestId
const functionArn = this.#context.invokedFunctionArn
const functionTimeout = this.#timeout

const getPayload = () => {
return new Promise((resolve) => {
const event = this.#event
if (!event) {
setTimeout(async () => {
log.verbose(`[${requestId}] Awaiting event...`)
resolve(await getPayload())
}, 100)
}
resolve(event)
})
}

return {
// https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next
async handler(_, h) {
log.verbose(`[${requestId}] Handling next request`)
const payload = await getPayload()
const statusCode = 200
const response = h.response(payload).code(statusCode)
response.header("Lambda-Runtime-Aws-Request-Id", requestId)
response.header("Lambda-Runtime-Invoked-Function-Arn", functionArn)
response.header(
"Lambda-Runtime-Deadline-Ms",
Date.now() + functionTimeout,
)
return response
},
method: "GET",
options: {
tags: ["runtime"],
},
path: "/2018-06-01/runtime/invocation/next",
}
}

responseRoute() {
const thisRequestId = this.#requestId

const emitPayload = (payload) => {
// once a payload is received, the "/next" route should hang indefinitely
// ref: https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next
this.#event = null
this.#callback(payload)
}

return {
async handler(request, h) {
const {
payload,
params: { requestId },
} = request
log.verbose(`[${requestId}] Handling response request`)

if (requestId === thisRequestId) {
emitPayload(payload)
}

return h.response("").code(202)
},
method: "POST",
options: {
payload: {
defaultContentType: "application/x-www-form-urlencoded",
parse: false,
},
tags: ["runtime"],
},
path: `/2018-06-01/runtime/invocation/{requestId}/response`,
}
}
}
9 changes: 9 additions & 0 deletions src/lambda/handler-runner/HandlerRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
supportedGo,
supportedJava,
supportedNodejs,
supportedProvided,
supportedPython,
supportedRuby,
unsupportedDockerRuntimes,
Expand Down Expand Up @@ -50,6 +51,14 @@ export default class HandlerRunner {
return new DockerRunner(this.#funOptions, this.#env, dockerOptions)
}

if (supportedProvided.has(runtime) && !useDocker) {
const { default: BootstrapRunner } = await import(
"./bootstrap-runner/index.js"
)

return new BootstrapRunner(this.#funOptions, this.#env)
}

if (supportedNodejs.has(runtime)) {
if (useInProcess) {
const { default: InProcessRunner } = await import(
Expand Down
120 changes: 120 additions & 0 deletions src/lambda/handler-runner/bootstrap-runner/BootstrapRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import process from "node:process"
import { resolve } from "node:path"
import { execa } from "execa"
import { existsSync } from "node:fs"
import { log } from "../../../utils/log.js"
import RuntimeServer from "../../RuntimeServer.js"

const { parse } = JSON

const BOOTSTRAP_PATHS = [
"bootstrap",
"node_modules/.bin/bootstrap",
"/var/runtime/bootstrap",
]

export default class BootstrapRunner {
#codeDir = null

#timeout = null

#env = null

#bootstrap = null

#runtimeServer = null

#payload = null

#subprocess = Promise.resolve()

constructor(funOptions, env) {
const { codeDir, timeout, layers } = funOptions

if (layers && layers.length > 0) {
// TODO: Support layers if possible?
throw new Error(
"Layers are not supported in local execution, please enable custom.offline.useDocker",
)
}

this.#codeDir = codeDir
this.#timeout = timeout
this.#env = env
this.#bootstrap = this.resolveBootstrap(codeDir)

if (!this.#bootstrap) {
throw new Error("Unable to locate boostrap a script", BOOTSTRAP_PATHS)
}
}

resolveBootstrap(codeDir) {
const path = BOOTSTRAP_PATHS.find((p) => existsSync(resolve(codeDir, p)))

if (!path) {
return undefined
}

return resolve(codeDir, path)
}

async cleanup() {
if (typeof this.#subprocess.kill === "function") {
this.#subprocess.kill("SIGTERM")
this.#subprocess = Promise.resolve()
}
if (this.#runtimeServer) {
await this.#runtimeServer.stop()
this.#runtimeServer = null
}
}

async run(event, context) {
this.#runtimeServer = new RuntimeServer(event, context, this.#timeout)

await this.#runtimeServer.start(
(runtimeApi) => {
const subprocess = execa(this.#bootstrap, {
all: true,
encoding: "utf8",
env: {
...this.#env,
AWS_LAMBDA_RUNTIME_API: runtimeApi,
LAMBDA_TASK_ROOT: this.#codeDir,
PATH: process.env.PATH,
},
})

subprocess.all.on("data", (data) => {
log.notice(String(data))
})

this.#subprocess = subprocess
},
(payload) => {
this.#subprocess.kill()

if (payload) {
this.#payload = parse(payload)
}
},
)

try {
await this.#subprocess
} catch (e) {
if (e.code === "ENOENT") {
throw new Error(
`Couldn't find valid bootstrap(s): [${this.#bootstrap}]`,
)
}
if (e.signal !== "SIGTERM") {
throw e
}
}

await this.cleanup()

return this.#payload
}
}
1 change: 1 addition & 0 deletions src/lambda/handler-runner/bootstrap-runner/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./BootstrapRunner.js"
21 changes: 21 additions & 0 deletions tests/lambda-run-mode/provided/bash-scripts/bootstrap
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash

set -euo pipefail

# Initialization - load function handler
source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"

# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

# Execute the handler function from the script
RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")

# Send the response
curl -s -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE" -o /dev/null
done
5 changes: 5 additions & 0 deletions tests/lambda-run-mode/provided/bash-scripts/handler.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function hello () {
RESPONSE="{\"body\": \"{\\\"message\\\": \\\"Hello Provided!\\\"}\", \"statusCode\": 200}"

echo $RESPONSE
}
Loading
Loading