Skip to content

Commit 0c9dbc3

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

File tree

2 files changed

+170
-0
lines changed

2 files changed

+170
-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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
[
37+
'run',
38+
'--rm',
39+
'--env',
40+
'PARENT_CHAIN_RPC',
41+
'offchainlabs/chain-actions',
42+
'orbit:contracts:version',
43+
],
44+
expect.objectContaining({
45+
env: expect.objectContaining({
46+
PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key',
47+
}),
48+
}),
49+
expect.any(Function),
50+
);
51+
expect(result).toEqual({
52+
argv: [
53+
'docker',
54+
'run',
55+
'--rm',
56+
'--env',
57+
'PARENT_CHAIN_RPC',
58+
'offchainlabs/chain-actions',
59+
'orbit:contracts:version',
60+
],
61+
stdout: 'ok',
62+
stderr: '',
63+
exitCode: 0,
64+
});
65+
expect(result.argv.join(' ')).not.toContain('my-secret-key');
66+
});
67+
68+
it('does not leak env values in failure metadata', async () => {
69+
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
70+
callback(
71+
Object.assign(new Error('Command failed'), {
72+
code: 17,
73+
}) as ExecFileException,
74+
'',
75+
'bad',
76+
);
77+
return {} as never;
78+
});
79+
80+
const promise = runDockerCommand({
81+
image: 'offchainlabs/chain-actions',
82+
env: {
83+
PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key',
84+
},
85+
});
86+
87+
const error = await promise.then(
88+
() => {
89+
throw new Error('Expected runDockerCommand to reject');
90+
},
91+
(rejection) =>
92+
rejection as Error & {
93+
name: string;
94+
argv: string[];
95+
stderr: string;
96+
exitCode: number;
97+
},
98+
);
99+
100+
expect(error).toBeInstanceOf(Error);
101+
expect(error).toMatchObject({
102+
name: 'RunDockerCommandError',
103+
argv: ['docker', 'run', '--rm', '--env', 'PARENT_CHAIN_RPC', 'offchainlabs/chain-actions'],
104+
stderr: 'bad',
105+
exitCode: 17,
106+
});
107+
expect(error.message).not.toContain('my-secret-key');
108+
expect(error.argv.join(' ')).not.toContain('my-secret-key');
109+
});
110+
});

0 commit comments

Comments
 (0)