@capsulos/sdk 0.1.2 → 0.3.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/dist/capsule.d.ts +16 -1
- package/dist/capsule.js +65 -17
- package/dist/capsule.js.map +1 -1
- package/dist/client.d.ts +7 -3
- package/dist/client.js +3 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/network.d.ts +7 -0
- package/dist/network.js +37 -0
- package/dist/network.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/capsule.test.ts +48 -0
- package/src/capsule.ts +65 -17
- package/src/client.ts +10 -3
- package/src/index.ts +1 -0
- package/src/network.test.ts +101 -0
- package/src/network.ts +34 -0
- package/src/types.ts +52 -0
package/dist/capsule.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DaemonClient, type ClientOptions } from './client.js';
|
|
2
2
|
import { Ports } from './ports.js';
|
|
3
3
|
import { PtySession } from './pty.js';
|
|
4
|
-
import { type CapsuleSpec, type ExecOptions, type ExecResult, type FileEntry, type Health, type Provider, type PtyOptions } from './types.js';
|
|
4
|
+
import { type CapsuleSpec, type ExecOptions, type ExecResult, type FileEntry, type Health, type Provider, type PtyOptions, type SnapshotResult } from './types.js';
|
|
5
5
|
export interface CapsuleOptions extends ClientOptions {
|
|
6
6
|
/** How long to wait for the daemon to answer /health after creation. */
|
|
7
7
|
readyTimeoutMs?: number;
|
|
@@ -14,6 +14,7 @@ export declare class Capsule {
|
|
|
14
14
|
readonly id: string;
|
|
15
15
|
private readonly provider;
|
|
16
16
|
private readonly client;
|
|
17
|
+
private readonly readyTimeoutMs;
|
|
17
18
|
readonly commands: Commands;
|
|
18
19
|
readonly files: Files;
|
|
19
20
|
readonly pty: Pty;
|
|
@@ -25,6 +26,20 @@ export declare class Capsule {
|
|
|
25
26
|
/** Reconnect to a capsule that already exists (it may be suspended: the first request resumes it). */
|
|
26
27
|
static connect(provider: Provider, id: string, opts?: CapsuleOptions): Promise<Capsule>;
|
|
27
28
|
health(): Promise<Health>;
|
|
29
|
+
/**
|
|
30
|
+
* Freeze the capsule: processes stop, memory stays. Resume with `resume()`
|
|
31
|
+
* (or, on providers with auto-resume, with the next request). Throws when
|
|
32
|
+
* the provider has no explicit pause.
|
|
33
|
+
*/
|
|
34
|
+
pause(): Promise<void>;
|
|
35
|
+
/** Wake a paused capsule and wait until the daemon answers again. */
|
|
36
|
+
resume(): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Save the capsule's disk as a new template under `name`; `Capsule.create`
|
|
39
|
+
* with that template starts from this state. Not every provider can
|
|
40
|
+
* (Lambda MicroVMs cannot image a live VM).
|
|
41
|
+
*/
|
|
42
|
+
snapshot(name: string): Promise<SnapshotResult>;
|
|
28
43
|
/** Put the capsule back in your pocket. */
|
|
29
44
|
kill(): Promise<void>;
|
|
30
45
|
}
|
package/dist/capsule.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The public face of a capsule. `Capsule.create()` asks the provider for a
|
|
2
2
|
// machine and waits until capsulod answers; everything after that goes
|
|
3
3
|
// straight to the daemon.
|
|
4
|
+
import { validateNetworkPolicy } from './network.js';
|
|
4
5
|
import { DaemonClient } from './client.js';
|
|
5
6
|
import { Ports } from './ports.js';
|
|
6
7
|
import { PtySession } from './pty.js';
|
|
@@ -9,15 +10,17 @@ export class Capsule {
|
|
|
9
10
|
id;
|
|
10
11
|
provider;
|
|
11
12
|
client;
|
|
13
|
+
readyTimeoutMs;
|
|
12
14
|
commands;
|
|
13
15
|
files;
|
|
14
16
|
pty;
|
|
15
17
|
/** Servers listening inside the capsule, reachable through the daemon (preview URLs). */
|
|
16
18
|
ports;
|
|
17
|
-
constructor(id, provider, client) {
|
|
19
|
+
constructor(id, provider, client, readyTimeoutMs = 120_000) {
|
|
18
20
|
this.id = id;
|
|
19
21
|
this.provider = provider;
|
|
20
22
|
this.client = client;
|
|
23
|
+
this.readyTimeoutMs = readyTimeoutMs;
|
|
21
24
|
this.commands = new Commands(client);
|
|
22
25
|
this.files = new Files(client);
|
|
23
26
|
this.pty = new Pty(client);
|
|
@@ -25,34 +28,76 @@ export class Capsule {
|
|
|
25
28
|
}
|
|
26
29
|
/** Throw a capsule: create it with the provider and wait until it is ready. */
|
|
27
30
|
static async create(provider, spec, opts = {}) {
|
|
31
|
+
const network = validateNetworkPolicy(spec.network);
|
|
28
32
|
const handle = await provider.create(spec, { timeoutMs: opts.createTimeoutMs, signal: opts.signal });
|
|
29
33
|
const client = new DaemonClient(handle.connection, opts);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
try {
|
|
35
|
+
const health = await waitReady(client, opts.readyTimeoutMs ?? 120_000, opts.signal);
|
|
36
|
+
// Providers deliver mounts and network policy through their boot hook.
|
|
37
|
+
// When that did not happen (no hook, or the hook came without its
|
|
38
|
+
// payload, or the policy failed there), hand it over directly. Either
|
|
39
|
+
// way, a mount that failed or a policy that is not enforced is an error
|
|
40
|
+
// here, not a surprise later.
|
|
41
|
+
let results = health.mounts ?? [];
|
|
42
|
+
let status = health.network;
|
|
43
|
+
const wanted = Object.keys(spec.mounts ?? {});
|
|
44
|
+
const applied = new Set(results.map((r) => r.path));
|
|
45
|
+
const hookMissed = !health.configured || wanted.some((p) => !applied.has(p)) || (network && !status?.enforced);
|
|
46
|
+
if (hookMissed && (spec.mounts || spec.metadata || network)) {
|
|
47
|
+
({ mounts: results, network: status } = await client.configure({ mounts: spec.mounts, metadata: spec.metadata, network }));
|
|
48
|
+
}
|
|
49
|
+
const failed = results.filter((r) => !r.ok);
|
|
50
|
+
if (failed.length) {
|
|
51
|
+
throw new CapsuleError(`mounts failed: ${failed.map((r) => `${r.path} (${r.driver}): ${r.error}`).join('; ')}`);
|
|
52
|
+
}
|
|
53
|
+
if (network && !status?.enforced) {
|
|
54
|
+
throw new CapsuleError(`network policy not enforced: ${status?.error ?? 'the daemon reported no policy (is it 0.3.0 or newer?)'}`);
|
|
55
|
+
}
|
|
41
56
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
catch (err) {
|
|
58
|
+
// A capsule that did not get its spec is not handed out: it goes, and
|
|
59
|
+
// the error says which one it was.
|
|
60
|
+
await provider.terminate(handle.id).catch(() => { });
|
|
61
|
+
const e = err instanceof CapsuleError ? err : new CapsuleError(err instanceof Error ? err.message : String(err));
|
|
62
|
+
throw new CapsuleError(`${e.message} (capsule ${handle.id} terminated)`, e.status, { capsuleId: handle.id });
|
|
45
63
|
}
|
|
46
|
-
return new Capsule(handle.id, provider, client);
|
|
64
|
+
return new Capsule(handle.id, provider, client, opts.readyTimeoutMs);
|
|
47
65
|
}
|
|
48
66
|
/** Reconnect to a capsule that already exists (it may be suspended: the first request resumes it). */
|
|
49
67
|
static async connect(provider, id, opts = {}) {
|
|
50
68
|
const conn = await provider.connect(id);
|
|
51
|
-
return new Capsule(id, provider, new DaemonClient(conn, opts));
|
|
69
|
+
return new Capsule(id, provider, new DaemonClient(conn, opts), opts.readyTimeoutMs);
|
|
52
70
|
}
|
|
53
71
|
health() {
|
|
54
72
|
return this.client.health();
|
|
55
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Freeze the capsule: processes stop, memory stays. Resume with `resume()`
|
|
76
|
+
* (or, on providers with auto-resume, with the next request). Throws when
|
|
77
|
+
* the provider has no explicit pause.
|
|
78
|
+
*/
|
|
79
|
+
async pause() {
|
|
80
|
+
if (!this.provider.pause)
|
|
81
|
+
throw unsupported(this.provider, 'pause');
|
|
82
|
+
await this.provider.pause(this.id);
|
|
83
|
+
}
|
|
84
|
+
/** Wake a paused capsule and wait until the daemon answers again. */
|
|
85
|
+
async resume() {
|
|
86
|
+
if (!this.provider.resume)
|
|
87
|
+
throw unsupported(this.provider, 'resume');
|
|
88
|
+
await this.provider.resume(this.id);
|
|
89
|
+
await waitReady(this.client, this.readyTimeoutMs);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Save the capsule's disk as a new template under `name`; `Capsule.create`
|
|
93
|
+
* with that template starts from this state. Not every provider can
|
|
94
|
+
* (Lambda MicroVMs cannot image a live VM).
|
|
95
|
+
*/
|
|
96
|
+
async snapshot(name) {
|
|
97
|
+
if (!this.provider.snapshot)
|
|
98
|
+
throw unsupported(this.provider, 'snapshot');
|
|
99
|
+
return this.provider.snapshot(this.id, name);
|
|
100
|
+
}
|
|
56
101
|
/** Put the capsule back in your pocket. */
|
|
57
102
|
async kill() {
|
|
58
103
|
await this.provider.terminate(this.id);
|
|
@@ -105,6 +150,9 @@ export class Files {
|
|
|
105
150
|
return this.client.remove(path);
|
|
106
151
|
}
|
|
107
152
|
}
|
|
153
|
+
function unsupported(provider, op) {
|
|
154
|
+
return new CapsuleError(`the ${provider.name} provider does not support ${op}()`);
|
|
155
|
+
}
|
|
108
156
|
async function waitReady(client, timeoutMs, signal) {
|
|
109
157
|
const t0 = Date.now();
|
|
110
158
|
let lastErr;
|
package/dist/capsule.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capsule.js","sourceRoot":"","sources":["../src/capsule.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,uEAAuE;AACvE,0BAA0B;AAC1B,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EACL,YAAY,
|
|
1
|
+
{"version":3,"file":"capsule.js","sourceRoot":"","sources":["../src/capsule.ts"],"names":[],"mappings":"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"}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Connection, type ExecEvent, type ExecOptions, type ExecResult, type FileEntry, type Health, type MountResult, type MountSpec } from './types.js';
|
|
1
|
+
import { type Connection, type ExecEvent, type ExecOptions, type ExecResult, type FileEntry, type Health, type MountResult, type MountSpec, type NetworkPolicy, type NetworkStatus } from './types.js';
|
|
2
2
|
export interface ClientOptions {
|
|
3
3
|
fetch?: typeof fetch;
|
|
4
4
|
requestTimeoutMs?: number;
|
|
@@ -10,11 +10,15 @@ export declare class DaemonClient {
|
|
|
10
10
|
constructor(connection: Connection, opts?: ClientOptions);
|
|
11
11
|
private request;
|
|
12
12
|
health(): Promise<Health>;
|
|
13
|
-
/** Apply the mount table when the provider's boot hook did not deliver
|
|
13
|
+
/** Apply the mount table and network policy when the provider's boot hook did not deliver them. */
|
|
14
14
|
configure(body: {
|
|
15
15
|
mounts?: Record<string, MountSpec>;
|
|
16
16
|
metadata?: Record<string, string>;
|
|
17
|
-
|
|
17
|
+
network?: NetworkPolicy;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
mounts: MountResult[];
|
|
20
|
+
network?: NetworkStatus;
|
|
21
|
+
}>;
|
|
18
22
|
/** Buffered or streamed depending on whether callbacks are given. */
|
|
19
23
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
20
24
|
readFile(path: string): Promise<Uint8Array>;
|
package/dist/client.js
CHANGED
|
@@ -37,7 +37,7 @@ export class DaemonClient {
|
|
|
37
37
|
const res = await this.request('/health', { timeoutMs: 10_000 });
|
|
38
38
|
return (await res.json());
|
|
39
39
|
}
|
|
40
|
-
/** Apply the mount table when the provider's boot hook did not deliver
|
|
40
|
+
/** Apply the mount table and network policy when the provider's boot hook did not deliver them. */
|
|
41
41
|
async configure(body) {
|
|
42
42
|
const res = await this.request('/configure', {
|
|
43
43
|
method: 'POST',
|
|
@@ -45,7 +45,8 @@ export class DaemonClient {
|
|
|
45
45
|
headers: { 'Content-Type': 'application/json' },
|
|
46
46
|
timeoutMs: 60_000,
|
|
47
47
|
});
|
|
48
|
-
|
|
48
|
+
const out = (await res.json());
|
|
49
|
+
return { mounts: out.mounts ?? [], network: out.network };
|
|
49
50
|
}
|
|
50
51
|
/** Buffered or streamed depending on whether callbacks are given. */
|
|
51
52
|
async exec(cmd, opts = {}) {
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,gFAAgF;AAChF,OAAO,EACL,YAAY,
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAEtC,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAErD,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAEtC,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAErD,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NetworkPolicy } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Check a network policy before a capsule is created with it (the daemon
|
|
4
|
+
* applies the same rules; failing here saves a launch). Returns the policy
|
|
5
|
+
* when it restricts something, undefined when it does not.
|
|
6
|
+
*/
|
|
7
|
+
export declare function validateNetworkPolicy(policy: NetworkPolicy | undefined): NetworkPolicy | undefined;
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CapsuleError } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Check a network policy before a capsule is created with it (the daemon
|
|
4
|
+
* applies the same rules; failing here saves a launch). Returns the policy
|
|
5
|
+
* when it restricts something, undefined when it does not.
|
|
6
|
+
*/
|
|
7
|
+
export function validateNetworkPolicy(policy) {
|
|
8
|
+
if (!policy)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (policy.internet !== 'allow' && policy.internet !== 'deny') {
|
|
11
|
+
throw new CapsuleError(`network.internet must be 'allow' or 'deny', got ${JSON.stringify(policy.internet)}`);
|
|
12
|
+
}
|
|
13
|
+
if (policy.internet === 'allow') {
|
|
14
|
+
if (policy.allow?.length)
|
|
15
|
+
throw new CapsuleError("network.allow needs network.internet: 'deny'");
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of policy.allow ?? []) {
|
|
19
|
+
if (!isAllowEntry(entry)) {
|
|
20
|
+
throw new CapsuleError(`bad network.allow entry ${JSON.stringify(entry)} (want host, *.domain or host:port)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return policy;
|
|
24
|
+
}
|
|
25
|
+
const HOST = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
26
|
+
function isAllowEntry(entry) {
|
|
27
|
+
if (typeof entry !== 'string')
|
|
28
|
+
return false;
|
|
29
|
+
const m = /^(.*?)(?::(\d{1,5}))?$/.exec(entry.trim());
|
|
30
|
+
if (!m)
|
|
31
|
+
return false;
|
|
32
|
+
const [, host, port] = m;
|
|
33
|
+
if (port !== undefined && (Number(port) < 1 || Number(port) > 65535))
|
|
34
|
+
return false;
|
|
35
|
+
return host !== undefined && HOST.test(host);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=network.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network.js","sourceRoot":"","sources":["../src/network.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAsB,MAAM,YAAY,CAAC;AAE9D;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAiC;IACrE,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,MAAM,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;QAC9D,MAAM,IAAI,YAAY,CAAC,mDAAmD,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC/G,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAChC,IAAI,MAAM,CAAC,KAAK,EAAE,MAAM;YAAE,MAAM,IAAI,YAAY,CAAC,8CAA8C,CAAC,CAAC;QACjG,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;QACvC,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,YAAY,CAAC,2BAA2B,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAChH,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,IAAI,GAAG,2EAA2E,CAAC;AAEzF,SAAS,YAAY,CAAC,KAAa;IACjC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,MAAM,CAAC,GAAG,wBAAwB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;IACtD,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACrB,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnF,OAAO,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/C,CAAC"}
|
package/dist/types.d.ts
CHANGED
|
@@ -9,6 +9,25 @@ export interface Provider {
|
|
|
9
9
|
terminate(id: string): Promise<void>;
|
|
10
10
|
/** How to reach the daemon of an existing capsule (endpoint + auth headers). */
|
|
11
11
|
connect(id: string): Promise<Connection>;
|
|
12
|
+
/**
|
|
13
|
+
* Freeze the capsule now (memory kept, no CPU, no billing for compute on
|
|
14
|
+
* providers that stop charging). Optional: a provider without it leaves
|
|
15
|
+
* suspension to its idle policy.
|
|
16
|
+
*/
|
|
17
|
+
pause?(id: string): Promise<void>;
|
|
18
|
+
/** Wake a paused capsule and return when the provider reports it running (the daemon may need a moment more). */
|
|
19
|
+
resume?(id: string): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Save the capsule's current disk as a new template, usable in `create()`.
|
|
22
|
+
* Optional: Lambda MicroVMs cannot image a live VM (images are built from
|
|
23
|
+
* a zip), Docker can (`docker commit`).
|
|
24
|
+
*/
|
|
25
|
+
snapshot?(id: string, name: string): Promise<SnapshotResult>;
|
|
26
|
+
}
|
|
27
|
+
export interface SnapshotResult {
|
|
28
|
+
/** The template name to pass to `create()` on the same provider. */
|
|
29
|
+
template: string;
|
|
30
|
+
provider: string;
|
|
12
31
|
}
|
|
13
32
|
export interface CreateOptions {
|
|
14
33
|
/**
|
|
@@ -81,6 +100,7 @@ export interface Health {
|
|
|
81
100
|
/** True once the mount table was applied (by the boot hook or /configure). */
|
|
82
101
|
configured: boolean;
|
|
83
102
|
mounts?: MountResult[];
|
|
103
|
+
network?: NetworkStatus;
|
|
84
104
|
/** The template's long-running process (CAPSULOD_START), when the template declares one. */
|
|
85
105
|
service?: ServiceStatus;
|
|
86
106
|
}
|
|
@@ -92,6 +112,33 @@ export interface ServiceStatus {
|
|
|
92
112
|
lastExitCode?: number;
|
|
93
113
|
startedAt?: string;
|
|
94
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* What the capsule may reach. With `internet: 'deny'` the daemon runs an
|
|
117
|
+
* egress proxy limited to `allow` (HTTP_PROXY/HTTPS_PROXY point at it for
|
|
118
|
+
* every process) and blackholes the default route, so a process that ignores
|
|
119
|
+
* the proxy gets "network unreachable" rather than the internet. Needs
|
|
120
|
+
* CAP_NET_ADMIN in the capsule: Lambda templates built with
|
|
121
|
+
* `--capabilities all`; the Docker provider adds it. Creation fails if the
|
|
122
|
+
* policy cannot be enforced.
|
|
123
|
+
*/
|
|
124
|
+
export interface NetworkPolicy {
|
|
125
|
+
/** 'allow' (default): unrestricted. 'deny': nothing leaves except `allow`. */
|
|
126
|
+
internet: 'allow' | 'deny';
|
|
127
|
+
/** Destinations still reachable with 'deny': 'host', '*.domain' (subdomains only) or 'host:port'. */
|
|
128
|
+
allow?: string[];
|
|
129
|
+
}
|
|
130
|
+
/** The daemon's view of the policy (`health().network`; absent without a policy). */
|
|
131
|
+
export interface NetworkStatus {
|
|
132
|
+
internet: 'allow' | 'deny';
|
|
133
|
+
allow?: string[];
|
|
134
|
+
/** True once the proxy runs and the routes are locked. */
|
|
135
|
+
enforced: boolean;
|
|
136
|
+
/** The egress proxy's address inside the capsule (127.0.0.1:3128). */
|
|
137
|
+
proxy?: string;
|
|
138
|
+
/** Connections refused so far. */
|
|
139
|
+
blocked: number;
|
|
140
|
+
error?: string;
|
|
141
|
+
}
|
|
95
142
|
export interface IdlePolicy {
|
|
96
143
|
/** Suspend after this long without traffic (seconds). */
|
|
97
144
|
suspendAfterSeconds: number;
|
|
@@ -107,6 +154,8 @@ export interface CapsuleSpec {
|
|
|
107
154
|
mounts?: Record<string, MountSpec>;
|
|
108
155
|
/** Opaque per-capsule configuration delivered to the daemon at boot (never secrets). */
|
|
109
156
|
metadata?: Record<string, string>;
|
|
157
|
+
/** Egress policy, enforced inside the capsule by the daemon (default: unrestricted). */
|
|
158
|
+
network?: NetworkPolicy;
|
|
110
159
|
/** Idempotency key: creating twice with the same key returns the same capsule. */
|
|
111
160
|
clientToken?: string;
|
|
112
161
|
}
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,0EAA0E;AAC1E,kEAAkE;AAClE,gFAAgF;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,0EAA0E;AAC1E,kEAAkE;AAClE,gFAAgF;AA4NhF,MAAM,OAAO,YAAa,SAAQ,KAAK;IAM1B;IALX,sGAAsG;IAC7F,SAAS,CAAU;IAE5B,YACE,OAAe,EACN,MAAe,EACxB,OAA+B,EAAE;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,WAAM,GAAN,MAAM,CAAS;QAIxB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,IAAI,CAAC,SAAS;YAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;IACtD,CAAC;CACF"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Capsule } from './capsule.js';
|
|
3
|
+
import type { Provider } from './types.js';
|
|
4
|
+
|
|
5
|
+
const bare: Provider = {
|
|
6
|
+
name: 'bare',
|
|
7
|
+
async create() {
|
|
8
|
+
throw new Error('unused');
|
|
9
|
+
},
|
|
10
|
+
async get() {
|
|
11
|
+
return null;
|
|
12
|
+
},
|
|
13
|
+
async list() {
|
|
14
|
+
return [];
|
|
15
|
+
},
|
|
16
|
+
async terminate() {},
|
|
17
|
+
async connect() {
|
|
18
|
+
return { baseUrl: 'http://capsule', headers: async () => ({}) };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('Capsule optional operations', () => {
|
|
23
|
+
it('names the provider and the operation when it is not supported', async () => {
|
|
24
|
+
const capsule = await Capsule.connect(bare, 'c1');
|
|
25
|
+
await expect(capsule.pause()).rejects.toThrow(/bare provider does not support pause\(\)/);
|
|
26
|
+
await expect(capsule.resume()).rejects.toThrow(/does not support resume\(\)/);
|
|
27
|
+
await expect(capsule.snapshot('x')).rejects.toThrow(/does not support snapshot\(\)/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('delegates pause and snapshot to the provider', async () => {
|
|
31
|
+
const calls: string[] = [];
|
|
32
|
+
const p: Provider = {
|
|
33
|
+
...bare,
|
|
34
|
+
name: 'full',
|
|
35
|
+
async pause(id) {
|
|
36
|
+
calls.push(`pause:${id}`);
|
|
37
|
+
},
|
|
38
|
+
async snapshot(id, name) {
|
|
39
|
+
calls.push(`snapshot:${id}:${name}`);
|
|
40
|
+
return { template: name, provider: 'full' };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const capsule = await Capsule.connect(p, 'c1');
|
|
44
|
+
await capsule.pause();
|
|
45
|
+
expect(await capsule.snapshot('tpl')).toEqual({ template: 'tpl', provider: 'full' });
|
|
46
|
+
expect(calls).toEqual(['pause:c1', 'snapshot:c1:tpl']);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/capsule.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The public face of a capsule. `Capsule.create()` asks the provider for a
|
|
2
2
|
// machine and waits until capsulod answers; everything after that goes
|
|
3
3
|
// straight to the daemon.
|
|
4
|
+
import { validateNetworkPolicy } from './network.js';
|
|
4
5
|
import { DaemonClient, type ClientOptions } from './client.js';
|
|
5
6
|
import { Ports } from './ports.js';
|
|
6
7
|
import { PtySession } from './pty.js';
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type Health,
|
|
14
15
|
type Provider,
|
|
15
16
|
type PtyOptions,
|
|
17
|
+
type SnapshotResult,
|
|
16
18
|
} from './types.js';
|
|
17
19
|
|
|
18
20
|
export interface CapsuleOptions extends ClientOptions {
|
|
@@ -35,6 +37,7 @@ export class Capsule {
|
|
|
35
37
|
readonly id: string,
|
|
36
38
|
private readonly provider: Provider,
|
|
37
39
|
private readonly client: DaemonClient,
|
|
40
|
+
private readonly readyTimeoutMs = 120_000,
|
|
38
41
|
) {
|
|
39
42
|
this.commands = new Commands(client);
|
|
40
43
|
this.files = new Files(client);
|
|
@@ -44,37 +47,78 @@ export class Capsule {
|
|
|
44
47
|
|
|
45
48
|
/** Throw a capsule: create it with the provider and wait until it is ready. */
|
|
46
49
|
static async create(provider: Provider, spec: CapsuleSpec, opts: CapsuleOptions = {}): Promise<Capsule> {
|
|
50
|
+
const network = validateNetworkPolicy(spec.network);
|
|
47
51
|
const handle = await provider.create(spec, { timeoutMs: opts.createTimeoutMs, signal: opts.signal });
|
|
48
52
|
const client = new DaemonClient(handle.connection, opts);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
53
|
+
try {
|
|
54
|
+
const health = await waitReady(client, opts.readyTimeoutMs ?? 120_000, opts.signal);
|
|
55
|
+
// Providers deliver mounts and network policy through their boot hook.
|
|
56
|
+
// When that did not happen (no hook, or the hook came without its
|
|
57
|
+
// payload, or the policy failed there), hand it over directly. Either
|
|
58
|
+
// way, a mount that failed or a policy that is not enforced is an error
|
|
59
|
+
// here, not a surprise later.
|
|
60
|
+
let results = health.mounts ?? [];
|
|
61
|
+
let status = health.network;
|
|
62
|
+
const wanted = Object.keys(spec.mounts ?? {});
|
|
63
|
+
const applied = new Set(results.map((r) => r.path));
|
|
64
|
+
const hookMissed = !health.configured || wanted.some((p) => !applied.has(p)) || (network && !status?.enforced);
|
|
65
|
+
if (hookMissed && (spec.mounts || spec.metadata || network)) {
|
|
66
|
+
({ mounts: results, network: status } = await client.configure({ mounts: spec.mounts, metadata: spec.metadata, network }));
|
|
67
|
+
}
|
|
68
|
+
const failed = results.filter((r) => !r.ok);
|
|
69
|
+
if (failed.length) {
|
|
70
|
+
throw new CapsuleError(`mounts failed: ${failed.map((r) => `${r.path} (${r.driver}): ${r.error}`).join('; ')}`);
|
|
71
|
+
}
|
|
72
|
+
if (network && !status?.enforced) {
|
|
73
|
+
throw new CapsuleError(`network policy not enforced: ${status?.error ?? 'the daemon reported no policy (is it 0.3.0 or newer?)'}`);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// A capsule that did not get its spec is not handed out: it goes, and
|
|
77
|
+
// the error says which one it was.
|
|
78
|
+
await provider.terminate(handle.id).catch(() => {});
|
|
79
|
+
const e = err instanceof CapsuleError ? err : new CapsuleError(err instanceof Error ? err.message : String(err));
|
|
80
|
+
throw new CapsuleError(`${e.message} (capsule ${handle.id} terminated)`, e.status, { capsuleId: handle.id });
|
|
64
81
|
}
|
|
65
|
-
return new Capsule(handle.id, provider, client);
|
|
82
|
+
return new Capsule(handle.id, provider, client, opts.readyTimeoutMs);
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
/** Reconnect to a capsule that already exists (it may be suspended: the first request resumes it). */
|
|
69
86
|
static async connect(provider: Provider, id: string, opts: CapsuleOptions = {}): Promise<Capsule> {
|
|
70
87
|
const conn = await provider.connect(id);
|
|
71
|
-
return new Capsule(id, provider, new DaemonClient(conn, opts));
|
|
88
|
+
return new Capsule(id, provider, new DaemonClient(conn, opts), opts.readyTimeoutMs);
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
health() {
|
|
75
92
|
return this.client.health();
|
|
76
93
|
}
|
|
77
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Freeze the capsule: processes stop, memory stays. Resume with `resume()`
|
|
97
|
+
* (or, on providers with auto-resume, with the next request). Throws when
|
|
98
|
+
* the provider has no explicit pause.
|
|
99
|
+
*/
|
|
100
|
+
async pause(): Promise<void> {
|
|
101
|
+
if (!this.provider.pause) throw unsupported(this.provider, 'pause');
|
|
102
|
+
await this.provider.pause(this.id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Wake a paused capsule and wait until the daemon answers again. */
|
|
106
|
+
async resume(): Promise<void> {
|
|
107
|
+
if (!this.provider.resume) throw unsupported(this.provider, 'resume');
|
|
108
|
+
await this.provider.resume(this.id);
|
|
109
|
+
await waitReady(this.client, this.readyTimeoutMs);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Save the capsule's disk as a new template under `name`; `Capsule.create`
|
|
114
|
+
* with that template starts from this state. Not every provider can
|
|
115
|
+
* (Lambda MicroVMs cannot image a live VM).
|
|
116
|
+
*/
|
|
117
|
+
async snapshot(name: string): Promise<SnapshotResult> {
|
|
118
|
+
if (!this.provider.snapshot) throw unsupported(this.provider, 'snapshot');
|
|
119
|
+
return this.provider.snapshot(this.id, name);
|
|
120
|
+
}
|
|
121
|
+
|
|
78
122
|
/** Put the capsule back in your pocket. */
|
|
79
123
|
async kill(): Promise<void> {
|
|
80
124
|
await this.provider.terminate(this.id);
|
|
@@ -122,6 +166,10 @@ export class Files {
|
|
|
122
166
|
}
|
|
123
167
|
}
|
|
124
168
|
|
|
169
|
+
function unsupported(provider: Provider, op: string): CapsuleError {
|
|
170
|
+
return new CapsuleError(`the ${provider.name} provider does not support ${op}()`);
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
async function waitReady(client: DaemonClient, timeoutMs: number, signal?: AbortSignal): Promise<Health> {
|
|
126
174
|
const t0 = Date.now();
|
|
127
175
|
let lastErr: unknown;
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type Health,
|
|
11
11
|
type MountResult,
|
|
12
12
|
type MountSpec,
|
|
13
|
+
type NetworkPolicy,
|
|
14
|
+
type NetworkStatus,
|
|
13
15
|
} from './types.js';
|
|
14
16
|
|
|
15
17
|
export interface ClientOptions {
|
|
@@ -57,15 +59,20 @@ export class DaemonClient {
|
|
|
57
59
|
return (await res.json()) as Health;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/** Apply the mount table when the provider's boot hook did not deliver
|
|
61
|
-
async configure(body: {
|
|
62
|
+
/** Apply the mount table and network policy when the provider's boot hook did not deliver them. */
|
|
63
|
+
async configure(body: {
|
|
64
|
+
mounts?: Record<string, MountSpec>;
|
|
65
|
+
metadata?: Record<string, string>;
|
|
66
|
+
network?: NetworkPolicy;
|
|
67
|
+
}): Promise<{ mounts: MountResult[]; network?: NetworkStatus }> {
|
|
62
68
|
const res = await this.request('/configure', {
|
|
63
69
|
method: 'POST',
|
|
64
70
|
body: JSON.stringify(body),
|
|
65
71
|
headers: { 'Content-Type': 'application/json' },
|
|
66
72
|
timeoutMs: 60_000,
|
|
67
73
|
});
|
|
68
|
-
|
|
74
|
+
const out = (await res.json()) as { mounts?: MountResult[]; network?: NetworkStatus };
|
|
75
|
+
return { mounts: out.mounts ?? [], network: out.network };
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** Buffered or streamed depending on whether callbacks are given. */
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Capsule } from './capsule.js';
|
|
3
|
+
import { validateNetworkPolicy } from './network.js';
|
|
4
|
+
import type { CapsuleHandle, Provider } from './types.js';
|
|
5
|
+
|
|
6
|
+
describe('validateNetworkPolicy', () => {
|
|
7
|
+
it('accepts hosts, wildcards and host:port, and treats allow-all as no policy', () => {
|
|
8
|
+
expect(validateNetworkPolicy(undefined)).toBeUndefined();
|
|
9
|
+
expect(validateNetworkPolicy({ internet: 'allow' })).toBeUndefined();
|
|
10
|
+
const p = { internet: 'deny' as const, allow: ['api.anthropic.com', '*.npmjs.org', 'db.internal:5432', 'Host.IO'] };
|
|
11
|
+
expect(validateNetworkPolicy(p)).toBe(p);
|
|
12
|
+
expect(validateNetworkPolicy({ internet: 'deny' })).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects what the daemon would reject, before launching anything', () => {
|
|
16
|
+
expect(() => validateNetworkPolicy({ internet: 'maybe' as 'allow' })).toThrow(/must be 'allow' or 'deny'/);
|
|
17
|
+
expect(() => validateNetworkPolicy({ internet: 'allow', allow: ['x.com'] })).toThrow(/needs network.internet: 'deny'/);
|
|
18
|
+
for (const bad of ['https://x.com', '*', 'a.*.com', 'x.com:99999', 'x.com/path', '', 'x com']) {
|
|
19
|
+
expect(() => validateNetworkPolicy({ internet: 'deny', allow: [bad] }), bad).toThrow(/bad network.allow entry/);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function fakeProvider(health: Record<string, unknown>, configure: Record<string, unknown> | Error) {
|
|
25
|
+
const calls: string[] = [];
|
|
26
|
+
const fetchStub: typeof fetch = async (input, init) => {
|
|
27
|
+
const path = new URL(String(input)).pathname;
|
|
28
|
+
calls.push(`${init?.method ?? 'GET'} ${path}`);
|
|
29
|
+
if (path === '/health') return new Response(JSON.stringify({ ok: true, ...health }), { status: 200 });
|
|
30
|
+
if (configure instanceof Error) return new Response(JSON.stringify({ error: configure.message }), { status: 400 });
|
|
31
|
+
return new Response(JSON.stringify(configure), { status: 200 });
|
|
32
|
+
};
|
|
33
|
+
const handle: CapsuleHandle = {
|
|
34
|
+
id: 'c1',
|
|
35
|
+
provider: 'fake',
|
|
36
|
+
state: 'running',
|
|
37
|
+
template: 't',
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
connection: { baseUrl: 'http://capsule', headers: async () => ({}) },
|
|
40
|
+
};
|
|
41
|
+
const provider: Provider = {
|
|
42
|
+
name: 'fake',
|
|
43
|
+
async create() {
|
|
44
|
+
calls.push('create');
|
|
45
|
+
return handle;
|
|
46
|
+
},
|
|
47
|
+
async get() {
|
|
48
|
+
return null;
|
|
49
|
+
},
|
|
50
|
+
async list() {
|
|
51
|
+
return [];
|
|
52
|
+
},
|
|
53
|
+
async terminate(id) {
|
|
54
|
+
calls.push(`terminate ${id}`);
|
|
55
|
+
},
|
|
56
|
+
async connect() {
|
|
57
|
+
return handle.connection;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { provider, calls, fetch: fetchStub };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Capsule.create with a network policy', () => {
|
|
64
|
+
const spec = { template: 't', network: { internet: 'deny' as const, allow: ['api.anthropic.com'] } };
|
|
65
|
+
|
|
66
|
+
it('is satisfied when the boot hook already enforced it', async () => {
|
|
67
|
+
const { provider, calls, fetch } = fakeProvider({ configured: true, network: { internet: 'deny', enforced: true, blocked: 0 } }, {});
|
|
68
|
+
const capsule = await Capsule.create(provider, spec, { fetch });
|
|
69
|
+
expect(capsule.id).toBe('c1');
|
|
70
|
+
expect(calls).toEqual(['create', 'GET /health']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('hands the policy over through /configure when the hook did not apply it', async () => {
|
|
74
|
+
const { provider, calls, fetch } = fakeProvider(
|
|
75
|
+
{ configured: false },
|
|
76
|
+
{ ok: true, mounts: [], network: { internet: 'deny', enforced: true, blocked: 0 } },
|
|
77
|
+
);
|
|
78
|
+
await Capsule.create(provider, spec, { fetch });
|
|
79
|
+
expect(calls).toEqual(['create', 'GET /health', 'POST /configure']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('terminates the capsule and says why when the policy cannot be enforced', async () => {
|
|
83
|
+
const { provider, calls, fetch } = fakeProvider(
|
|
84
|
+
{ configured: true, network: { internet: 'deny', enforced: false, blocked: 0, error: 'network policy needs CAP_NET_ADMIN' } },
|
|
85
|
+
new Error('network policy needs CAP_NET_ADMIN: build the template with --capabilities all'),
|
|
86
|
+
);
|
|
87
|
+
const err = (await Capsule.create(provider, spec, { fetch }).catch((e: unknown) => e)) as Error & { capsuleId?: string };
|
|
88
|
+
expect(err.message).toMatch(/CAP_NET_ADMIN/);
|
|
89
|
+
expect(err.message).toMatch(/capsule c1 terminated/);
|
|
90
|
+
expect(err.capsuleId).toBe('c1');
|
|
91
|
+
expect(calls.at(-1)).toBe('terminate c1');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a bad policy before creating anything', async () => {
|
|
95
|
+
const { provider, calls, fetch } = fakeProvider({}, {});
|
|
96
|
+
await expect(Capsule.create(provider, { template: 't', network: { internet: 'deny', allow: ['*'] } }, { fetch })).rejects.toThrow(
|
|
97
|
+
/bad network.allow entry/,
|
|
98
|
+
);
|
|
99
|
+
expect(calls).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/network.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CapsuleError, type NetworkPolicy } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check a network policy before a capsule is created with it (the daemon
|
|
5
|
+
* applies the same rules; failing here saves a launch). Returns the policy
|
|
6
|
+
* when it restricts something, undefined when it does not.
|
|
7
|
+
*/
|
|
8
|
+
export function validateNetworkPolicy(policy: NetworkPolicy | undefined): NetworkPolicy | undefined {
|
|
9
|
+
if (!policy) return undefined;
|
|
10
|
+
if (policy.internet !== 'allow' && policy.internet !== 'deny') {
|
|
11
|
+
throw new CapsuleError(`network.internet must be 'allow' or 'deny', got ${JSON.stringify(policy.internet)}`);
|
|
12
|
+
}
|
|
13
|
+
if (policy.internet === 'allow') {
|
|
14
|
+
if (policy.allow?.length) throw new CapsuleError("network.allow needs network.internet: 'deny'");
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of policy.allow ?? []) {
|
|
18
|
+
if (!isAllowEntry(entry)) {
|
|
19
|
+
throw new CapsuleError(`bad network.allow entry ${JSON.stringify(entry)} (want host, *.domain or host:port)`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return policy;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOST = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
26
|
+
|
|
27
|
+
function isAllowEntry(entry: string): boolean {
|
|
28
|
+
if (typeof entry !== 'string') return false;
|
|
29
|
+
const m = /^(.*?)(?::(\d{1,5}))?$/.exec(entry.trim());
|
|
30
|
+
if (!m) return false;
|
|
31
|
+
const [, host, port] = m;
|
|
32
|
+
if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) return false;
|
|
33
|
+
return host !== undefined && HOST.test(host);
|
|
34
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -14,6 +14,26 @@ export interface Provider {
|
|
|
14
14
|
terminate(id: string): Promise<void>;
|
|
15
15
|
/** How to reach the daemon of an existing capsule (endpoint + auth headers). */
|
|
16
16
|
connect(id: string): Promise<Connection>;
|
|
17
|
+
/**
|
|
18
|
+
* Freeze the capsule now (memory kept, no CPU, no billing for compute on
|
|
19
|
+
* providers that stop charging). Optional: a provider without it leaves
|
|
20
|
+
* suspension to its idle policy.
|
|
21
|
+
*/
|
|
22
|
+
pause?(id: string): Promise<void>;
|
|
23
|
+
/** Wake a paused capsule and return when the provider reports it running (the daemon may need a moment more). */
|
|
24
|
+
resume?(id: string): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Save the capsule's current disk as a new template, usable in `create()`.
|
|
27
|
+
* Optional: Lambda MicroVMs cannot image a live VM (images are built from
|
|
28
|
+
* a zip), Docker can (`docker commit`).
|
|
29
|
+
*/
|
|
30
|
+
snapshot?(id: string, name: string): Promise<SnapshotResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SnapshotResult {
|
|
34
|
+
/** The template name to pass to `create()` on the same provider. */
|
|
35
|
+
template: string;
|
|
36
|
+
provider: string;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
export interface CreateOptions {
|
|
@@ -85,6 +105,7 @@ export interface Health {
|
|
|
85
105
|
/** True once the mount table was applied (by the boot hook or /configure). */
|
|
86
106
|
configured: boolean;
|
|
87
107
|
mounts?: MountResult[];
|
|
108
|
+
network?: NetworkStatus;
|
|
88
109
|
/** The template's long-running process (CAPSULOD_START), when the template declares one. */
|
|
89
110
|
service?: ServiceStatus;
|
|
90
111
|
}
|
|
@@ -98,6 +119,35 @@ export interface ServiceStatus {
|
|
|
98
119
|
startedAt?: string;
|
|
99
120
|
}
|
|
100
121
|
|
|
122
|
+
/**
|
|
123
|
+
* What the capsule may reach. With `internet: 'deny'` the daemon runs an
|
|
124
|
+
* egress proxy limited to `allow` (HTTP_PROXY/HTTPS_PROXY point at it for
|
|
125
|
+
* every process) and blackholes the default route, so a process that ignores
|
|
126
|
+
* the proxy gets "network unreachable" rather than the internet. Needs
|
|
127
|
+
* CAP_NET_ADMIN in the capsule: Lambda templates built with
|
|
128
|
+
* `--capabilities all`; the Docker provider adds it. Creation fails if the
|
|
129
|
+
* policy cannot be enforced.
|
|
130
|
+
*/
|
|
131
|
+
export interface NetworkPolicy {
|
|
132
|
+
/** 'allow' (default): unrestricted. 'deny': nothing leaves except `allow`. */
|
|
133
|
+
internet: 'allow' | 'deny';
|
|
134
|
+
/** Destinations still reachable with 'deny': 'host', '*.domain' (subdomains only) or 'host:port'. */
|
|
135
|
+
allow?: string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The daemon's view of the policy (`health().network`; absent without a policy). */
|
|
139
|
+
export interface NetworkStatus {
|
|
140
|
+
internet: 'allow' | 'deny';
|
|
141
|
+
allow?: string[];
|
|
142
|
+
/** True once the proxy runs and the routes are locked. */
|
|
143
|
+
enforced: boolean;
|
|
144
|
+
/** The egress proxy's address inside the capsule (127.0.0.1:3128). */
|
|
145
|
+
proxy?: string;
|
|
146
|
+
/** Connections refused so far. */
|
|
147
|
+
blocked: number;
|
|
148
|
+
error?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
101
151
|
export interface IdlePolicy {
|
|
102
152
|
/** Suspend after this long without traffic (seconds). */
|
|
103
153
|
suspendAfterSeconds: number;
|
|
@@ -114,6 +164,8 @@ export interface CapsuleSpec {
|
|
|
114
164
|
mounts?: Record<string, MountSpec>;
|
|
115
165
|
/** Opaque per-capsule configuration delivered to the daemon at boot (never secrets). */
|
|
116
166
|
metadata?: Record<string, string>;
|
|
167
|
+
/** Egress policy, enforced inside the capsule by the daemon (default: unrestricted). */
|
|
168
|
+
network?: NetworkPolicy;
|
|
117
169
|
/** Idempotency key: creating twice with the same key returns the same capsule. */
|
|
118
170
|
clientToken?: string;
|
|
119
171
|
}
|