@capsulos/provider-lambda 0.1.0 → 0.1.2
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/dist/index.d.ts +22 -3
- package/dist/index.js +95 -28
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/create.test.ts +60 -0
- package/src/index.ts +95 -27
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LambdaMicrovmsClientConfig } from '@aws-sdk/client-lambda-microvms';
|
|
2
|
-
import { type CapsuleHandle, type CapsuleInfo, type CapsuleSpec, type Connection, type Provider } from '@capsulos/sdk';
|
|
2
|
+
import { type CapsuleHandle, type CapsuleInfo, type CapsuleSpec, type Connection, type CreateOptions, type HeadersOptions, type Provider } from '@capsulos/sdk';
|
|
3
3
|
export interface LambdaProviderOptions {
|
|
4
4
|
region: string;
|
|
5
5
|
/** AWS account id, used to build image ARNs from template names. */
|
|
@@ -26,14 +26,33 @@ export declare class LambdaProvider implements Provider {
|
|
|
26
26
|
constructor(opts: LambdaProviderOptions);
|
|
27
27
|
/** Templates are MicrovmImages; the API wants the full ARN (a plain name fails). */
|
|
28
28
|
imageArn(template: string): string;
|
|
29
|
-
create(spec: CapsuleSpec): Promise<CapsuleHandle>;
|
|
29
|
+
create(spec: CapsuleSpec, opts?: CreateOptions): Promise<CapsuleHandle>;
|
|
30
30
|
get(id: string): Promise<CapsuleInfo | null>;
|
|
31
31
|
/** All microVMs of one template (the API lists per image). */
|
|
32
32
|
list(template?: string): Promise<CapsuleInfo[]>;
|
|
33
33
|
terminate(id: string): Promise<void>;
|
|
34
34
|
connect(id: string): Promise<Connection>;
|
|
35
|
+
/**
|
|
36
|
+
* Did this call's RunMicrovm launch the VM, or did its clientToken hand
|
|
37
|
+
* back one launched earlier (idempotency/recovery)? The API does not say,
|
|
38
|
+
* so: no clientToken → ours; otherwise ours when the VM started after we
|
|
39
|
+
* asked (with a minute of slack for clock skew). Unknown → not ours, which
|
|
40
|
+
* errs on the side of leaving a VM alive.
|
|
41
|
+
*/
|
|
42
|
+
private launchedBy;
|
|
43
|
+
/** A Connection from an endpoint already in hand (get() returns it): no extra GetMicrovm. */
|
|
44
|
+
connectionFor(id: string, endpoint: string): Connection;
|
|
35
45
|
/** Headers to reach another port of the capsule through the same endpoint. */
|
|
36
|
-
portHeaders(id: string, port: number): Promise<Record<string, string>>;
|
|
46
|
+
portHeaders(id: string, port: number, opts?: HeadersOptions): Promise<Record<string, string>>;
|
|
47
|
+
/** Forget the cached endpoint token: the next request mints a new one. */
|
|
48
|
+
invalidateToken(id: string): void;
|
|
49
|
+
private infoOf;
|
|
37
50
|
private token;
|
|
51
|
+
/**
|
|
52
|
+
* Poll until the VM runs. A SUSPENDED VM (a clientToken that recovered an
|
|
53
|
+
* idle one) counts as alive: the first request auto-resumes it. Timeout and
|
|
54
|
+
* abort throw a CapsuleError that names the VM (capsuleId) for the caller
|
|
55
|
+
* to clean up; create() does that itself.
|
|
56
|
+
*/
|
|
38
57
|
private waitForRunning;
|
|
39
58
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// - endpoint tokens expire after at most 60 min: cached in memory, re-minted
|
|
9
9
|
// - other ports are addressed with the X-aws-proxy-port header
|
|
10
10
|
// - the idle policy suspends and the first request auto-resumes (~0.8 s)
|
|
11
|
-
// - clientToken is both idempotency and recovery (same token → same VM)
|
|
11
|
+
// - clientToken is both idempotency and recovery (same token → same VM);
|
|
12
|
+
// a recovered VM may be SUSPENDED, which create() accepts as alive
|
|
12
13
|
import { LambdaMicrovmsClient, RunMicrovmCommand, GetMicrovmCommand, ListMicrovmsCommand, TerminateMicrovmCommand, CreateMicrovmAuthTokenCommand, } from '@aws-sdk/client-lambda-microvms';
|
|
13
14
|
import { CapsuleError, } from '@capsulos/sdk';
|
|
14
15
|
const CAPSULOD_PORT = 8080;
|
|
@@ -46,7 +47,8 @@ export class LambdaProvider {
|
|
|
46
47
|
return template;
|
|
47
48
|
return `arn:aws:lambda:${this.opts.region}:${this.opts.accountId}:microvm-image:${template}`;
|
|
48
49
|
}
|
|
49
|
-
async create(spec) {
|
|
50
|
+
async create(spec, opts = {}) {
|
|
51
|
+
const t0 = Date.now();
|
|
50
52
|
const ingress = this.opts.ingressConnectorArn ??
|
|
51
53
|
`arn:aws:lambda:${this.opts.region}:aws:network-connector:aws-network-connector:ALL_INGRESS`;
|
|
52
54
|
const res = await this.client.send(new RunMicrovmCommand({
|
|
@@ -70,21 +72,35 @@ export class LambdaProvider {
|
|
|
70
72
|
}));
|
|
71
73
|
if (!res.microvmId)
|
|
72
74
|
throw new CapsuleError('RunMicrovm returned no microvmId');
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
+
const id = res.microvmId;
|
|
76
|
+
let info;
|
|
77
|
+
try {
|
|
78
|
+
info = await this.waitForRunning(id, opts);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// Never leave a VM running without an owner: the caller gets an error,
|
|
82
|
+
// not a capsule, so the VM goes too (the error still names it). Unless
|
|
83
|
+
// the clientToken recovered a VM someone else launched: that one has
|
|
84
|
+
// an owner, so it stays and the error says so.
|
|
85
|
+
if (err instanceof CapsuleError && err.capsuleId === id) {
|
|
86
|
+
if (await this.launchedBy(id, t0, spec.clientToken)) {
|
|
87
|
+
await this.terminate(id).catch(() => undefined);
|
|
88
|
+
throw new CapsuleError(`${err.message}; terminated it`, err.status, { capsuleId: id });
|
|
89
|
+
}
|
|
90
|
+
throw new CapsuleError(`${err.message}; left it running (recovered through clientToken, it has another owner)`, err.status, {
|
|
91
|
+
capsuleId: id,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
if (!info.endpoint)
|
|
97
|
+
throw new CapsuleError(`microVM ${id} is ${info.state} but has no endpoint`, undefined, { capsuleId: id });
|
|
98
|
+
return { ...info, connection: this.connectionFor(id, info.endpoint) };
|
|
75
99
|
}
|
|
76
100
|
async get(id) {
|
|
77
101
|
try {
|
|
78
102
|
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
79
|
-
return
|
|
80
|
-
id,
|
|
81
|
-
provider: this.name,
|
|
82
|
-
state: toState(vm.state),
|
|
83
|
-
template: vm.imageArn ?? null,
|
|
84
|
-
createdAt: vm.startedAt ?? null,
|
|
85
|
-
terminatedAt: vm.terminatedAt ?? null,
|
|
86
|
-
stateReason: vm.stateReason ?? null,
|
|
87
|
-
};
|
|
103
|
+
return this.infoOf(id, vm);
|
|
88
104
|
}
|
|
89
105
|
catch (err) {
|
|
90
106
|
if (err instanceof Error && err.name === 'ResourceNotFoundException')
|
|
@@ -103,11 +119,13 @@ export class LambdaProvider {
|
|
|
103
119
|
for (const item of page.items ?? []) {
|
|
104
120
|
if (!item.microvmId)
|
|
105
121
|
continue;
|
|
122
|
+
// The list item carries no endpoint, terminatedAt or stateReason: get() does.
|
|
106
123
|
out.push({
|
|
107
124
|
id: item.microvmId,
|
|
108
125
|
provider: this.name,
|
|
109
126
|
state: toState(item.state),
|
|
110
127
|
template: item.imageArn ?? null,
|
|
128
|
+
templateVersion: item.imageVersion ?? null,
|
|
111
129
|
createdAt: item.startedAt ?? null,
|
|
112
130
|
});
|
|
113
131
|
}
|
|
@@ -131,19 +149,59 @@ export class LambdaProvider {
|
|
|
131
149
|
async connect(id) {
|
|
132
150
|
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
133
151
|
if (!vm.endpoint)
|
|
134
|
-
throw new CapsuleError(`microVM ${id} has no endpoint yet (state ${vm.state})
|
|
152
|
+
throw new CapsuleError(`microVM ${id} has no endpoint yet (state ${vm.state})`, undefined, { capsuleId: id });
|
|
153
|
+
return this.connectionFor(id, vm.endpoint);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Did this call's RunMicrovm launch the VM, or did its clientToken hand
|
|
157
|
+
* back one launched earlier (idempotency/recovery)? The API does not say,
|
|
158
|
+
* so: no clientToken → ours; otherwise ours when the VM started after we
|
|
159
|
+
* asked (with a minute of slack for clock skew). Unknown → not ours, which
|
|
160
|
+
* errs on the side of leaving a VM alive.
|
|
161
|
+
*/
|
|
162
|
+
async launchedBy(id, t0, clientToken) {
|
|
163
|
+
if (!clientToken)
|
|
164
|
+
return true;
|
|
165
|
+
try {
|
|
166
|
+
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
167
|
+
return vm.startedAt !== undefined && vm.startedAt.getTime() >= t0 - 60_000;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** A Connection from an endpoint already in hand (get() returns it): no extra GetMicrovm. */
|
|
174
|
+
connectionFor(id, endpoint) {
|
|
135
175
|
return {
|
|
136
|
-
baseUrl: `https://${
|
|
137
|
-
headers: async () => ({ 'X-aws-proxy-auth': await this.token(id) }),
|
|
176
|
+
baseUrl: endpoint.startsWith('https://') ? endpoint : `https://${endpoint}`,
|
|
177
|
+
headers: async (opts) => ({ 'X-aws-proxy-auth': await this.token(id, opts?.fresh) }),
|
|
138
178
|
};
|
|
139
179
|
}
|
|
140
180
|
/** Headers to reach another port of the capsule through the same endpoint. */
|
|
141
|
-
async portHeaders(id, port) {
|
|
142
|
-
return { 'X-aws-proxy-auth': await this.token(id), 'X-aws-proxy-port': String(port) };
|
|
181
|
+
async portHeaders(id, port, opts) {
|
|
182
|
+
return { 'X-aws-proxy-auth': await this.token(id, opts?.fresh), 'X-aws-proxy-port': String(port) };
|
|
183
|
+
}
|
|
184
|
+
/** Forget the cached endpoint token: the next request mints a new one. */
|
|
185
|
+
invalidateToken(id) {
|
|
186
|
+
this.tokens.delete(id);
|
|
143
187
|
}
|
|
144
|
-
|
|
188
|
+
infoOf(id, vm) {
|
|
189
|
+
return {
|
|
190
|
+
id,
|
|
191
|
+
provider: this.name,
|
|
192
|
+
state: toState(vm.state),
|
|
193
|
+
template: vm.imageArn ?? null,
|
|
194
|
+
templateVersion: vm.imageVersion ?? null,
|
|
195
|
+
createdAt: vm.startedAt ?? null,
|
|
196
|
+
terminatedAt: vm.terminatedAt ?? null,
|
|
197
|
+
stateReason: vm.stateReason ?? null,
|
|
198
|
+
endpoint: vm.endpoint ? `https://${vm.endpoint}` : null,
|
|
199
|
+
maxDurationSeconds: vm.maximumDurationInSeconds ?? null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async token(id, fresh = false) {
|
|
145
203
|
const cached = this.tokens.get(id);
|
|
146
|
-
if (cached && cached.expiresAt > Date.now())
|
|
204
|
+
if (cached && cached.expiresAt > Date.now() && !fresh)
|
|
147
205
|
return cached.value;
|
|
148
206
|
const ttl = Math.min(this.opts.tokenTtlMinutes ?? 60, 60);
|
|
149
207
|
const ports = new Set([CAPSULOD_PORT, ...(this.opts.allowedPorts ?? [])]);
|
|
@@ -159,18 +217,27 @@ export class LambdaProvider {
|
|
|
159
217
|
this.tokens.set(id, { value, expiresAt: Date.now() + (ttl - 5) * 60_000 });
|
|
160
218
|
return value;
|
|
161
219
|
}
|
|
162
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Poll until the VM runs. A SUSPENDED VM (a clientToken that recovered an
|
|
222
|
+
* idle one) counts as alive: the first request auto-resumes it. Timeout and
|
|
223
|
+
* abort throw a CapsuleError that names the VM (capsuleId) for the caller
|
|
224
|
+
* to clean up; create() does that itself.
|
|
225
|
+
*/
|
|
226
|
+
async waitForRunning(id, opts = {}) {
|
|
227
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
163
228
|
const t0 = Date.now();
|
|
164
229
|
for (;;) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
if (opts.signal?.aborted)
|
|
231
|
+
throw new CapsuleError(`creation of microVM ${id} aborted`, undefined, { capsuleId: id });
|
|
232
|
+
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }), { abortSignal: opts.signal });
|
|
233
|
+
if (vm.state === 'RUNNING' || vm.state === 'SUSPENDED')
|
|
234
|
+
return this.infoOf(id, vm);
|
|
169
235
|
if (vm.state === 'TERMINATED' || vm.state === 'TERMINATING') {
|
|
170
|
-
throw new CapsuleError(`microVM ${id} died while starting: ${vm.stateReason ?? vm.state}
|
|
236
|
+
throw new CapsuleError(`microVM ${id} died while starting: ${vm.stateReason ?? vm.state}`, undefined, { capsuleId: id });
|
|
237
|
+
}
|
|
238
|
+
if (Date.now() - t0 > timeoutMs) {
|
|
239
|
+
throw new CapsuleError(`microVM ${id} did not run within ${timeoutMs}ms (state ${vm.state})`, undefined, { capsuleId: id });
|
|
171
240
|
}
|
|
172
|
-
if (Date.now() - t0 > timeoutMs)
|
|
173
|
-
throw new CapsuleError(`timeout waiting for microVM ${id} to run`);
|
|
174
241
|
await new Promise((r) => setTimeout(r, 400));
|
|
175
242
|
}
|
|
176
243
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,6EAA6E;AAC7E,yEAAyE;AACzE,EAAE;AACF,8DAA8D;AAC9D,gFAAgF;AAChF,8EAA8E;AAC9E,+EAA+E;AAC/E,iEAAiE;AACjE,2EAA2E;AAC3E,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capsulos/provider-lambda",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "capsulos provider for AWS Lambda MicroVMs: capsules on your own AWS account.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@aws-sdk/client-lambda-microvms": "^3.1158.0",
|
|
47
|
-
"@capsulos/sdk": "0.1.
|
|
47
|
+
"@capsulos/sdk": "0.1.2"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^22.20.1",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// create(): what happens to the VM when the wait for RUNNING fails. The AWS
|
|
2
|
+
// client is stubbed: these tests are about ownership, not about the service.
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { CapsuleError } from '@capsulos/sdk';
|
|
5
|
+
import { LambdaProvider } from './index.js';
|
|
6
|
+
|
|
7
|
+
type Sent = { name: string; input: Record<string, unknown> };
|
|
8
|
+
|
|
9
|
+
function provider(vm: { state: string; startedAt?: Date }) {
|
|
10
|
+
const sent: Sent[] = [];
|
|
11
|
+
const p = new LambdaProvider({ region: 'us-east-1', accountId: '123' });
|
|
12
|
+
(p as unknown as { client: { send: (cmd: { constructor: { name: string }; input: Record<string, unknown> }) => Promise<unknown> } }).client = {
|
|
13
|
+
async send(cmd) {
|
|
14
|
+
sent.push({ name: cmd.constructor.name, input: cmd.input });
|
|
15
|
+
switch (cmd.constructor.name) {
|
|
16
|
+
case 'RunMicrovmCommand':
|
|
17
|
+
return { microvmId: 'mvm-1' };
|
|
18
|
+
case 'GetMicrovmCommand':
|
|
19
|
+
return { microvmId: 'mvm-1', state: vm.state, startedAt: vm.startedAt, endpoint: 'x.on.aws' };
|
|
20
|
+
case 'TerminateMicrovmCommand':
|
|
21
|
+
return {};
|
|
22
|
+
default:
|
|
23
|
+
throw new Error(`unexpected ${cmd.constructor.name}`);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return { p, sent, terminated: () => sent.filter((s) => s.name === 'TerminateMicrovmCommand').length };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('LambdaProvider.create on timeout', () => {
|
|
31
|
+
it('terminates a VM it launched itself and names it in the error', async () => {
|
|
32
|
+
const { p, terminated } = provider({ state: 'PENDING', startedAt: new Date() });
|
|
33
|
+
const err = await p.create({ template: 't' }, { timeoutMs: 1 }).catch((e: unknown) => e);
|
|
34
|
+
expect(err).toBeInstanceOf(CapsuleError);
|
|
35
|
+
expect((err as CapsuleError).capsuleId).toBe('mvm-1');
|
|
36
|
+
expect((err as CapsuleError).message).toMatch(/terminated it/);
|
|
37
|
+
expect(terminated()).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('leaves alone a VM that the clientToken recovered from an earlier launch', async () => {
|
|
41
|
+
const { p, terminated } = provider({ state: 'PENDING', startedAt: new Date(Date.now() - 10 * 60_000) });
|
|
42
|
+
const err = await p.create({ template: 't', clientToken: 'row-1' }, { timeoutMs: 1 }).catch((e: unknown) => e);
|
|
43
|
+
expect((err as CapsuleError).message).toMatch(/left it running/);
|
|
44
|
+
expect((err as CapsuleError).capsuleId).toBe('mvm-1');
|
|
45
|
+
expect(terminated()).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('with a clientToken, a VM that started after the call is still ours', async () => {
|
|
49
|
+
const { p, terminated } = provider({ state: 'PENDING', startedAt: new Date(Date.now() + 1000) });
|
|
50
|
+
await p.create({ template: 't', clientToken: 'row-1' }, { timeoutMs: 1 }).catch(() => undefined);
|
|
51
|
+
expect(terminated()).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('accepts a SUSPENDED VM as alive', async () => {
|
|
55
|
+
const { p } = provider({ state: 'SUSPENDED', startedAt: new Date() });
|
|
56
|
+
const handle = await p.create({ template: 't', clientToken: 'row-1' });
|
|
57
|
+
expect(handle.state).toBe('suspended');
|
|
58
|
+
expect(handle.connection.baseUrl).toBe('https://x.on.aws');
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// - endpoint tokens expire after at most 60 min: cached in memory, re-minted
|
|
9
9
|
// - other ports are addressed with the X-aws-proxy-port header
|
|
10
10
|
// - the idle policy suspends and the first request auto-resumes (~0.8 s)
|
|
11
|
-
// - clientToken is both idempotency and recovery (same token → same VM)
|
|
11
|
+
// - clientToken is both idempotency and recovery (same token → same VM);
|
|
12
|
+
// a recovered VM may be SUSPENDED, which create() accepts as alive
|
|
12
13
|
import {
|
|
13
14
|
LambdaMicrovmsClient,
|
|
14
15
|
RunMicrovmCommand,
|
|
@@ -25,6 +26,8 @@ import {
|
|
|
25
26
|
type CapsuleSpec,
|
|
26
27
|
type CapsuleState,
|
|
27
28
|
type Connection,
|
|
29
|
+
type CreateOptions,
|
|
30
|
+
type HeadersOptions,
|
|
28
31
|
type Provider,
|
|
29
32
|
} from '@capsulos/sdk';
|
|
30
33
|
|
|
@@ -84,7 +87,8 @@ export class LambdaProvider implements Provider {
|
|
|
84
87
|
return `arn:aws:lambda:${this.opts.region}:${this.opts.accountId}:microvm-image:${template}`;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
async create(spec: CapsuleSpec): Promise<CapsuleHandle> {
|
|
90
|
+
async create(spec: CapsuleSpec, opts: CreateOptions = {}): Promise<CapsuleHandle> {
|
|
91
|
+
const t0 = Date.now();
|
|
88
92
|
const ingress =
|
|
89
93
|
this.opts.ingressConnectorArn ??
|
|
90
94
|
`arn:aws:lambda:${this.opts.region}:aws:network-connector:aws-network-connector:ALL_INGRESS`;
|
|
@@ -110,22 +114,34 @@ export class LambdaProvider implements Provider {
|
|
|
110
114
|
}),
|
|
111
115
|
);
|
|
112
116
|
if (!res.microvmId) throw new CapsuleError('RunMicrovm returned no microvmId');
|
|
113
|
-
const
|
|
114
|
-
|
|
117
|
+
const id = res.microvmId;
|
|
118
|
+
let info: CapsuleInfo;
|
|
119
|
+
try {
|
|
120
|
+
info = await this.waitForRunning(id, opts);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Never leave a VM running without an owner: the caller gets an error,
|
|
123
|
+
// not a capsule, so the VM goes too (the error still names it). Unless
|
|
124
|
+
// the clientToken recovered a VM someone else launched: that one has
|
|
125
|
+
// an owner, so it stays and the error says so.
|
|
126
|
+
if (err instanceof CapsuleError && err.capsuleId === id) {
|
|
127
|
+
if (await this.launchedBy(id, t0, spec.clientToken)) {
|
|
128
|
+
await this.terminate(id).catch(() => undefined);
|
|
129
|
+
throw new CapsuleError(`${err.message}; terminated it`, err.status, { capsuleId: id });
|
|
130
|
+
}
|
|
131
|
+
throw new CapsuleError(`${err.message}; left it running (recovered through clientToken, it has another owner)`, err.status, {
|
|
132
|
+
capsuleId: id,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
if (!info.endpoint) throw new CapsuleError(`microVM ${id} is ${info.state} but has no endpoint`, undefined, { capsuleId: id });
|
|
138
|
+
return { ...info, connection: this.connectionFor(id, info.endpoint) };
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
async get(id: string): Promise<CapsuleInfo | null> {
|
|
118
142
|
try {
|
|
119
143
|
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
120
|
-
return
|
|
121
|
-
id,
|
|
122
|
-
provider: this.name,
|
|
123
|
-
state: toState(vm.state),
|
|
124
|
-
template: vm.imageArn ?? null,
|
|
125
|
-
createdAt: vm.startedAt ?? null,
|
|
126
|
-
terminatedAt: vm.terminatedAt ?? null,
|
|
127
|
-
stateReason: vm.stateReason ?? null,
|
|
128
|
-
};
|
|
144
|
+
return this.infoOf(id, vm);
|
|
129
145
|
} catch (err) {
|
|
130
146
|
if (err instanceof Error && err.name === 'ResourceNotFoundException') return null;
|
|
131
147
|
throw err;
|
|
@@ -143,11 +159,13 @@ export class LambdaProvider implements Provider {
|
|
|
143
159
|
);
|
|
144
160
|
for (const item of page.items ?? []) {
|
|
145
161
|
if (!item.microvmId) continue;
|
|
162
|
+
// The list item carries no endpoint, terminatedAt or stateReason: get() does.
|
|
146
163
|
out.push({
|
|
147
164
|
id: item.microvmId,
|
|
148
165
|
provider: this.name,
|
|
149
166
|
state: toState(item.state),
|
|
150
167
|
template: item.imageArn ?? null,
|
|
168
|
+
templateVersion: item.imageVersion ?? null,
|
|
151
169
|
createdAt: item.startedAt ?? null,
|
|
152
170
|
});
|
|
153
171
|
}
|
|
@@ -169,21 +187,63 @@ export class LambdaProvider implements Provider {
|
|
|
169
187
|
|
|
170
188
|
async connect(id: string): Promise<Connection> {
|
|
171
189
|
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
172
|
-
if (!vm.endpoint) throw new CapsuleError(`microVM ${id} has no endpoint yet (state ${vm.state})
|
|
190
|
+
if (!vm.endpoint) throw new CapsuleError(`microVM ${id} has no endpoint yet (state ${vm.state})`, undefined, { capsuleId: id });
|
|
191
|
+
return this.connectionFor(id, vm.endpoint);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Did this call's RunMicrovm launch the VM, or did its clientToken hand
|
|
196
|
+
* back one launched earlier (idempotency/recovery)? The API does not say,
|
|
197
|
+
* so: no clientToken → ours; otherwise ours when the VM started after we
|
|
198
|
+
* asked (with a minute of slack for clock skew). Unknown → not ours, which
|
|
199
|
+
* errs on the side of leaving a VM alive.
|
|
200
|
+
*/
|
|
201
|
+
private async launchedBy(id: string, t0: number, clientToken?: string): Promise<boolean> {
|
|
202
|
+
if (!clientToken) return true;
|
|
203
|
+
try {
|
|
204
|
+
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }));
|
|
205
|
+
return vm.startedAt !== undefined && vm.startedAt.getTime() >= t0 - 60_000;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A Connection from an endpoint already in hand (get() returns it): no extra GetMicrovm. */
|
|
212
|
+
connectionFor(id: string, endpoint: string): Connection {
|
|
173
213
|
return {
|
|
174
|
-
baseUrl: `https://${
|
|
175
|
-
headers: async () => ({ 'X-aws-proxy-auth': await this.token(id) }),
|
|
214
|
+
baseUrl: endpoint.startsWith('https://') ? endpoint : `https://${endpoint}`,
|
|
215
|
+
headers: async (opts?: HeadersOptions) => ({ 'X-aws-proxy-auth': await this.token(id, opts?.fresh) }),
|
|
176
216
|
};
|
|
177
217
|
}
|
|
178
218
|
|
|
179
219
|
/** Headers to reach another port of the capsule through the same endpoint. */
|
|
180
|
-
async portHeaders(id: string, port: number): Promise<Record<string, string>> {
|
|
181
|
-
return { 'X-aws-proxy-auth': await this.token(id), 'X-aws-proxy-port': String(port) };
|
|
220
|
+
async portHeaders(id: string, port: number, opts?: HeadersOptions): Promise<Record<string, string>> {
|
|
221
|
+
return { 'X-aws-proxy-auth': await this.token(id, opts?.fresh), 'X-aws-proxy-port': String(port) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Forget the cached endpoint token: the next request mints a new one. */
|
|
225
|
+
invalidateToken(id: string): void {
|
|
226
|
+
this.tokens.delete(id);
|
|
182
227
|
}
|
|
183
228
|
|
|
184
|
-
private
|
|
229
|
+
private infoOf(id: string, vm: { state?: string; imageArn?: string; imageVersion?: string; startedAt?: Date; terminatedAt?: Date; stateReason?: string; endpoint?: string; maximumDurationInSeconds?: number }): CapsuleInfo {
|
|
230
|
+
return {
|
|
231
|
+
id,
|
|
232
|
+
provider: this.name,
|
|
233
|
+
state: toState(vm.state),
|
|
234
|
+
template: vm.imageArn ?? null,
|
|
235
|
+
templateVersion: vm.imageVersion ?? null,
|
|
236
|
+
createdAt: vm.startedAt ?? null,
|
|
237
|
+
terminatedAt: vm.terminatedAt ?? null,
|
|
238
|
+
stateReason: vm.stateReason ?? null,
|
|
239
|
+
endpoint: vm.endpoint ? `https://${vm.endpoint}` : null,
|
|
240
|
+
maxDurationSeconds: vm.maximumDurationInSeconds ?? null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async token(id: string, fresh = false): Promise<string> {
|
|
185
245
|
const cached = this.tokens.get(id);
|
|
186
|
-
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
246
|
+
if (cached && cached.expiresAt > Date.now() && !fresh) return cached.value;
|
|
187
247
|
const ttl = Math.min(this.opts.tokenTtlMinutes ?? 60, 60);
|
|
188
248
|
const ports = new Set([CAPSULOD_PORT, ...(this.opts.allowedPorts ?? [])]);
|
|
189
249
|
const res = await this.client.send(
|
|
@@ -200,17 +260,25 @@ export class LambdaProvider implements Provider {
|
|
|
200
260
|
return value;
|
|
201
261
|
}
|
|
202
262
|
|
|
203
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Poll until the VM runs. A SUSPENDED VM (a clientToken that recovered an
|
|
265
|
+
* idle one) counts as alive: the first request auto-resumes it. Timeout and
|
|
266
|
+
* abort throw a CapsuleError that names the VM (capsuleId) for the caller
|
|
267
|
+
* to clean up; create() does that itself.
|
|
268
|
+
*/
|
|
269
|
+
private async waitForRunning(id: string, opts: CreateOptions = {}): Promise<CapsuleInfo> {
|
|
270
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
204
271
|
const t0 = Date.now();
|
|
205
272
|
for (;;) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
273
|
+
if (opts.signal?.aborted) throw new CapsuleError(`creation of microVM ${id} aborted`, undefined, { capsuleId: id });
|
|
274
|
+
const vm = await this.client.send(new GetMicrovmCommand({ microvmIdentifier: id }), { abortSignal: opts.signal });
|
|
275
|
+
if (vm.state === 'RUNNING' || vm.state === 'SUSPENDED') return this.infoOf(id, vm);
|
|
210
276
|
if (vm.state === 'TERMINATED' || vm.state === 'TERMINATING') {
|
|
211
|
-
throw new CapsuleError(`microVM ${id} died while starting: ${vm.stateReason ?? vm.state}
|
|
277
|
+
throw new CapsuleError(`microVM ${id} died while starting: ${vm.stateReason ?? vm.state}`, undefined, { capsuleId: id });
|
|
278
|
+
}
|
|
279
|
+
if (Date.now() - t0 > timeoutMs) {
|
|
280
|
+
throw new CapsuleError(`microVM ${id} did not run within ${timeoutMs}ms (state ${vm.state})`, undefined, { capsuleId: id });
|
|
212
281
|
}
|
|
213
|
-
if (Date.now() - t0 > timeoutMs) throw new CapsuleError(`timeout waiting for microVM ${id} to run`);
|
|
214
282
|
await new Promise((r) => setTimeout(r, 400));
|
|
215
283
|
}
|
|
216
284
|
}
|