@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 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.2";
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 S3Client({
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 PutObjectCommand({
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);