@multiplayer-app/sandbox 7.0.2 → 7.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/dist/cli.js +469 -5
- package/dist/cli.js.map +4 -4
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/microvm-guest-agents.d.ts +18 -0
- package/dist/microvm-guest-agents.d.ts.map +1 -0
- package/dist/microvm-images.d.ts +37 -0
- package/dist/microvm-images.d.ts.map +1 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -205,6 +205,79 @@ sandbox worker, or otherwise restarts/starts the Compose sandbox service;
|
|
|
205
205
|
`VM_IMAGES_REFRESH=true` makes that worker replace its cached QEMU/Firecracker
|
|
206
206
|
images from MinIO before advertising its engines.
|
|
207
207
|
|
|
208
|
+
## Publishing your own AWS Lambda MicroVM images
|
|
209
|
+
|
|
210
|
+
Unlike the ops commands above, this works from a plain `npm install
|
|
211
|
+
@multiplayer-app/sandbox` — no monorepo checkout needed. It builds the guest
|
|
212
|
+
agent for each stack (`node`/`python`/`static`) and zips it entirely in memory
|
|
213
|
+
(see `src/microvm-guest-agents.ts`), then registers or updates it as an AWS
|
|
214
|
+
Lambda MicroVM image in **your own AWS account**, using whatever credentials
|
|
215
|
+
are already active (`AWS_PROFILE`, static env vars, an assumed role, ...).
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
# Discover a base OS image ARN to build on top of
|
|
219
|
+
npx @multiplayer-app/sandbox images microvm-list-base --region us-east-1
|
|
220
|
+
|
|
221
|
+
# Build and register/update sandbox-node, sandbox-python, sandbox-static
|
|
222
|
+
npx @multiplayer-app/sandbox images microvm-create \
|
|
223
|
+
--region us-east-1 \
|
|
224
|
+
--bucket my-artifacts-bucket \
|
|
225
|
+
--base-image-arn arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1 \
|
|
226
|
+
--build-role-arn arn:aws:iam::123456789012:role/microvm-image-build
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`--build-role-arn` is an IAM role the build assumes to read the uploaded code
|
|
230
|
+
artifact from `--bucket` — trust policy allowing `lambda.amazonaws.com` to
|
|
231
|
+
assume it, permissions limited to `s3:GetObject` on that bucket. It's a
|
|
232
|
+
one-time setup step this command doesn't automate; see the "MicroVM base image
|
|
233
|
+
build role" section below for the exact `aws iam` commands.
|
|
234
|
+
|
|
235
|
+
The first run creates each image; every later run updates it in place — AWS
|
|
236
|
+
records that as a new numbered version under the same stable ARN, so nothing
|
|
237
|
+
else needs to change when you publish a new version. The command prints a
|
|
238
|
+
ready-to-paste `MICROVM_STACK_IMAGE_ARNS` line for your worker's environment
|
|
239
|
+
once every image finishes building.
|
|
240
|
+
|
|
241
|
+
Lambda MicroVMs is ARM/Graviton-only today, so there's no architecture flag —
|
|
242
|
+
run `--help` on either command for the rest of the options (`--stack`,
|
|
243
|
+
`--min-memory-mib`, `--image-name-prefix`).
|
|
244
|
+
|
|
245
|
+
### MicroVM base image build role
|
|
246
|
+
|
|
247
|
+
One-time, per AWS account:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
aws iam create-role --profile my-profile \
|
|
251
|
+
--role-name microvm-image-build \
|
|
252
|
+
--assume-role-policy-document '{
|
|
253
|
+
"Version": "2012-10-17",
|
|
254
|
+
"Statement": [{
|
|
255
|
+
"Effect": "Allow",
|
|
256
|
+
"Principal": { "Service": "lambda.amazonaws.com" },
|
|
257
|
+
"Action": ["sts:AssumeRole", "sts:TagSession"]
|
|
258
|
+
}]
|
|
259
|
+
}'
|
|
260
|
+
|
|
261
|
+
aws iam put-role-policy --profile my-profile \
|
|
262
|
+
--role-name microvm-image-build \
|
|
263
|
+
--policy-name microvm-build-permissions \
|
|
264
|
+
--policy-document '{
|
|
265
|
+
"Version": "2012-10-17",
|
|
266
|
+
"Statement": [
|
|
267
|
+
{
|
|
268
|
+
"Effect": "Allow",
|
|
269
|
+
"Action": "s3:GetObject",
|
|
270
|
+
"Resource": "arn:aws:s3:::my-artifacts-bucket/microvm-images/*"
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"Effect": "Allow",
|
|
274
|
+
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
|
|
275
|
+
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda-microvms/sandbox-*:*"
|
|
276
|
+
}
|
|
277
|
+
]
|
|
278
|
+
}'
|
|
279
|
+
```
|
|
280
|
+
|
|
208
281
|
If the build already succeeded and only the upload needs retrying, run:
|
|
209
282
|
|
|
210
283
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ var import_config5 = require("dotenv/config");
|
|
|
28
28
|
var import_commander = require("commander");
|
|
29
29
|
|
|
30
30
|
// package.json
|
|
31
|
-
var version = "7.0.
|
|
31
|
+
var version = "7.0.3";
|
|
32
32
|
|
|
33
33
|
// src/config.ts
|
|
34
34
|
var import_os2 = __toESM(require("os"));
|
|
@@ -130,8 +130,8 @@ function s3Key(prefix, arch, stack, file) {
|
|
|
130
130
|
return [prefix, arch, stack, file].filter(Boolean).join("/");
|
|
131
131
|
}
|
|
132
132
|
async function makeS3Client(endpoint) {
|
|
133
|
-
const { S3Client } = await import("@aws-sdk/client-s3");
|
|
134
|
-
return new
|
|
133
|
+
const { S3Client: S3Client2 } = await import("@aws-sdk/client-s3");
|
|
134
|
+
return new S3Client2({
|
|
135
135
|
...endpoint ? {
|
|
136
136
|
endpoint,
|
|
137
137
|
forcePathStyle: true,
|
|
@@ -189,7 +189,7 @@ async function pullImages(opts, destDir) {
|
|
|
189
189
|
return downloaded;
|
|
190
190
|
}
|
|
191
191
|
async function pushImages(opts, srcDir) {
|
|
192
|
-
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
192
|
+
const { PutObjectCommand: PutObjectCommand2 } = await import("@aws-sdk/client-s3");
|
|
193
193
|
const { bucket, prefix } = parseS3Uri(opts.location);
|
|
194
194
|
const client = await makeS3Client(opts.endpoint);
|
|
195
195
|
const uploaded = [];
|
|
@@ -205,7 +205,7 @@ async function pushImages(opts, srcDir) {
|
|
|
205
205
|
}
|
|
206
206
|
const key = s3Key(prefix, opts.arch, stack, file);
|
|
207
207
|
await client.send(
|
|
208
|
-
new
|
|
208
|
+
new PutObjectCommand2({
|
|
209
209
|
Bucket: bucket,
|
|
210
210
|
Key: key,
|
|
211
211
|
Body: import_fs.default.readFileSync(src)
|
|
@@ -4210,6 +4210,423 @@ async function startSandboxHealthServer(options) {
|
|
|
4210
4210
|
return server;
|
|
4211
4211
|
}
|
|
4212
4212
|
|
|
4213
|
+
// src/microvm-images.ts
|
|
4214
|
+
var import_archiver = require("archiver");
|
|
4215
|
+
var import_client_lambda_microvms2 = require("@aws-sdk/client-lambda-microvms");
|
|
4216
|
+
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
4217
|
+
|
|
4218
|
+
// src/microvm-guest-agents.ts
|
|
4219
|
+
var MICROVM_BASE_IMAGE_STACKS = ["node", "python", "static"];
|
|
4220
|
+
function isMicrovmBaseImageStack(stack) {
|
|
4221
|
+
return MICROVM_BASE_IMAGE_STACKS.includes(stack);
|
|
4222
|
+
}
|
|
4223
|
+
var NODE_APP = `const http = require('http')
|
|
4224
|
+
const fs = require('fs')
|
|
4225
|
+
const { execSync, spawn } = require('child_process')
|
|
4226
|
+
|
|
4227
|
+
const HOOK_PREFIX = '/aws/lambda-microvms/runtime/v1/'
|
|
4228
|
+
let session = null // set by the /run hook (payload from the worker executor)
|
|
4229
|
+
|
|
4230
|
+
// Release deployment: pull the presigned artifact, unzip, then hand :8080 over
|
|
4231
|
+
// to the app (it must answer the lifecycle hooks itself from that point on).
|
|
4232
|
+
async function startApp(url) {
|
|
4233
|
+
try {
|
|
4234
|
+
const res = await fetch(url)
|
|
4235
|
+
if (!res.ok) throw new Error(\`artifact download failed: HTTP \${res.status}\`)
|
|
4236
|
+
fs.writeFileSync('/tmp/app.zip', Buffer.from(await res.arrayBuffer()))
|
|
4237
|
+
execSync('unzip -o -q /tmp/app.zip -d /workspace')
|
|
4238
|
+
server.close(() => {
|
|
4239
|
+
console.log('starting release app (node app.js)')
|
|
4240
|
+
const child = spawn('node', ['app.js'], { cwd: '/workspace', stdio: 'inherit' })
|
|
4241
|
+
child.on('exit', (code) => process.exit(code ?? 1))
|
|
4242
|
+
})
|
|
4243
|
+
} catch (error) {
|
|
4244
|
+
console.error('release deploy failed, agent keeps serving:', error)
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
const server = http.createServer((req, res) => {
|
|
4249
|
+
if (req.url.startsWith(HOOK_PREFIX) && req.method === 'POST') {
|
|
4250
|
+
const hook = req.url.slice(HOOK_PREFIX.length)
|
|
4251
|
+
const chunks = []
|
|
4252
|
+
req.on('data', (c) => chunks.push(c))
|
|
4253
|
+
req.on('end', () => {
|
|
4254
|
+
const body = Buffer.concat(chunks).toString()
|
|
4255
|
+
if (hook === 'run' && body) {
|
|
4256
|
+
try {
|
|
4257
|
+
const parsed = JSON.parse(body)
|
|
4258
|
+
session = parsed.runHookPayload ? JSON.parse(parsed.runHookPayload) : null
|
|
4259
|
+
session = { ...session, microvmId: parsed.microvmId }
|
|
4260
|
+
for (const [key, value] of Object.entries(session?.env ?? {})) {
|
|
4261
|
+
if (typeof value === 'string') process.env[key] = value
|
|
4262
|
+
}
|
|
4263
|
+
} catch { /* payload is optional */ }
|
|
4264
|
+
}
|
|
4265
|
+
// Runtime env may contain a presigned release URL or other secrets; never log the body.
|
|
4266
|
+
console.log(\`lifecycle hook: \${hook}\`)
|
|
4267
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
4268
|
+
res.end('{}')
|
|
4269
|
+
if (hook === 'run' && process.env.APP_URL) {
|
|
4270
|
+
setImmediate(() => startApp(process.env.APP_URL))
|
|
4271
|
+
}
|
|
4272
|
+
})
|
|
4273
|
+
return
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
4277
|
+
res.end(JSON.stringify({ status: 'ok', path: req.url, session }))
|
|
4278
|
+
})
|
|
4279
|
+
|
|
4280
|
+
server.listen(8080, () => {
|
|
4281
|
+
console.log('vm agent listening on 8080')
|
|
4282
|
+
// Fargate/Docker: no lifecycle hook caller, so APP_URL (if set) drives startup directly.
|
|
4283
|
+
if (process.env.APP_URL) setImmediate(() => startApp(process.env.APP_URL))
|
|
4284
|
+
})
|
|
4285
|
+
`;
|
|
4286
|
+
var NODE_DOCKERFILE = `FROM node:24-alpine
|
|
4287
|
+
WORKDIR /app
|
|
4288
|
+
COPY app.js .
|
|
4289
|
+
RUN mkdir -p /workspace
|
|
4290
|
+
EXPOSE 8080
|
|
4291
|
+
CMD ["node", "app.js"]
|
|
4292
|
+
`;
|
|
4293
|
+
var PYTHON_APP = `import json
|
|
4294
|
+
import os
|
|
4295
|
+
import threading
|
|
4296
|
+
import urllib.request
|
|
4297
|
+
import zipfile
|
|
4298
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4299
|
+
|
|
4300
|
+
HOOK_PREFIX = "/aws/lambda-microvms/runtime/v1/"
|
|
4301
|
+
session = None # set by the /run hook (payload from the worker executor)
|
|
4302
|
+
|
|
4303
|
+
|
|
4304
|
+
def start_app(url):
|
|
4305
|
+
"""Release deployment: pull the presigned artifact, unzip, then replace this
|
|
4306
|
+
process with the app on :8080 (it must answer lifecycle hooks itself)."""
|
|
4307
|
+
try:
|
|
4308
|
+
urllib.request.urlretrieve(url, "/tmp/app.zip")
|
|
4309
|
+
with zipfile.ZipFile("/tmp/app.zip") as zf:
|
|
4310
|
+
zf.extractall("/workspace")
|
|
4311
|
+
print("starting release app (python app.py)", flush=True)
|
|
4312
|
+
os.chdir("/workspace")
|
|
4313
|
+
os.execvp("python", ["python", "app.py"])
|
|
4314
|
+
except Exception as error: # noqa: BLE001 \u2014 agent keeps serving on failure
|
|
4315
|
+
print(f"release deploy failed, agent keeps serving: {error}", flush=True)
|
|
4316
|
+
|
|
4317
|
+
|
|
4318
|
+
class Handler(BaseHTTPRequestHandler):
|
|
4319
|
+
def _send(self, body):
|
|
4320
|
+
data = body.encode()
|
|
4321
|
+
self.send_response(200)
|
|
4322
|
+
self.send_header("Content-Type", "application/json")
|
|
4323
|
+
self.send_header("Content-Length", str(len(data)))
|
|
4324
|
+
self.end_headers()
|
|
4325
|
+
self.wfile.write(data)
|
|
4326
|
+
|
|
4327
|
+
def do_POST(self):
|
|
4328
|
+
global session
|
|
4329
|
+
if self.path.startswith(HOOK_PREFIX):
|
|
4330
|
+
hook = self.path[len(HOOK_PREFIX):]
|
|
4331
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
4332
|
+
body = self.rfile.read(length).decode() if length else ""
|
|
4333
|
+
if hook == "run" and body:
|
|
4334
|
+
try:
|
|
4335
|
+
parsed = json.loads(body)
|
|
4336
|
+
payload = parsed.get("runHookPayload")
|
|
4337
|
+
session = json.loads(payload) if payload else {}
|
|
4338
|
+
session["microvmId"] = parsed.get("microvmId")
|
|
4339
|
+
os.environ.update({
|
|
4340
|
+
key: value for key, value in session.get("env", {}).items()
|
|
4341
|
+
if isinstance(value, str)
|
|
4342
|
+
})
|
|
4343
|
+
except (ValueError, TypeError):
|
|
4344
|
+
pass # payload is optional
|
|
4345
|
+
# Runtime env may contain a presigned release URL or other secrets.
|
|
4346
|
+
print(f"lifecycle hook: {hook}")
|
|
4347
|
+
self._send("{}")
|
|
4348
|
+
if hook == "run" and os.environ.get("APP_URL"):
|
|
4349
|
+
threading.Timer(0.1, start_app, args=[os.environ["APP_URL"]]).start()
|
|
4350
|
+
return
|
|
4351
|
+
self._send(json.dumps({"status": "ok", "path": self.path, "session": session}))
|
|
4352
|
+
|
|
4353
|
+
def do_GET(self):
|
|
4354
|
+
self._send(json.dumps({"status": "ok", "path": self.path, "session": session}))
|
|
4355
|
+
|
|
4356
|
+
def log_message(self, fmt, *args):
|
|
4357
|
+
pass
|
|
4358
|
+
|
|
4359
|
+
|
|
4360
|
+
if __name__ == "__main__":
|
|
4361
|
+
print("vm agent listening on 8080")
|
|
4362
|
+
# Fargate/Docker: no lifecycle hook caller, so APP_URL (if set) drives startup directly.
|
|
4363
|
+
if os.environ.get("APP_URL"):
|
|
4364
|
+
threading.Timer(0.1, start_app, args=[os.environ["APP_URL"]]).start()
|
|
4365
|
+
ThreadingHTTPServer(("", 8080), Handler).serve_forever()
|
|
4366
|
+
`;
|
|
4367
|
+
var PYTHON_DOCKERFILE = `FROM python:3.12-alpine
|
|
4368
|
+
WORKDIR /app
|
|
4369
|
+
COPY app.py .
|
|
4370
|
+
RUN mkdir -p /workspace
|
|
4371
|
+
EXPOSE 8080
|
|
4372
|
+
CMD ["python", "app.py"]
|
|
4373
|
+
`;
|
|
4374
|
+
var STATIC_APP = `const http = require('http')
|
|
4375
|
+
const fs = require('fs')
|
|
4376
|
+
const path = require('path')
|
|
4377
|
+
const { execSync } = require('child_process')
|
|
4378
|
+
|
|
4379
|
+
const HOOK_PREFIX = '/aws/lambda-microvms/runtime/v1/'
|
|
4380
|
+
const STATIC_ROOT = '/workspace'
|
|
4381
|
+
let session = null // set by the /run hook (payload from the worker executor)
|
|
4382
|
+
let serving = false // true once a release has been unzipped into STATIC_ROOT
|
|
4383
|
+
|
|
4384
|
+
const CONTENT_TYPES = {
|
|
4385
|
+
'.html': 'text/html; charset=utf-8',
|
|
4386
|
+
'.htm': 'text/html; charset=utf-8',
|
|
4387
|
+
'.css': 'text/css; charset=utf-8',
|
|
4388
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
4389
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
4390
|
+
'.json': 'application/json; charset=utf-8',
|
|
4391
|
+
'.svg': 'image/svg+xml',
|
|
4392
|
+
'.png': 'image/png',
|
|
4393
|
+
'.jpg': 'image/jpeg',
|
|
4394
|
+
'.jpeg': 'image/jpeg',
|
|
4395
|
+
'.gif': 'image/gif',
|
|
4396
|
+
'.ico': 'image/x-icon',
|
|
4397
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
4398
|
+
'.wasm': 'application/wasm',
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
// Release deployment: pull the presigned artifact, unzip it into STATIC_ROOT, and
|
|
4402
|
+
// switch the request handler into "serve static files" mode. No process is spawned \u2014
|
|
4403
|
+
// unlike the node/python base images, there is no entrypoint to run.
|
|
4404
|
+
async function deployRelease(url) {
|
|
4405
|
+
const res = await fetch(url)
|
|
4406
|
+
if (!res.ok) throw new Error(\`artifact download failed: HTTP \${res.status}\`)
|
|
4407
|
+
fs.mkdirSync(STATIC_ROOT, { recursive: true })
|
|
4408
|
+
fs.writeFileSync('/tmp/site.zip', Buffer.from(await res.arrayBuffer()))
|
|
4409
|
+
execSync(\`unzip -o -q /tmp/site.zip -d \${STATIC_ROOT}\`)
|
|
4410
|
+
fs.rmSync('/tmp/site.zip', { force: true })
|
|
4411
|
+
serving = true
|
|
4412
|
+
console.log('serving static release from', STATIC_ROOT)
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
function serveStatic(req, res) {
|
|
4416
|
+
const urlPath = decodeURIComponent(req.url.split('?')[0])
|
|
4417
|
+
let filePath = path.join(STATIC_ROOT, path.normalize(urlPath).replace(/^(\\.\\.[/\\\\])+/, ''))
|
|
4418
|
+
if (urlPath.endsWith('/') || urlPath === '') filePath = path.join(filePath, 'index.html')
|
|
4419
|
+
|
|
4420
|
+
fs.readFile(filePath, (err, data) => {
|
|
4421
|
+
if (err) {
|
|
4422
|
+
// SPA-style fallback: unknown paths without a file extension serve index.html
|
|
4423
|
+
if (!path.extname(filePath)) {
|
|
4424
|
+
return fs.readFile(path.join(STATIC_ROOT, 'index.html'), (err2, index) => {
|
|
4425
|
+
if (err2) return send404(res)
|
|
4426
|
+
res.writeHead(200, { 'Content-Type': CONTENT_TYPES['.html'] })
|
|
4427
|
+
res.end(index)
|
|
4428
|
+
})
|
|
4429
|
+
}
|
|
4430
|
+
return send404(res)
|
|
4431
|
+
}
|
|
4432
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
4433
|
+
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream' })
|
|
4434
|
+
res.end(data)
|
|
4435
|
+
})
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
function send404(res) {
|
|
4439
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
4440
|
+
res.end('not found')
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
const server = http.createServer((req, res) => {
|
|
4444
|
+
if (req.url.startsWith(HOOK_PREFIX) && req.method === 'POST') {
|
|
4445
|
+
const hook = req.url.slice(HOOK_PREFIX.length)
|
|
4446
|
+
const chunks = []
|
|
4447
|
+
req.on('data', (c) => chunks.push(c))
|
|
4448
|
+
req.on('end', () => {
|
|
4449
|
+
const body = Buffer.concat(chunks).toString()
|
|
4450
|
+
if (hook === 'run' && body) {
|
|
4451
|
+
try {
|
|
4452
|
+
const parsed = JSON.parse(body)
|
|
4453
|
+
session = parsed.runHookPayload ? JSON.parse(parsed.runHookPayload) : null
|
|
4454
|
+
session = { ...session, microvmId: parsed.microvmId }
|
|
4455
|
+
for (const [key, value] of Object.entries(session?.env ?? {})) {
|
|
4456
|
+
if (typeof value === 'string') process.env[key] = value
|
|
4457
|
+
}
|
|
4458
|
+
} catch { /* payload is optional */ }
|
|
4459
|
+
}
|
|
4460
|
+
// Runtime env may contain a presigned release URL or other secrets; never log the body.
|
|
4461
|
+
console.log(\`lifecycle hook: \${hook}\`)
|
|
4462
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
4463
|
+
res.end('{}')
|
|
4464
|
+
if (hook === 'run' && process.env.APP_URL) {
|
|
4465
|
+
deployRelease(process.env.APP_URL).catch((error) => console.error('release deploy failed:', error))
|
|
4466
|
+
}
|
|
4467
|
+
})
|
|
4468
|
+
return
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
if (serving) return serveStatic(req, res)
|
|
4472
|
+
|
|
4473
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
4474
|
+
res.end(JSON.stringify({ status: 'ok', path: req.url, session }))
|
|
4475
|
+
})
|
|
4476
|
+
|
|
4477
|
+
server.listen(8080, () => {
|
|
4478
|
+
console.log('vm agent listening on 8080')
|
|
4479
|
+
// Fargate/Docker: no lifecycle hook caller, so APP_URL (if set) drives startup directly.
|
|
4480
|
+
if (process.env.APP_URL) deployRelease(process.env.APP_URL).catch((error) => console.error('release deploy failed:', error))
|
|
4481
|
+
})
|
|
4482
|
+
`;
|
|
4483
|
+
var STATIC_DOCKERFILE = `FROM node:24-alpine
|
|
4484
|
+
RUN apk add --no-cache unzip
|
|
4485
|
+
WORKDIR /app
|
|
4486
|
+
COPY app.js .
|
|
4487
|
+
RUN mkdir -p /workspace
|
|
4488
|
+
EXPOSE 8080
|
|
4489
|
+
CMD ["node", "app.js"]
|
|
4490
|
+
`;
|
|
4491
|
+
function guestAgentFiles(stack) {
|
|
4492
|
+
switch (stack) {
|
|
4493
|
+
case "node":
|
|
4494
|
+
return { "app.js": NODE_APP, Dockerfile: NODE_DOCKERFILE };
|
|
4495
|
+
case "python":
|
|
4496
|
+
return { "app.py": PYTHON_APP, Dockerfile: PYTHON_DOCKERFILE };
|
|
4497
|
+
case "static":
|
|
4498
|
+
return { "app.js": STATIC_APP, Dockerfile: STATIC_DOCKERFILE };
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
// src/microvm-images.ts
|
|
4503
|
+
var HOOKS = {
|
|
4504
|
+
port: 8080,
|
|
4505
|
+
microvmHooks: { run: "ENABLED", resume: "ENABLED", suspend: "ENABLED", terminate: "ENABLED" },
|
|
4506
|
+
microvmImageHooks: { ready: "ENABLED" }
|
|
4507
|
+
};
|
|
4508
|
+
async function zipFiles(files) {
|
|
4509
|
+
const archive = new import_archiver.ZipArchive({ zlib: { level: 9 } });
|
|
4510
|
+
const chunks = [];
|
|
4511
|
+
const collected = new Promise((resolve, reject) => {
|
|
4512
|
+
archive.on("data", (chunk) => chunks.push(chunk));
|
|
4513
|
+
archive.on("error", reject);
|
|
4514
|
+
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4515
|
+
});
|
|
4516
|
+
for (const [name, content] of Object.entries(files)) {
|
|
4517
|
+
archive.append(content, { name });
|
|
4518
|
+
}
|
|
4519
|
+
await archive.finalize();
|
|
4520
|
+
return collected;
|
|
4521
|
+
}
|
|
4522
|
+
async function listAllManagedImages(client) {
|
|
4523
|
+
const images2 = [];
|
|
4524
|
+
let nextToken;
|
|
4525
|
+
do {
|
|
4526
|
+
const page = await client.send(new import_client_lambda_microvms2.ListManagedMicrovmImagesCommand({ nextToken }));
|
|
4527
|
+
images2.push(...page.items ?? []);
|
|
4528
|
+
nextToken = page.nextToken;
|
|
4529
|
+
} while (nextToken);
|
|
4530
|
+
return images2;
|
|
4531
|
+
}
|
|
4532
|
+
async function listBaseMicrovmImages(options) {
|
|
4533
|
+
const client = new import_client_lambda_microvms2.LambdaMicrovmsClient({ region: options.region });
|
|
4534
|
+
const images2 = await listAllManagedImages(client);
|
|
4535
|
+
return images2.map((image) => image.imageArn).filter((arn) => Boolean(arn));
|
|
4536
|
+
}
|
|
4537
|
+
async function imageExists(client, imageIdentifier) {
|
|
4538
|
+
try {
|
|
4539
|
+
await client.send(new import_client_lambda_microvms2.GetMicrovmImageCommand({ imageIdentifier }));
|
|
4540
|
+
return true;
|
|
4541
|
+
} catch (error) {
|
|
4542
|
+
if (error instanceof Error && error.name === "ResourceNotFoundException") return false;
|
|
4543
|
+
throw error;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
async function waitForVersion(client, imageIdentifier, imageVersion, pollIntervalMs, pollTimeoutMs) {
|
|
4547
|
+
const deadline = Date.now() + pollTimeoutMs;
|
|
4548
|
+
for (; ; ) {
|
|
4549
|
+
const version2 = await client.send(
|
|
4550
|
+
new import_client_lambda_microvms2.GetMicrovmImageVersionCommand({ imageIdentifier, imageVersion })
|
|
4551
|
+
);
|
|
4552
|
+
if (version2.state === "SUCCESSFUL") return;
|
|
4553
|
+
if (version2.state === "FAILED") {
|
|
4554
|
+
throw new Error(`${imageIdentifier} version ${imageVersion} failed to build`);
|
|
4555
|
+
}
|
|
4556
|
+
if (Date.now() > deadline) {
|
|
4557
|
+
throw new Error(
|
|
4558
|
+
`timed out waiting for ${imageIdentifier} version ${imageVersion} (still ${version2.state})`
|
|
4559
|
+
);
|
|
4560
|
+
}
|
|
4561
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
async function createMicrovmImages(options) {
|
|
4565
|
+
const {
|
|
4566
|
+
region,
|
|
4567
|
+
bucket,
|
|
4568
|
+
baseImageArn,
|
|
4569
|
+
buildRoleArn,
|
|
4570
|
+
stacks = [...MICROVM_BASE_IMAGE_STACKS],
|
|
4571
|
+
minMemoryMib = 1024,
|
|
4572
|
+
imageNamePrefix = "sandbox",
|
|
4573
|
+
pollIntervalMs = 5e3,
|
|
4574
|
+
pollTimeoutMs = 6e5,
|
|
4575
|
+
onProgress = () => {
|
|
4576
|
+
}
|
|
4577
|
+
} = options;
|
|
4578
|
+
for (const stack of stacks) {
|
|
4579
|
+
if (!isMicrovmBaseImageStack(stack)) {
|
|
4580
|
+
throw new Error(
|
|
4581
|
+
`unsupported stack "${stack}" \u2014 must be one of: ${MICROVM_BASE_IMAGE_STACKS.join(", ")}`
|
|
4582
|
+
);
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
const accountId = buildRoleArn.split(":")[4];
|
|
4586
|
+
if (!accountId) throw new Error(`could not parse account ID from buildRoleArn: ${buildRoleArn}`);
|
|
4587
|
+
const cpuArchitecture = "ARM_64";
|
|
4588
|
+
const s3 = new import_client_s3.S3Client({ region });
|
|
4589
|
+
const lambdaMicrovms = new import_client_lambda_microvms2.LambdaMicrovmsClient({ region });
|
|
4590
|
+
const stackArns = {};
|
|
4591
|
+
for (const stack of stacks) {
|
|
4592
|
+
const files = guestAgentFiles(stack);
|
|
4593
|
+
const zip = await zipFiles(files);
|
|
4594
|
+
const key = `microvm-images/${stack}/app.zip`;
|
|
4595
|
+
onProgress(`[${stack}] uploading ${zip.byteLength}-byte artifact -> s3://${bucket}/${key}`);
|
|
4596
|
+
await s3.send(new import_client_s3.PutObjectCommand({ Bucket: bucket, Key: key, Body: zip }));
|
|
4597
|
+
const imageName = `${imageNamePrefix}-${stack}`;
|
|
4598
|
+
const imageArn = `arn:aws:lambda:${region}:${accountId}:microvm-image:${imageName}`;
|
|
4599
|
+
const commonFields = {
|
|
4600
|
+
baseImageArn,
|
|
4601
|
+
buildRoleArn,
|
|
4602
|
+
codeArtifact: { uri: `s3://${bucket}/${key}` },
|
|
4603
|
+
cpuConfigurations: [{ architecture: cpuArchitecture }],
|
|
4604
|
+
resources: [{ minimumMemoryInMiB: minMemoryMib }],
|
|
4605
|
+
description: `sandbox v${version}`,
|
|
4606
|
+
hooks: HOOKS
|
|
4607
|
+
};
|
|
4608
|
+
const exists = await imageExists(lambdaMicrovms, imageArn);
|
|
4609
|
+
onProgress(`[${stack}] ${exists ? "updating" : "creating"} MicroVM image ${imageName}...`);
|
|
4610
|
+
const result = exists ? await lambdaMicrovms.send(
|
|
4611
|
+
new import_client_lambda_microvms2.UpdateMicrovmImageCommand({ imageIdentifier: imageArn, ...commonFields })
|
|
4612
|
+
) : await lambdaMicrovms.send(
|
|
4613
|
+
new import_client_lambda_microvms2.CreateMicrovmImageCommand({ name: imageName, tags: { stack }, ...commonFields })
|
|
4614
|
+
);
|
|
4615
|
+
onProgress(`[${stack}] waiting for version ${result.imageVersion} to build...`);
|
|
4616
|
+
await waitForVersion(
|
|
4617
|
+
lambdaMicrovms,
|
|
4618
|
+
imageArn,
|
|
4619
|
+
result.imageVersion,
|
|
4620
|
+
pollIntervalMs,
|
|
4621
|
+
pollTimeoutMs
|
|
4622
|
+
);
|
|
4623
|
+
const finalArn = result.imageArn ?? imageArn;
|
|
4624
|
+
onProgress(`[${stack}] ready: ${finalArn} (version ${result.imageVersion})`);
|
|
4625
|
+
stackArns[stack] = finalArn;
|
|
4626
|
+
}
|
|
4627
|
+
return { stackArns };
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4213
4630
|
// src/cli.ts
|
|
4214
4631
|
process.on("unhandledRejection", (reason) => {
|
|
4215
4632
|
src_default.error({ err: reason }, "unhandled rejection \u2014 sandbox worker continues running");
|
|
@@ -4316,6 +4733,53 @@ images.command("push").description("upload locally built per-stack base images (
|
|
|
4316
4733
|
for (const uri of uploaded) console.log(`pushed ${uri}`);
|
|
4317
4734
|
}
|
|
4318
4735
|
);
|
|
4736
|
+
images.command("microvm-list-base").description("list AWS-managed base OS images available in your account/region (candidates for --base-image-arn)").requiredOption("--region <region>", "AWS region", process.env.AWS_REGION).action(async (opts) => {
|
|
4737
|
+
const arns = await listBaseMicrovmImages({ region: opts.region });
|
|
4738
|
+
console.log(`AWS-managed base images in ${opts.region}:`);
|
|
4739
|
+
for (const arn of arns) console.log(` ${arn}`);
|
|
4740
|
+
});
|
|
4741
|
+
images.command("microvm-create").description(
|
|
4742
|
+
"build this package's guest-agent base images (node/python/static) and register/update them as AWS Lambda MicroVM images in your own AWS account (uses ambient AWS credentials \u2014 AWS_PROFILE, env vars, an assumed role, ...)"
|
|
4743
|
+
).requiredOption("--region <region>", "AWS region", process.env.AWS_REGION).requiredOption(
|
|
4744
|
+
"--bucket <bucket>",
|
|
4745
|
+
"S3 bucket the built code artifact is uploaded to (must already exist)",
|
|
4746
|
+
process.env.S3_BUCKET_ARTIFACTS
|
|
4747
|
+
).requiredOption(
|
|
4748
|
+
"--base-image-arn <arn>",
|
|
4749
|
+
'AWS-managed base OS image ARN \u2014 see "sandbox images microvm-list-base"',
|
|
4750
|
+
process.env.MICROVM_BASE_IMAGE_ARN
|
|
4751
|
+
).requiredOption(
|
|
4752
|
+
"--build-role-arn <arn>",
|
|
4753
|
+
"IAM role ARN the build assumes to read the artifact from --bucket (needs s3:GetObject)",
|
|
4754
|
+
process.env.MICROVM_BUILD_ROLE_ARN
|
|
4755
|
+
).option(
|
|
4756
|
+
"--stack <stack...>",
|
|
4757
|
+
`stacks to build/register (default: ${MICROVM_BASE_IMAGE_STACKS.join(" ")})`
|
|
4758
|
+
).option("--min-memory-mib <mib>", "minimum guest memory, MiB", "1024").option("--image-name-prefix <prefix>", 'MicroVM image name prefix, "<prefix>-<stack>"', "sandbox").action(
|
|
4759
|
+
async (opts) => {
|
|
4760
|
+
for (const stack of opts.stack ?? []) {
|
|
4761
|
+
if (!isMicrovmBaseImageStack(stack)) {
|
|
4762
|
+
throw new Error(
|
|
4763
|
+
`unsupported stack "${stack}" \u2014 must be one of: ${MICROVM_BASE_IMAGE_STACKS.join(", ")}`
|
|
4764
|
+
);
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
const { stackArns } = await createMicrovmImages({
|
|
4768
|
+
region: opts.region,
|
|
4769
|
+
bucket: opts.bucket,
|
|
4770
|
+
baseImageArn: opts.baseImageArn,
|
|
4771
|
+
buildRoleArn: opts.buildRoleArn,
|
|
4772
|
+
stacks: opts.stack?.filter(isMicrovmBaseImageStack),
|
|
4773
|
+
minMemoryMib: Number(opts.minMemoryMib),
|
|
4774
|
+
imageNamePrefix: opts.imageNamePrefix,
|
|
4775
|
+
onProgress: (message) => console.log(message)
|
|
4776
|
+
});
|
|
4777
|
+
console.log("\nSet this in your worker environment:");
|
|
4778
|
+
console.log(
|
|
4779
|
+
`MICROVM_STACK_IMAGE_ARNS=${Object.entries(stackArns).map(([stack, arn]) => `${stack}:${arn}`).join(",")}`
|
|
4780
|
+
);
|
|
4781
|
+
}
|
|
4782
|
+
);
|
|
4319
4783
|
program.parseAsync().catch((error) => {
|
|
4320
4784
|
console.error(`Error: ${error.message}`);
|
|
4321
4785
|
process.exit(1);
|