@capsulos/cli 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/bin/capsulos.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +165 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -0
- package/dist/forward.d.ts +10 -0
- package/dist/forward.js +89 -0
- package/dist/forward.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +106 -0
- package/dist/init.js.map +1 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +31 -0
- package/dist/shell.js.map +1 -0
- package/dist/template.d.ts +24 -0
- package/dist/template.js +120 -0
- package/dist/template.js.map +1 -0
- package/package.json +68 -3
- package/src/cli.ts +162 -0
- package/src/config.ts +47 -0
- package/src/forward.ts +92 -0
- package/src/index.ts +3 -0
- package/src/init.ts +125 -0
- package/src/shell.ts +29 -0
- package/src/template.test.ts +35 -0
- package/src/template.ts +147 -0
- package/README.md +0 -3
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,35 @@
|
|
|
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, 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
|
+
});
|
|
22
|
+
|
|
23
|
+
it('fails without a Dockerfile', () => {
|
|
24
|
+
const ctx = mkdtempSync(join(tmpdir(), 'ctx-'));
|
|
25
|
+
expect(() => assembleArtifact({ name: 't', context: ctx }, '/bin/true')).toThrow(/Dockerfile not found/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('imageArn', () => {
|
|
30
|
+
it('builds the full ARN the API requires', () => {
|
|
31
|
+
expect(imageArn({ region: 'us-east-1', accountId: '123', bucket: '', buildRoleArn: '', executionRoleArn: '' }, 'python')).toBe(
|
|
32
|
+
'arn:aws:lambda:us-east-1:123:microvm-image:python',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
|
5
|
+
// additionalOsCapabilities=ALL (mounts) and the /ready + /run hooks
|
|
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 { cpSync, existsSync, mkdtempSync, readFileSync, 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
|
+
profile?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
log?: (msg: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HOOKS_PORT = 9000;
|
|
38
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
export function imageArn(cfg: CapsulosConfig, name: string): string {
|
|
41
|
+
return `arn:aws:lambda:${cfg.region}:${cfg.accountId}:microvm-image:${name}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve or cross-compile capsulod for linux/arm64 (the service runs Graviton). */
|
|
45
|
+
export function resolveCapsulod(opts: Pick<TemplateBuildOptions, 'capsulod' | 'daemonSrc' | 'log'>): string {
|
|
46
|
+
if (opts.capsulod) {
|
|
47
|
+
if (!existsSync(opts.capsulod)) throw new Error(`capsulod binary not found: ${opts.capsulod}`);
|
|
48
|
+
return resolve(opts.capsulod);
|
|
49
|
+
}
|
|
50
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const src = opts.daemonSrc ?? resolve(here, '..', '..', '..', 'daemon');
|
|
52
|
+
if (!existsSync(join(src, 'go.mod'))) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`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>`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const out = join(mkdtempSync(join(tmpdir(), 'capsulod-')), 'capsulod');
|
|
58
|
+
opts.log?.(`building capsulod for linux/arm64 from ${src}`);
|
|
59
|
+
execFileSync('go', ['build', '-trimpath', '-ldflags', '-s -w', '-o', out, './cmd/capsulod'], {
|
|
60
|
+
cwd: src,
|
|
61
|
+
env: { ...process.env, CGO_ENABLED: '0', GOOS: 'linux', GOARCH: 'arm64' },
|
|
62
|
+
stdio: 'inherit',
|
|
63
|
+
});
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build the zip the image builder consumes: context + Dockerfile + capsulod at the root. */
|
|
68
|
+
export function assembleArtifact(opts: TemplateBuildOptions, capsulod: string): Buffer {
|
|
69
|
+
const context = resolve(opts.context);
|
|
70
|
+
const dockerfile = resolve(opts.dockerfile ?? join(context, 'Dockerfile'));
|
|
71
|
+
if (!existsSync(dockerfile)) throw new Error(`Dockerfile not found: ${dockerfile}`);
|
|
72
|
+
const stage = mkdtempSync(join(tmpdir(), 'capsulos-template-'));
|
|
73
|
+
try {
|
|
74
|
+
cpSync(context, stage, { recursive: true });
|
|
75
|
+
cpSync(dockerfile, join(stage, 'Dockerfile'));
|
|
76
|
+
cpSync(capsulod, join(stage, 'capsulod'));
|
|
77
|
+
const zipPath = join(stage, '..', `capsulos-${opts.name}.zip`);
|
|
78
|
+
rmSync(zipPath, { force: true });
|
|
79
|
+
execFileSync('zip', ['-q', '-r', '-X', zipPath, '.'], { cwd: stage });
|
|
80
|
+
return readFileSync(zipPath);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(stage, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function templateBuild(cfg: CapsulosConfig, opts: TemplateBuildOptions): Promise<{ imageArn: string; version: string }> {
|
|
87
|
+
const log = opts.log ?? (() => {});
|
|
88
|
+
const credentials = credentialsFor(opts.profile);
|
|
89
|
+
const s3 = new S3Client({ region: cfg.region, credentials });
|
|
90
|
+
const mvm = new LambdaMicrovmsClient({ region: cfg.region, credentials });
|
|
91
|
+
|
|
92
|
+
const capsulod = resolveCapsulod(opts);
|
|
93
|
+
const zip = assembleArtifact(opts, capsulod);
|
|
94
|
+
const key = `templates/${opts.name}.zip`;
|
|
95
|
+
await s3.send(new PutObjectCommand({ Bucket: cfg.bucket, Key: key, Body: zip }));
|
|
96
|
+
log(`uploaded s3://${cfg.bucket}/${key} (${zip.length} bytes)`);
|
|
97
|
+
|
|
98
|
+
const arn = imageArn(cfg, opts.name);
|
|
99
|
+
const spec = {
|
|
100
|
+
codeArtifact: { uri: `s3://${cfg.bucket}/${key}` },
|
|
101
|
+
baseImageArn: `arn:aws:lambda:${cfg.region}:aws:microvm-image:al2023-1`,
|
|
102
|
+
buildRoleArn: cfg.buildRoleArn,
|
|
103
|
+
cpuConfigurations: [{ architecture: 'ARM_64' as const }],
|
|
104
|
+
resources: [{ minimumMemoryInMiB: opts.memoryMiB ?? 512 }],
|
|
105
|
+
// Verified: without this the capsule has Docker's capability set and
|
|
106
|
+
// cannot mount anything (docs/lambda-microvms-findings.md).
|
|
107
|
+
additionalOsCapabilities: ['ALL' as const],
|
|
108
|
+
hooks: {
|
|
109
|
+
port: HOOKS_PORT,
|
|
110
|
+
microvmHooks: { run: 'ENABLED' as const, runTimeoutInSeconds: 60 },
|
|
111
|
+
microvmImageHooks: { ready: 'ENABLED' as const, readyTimeoutInSeconds: 120 },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const t0 = Date.now();
|
|
116
|
+
try {
|
|
117
|
+
await mvm.send(
|
|
118
|
+
new CreateMicrovmImageCommand({
|
|
119
|
+
name: opts.name,
|
|
120
|
+
description: opts.description ?? `capsulos template ${opts.name}`,
|
|
121
|
+
tags: { project: 'capsulos' },
|
|
122
|
+
...spec,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
log(`image ${arn} created; building…`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof Error && err.name === 'ConflictException') {
|
|
128
|
+
await mvm.send(new UpdateMicrovmImageCommand({ imageIdentifier: arn, ...spec }));
|
|
129
|
+
log(`image ${arn} exists → new version; building…`);
|
|
130
|
+
} else {
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (;;) {
|
|
136
|
+
await sleep(10_000);
|
|
137
|
+
const img = await mvm.send(new GetMicrovmImageCommand({ imageIdentifier: arn }));
|
|
138
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
|
|
139
|
+
log(`state=${img.state} (t+${elapsed}s)`);
|
|
140
|
+
if (img.state === 'CREATED' || img.state === 'UPDATED') {
|
|
141
|
+
return { imageArn: arn, version: String(img.latestActiveImageVersion ?? '') };
|
|
142
|
+
}
|
|
143
|
+
if (img.state?.endsWith('_FAILED')) {
|
|
144
|
+
throw new Error(`build failed: ${img.state} — see CloudWatch /aws/lambda-microvms/${opts.name}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/README.md
DELETED