@larkup/sandbox 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -4
- package/CHANGELOG.md +12 -0
- package/dist/docker-runner.js +35 -21
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/local-runner.d.ts +5 -0
- package/dist/local-runner.js +176 -0
- package/dist/mime.d.ts +1 -0
- package/dist/mime.js +16 -0
- package/dist/providers/browserbase.d.ts +12 -0
- package/dist/providers/browserbase.js +61 -0
- package/dist/providers/cloudflare.d.ts +5 -0
- package/dist/providers/cloudflare.js +55 -0
- package/dist/providers/daytona.d.ts +12 -0
- package/dist/providers/daytona.js +84 -0
- package/dist/providers/e2b.d.ts +9 -0
- package/dist/providers/e2b.js +70 -0
- package/dist/providers/flyio.d.ts +9 -0
- package/dist/providers/flyio.js +98 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.js +26 -0
- package/dist/providers/modal.d.ts +8 -0
- package/dist/providers/modal.js +96 -0
- package/dist/providers/northflank.d.ts +9 -0
- package/dist/providers/northflank.js +122 -0
- package/dist/providers/vercel.d.ts +9 -0
- package/dist/providers/vercel.js +79 -0
- package/dist/providers/webcontainers.d.ts +13 -0
- package/dist/providers/webcontainers.js +28 -0
- package/dist/registry.d.ts +20 -0
- package/dist/registry.js +281 -0
- package/dist/remote-exec.d.ts +42 -0
- package/dist/remote-exec.js +144 -0
- package/dist/sandbox-manager.d.ts +5 -2
- package/dist/sandbox-manager.js +67 -46
- package/dist/types.d.ts +58 -9
- package/dist/types.js +1 -1
- package/package.json +23 -6
- package/src/docker-runner.ts +39 -27
- package/src/index.ts +11 -2
- package/src/local-runner.test.ts +22 -0
- package/src/local-runner.ts +199 -0
- package/src/mime.ts +17 -0
- package/src/providers/browserbase.test.ts +55 -0
- package/src/providers/browserbase.ts +88 -0
- package/src/providers/cloudflare.test.ts +63 -0
- package/src/providers/cloudflare.ts +72 -0
- package/src/providers/daytona.test.ts +60 -0
- package/src/providers/daytona.ts +144 -0
- package/src/providers/e2b.test.ts +100 -0
- package/src/providers/e2b.ts +115 -0
- package/src/providers/flyio.test.ts +69 -0
- package/src/providers/flyio.ts +148 -0
- package/src/providers/index.ts +42 -0
- package/src/providers/modal.test.ts +107 -0
- package/src/providers/modal.ts +167 -0
- package/src/providers/northflank.test.ts +110 -0
- package/src/providers/northflank.ts +199 -0
- package/src/providers/vercel.test.ts +84 -0
- package/src/providers/vercel.ts +143 -0
- package/src/providers/webcontainers.test.ts +21 -0
- package/src/providers/webcontainers.ts +36 -0
- package/src/registry.test.ts +84 -0
- package/src/registry.ts +308 -0
- package/src/remote-exec.test.ts +155 -0
- package/src/remote-exec.ts +190 -0
- package/src/sandbox-manager.test.ts +74 -0
- package/src/sandbox-manager.ts +71 -51
- package/src/types.ts +81 -23
- package/tsconfig.build.json +4 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const execMock = vi.fn();
|
|
4
|
+
const deleteMock = vi.fn();
|
|
5
|
+
const createSpriteMock = vi.fn();
|
|
6
|
+
let listSpritesMock: ReturnType<typeof vi.fn> | undefined;
|
|
7
|
+
|
|
8
|
+
vi.mock('@fly/sprites', () => ({
|
|
9
|
+
SpritesClient: vi.fn().mockImplementation(function () {
|
|
10
|
+
return { createSprite: createSpriteMock, listSprites: listSpritesMock };
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { flyioAdapter } = await import('./flyio.js');
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
execMock.mockReset();
|
|
18
|
+
deleteMock.mockReset();
|
|
19
|
+
createSpriteMock.mockReset();
|
|
20
|
+
listSpritesMock = undefined;
|
|
21
|
+
createSpriteMock.mockResolvedValue({ exec: execMock, delete: deleteMock });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('flyioAdapter.verifyCredentials', () => {
|
|
25
|
+
it('requires a token', async () => {
|
|
26
|
+
await expect(flyioAdapter.verifyCredentials({})).rejects.toThrow('Fly Sprites token is required.');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('falls back to a throwaway sprite when listSprites is not exposed', async () => {
|
|
30
|
+
await flyioAdapter.verifyCredentials({ token: 't' });
|
|
31
|
+
expect(createSpriteMock).toHaveBeenCalledTimes(1);
|
|
32
|
+
expect(deleteMock).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('flyioAdapter.execute — ExecError handling', () => {
|
|
37
|
+
it('reports exitCode 0 on a plain successful exec', async () => {
|
|
38
|
+
execMock.mockResolvedValue({ stdout: 'ok\n', stderr: '' });
|
|
39
|
+
const result = await flyioAdapter.execute({ code: 'print(1)', language: 'python' }, { token: 't' });
|
|
40
|
+
expect(result.exitCode).toBe(0);
|
|
41
|
+
expect(result.stdout).toBe('ok\n');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('extracts exitCode/stdout/stderr from a thrown ExecError', async () => {
|
|
45
|
+
const execError = Object.assign(new Error('non-zero exit'), {
|
|
46
|
+
exitCode: 7,
|
|
47
|
+
stdout: 'partial\n',
|
|
48
|
+
stderr: 'failure detail',
|
|
49
|
+
});
|
|
50
|
+
execMock.mockRejectedValue(execError);
|
|
51
|
+
|
|
52
|
+
const result = await flyioAdapter.execute({ code: 'exit(7)', language: 'python' }, { token: 't' });
|
|
53
|
+
expect(result.exitCode).toBe(7);
|
|
54
|
+
expect(result.stderr).toBe('failure detail');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rethrows and produces a failed result for errors without an exitCode', async () => {
|
|
58
|
+
execMock.mockRejectedValue(new Error('network down'));
|
|
59
|
+
const result = await flyioAdapter.execute({ code: 'x=1', language: 'python' }, { token: 't' });
|
|
60
|
+
expect(result.exitCode).toBe(1);
|
|
61
|
+
expect(result.stderr).toContain('network down');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('always deletes the sprite afterward', async () => {
|
|
65
|
+
execMock.mockResolvedValue({ stdout: '', stderr: '' });
|
|
66
|
+
await flyioAdapter.execute({ code: 'x=1', language: 'python' }, { token: 't' });
|
|
67
|
+
expect(deleteMock).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Sprites adapter — https://fly.io/sprites/
|
|
3
|
+
*
|
|
4
|
+
* The `@fly/sprites` SDK is very early (0.2.x) and its docs don't confirm a
|
|
5
|
+
* dedicated whoami/list call. `verifyCredentials` falls back to creating and
|
|
6
|
+
* immediately deleting a throwaway sprite if no lighter check is exposed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import type {
|
|
11
|
+
SandboxHealthCheck,
|
|
12
|
+
ExecutionRequest,
|
|
13
|
+
ExecutionResult,
|
|
14
|
+
SandboxProviderAdapter,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_TIMEOUT_MS,
|
|
18
|
+
failedResult,
|
|
19
|
+
runScriptedExecution,
|
|
20
|
+
safeCleanup,
|
|
21
|
+
withTimeout,
|
|
22
|
+
} from '../remote-exec.js';
|
|
23
|
+
|
|
24
|
+
interface Sprite {
|
|
25
|
+
exec(script: string): Promise<{ stdout?: string; stderr?: string }>;
|
|
26
|
+
delete(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SpritesClientInstance {
|
|
30
|
+
createSprite(name: string): Promise<Sprite>;
|
|
31
|
+
listSprites?(): Promise<unknown[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SpritesModule {
|
|
35
|
+
SpritesClient: new (token: string) => SpritesClientInstance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SpriteExecError extends Error {
|
|
39
|
+
exitCode?: number;
|
|
40
|
+
stdout?: string;
|
|
41
|
+
stderr?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadSdk(): Promise<SpritesModule> {
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
return (await import(/* webpackIgnore: true */ '@fly/sprites')) as unknown as SpritesModule;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireToken(credentials: Record<string, string>): string {
|
|
50
|
+
const token = credentials.token?.trim();
|
|
51
|
+
if (!token) throw new Error('Fly Sprites token is required.');
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
|
|
56
|
+
const token = requireToken(credentials);
|
|
57
|
+
const { SpritesClient } = await loadSdk();
|
|
58
|
+
const client = new SpritesClient(token);
|
|
59
|
+
if (typeof client.listSprites === 'function') {
|
|
60
|
+
await client.listSprites();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const sprite = await client.createSprite(`larkup-verify-${randomUUID().slice(0, 8)}`);
|
|
64
|
+
await safeCleanup(() => sprite.delete());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
|
|
68
|
+
if (!credentials.token?.trim()) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'missing-credentials',
|
|
71
|
+
backend: 'flyio',
|
|
72
|
+
error: 'Fly Sprites token is required.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await verifyCredentials(credentials);
|
|
77
|
+
return { status: 'ready', backend: 'flyio' };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'error',
|
|
81
|
+
backend: 'flyio',
|
|
82
|
+
error: err instanceof Error ? err.message : 'Fly Sprites health check failed.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function execute(
|
|
88
|
+
request: ExecutionRequest,
|
|
89
|
+
credentials: Record<string, string>,
|
|
90
|
+
): Promise<ExecutionResult> {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
let token: string;
|
|
93
|
+
try {
|
|
94
|
+
token = requireToken(credentials);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return failedResult(
|
|
97
|
+
err instanceof Error ? err.message : 'Missing Fly Sprites token.',
|
|
98
|
+
startTime,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
103
|
+
let sprite: Sprite | undefined;
|
|
104
|
+
try {
|
|
105
|
+
const { SpritesClient } = await loadSdk();
|
|
106
|
+
const client = new SpritesClient(token);
|
|
107
|
+
sprite = await withTimeout(
|
|
108
|
+
client.createSprite(`larkup-${randomUUID().slice(0, 8)}`),
|
|
109
|
+
timeoutMs + 30_000,
|
|
110
|
+
'Fly Sprite create',
|
|
111
|
+
);
|
|
112
|
+
const activeSprite = sprite;
|
|
113
|
+
return await withTimeout(
|
|
114
|
+
runScriptedExecution(async (script) => {
|
|
115
|
+
try {
|
|
116
|
+
const result = await activeSprite.exec(script);
|
|
117
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: 0 };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const execErr = err as SpriteExecError;
|
|
120
|
+
if (execErr && typeof execErr.exitCode === 'number') {
|
|
121
|
+
return {
|
|
122
|
+
stdout: execErr.stdout ?? '',
|
|
123
|
+
stderr: execErr.stderr ?? execErr.message,
|
|
124
|
+
exitCode: execErr.exitCode,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}, request),
|
|
130
|
+
timeoutMs + 15_000,
|
|
131
|
+
'Fly Sprite execution',
|
|
132
|
+
);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return failedResult(
|
|
135
|
+
err instanceof Error ? err.message : 'Fly Sprites execution failed.',
|
|
136
|
+
startTime,
|
|
137
|
+
);
|
|
138
|
+
} finally {
|
|
139
|
+
if (sprite) await safeCleanup(() => sprite!.delete());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const flyioAdapter: SandboxProviderAdapter = {
|
|
144
|
+
id: 'flyio',
|
|
145
|
+
verifyCredentials,
|
|
146
|
+
healthCheck,
|
|
147
|
+
execute,
|
|
148
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SandboxBackend, SandboxProviderAdapter } from '../types.js';
|
|
2
|
+
import { e2bAdapter } from './e2b.js';
|
|
3
|
+
import { vercelAdapter } from './vercel.js';
|
|
4
|
+
import { modalAdapter } from './modal.js';
|
|
5
|
+
import { daytonaAdapter } from './daytona.js';
|
|
6
|
+
import { browserbaseAdapter } from './browserbase.js';
|
|
7
|
+
import { flyioAdapter } from './flyio.js';
|
|
8
|
+
import { northflankAdapter } from './northflank.js';
|
|
9
|
+
import { cloudflareAdapter } from './cloudflare.js';
|
|
10
|
+
import { webcontainersAdapter } from './webcontainers.js';
|
|
11
|
+
|
|
12
|
+
export const SANDBOX_PROVIDER_ADAPTERS: Record<
|
|
13
|
+
Exclude<SandboxBackend, 'local' | 'docker' | 'custom'>,
|
|
14
|
+
SandboxProviderAdapter
|
|
15
|
+
> = {
|
|
16
|
+
e2b: e2bAdapter,
|
|
17
|
+
vercel: vercelAdapter,
|
|
18
|
+
modal: modalAdapter,
|
|
19
|
+
daytona: daytonaAdapter,
|
|
20
|
+
browserbase: browserbaseAdapter,
|
|
21
|
+
flyio: flyioAdapter,
|
|
22
|
+
northflank: northflankAdapter,
|
|
23
|
+
cloudflare: cloudflareAdapter,
|
|
24
|
+
webcontainers: webcontainersAdapter,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function getSandboxProviderAdapter(id: SandboxBackend): SandboxProviderAdapter | undefined {
|
|
28
|
+
if (id === 'local' || id === 'docker' || id === 'custom') return undefined;
|
|
29
|
+
return SANDBOX_PROVIDER_ADAPTERS[id];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
e2bAdapter,
|
|
34
|
+
vercelAdapter,
|
|
35
|
+
modalAdapter,
|
|
36
|
+
daytonaAdapter,
|
|
37
|
+
browserbaseAdapter,
|
|
38
|
+
flyioAdapter,
|
|
39
|
+
northflankAdapter,
|
|
40
|
+
cloudflareAdapter,
|
|
41
|
+
webcontainersAdapter,
|
|
42
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const clientConstructorMock = vi.fn();
|
|
4
|
+
const imageBuilderVersionMock = vi.fn();
|
|
5
|
+
const appFromNameMock = vi.fn();
|
|
6
|
+
const imageFromRegistryMock = vi.fn();
|
|
7
|
+
const sandboxCreateMock = vi.fn();
|
|
8
|
+
const execMock = vi.fn();
|
|
9
|
+
const terminateMock = vi.fn();
|
|
10
|
+
const closeMock = vi.fn();
|
|
11
|
+
const stdoutReadMock = vi.fn();
|
|
12
|
+
const stderrReadMock = vi.fn();
|
|
13
|
+
const waitMock = vi.fn();
|
|
14
|
+
|
|
15
|
+
vi.mock('modal', () => ({
|
|
16
|
+
ModalClient: class {
|
|
17
|
+
apps = { fromName: appFromNameMock };
|
|
18
|
+
images = { fromRegistry: imageFromRegistryMock };
|
|
19
|
+
sandboxes = { create: sandboxCreateMock };
|
|
20
|
+
getImageBuilderVersion = imageBuilderVersionMock;
|
|
21
|
+
close = closeMock;
|
|
22
|
+
|
|
23
|
+
constructor(options: unknown) {
|
|
24
|
+
clientConstructorMock(options);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { modalAdapter } = await import('./modal.js');
|
|
30
|
+
|
|
31
|
+
const fields = { tokenId: 'ak-123', tokenSecret: 'as-456' };
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
clientConstructorMock.mockReset();
|
|
35
|
+
imageBuilderVersionMock.mockReset();
|
|
36
|
+
appFromNameMock.mockReset();
|
|
37
|
+
imageFromRegistryMock.mockReset();
|
|
38
|
+
sandboxCreateMock.mockReset();
|
|
39
|
+
execMock.mockReset();
|
|
40
|
+
terminateMock.mockReset();
|
|
41
|
+
closeMock.mockReset();
|
|
42
|
+
stdoutReadMock.mockReset();
|
|
43
|
+
stderrReadMock.mockReset();
|
|
44
|
+
waitMock.mockReset();
|
|
45
|
+
|
|
46
|
+
imageBuilderVersionMock.mockResolvedValue('2026.1');
|
|
47
|
+
appFromNameMock.mockResolvedValue({ appId: 'ap-123' });
|
|
48
|
+
imageFromRegistryMock.mockReturnValue({ imageId: 'im-123' });
|
|
49
|
+
sandboxCreateMock.mockResolvedValue({ exec: execMock, terminate: terminateMock });
|
|
50
|
+
stdoutReadMock.mockResolvedValue('done\n');
|
|
51
|
+
stderrReadMock.mockResolvedValue('');
|
|
52
|
+
waitMock.mockResolvedValue(0);
|
|
53
|
+
execMock.mockResolvedValue({
|
|
54
|
+
stdout: { readText: stdoutReadMock },
|
|
55
|
+
stderr: { readText: stderrReadMock },
|
|
56
|
+
wait: waitMock,
|
|
57
|
+
});
|
|
58
|
+
terminateMock.mockResolvedValue(undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('modalAdapter', () => {
|
|
62
|
+
it('rejects immediately when either token field is missing', async () => {
|
|
63
|
+
await expect(
|
|
64
|
+
modalAdapter.execute({ code: 'x=1', language: 'python' }, { tokenId: 'ak-1' }),
|
|
65
|
+
).resolves.toMatchObject({ exitCode: 1 });
|
|
66
|
+
expect(clientConstructorMock).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('verifies credentials through the current Modal client API', async () => {
|
|
70
|
+
await expect(modalAdapter.healthCheck(fields)).resolves.toMatchObject({ status: 'ready' });
|
|
71
|
+
expect(clientConstructorMock).toHaveBeenCalledWith(fields);
|
|
72
|
+
expect(imageBuilderVersionMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(closeMock).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('creates a sandbox through the client with the runtime image for the request', async () => {
|
|
77
|
+
await modalAdapter.execute({ code: 'print(1)', language: 'python', timeout: 5_000 }, fields);
|
|
78
|
+
|
|
79
|
+
expect(appFromNameMock).toHaveBeenCalledWith('larkup-sandbox', { createIfMissing: true });
|
|
80
|
+
expect(imageFromRegistryMock).toHaveBeenCalledWith('python:3.13-slim');
|
|
81
|
+
expect(sandboxCreateMock).toHaveBeenCalledWith(
|
|
82
|
+
{ appId: 'ap-123' },
|
|
83
|
+
{ imageId: 'im-123' },
|
|
84
|
+
{ timeoutMs: 65_000, idleTimeoutMs: 35_000 },
|
|
85
|
+
);
|
|
86
|
+
expect(execMock).toHaveBeenCalledWith(expect.arrayContaining(['bash', '-lc']), {
|
|
87
|
+
timeoutMs: 5_000,
|
|
88
|
+
});
|
|
89
|
+
expect(terminateMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(closeMock).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns the command process exit code and output', async () => {
|
|
94
|
+
stdoutReadMock.mockResolvedValue('result\n');
|
|
95
|
+
stderrReadMock.mockResolvedValue('warning\n');
|
|
96
|
+
waitMock.mockResolvedValue(2);
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
modalAdapter.execute({ code: 'x=1', language: 'typescript' }, fields),
|
|
100
|
+
).resolves.toMatchObject({
|
|
101
|
+
stdout: 'result\n',
|
|
102
|
+
stderr: 'warning\n',
|
|
103
|
+
exitCode: 2,
|
|
104
|
+
});
|
|
105
|
+
expect(imageFromRegistryMock).toHaveBeenCalledWith('node:22-bookworm-slim');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal adapter — https://modal.com/docs/guide/sandboxes
|
|
3
|
+
*
|
|
4
|
+
* The Modal JavaScript SDK is client-based: create a ModalClient with the
|
|
5
|
+
* configured credentials, then create sandboxes through `modal.sandboxes`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SandboxHealthCheck,
|
|
10
|
+
ExecutionRequest,
|
|
11
|
+
ExecutionResult,
|
|
12
|
+
SandboxProviderAdapter,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_TIMEOUT_MS,
|
|
16
|
+
failedResult,
|
|
17
|
+
runScriptedExecution,
|
|
18
|
+
safeCleanup,
|
|
19
|
+
withTimeout,
|
|
20
|
+
} from '../remote-exec.js';
|
|
21
|
+
|
|
22
|
+
interface ModalProcess {
|
|
23
|
+
stdout: { readText(): Promise<string> };
|
|
24
|
+
stderr: { readText(): Promise<string> };
|
|
25
|
+
wait(): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ModalSandbox {
|
|
29
|
+
exec(command: string[], params?: { timeoutMs?: number }): Promise<ModalProcess>;
|
|
30
|
+
terminate(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ModalClient {
|
|
34
|
+
apps: {
|
|
35
|
+
fromName(name: string, options?: { createIfMissing?: boolean }): Promise<unknown>;
|
|
36
|
+
};
|
|
37
|
+
images: {
|
|
38
|
+
fromRegistry(name: string): unknown;
|
|
39
|
+
};
|
|
40
|
+
sandboxes: {
|
|
41
|
+
create(
|
|
42
|
+
app: unknown,
|
|
43
|
+
image: unknown,
|
|
44
|
+
options?: { timeoutMs?: number; idleTimeoutMs?: number },
|
|
45
|
+
): Promise<ModalSandbox>;
|
|
46
|
+
};
|
|
47
|
+
getImageBuilderVersion(): Promise<string>;
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ModalModule {
|
|
52
|
+
ModalClient: new (options: { tokenId: string; tokenSecret: string }) => ModalClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadSdk(): Promise<ModalModule> {
|
|
56
|
+
return (await import('modal')) as unknown as ModalModule;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireFields(credentials: Record<string, string>): {
|
|
60
|
+
tokenId: string;
|
|
61
|
+
tokenSecret: string;
|
|
62
|
+
} {
|
|
63
|
+
const tokenId = credentials.tokenId?.trim();
|
|
64
|
+
const tokenSecret = credentials.tokenSecret?.trim();
|
|
65
|
+
if (!tokenId || !tokenSecret)
|
|
66
|
+
throw new Error('Modal token ID and token secret are both required.');
|
|
67
|
+
return { tokenId, tokenSecret };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createClient(tokenId: string, tokenSecret: string): Promise<ModalClient> {
|
|
71
|
+
const { ModalClient } = await loadSdk();
|
|
72
|
+
return new ModalClient({ tokenId, tokenSecret });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
|
|
76
|
+
const { tokenId, tokenSecret } = requireFields(credentials);
|
|
77
|
+
const modal = await createClient(tokenId, tokenSecret);
|
|
78
|
+
try {
|
|
79
|
+
await withTimeout(modal.getImageBuilderVersion(), 20_000, 'Modal credential check');
|
|
80
|
+
} finally {
|
|
81
|
+
modal.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
|
|
86
|
+
if (!credentials.tokenId?.trim() || !credentials.tokenSecret?.trim()) {
|
|
87
|
+
return {
|
|
88
|
+
status: 'missing-credentials',
|
|
89
|
+
backend: 'modal',
|
|
90
|
+
error: 'Modal token ID and secret are required.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await verifyCredentials(credentials);
|
|
95
|
+
return { status: 'ready', backend: 'modal' };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return {
|
|
98
|
+
status: 'error',
|
|
99
|
+
backend: 'modal',
|
|
100
|
+
error: err instanceof Error ? err.message : 'Modal health check failed.',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function execute(
|
|
106
|
+
request: ExecutionRequest,
|
|
107
|
+
credentials: Record<string, string>,
|
|
108
|
+
): Promise<ExecutionResult> {
|
|
109
|
+
const startTime = Date.now();
|
|
110
|
+
let fields: { tokenId: string; tokenSecret: string };
|
|
111
|
+
try {
|
|
112
|
+
fields = requireFields(credentials);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return failedResult(
|
|
115
|
+
err instanceof Error ? err.message : 'Missing Modal credentials.',
|
|
116
|
+
startTime,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
121
|
+
const modal = await createClient(fields.tokenId, fields.tokenSecret);
|
|
122
|
+
let sandbox: ModalSandbox | undefined;
|
|
123
|
+
try {
|
|
124
|
+
const app = await withTimeout(
|
|
125
|
+
modal.apps.fromName('larkup-sandbox', { createIfMissing: true }),
|
|
126
|
+
timeoutMs + 15_000,
|
|
127
|
+
'Modal app setup',
|
|
128
|
+
);
|
|
129
|
+
const image = modal.images.fromRegistry(
|
|
130
|
+
request.language === 'python' ? 'python:3.13-slim' : 'node:22-bookworm-slim',
|
|
131
|
+
);
|
|
132
|
+
sandbox = await withTimeout(
|
|
133
|
+
modal.sandboxes.create(app, image, {
|
|
134
|
+
timeoutMs: timeoutMs + 60_000,
|
|
135
|
+
idleTimeoutMs: timeoutMs + 30_000,
|
|
136
|
+
}),
|
|
137
|
+
timeoutMs + 30_000,
|
|
138
|
+
'Modal sandbox create',
|
|
139
|
+
);
|
|
140
|
+
const activeSandbox = sandbox;
|
|
141
|
+
return await withTimeout(
|
|
142
|
+
runScriptedExecution(async (script) => {
|
|
143
|
+
const process = await activeSandbox.exec(['bash', '-lc', script], { timeoutMs });
|
|
144
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
145
|
+
process.stdout.readText(),
|
|
146
|
+
process.stderr.readText(),
|
|
147
|
+
process.wait(),
|
|
148
|
+
]);
|
|
149
|
+
return { stdout, stderr, exitCode };
|
|
150
|
+
}, request),
|
|
151
|
+
timeoutMs + 15_000,
|
|
152
|
+
'Modal execution',
|
|
153
|
+
);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return failedResult(err instanceof Error ? err.message : 'Modal execution failed.', startTime);
|
|
156
|
+
} finally {
|
|
157
|
+
if (sandbox) await safeCleanup(() => sandbox!.terminate());
|
|
158
|
+
modal.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const modalAdapter: SandboxProviderAdapter = {
|
|
163
|
+
id: 'modal',
|
|
164
|
+
verifyCredentials,
|
|
165
|
+
healthCheck,
|
|
166
|
+
execute,
|
|
167
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const deploymentMock = vi.fn();
|
|
4
|
+
const getProjectMock = vi.fn();
|
|
5
|
+
const getServiceMock = vi.fn();
|
|
6
|
+
const deleteServiceMock = vi.fn();
|
|
7
|
+
const execServiceSessionMock = vi.fn();
|
|
8
|
+
const addContextMock = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock('@northflank/js-client', () => ({
|
|
11
|
+
ApiClient: vi.fn().mockImplementation(function () {
|
|
12
|
+
return {
|
|
13
|
+
create: { service: { deployment: deploymentMock } },
|
|
14
|
+
get: { project: getProjectMock, service: getServiceMock },
|
|
15
|
+
delete: { service: deleteServiceMock },
|
|
16
|
+
exec: { execServiceSession: execServiceSessionMock },
|
|
17
|
+
};
|
|
18
|
+
}),
|
|
19
|
+
ApiClientInMemoryContextProvider: vi.fn().mockImplementation(function () {
|
|
20
|
+
return { addContext: addContextMock };
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { northflankAdapter } = await import('./northflank.js');
|
|
25
|
+
|
|
26
|
+
const fields = { token: 'tok', projectId: 'proj_1' };
|
|
27
|
+
|
|
28
|
+
function makeExecHandle(stdout: string, stderr: string, exitCode: number) {
|
|
29
|
+
return {
|
|
30
|
+
stdOut: { on: (_event: string, cb: (chunk: string) => void) => cb(stdout) },
|
|
31
|
+
stdErr: { on: (_event: string, cb: (chunk: string) => void) => cb(stderr) },
|
|
32
|
+
waitForCommandResult: vi.fn().mockResolvedValue({ exitCode }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
deploymentMock.mockReset();
|
|
38
|
+
getProjectMock.mockReset();
|
|
39
|
+
getServiceMock.mockReset();
|
|
40
|
+
deleteServiceMock.mockReset();
|
|
41
|
+
execServiceSessionMock.mockReset();
|
|
42
|
+
addContextMock.mockReset();
|
|
43
|
+
deploymentMock.mockResolvedValue({});
|
|
44
|
+
getProjectMock.mockResolvedValue({ data: { id: 'proj_1' } });
|
|
45
|
+
getServiceMock.mockResolvedValue({ data: { status: 'COMPLETED' } });
|
|
46
|
+
deleteServiceMock.mockResolvedValue({});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('northflankAdapter.verifyCredentials', () => {
|
|
50
|
+
it('requires both token and projectId', async () => {
|
|
51
|
+
await expect(northflankAdapter.verifyCredentials({ token: 'tok' })).rejects.toThrow(
|
|
52
|
+
'Northflank API token and project ID are both required.',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('checks the project with the authenticated API client', async () => {
|
|
57
|
+
await northflankAdapter.verifyCredentials(fields);
|
|
58
|
+
expect(getProjectMock).toHaveBeenCalledWith({ parameters: { projectId: 'proj_1' } });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects a project ID that the token cannot access', async () => {
|
|
62
|
+
getProjectMock.mockRejectedValue(new Error('Northflank API returned 404.'));
|
|
63
|
+
await expect(
|
|
64
|
+
northflankAdapter.verifyCredentials({ token: 'tok', projectId: 'missing-project' }),
|
|
65
|
+
).rejects.toThrow('404');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('northflankAdapter.execute', () => {
|
|
70
|
+
it('creates a service, waits for it to be ready, execs, and deletes it', async () => {
|
|
71
|
+
execServiceSessionMock.mockResolvedValue(makeExecHandle('out\n', '', 0));
|
|
72
|
+
|
|
73
|
+
const result = await northflankAdapter.execute(
|
|
74
|
+
{ code: 'print(1)', language: 'python' },
|
|
75
|
+
fields,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(deploymentMock).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(getServiceMock).toHaveBeenCalled();
|
|
80
|
+
expect(execServiceSessionMock).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(result.stdout).toBe('out\n');
|
|
82
|
+
expect(result.exitCode).toBe(0);
|
|
83
|
+
expect(deleteServiceMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('aggregates stdErr stream chunks into ExecutionResult.stderr', async () => {
|
|
87
|
+
execServiceSessionMock.mockResolvedValue(makeExecHandle('', 'boom', 1));
|
|
88
|
+
const result = await northflankAdapter.execute({ code: 'x=1', language: 'python' }, fields);
|
|
89
|
+
expect(result.stderr).toBe('boom');
|
|
90
|
+
expect(result.exitCode).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('throws when the service reports FAILED while waiting for readiness', async () => {
|
|
94
|
+
getServiceMock.mockResolvedValue({ data: { status: 'FAILED' } });
|
|
95
|
+
const result = await northflankAdapter.execute({ code: 'x=1', language: 'python' }, fields);
|
|
96
|
+
expect(result.exitCode).toBe(1);
|
|
97
|
+
expect(result.stderr).toContain('failed to start');
|
|
98
|
+
// Cleanup still runs even though the service never became ready.
|
|
99
|
+
expect(deleteServiceMock).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns a failed result without calling the API when projectId is missing', async () => {
|
|
103
|
+
const result = await northflankAdapter.execute(
|
|
104
|
+
{ code: 'x=1', language: 'python' },
|
|
105
|
+
{ token: 'tok' },
|
|
106
|
+
);
|
|
107
|
+
expect(result.exitCode).toBe(1);
|
|
108
|
+
expect(deploymentMock).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|