Skip to content

Commit e567549

Browse files
committed
feat: add a docker command runner to the sdk
1 parent a0ec198 commit e567549

File tree

2 files changed

+159
-0
lines changed

2 files changed

+159
-0
lines changed

src/runDockerCommand.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { execFile } from 'node:child_process';
2+
3+
export type RunDockerCommandParameters = {
4+
image: string;
5+
command?: string[];
6+
entrypoint?: string;
7+
env?: Record<string, string | undefined>;
8+
};
9+
10+
export type RunDockerCommandResult = {
11+
argv: string[];
12+
stdout: string;
13+
stderr: string;
14+
exitCode: number;
15+
};
16+
17+
export function runDockerCommand(
18+
params: RunDockerCommandParameters,
19+
): Promise<RunDockerCommandResult> {
20+
const envEntries = Object.entries(params.env ?? {}).filter((entry) => entry[1] !== undefined);
21+
22+
const argv = [
23+
'docker',
24+
'run',
25+
'--rm',
26+
...envEntries.flatMap(([name]) => ['--env', name]),
27+
...(params.entrypoint ? ['--entrypoint', params.entrypoint] : []),
28+
params.image,
29+
...(params.command ?? []),
30+
];
31+
32+
const environment = {
33+
...process.env,
34+
...Object.fromEntries(envEntries),
35+
};
36+
37+
return new Promise((resolve, reject) => {
38+
execFile('docker', argv.slice(1), { env: environment }, (error, stdout, stderr) => {
39+
if (error) {
40+
return reject(
41+
Object.assign(new Error(`Docker command failed: ${error.message}`), {
42+
name: 'RunDockerCommandError',
43+
argv,
44+
stdout,
45+
stderr,
46+
exitCode: typeof error.code === 'number' ? error.code : 1,
47+
cause: error,
48+
}),
49+
);
50+
}
51+
52+
resolve({
53+
argv,
54+
stdout,
55+
stderr,
56+
exitCode: 0,
57+
});
58+
});
59+
});
60+
}

src/runDockerCommand.unit.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { ExecFileException } from 'node:child_process';
2+
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
const { execFileMock } = vi.hoisted(() => ({
6+
execFileMock: vi.fn(),
7+
}));
8+
9+
vi.mock('node:child_process', () => ({
10+
execFile: execFileMock,
11+
}));
12+
13+
import { runDockerCommand } from './runDockerCommand';
14+
15+
describe('runDockerCommand', () => {
16+
beforeEach(() => {
17+
execFileMock.mockReset();
18+
});
19+
20+
it('passes env values through the child process instead of the docker argv', async () => {
21+
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
22+
callback(null, 'ok', '');
23+
return {} as never;
24+
});
25+
26+
const result = await runDockerCommand({
27+
image: 'offchainlabs/chain-actions',
28+
command: ['orbit:contracts:version'],
29+
env: {
30+
PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key',
31+
},
32+
});
33+
34+
expect(execFileMock).toHaveBeenCalledWith(
35+
'docker',
36+
['run', '--rm', '--env', 'PARENT_CHAIN_RPC', 'offchainlabs/chain-actions', 'orbit:contracts:version'],
37+
expect.objectContaining({
38+
env: expect.objectContaining({
39+
PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key',
40+
}),
41+
}),
42+
expect.any(Function),
43+
);
44+
expect(result).toEqual({
45+
argv: [
46+
'docker',
47+
'run',
48+
'--rm',
49+
'--env',
50+
'PARENT_CHAIN_RPC',
51+
'offchainlabs/chain-actions',
52+
'orbit:contracts:version',
53+
],
54+
stdout: 'ok',
55+
stderr: '',
56+
exitCode: 0,
57+
});
58+
expect(result.argv.join(' ')).not.toContain('my-secret-key');
59+
});
60+
61+
it('does not leak env values in failure metadata', async () => {
62+
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
63+
callback(Object.assign(new Error('Command failed'), {
64+
code: 17,
65+
}) as ExecFileException, '', 'bad');
66+
return {} as never;
67+
});
68+
69+
const promise = runDockerCommand({
70+
image: 'offchainlabs/chain-actions',
71+
env: {
72+
PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key',
73+
},
74+
});
75+
76+
const error = await promise.then(
77+
() => {
78+
throw new Error('Expected runDockerCommand to reject');
79+
},
80+
rejection =>
81+
rejection as Error & {
82+
name: string;
83+
argv: string[];
84+
stderr: string;
85+
exitCode: number;
86+
},
87+
);
88+
89+
expect(error).toBeInstanceOf(Error);
90+
expect(error).toMatchObject({
91+
name: 'RunDockerCommandError',
92+
argv: ['docker', 'run', '--rm', '--env', 'PARENT_CHAIN_RPC', 'offchainlabs/chain-actions'],
93+
stderr: 'bad',
94+
exitCode: 17,
95+
});
96+
expect(error.message).not.toContain('my-secret-key');
97+
expect(error.argv.join(' ')).not.toContain('my-secret-key');
98+
});
99+
});

0 commit comments

Comments
 (0)