@capsulos/cli 0.0.0-stage → 0.1.1

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/src/forward.ts ADDED
@@ -0,0 +1,92 @@
1
+ // `capsulos forward <id> <port>`: a local HTTP server that relays every
2
+ // request (WebSockets included) to the same port inside the capsule, through
3
+ // capsulod's /proxy. Opens the capsule's dev server at http://localhost:<port>
4
+ // in your browser, with the capsule's auth headers added on the way.
5
+ import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http';
6
+ import { request as httpsRequest } from 'node:https';
7
+ import type { Duplex } from 'node:stream';
8
+ import { Capsule, type Provider } from '@capsulos/sdk';
9
+
10
+ export interface ForwardOptions {
11
+ /** Port of the local listener; defaults to the capsule port. */
12
+ localPort?: number;
13
+ log?: (msg: string) => void;
14
+ }
15
+
16
+ export async function forward(provider: Provider, id: string, port: number, opts: ForwardOptions = {}): Promise<{ url: string; close: () => void }> {
17
+ const log = opts.log ?? (() => undefined);
18
+ const capsule = await Capsule.connect(provider, id);
19
+ const target = capsule.ports.connection(port);
20
+ const base = new URL(target.baseUrl);
21
+ const req = base.protocol === 'https:' ? httpsRequest : httpRequest;
22
+
23
+ const upstream = async (incoming: IncomingMessage, upgrade = false) => {
24
+ const headers: Record<string, string | string[] | undefined> = { ...incoming.headers, ...(await target.headers()) };
25
+ headers.host = base.host;
26
+ // Plain requests get a fresh keep-alive connection; an upgrade must keep
27
+ // its `Connection: Upgrade` so the far end switches protocols.
28
+ if (!upgrade) delete headers.connection;
29
+ return req({
30
+ protocol: base.protocol,
31
+ hostname: base.hostname,
32
+ port: base.port || undefined,
33
+ method: incoming.method,
34
+ path: base.pathname.replace(/\/$/, '') + (incoming.url ?? '/'),
35
+ headers,
36
+ });
37
+ };
38
+
39
+ const server = createServer(async (incoming: IncomingMessage, res: ServerResponse) => {
40
+ try {
41
+ const out = await upstream(incoming);
42
+ out.on('response', (r) => {
43
+ res.writeHead(r.statusCode ?? 502, r.headers);
44
+ r.pipe(res);
45
+ });
46
+ out.on('error', (err) => {
47
+ if (!res.headersSent) res.writeHead(502);
48
+ res.end(`forward: ${err.message}`);
49
+ });
50
+ incoming.pipe(out);
51
+ } catch (err) {
52
+ res.writeHead(502);
53
+ res.end(`forward: ${err instanceof Error ? err.message : String(err)}`);
54
+ }
55
+ });
56
+
57
+ // WebSocket upgrades: hand the raw sockets to each other once upstream says 101.
58
+ server.on('upgrade', async (incoming: IncomingMessage, socket: Duplex, head: Buffer) => {
59
+ socket.on('error', () => socket.destroy());
60
+ try {
61
+ const out = await upstream(incoming, true);
62
+ out.on('upgrade', (r, upSocket, upHead) => {
63
+ const lines = [`HTTP/1.1 101 Switching Protocols`];
64
+ for (const [k, v] of Object.entries(r.headers)) if (v !== undefined) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
65
+ socket.write(lines.join('\r\n') + '\r\n\r\n');
66
+ if (upHead.length) socket.write(upHead);
67
+ // Either side dropping the connection just ends the other; nobody
68
+ // gets to crash the forwarder.
69
+ socket.on('error', () => upSocket.destroy());
70
+ upSocket.on('error', () => socket.destroy());
71
+ upSocket.pipe(socket).pipe(upSocket);
72
+ });
73
+ out.on('response', (r) => {
74
+ socket.end(`HTTP/1.1 ${r.statusCode} ${r.statusMessage}\r\n\r\n`);
75
+ });
76
+ out.on('error', () => socket.destroy());
77
+ if (head.length) out.write(head);
78
+ out.end();
79
+ } catch {
80
+ socket.destroy();
81
+ }
82
+ });
83
+
84
+ const localPort = opts.localPort ?? port;
85
+ await new Promise<void>((resolve, reject) => {
86
+ server.once('error', reject);
87
+ server.listen(localPort, '127.0.0.1', resolve);
88
+ });
89
+ const url = `http://localhost:${localPort}`;
90
+ log(`forwarding ${url} → capsule ${id} port ${port} (ctrl-c to stop)`);
91
+ return { url, close: () => server.close() };
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { init, type InitOptions } from './init.js';
2
+ export { templateBuild, assembleArtifact, resolveCapsulod, imageArn, type TemplateBuildOptions } from './template.js';
3
+ export { loadConfig, saveConfig, configPath, credentialsFor, CONFIG_FILE, type CapsulosConfig } from './config.js';
package/src/init.ts ADDED
@@ -0,0 +1,125 @@
1
+ // `capsulos init`: the one-time setup in an AWS account. Idempotent.
2
+ // - artifact bucket capsulos-artifacts-<account>
3
+ // - build role CapsulosBuildRole (assumed by the image builder)
4
+ // - execution role CapsulosExecutionRole (assumed by every capsule)
5
+ // Network connectors are deliberately not created here: they are an account
6
+ // design decision (which VPC, which security group). Pass one with
7
+ // --egress-connector to record it in capsulos.json.
8
+ import { IAMClient, GetRoleCommand, CreateRoleCommand, PutRolePolicyCommand } from '@aws-sdk/client-iam';
9
+ import { S3Client, HeadBucketCommand, CreateBucketCommand } from '@aws-sdk/client-s3';
10
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
11
+ import { credentialsFor, saveConfig, type CapsulosConfig } from './config.js';
12
+
13
+ export interface InitOptions {
14
+ region: string;
15
+ profile?: string;
16
+ /** Buckets (names) the execution role may read and write: your capsules' S3 mounts. */
17
+ s3Buckets?: string[];
18
+ egressConnectorArn?: string;
19
+ log?: (msg: string) => void;
20
+ }
21
+
22
+ const BUILD_ROLE = 'CapsulosBuildRole';
23
+ const EXEC_ROLE = 'CapsulosExecutionRole';
24
+ const TAGS = [
25
+ { Key: 'Project', Value: 'capsulos' },
26
+ { Key: 'ManagedBy', Value: 'capsulos-cli' },
27
+ ];
28
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ export async function init(opts: InitOptions): Promise<CapsulosConfig> {
31
+ const log = opts.log ?? (() => {});
32
+ const credentials = credentialsFor(opts.profile);
33
+ const clientOpts = { region: opts.region, credentials };
34
+ const sts = new STSClient(clientOpts);
35
+ const iam = new IAMClient(clientOpts);
36
+ const s3 = new S3Client(clientOpts);
37
+
38
+ const me = await sts.send(new GetCallerIdentityCommand({}));
39
+ const accountId = me.Account;
40
+ if (!accountId) throw new Error('could not resolve the AWS account id');
41
+ const bucket = `capsulos-artifacts-${accountId}`;
42
+
43
+ try {
44
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
45
+ log(`bucket ${bucket} exists`);
46
+ } catch {
47
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
48
+ log(`bucket ${bucket} created`);
49
+ }
50
+
51
+ const trust = JSON.stringify({
52
+ Version: '2012-10-17',
53
+ Statement: [
54
+ {
55
+ Effect: 'Allow',
56
+ Principal: { Service: 'lambda.amazonaws.com' },
57
+ Action: ['sts:AssumeRole', 'sts:TagSession'],
58
+ },
59
+ ],
60
+ });
61
+
62
+ let created = false;
63
+ const ensureRole = async (name: string, description: string) => {
64
+ try {
65
+ await iam.send(new GetRoleCommand({ RoleName: name }));
66
+ log(`role ${name} exists`);
67
+ } catch {
68
+ await iam.send(new CreateRoleCommand({ RoleName: name, AssumeRolePolicyDocument: trust, Description: description, Tags: TAGS }));
69
+ created = true;
70
+ log(`role ${name} created`);
71
+ }
72
+ };
73
+
74
+ await ensureRole(BUILD_ROLE, 'capsulos: assumed by the Lambda MicroVMs image builder');
75
+ await iam.send(
76
+ new PutRolePolicyCommand({
77
+ RoleName: BUILD_ROLE,
78
+ PolicyName: 'capsulos-build',
79
+ PolicyDocument: JSON.stringify({
80
+ Version: '2012-10-17',
81
+ Statement: [
82
+ { Effect: 'Allow', Action: ['s3:GetObject'], Resource: `arn:aws:s3:::${bucket}/*` },
83
+ { Effect: 'Allow', Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], Resource: 'arn:aws:logs:*:*:*' },
84
+ ],
85
+ }),
86
+ }),
87
+ );
88
+
89
+ await ensureRole(EXEC_ROLE, 'capsulos: assumed by every capsule (S3 mounts)');
90
+ const buckets = opts.s3Buckets ?? [];
91
+ await iam.send(
92
+ new PutRolePolicyCommand({
93
+ RoleName: EXEC_ROLE,
94
+ PolicyName: 'capsulos-execution',
95
+ PolicyDocument: JSON.stringify({
96
+ Version: '2012-10-17',
97
+ Statement: buckets.length
98
+ ? [
99
+ { Effect: 'Allow', Action: ['s3:ListBucket'], Resource: buckets.map((b) => `arn:aws:s3:::${b}`) },
100
+ { Effect: 'Allow', Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'], Resource: buckets.map((b) => `arn:aws:s3:::${b}/*`) },
101
+ ]
102
+ : // IAM rejects an empty statement list; grant nothing explicitly.
103
+ [{ Effect: 'Deny', Action: ['s3:*'], Resource: '*', Sid: 'NoBucketsConfiguredYet' }],
104
+ }),
105
+ }),
106
+ );
107
+
108
+ log(buckets.length ? `execution role: S3 access to ${buckets.join(', ')}` : 'execution role: no S3 buckets (pass --s3-bucket)');
109
+ // IAM is eventually consistent: a capsule launched right after a policy
110
+ // change can still be refused for a few seconds.
111
+ log(created ? 'roles just created: waiting 10s for IAM to propagate' : 'policies updated: waiting 10s for IAM to propagate');
112
+ await sleep(10_000);
113
+
114
+ const cfg: CapsulosConfig = {
115
+ region: opts.region,
116
+ accountId,
117
+ bucket,
118
+ buildRoleArn: `arn:aws:iam::${accountId}:role/${BUILD_ROLE}`,
119
+ executionRoleArn: `arn:aws:iam::${accountId}:role/${EXEC_ROLE}`,
120
+ ...(opts.egressConnectorArn ? { egressConnectorArn: opts.egressConnectorArn } : {}),
121
+ };
122
+ const path = saveConfig(cfg);
123
+ log(`wrote ${path}`);
124
+ return cfg;
125
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,29 @@
1
+ // `capsulos shell <id>`: your terminal, attached to a capsule's pty. Raw mode
2
+ // so that every key (ctrl-c, arrows, tab) goes to the capsule; the session
3
+ // ends when the remote shell exits.
4
+ import { Capsule, type Provider } from '@capsulos/sdk';
5
+
6
+ export async function shell(provider: Provider, id: string, cmd?: string): Promise<number> {
7
+ const capsule = await Capsule.connect(provider, id);
8
+ const { stdin, stdout } = process;
9
+ const size = () => ({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
10
+ const term = await capsule.pty.open({ cmd, ...size(), onData: (chunk) => stdout.write(chunk) });
11
+
12
+ const onKey = (data: Buffer) => term.write(new Uint8Array(data));
13
+ const onResize = () => {
14
+ const s = size();
15
+ term.resize(s.cols, s.rows);
16
+ };
17
+ if (stdin.isTTY) stdin.setRawMode(true);
18
+ stdin.resume();
19
+ stdin.on('data', onKey);
20
+ stdout.on('resize', onResize);
21
+ try {
22
+ return await term.wait();
23
+ } finally {
24
+ stdin.off('data', onKey);
25
+ stdout.off('resize', onResize);
26
+ if (stdin.isTTY) stdin.setRawMode(false);
27
+ stdin.pause();
28
+ }
29
+ }
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { assembleArtifact, checkCapsulod, imageArn } from './template.js';
7
+
8
+ describe('assembleArtifact', () => {
9
+ it('zips the context with the Dockerfile and capsulod at the root', () => {
10
+ const ctx = mkdtempSync(join(tmpdir(), 'ctx-'));
11
+ writeFileSync(join(ctx, 'Dockerfile'), 'FROM debian\nCOPY capsulod /usr/local/bin/\n');
12
+ writeFileSync(join(ctx, 'app.py'), 'print(1)\n');
13
+ const bin = join(mkdtempSync(join(tmpdir(), 'bin-')), 'capsulod');
14
+ writeFileSync(bin, '#!/bin/sh\n');
15
+
16
+ const zip = assembleArtifact({ name: 't', context: ctx }, bin);
17
+ const zipPath = join(mkdtempSync(join(tmpdir(), 'z-')), 't.zip');
18
+ writeFileSync(zipPath, zip);
19
+ const listing = execFileSync('unzip', ['-Z1', zipPath]).toString();
20
+ expect(listing.split('\n').filter(Boolean).sort()).toEqual(['Dockerfile', 'app.py', 'capsulod']);
21
+ // A downloaded binary arrives 0644; the zip must carry it executable.
22
+ const modes = execFileSync('unzip', ['-Z', zipPath]).toString();
23
+ expect(modes).toMatch(/-rwxr-xr-x.*capsulod/);
24
+ });
25
+
26
+ it('fails without a Dockerfile', () => {
27
+ const ctx = mkdtempSync(join(tmpdir(), 'ctx-'));
28
+ expect(() => assembleArtifact({ name: 't', context: ctx }, '/bin/true')).toThrow(/Dockerfile not found/);
29
+ });
30
+ });
31
+
32
+ describe('checkCapsulod', () => {
33
+ const elf = (cls: number, machine: number) => {
34
+ const b = Buffer.alloc(64);
35
+ b.writeUInt32BE(0x7f454c46, 0);
36
+ b[4] = cls;
37
+ b.writeUInt16LE(machine, 18);
38
+ return b;
39
+ };
40
+ const file = (content: Buffer | string) => {
41
+ const p = join(mkdtempSync(join(tmpdir(), 'bin-')), 'capsulod');
42
+ writeFileSync(p, content);
43
+ return p;
44
+ };
45
+
46
+ it('accepts a linux/arm64 ELF', () => {
47
+ expect(() => checkCapsulod(file(elf(2, 0xb7)))).not.toThrow();
48
+ });
49
+
50
+ it('rejects scripts, 32-bit and x86_64 binaries with a pointer to the fix', () => {
51
+ expect(() => checkCapsulod(file('#!/bin/sh\n'))).toThrow(/not an ELF binary.*releases/);
52
+ expect(() => checkCapsulod(file(elf(1, 0xb7)))).toThrow(/not a 64-bit/);
53
+ expect(() => checkCapsulod(file(elf(2, 0x3e)))).toThrow(/not built for aarch64/);
54
+ });
55
+ });
56
+
57
+ describe('imageArn', () => {
58
+ it('builds the full ARN the API requires', () => {
59
+ expect(imageArn({ region: 'us-east-1', accountId: '123', bucket: '', buildRoleArn: '', executionRoleArn: '' }, 'python')).toBe(
60
+ 'arn:aws:lambda:us-east-1:123:microvm-image:python',
61
+ );
62
+ });
63
+ });
@@ -0,0 +1,190 @@
1
+ // `capsulos template build <name>`: a Dockerfile becomes a MicrovmImage.
2
+ // 1. assemble a build context: the user's directory + Dockerfile + capsulod
3
+ // 2. zip it and upload to the artifact bucket
4
+ // 3. CreateMicrovmImage (or UpdateMicrovmImage → new version) with the
5
+ // /ready + /run hooks and, when asked, additionalOsCapabilities=ALL (mounts)
6
+ // 4. poll until CREATED/UPDATED
7
+ // The Dockerfile must end up running capsulod on :8080 (see templates/base).
8
+ import { execFileSync } from 'node:child_process';
9
+ import { chmodSync, closeSync, cpSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { dirname, join, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
14
+ import {
15
+ LambdaMicrovmsClient,
16
+ CreateMicrovmImageCommand,
17
+ UpdateMicrovmImageCommand,
18
+ GetMicrovmImageCommand,
19
+ } from '@aws-sdk/client-lambda-microvms';
20
+ import { credentialsFor, type CapsulosConfig } from './config.js';
21
+
22
+ export interface TemplateBuildOptions {
23
+ name: string;
24
+ /** Directory with the Dockerfile (and anything it COPYs). */
25
+ context: string;
26
+ dockerfile?: string;
27
+ /** Prebuilt linux/arm64 capsulod; when omitted, built from --daemon-src with Go. */
28
+ capsulod?: string;
29
+ /** Path to the daemon's Go module (defaults to the monorepo's daemon/). */
30
+ daemonSrc?: string;
31
+ memoryMiB?: number;
32
+ /**
33
+ * OS capabilities of the capsules: 'default' (Docker's set, seccomp on) or
34
+ * 'all' (every capability, seccomp off: what kernel mounts — tmpfs, overlay,
35
+ * FUSE/S3 — need). Default 'default': opt into 'all' when the template
36
+ * mounts things, knowing untrusted code inside gets a less hardened guest.
37
+ */
38
+ capabilities?: Capabilities;
39
+ /** Tags on the MicrovmImage (cost allocation); `project: capsulos` unless you say otherwise. */
40
+ tags?: Record<string, string>;
41
+ profile?: string;
42
+ description?: string;
43
+ log?: (msg: string) => void;
44
+ }
45
+
46
+ export type Capabilities = 'default' | 'all';
47
+
48
+ const HOOKS_PORT = 9000;
49
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
50
+
51
+ export function imageArn(cfg: CapsulosConfig, name: string): string {
52
+ return `arn:aws:lambda:${cfg.region}:${cfg.accountId}:microvm-image:${name}`;
53
+ }
54
+
55
+ /** Resolve or cross-compile capsulod for linux/arm64 (the service runs Graviton). */
56
+ export function resolveCapsulod(opts: Pick<TemplateBuildOptions, 'capsulod' | 'daemonSrc' | 'log'>): string {
57
+ if (opts.capsulod) {
58
+ if (!existsSync(opts.capsulod)) throw new Error(`capsulod binary not found: ${opts.capsulod}`);
59
+ return resolve(opts.capsulod);
60
+ }
61
+ const here = dirname(fileURLToPath(import.meta.url));
62
+ const src = opts.daemonSrc ?? resolve(here, '..', '..', '..', 'daemon');
63
+ if (!existsSync(join(src, 'go.mod'))) {
64
+ throw new Error(
65
+ `no capsulod binary given and no daemon source at ${src}; pass --capsulod <path> (download capsulod-linux-arm64 from https://github.com/tornoai/capsulos/releases) or --daemon-src <dir>`,
66
+ );
67
+ }
68
+ const out = join(mkdtempSync(join(tmpdir(), 'capsulod-')), 'capsulod');
69
+ opts.log?.(`building capsulod for linux/arm64 from ${src}`);
70
+ execFileSync('go', ['build', '-trimpath', '-ldflags', '-s -w', '-o', out, './cmd/capsulod'], {
71
+ cwd: src,
72
+ env: { ...process.env, CGO_ENABLED: '0', GOOS: 'linux', GOARCH: 'arm64' },
73
+ stdio: 'inherit',
74
+ });
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Refuse a capsulod that cannot run on the service: not an ELF, not 64-bit,
80
+ * not aarch64. A wrong file makes the build die at /ready with no log at all.
81
+ */
82
+ export function checkCapsulod(path: string): void {
83
+ const head = Buffer.alloc(20);
84
+ const fd = openSync(path, 'r');
85
+ let n = 0;
86
+ try {
87
+ n = readSync(fd, head, 0, 20, 0);
88
+ } finally {
89
+ closeSync(fd);
90
+ }
91
+ const why = (() => {
92
+ if (n < 20 || head.readUInt32BE(0) !== 0x7f454c46) return 'not an ELF binary';
93
+ if (head[4] !== 2) return 'not a 64-bit binary';
94
+ if (head.readUInt16LE(18) !== 0xb7) return 'not built for aarch64 (Lambda MicroVMs run Graviton)';
95
+ return null;
96
+ })();
97
+ if (why) {
98
+ throw new Error(
99
+ `capsulod at ${path} is ${why}; download capsulod-linux-arm64 from https://github.com/tornoai/capsulos/releases or build it with GOOS=linux GOARCH=arm64`,
100
+ );
101
+ }
102
+ }
103
+
104
+ /** Build the zip the image builder consumes: context + Dockerfile + capsulod at the root. */
105
+ export function assembleArtifact(opts: TemplateBuildOptions, capsulod: string): Buffer {
106
+ const context = resolve(opts.context);
107
+ const dockerfile = resolve(opts.dockerfile ?? join(context, 'Dockerfile'));
108
+ if (!existsSync(dockerfile)) throw new Error(`Dockerfile not found: ${dockerfile}`);
109
+ const stage = mkdtempSync(join(tmpdir(), 'capsulos-template-'));
110
+ try {
111
+ cpSync(context, stage, { recursive: true });
112
+ cpSync(dockerfile, join(stage, 'Dockerfile'));
113
+ cpSync(capsulod, join(stage, 'capsulod'));
114
+ // A downloaded release asset comes as 0644; COPY keeps the mode and CMD ["capsulod"] then cannot run.
115
+ chmodSync(join(stage, 'capsulod'), 0o755);
116
+ const zipPath = join(stage, '..', `capsulos-${opts.name}.zip`);
117
+ rmSync(zipPath, { force: true });
118
+ execFileSync('zip', ['-q', '-r', '-X', zipPath, '.'], { cwd: stage });
119
+ return readFileSync(zipPath);
120
+ } finally {
121
+ rmSync(stage, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ export async function templateBuild(cfg: CapsulosConfig, opts: TemplateBuildOptions): Promise<{ imageArn: string; version: string }> {
126
+ const log = opts.log ?? (() => {});
127
+ const credentials = credentialsFor(opts.profile);
128
+ const s3 = new S3Client({ region: cfg.region, credentials });
129
+ const mvm = new LambdaMicrovmsClient({ region: cfg.region, credentials });
130
+
131
+ const capsulod = resolveCapsulod(opts);
132
+ checkCapsulod(capsulod);
133
+ const zip = assembleArtifact(opts, capsulod);
134
+ const key = `templates/${opts.name}.zip`;
135
+ await s3.send(new PutObjectCommand({ Bucket: cfg.bucket, Key: key, Body: zip }));
136
+ log(`uploaded s3://${cfg.bucket}/${key} (${zip.length} bytes)`);
137
+
138
+ const arn = imageArn(cfg, opts.name);
139
+ const capabilities: Capabilities = opts.capabilities ?? cfg.capabilities ?? 'default';
140
+ log(`os capabilities: ${capabilities}${capabilities === 'all' ? ' (kernel mounts allowed, guest less hardened)' : ' (no kernel mounts; pass --capabilities all for tmpfs/overlay/S3)'}`);
141
+ const spec = {
142
+ codeArtifact: { uri: `s3://${cfg.bucket}/${key}` },
143
+ baseImageArn: `arn:aws:lambda:${cfg.region}:aws:microvm-image:al2023-1`,
144
+ buildRoleArn: cfg.buildRoleArn,
145
+ cpuConfigurations: [{ architecture: 'ARM_64' as const }],
146
+ resources: [{ minimumMemoryInMiB: opts.memoryMiB ?? 512 }],
147
+ // Verified: without this the capsule has Docker's capability set and
148
+ // cannot mount anything (docs/lambda-microvms-findings.md); with it,
149
+ // every process inside runs with all capabilities and no seccomp.
150
+ ...(capabilities === 'all' ? { additionalOsCapabilities: ['ALL' as const] } : {}),
151
+ hooks: {
152
+ port: HOOKS_PORT,
153
+ microvmHooks: { run: 'ENABLED' as const, runTimeoutInSeconds: 60 },
154
+ microvmImageHooks: { ready: 'ENABLED' as const, readyTimeoutInSeconds: 120 },
155
+ },
156
+ };
157
+
158
+ const t0 = Date.now();
159
+ try {
160
+ await mvm.send(
161
+ new CreateMicrovmImageCommand({
162
+ name: opts.name,
163
+ description: opts.description ?? `capsulos template ${opts.name}`,
164
+ tags: opts.tags ?? cfg.tags ?? { project: 'capsulos' },
165
+ ...spec,
166
+ }),
167
+ );
168
+ log(`image ${arn} created; building…`);
169
+ } catch (err) {
170
+ if (err instanceof Error && err.name === 'ConflictException') {
171
+ await mvm.send(new UpdateMicrovmImageCommand({ imageIdentifier: arn, ...spec }));
172
+ log(`image ${arn} exists → new version; building…`);
173
+ } else {
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ for (;;) {
179
+ await sleep(10_000);
180
+ const img = await mvm.send(new GetMicrovmImageCommand({ imageIdentifier: arn }));
181
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
182
+ log(`state=${img.state} (t+${elapsed}s)`);
183
+ if (img.state === 'CREATED' || img.state === 'UPDATED') {
184
+ return { imageArn: arn, version: String(img.latestActiveImageVersion ?? '') };
185
+ }
186
+ if (img.state?.endsWith('_FAILED')) {
187
+ throw new Error(`build failed: ${img.state} — see CloudWatch /aws/lambda-microvms/${opts.name}`);
188
+ }
189
+ }
190
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.