From 3860acdfb4ec253a4983139a2d8582b8d0268f20 Mon Sep 17 00:00:00 2001 From: alxdca Date: Thu, 2 Apr 2026 14:21:06 +0200 Subject: [PATCH] feat: add a docker command runner to the sdk --- src/runDockerCommand.ts | 60 ++++++++++++++++ src/runDockerCommand.unit.test.ts | 110 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/runDockerCommand.ts create mode 100644 src/runDockerCommand.unit.test.ts diff --git a/src/runDockerCommand.ts b/src/runDockerCommand.ts new file mode 100644 index 00000000..d81e450f --- /dev/null +++ b/src/runDockerCommand.ts @@ -0,0 +1,60 @@ +import { execFile } from 'node:child_process'; + +export type RunDockerCommandParameters = { + image: string; + command?: string[]; + entrypoint?: string; + env?: Record; +}; + +export type RunDockerCommandResult = { + argv: string[]; + stdout: string; + stderr: string; + exitCode: number; +}; + +export function runDockerCommand( + params: RunDockerCommandParameters, +): Promise { + const envEntries = Object.entries(params.env ?? {}).filter((entry) => entry[1] !== undefined); + + const argv = [ + 'docker', + 'run', + '--rm', + ...envEntries.flatMap(([name]) => ['--env', name]), + ...(params.entrypoint ? ['--entrypoint', params.entrypoint] : []), + params.image, + ...(params.command ?? []), + ]; + + const environment = { + ...process.env, + ...Object.fromEntries(envEntries), + }; + + return new Promise((resolve, reject) => { + execFile('docker', argv.slice(1), { env: environment }, (error, stdout, stderr) => { + if (error) { + return reject( + Object.assign(new Error(`Docker command failed: ${error.message}`), { + name: 'RunDockerCommandError', + argv, + stdout, + stderr, + exitCode: typeof error.code === 'number' ? error.code : 1, + cause: error, + }), + ); + } + + resolve({ + argv, + stdout, + stderr, + exitCode: 0, + }); + }); + }); +} diff --git a/src/runDockerCommand.unit.test.ts b/src/runDockerCommand.unit.test.ts new file mode 100644 index 00000000..c6043c3d --- /dev/null +++ b/src/runDockerCommand.unit.test.ts @@ -0,0 +1,110 @@ +import type { ExecFileException } from 'node:child_process'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})); + +import { runDockerCommand } from './runDockerCommand'; + +describe('runDockerCommand', () => { + beforeEach(() => { + execFileMock.mockReset(); + }); + + it('passes env values through the child process instead of the docker argv', async () => { + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + callback(null, 'ok', ''); + return {} as never; + }); + + const result = await runDockerCommand({ + image: 'offchainlabs/chain-actions', + command: ['orbit:contracts:version'], + env: { + PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key', + }, + }); + + expect(execFileMock).toHaveBeenCalledWith( + 'docker', + [ + 'run', + '--rm', + '--env', + 'PARENT_CHAIN_RPC', + 'offchainlabs/chain-actions', + 'orbit:contracts:version', + ], + expect.objectContaining({ + env: expect.objectContaining({ + PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key', + }), + }), + expect.any(Function), + ); + expect(result).toEqual({ + argv: [ + 'docker', + 'run', + '--rm', + '--env', + 'PARENT_CHAIN_RPC', + 'offchainlabs/chain-actions', + 'orbit:contracts:version', + ], + stdout: 'ok', + stderr: '', + exitCode: 0, + }); + expect(result.argv.join(' ')).not.toContain('my-secret-key'); + }); + + it('does not leak env values in failure metadata', async () => { + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + callback( + Object.assign(new Error('Command failed'), { + code: 17, + }) as ExecFileException, + '', + 'bad', + ); + return {} as never; + }); + + const promise = runDockerCommand({ + image: 'offchainlabs/chain-actions', + env: { + PARENT_CHAIN_RPC: 'https://rpc.example/my-secret-key', + }, + }); + + const error = await promise.then( + () => { + throw new Error('Expected runDockerCommand to reject'); + }, + (rejection) => + rejection as Error & { + name: string; + argv: string[]; + stderr: string; + exitCode: number; + }, + ); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: 'RunDockerCommandError', + argv: ['docker', 'run', '--rm', '--env', 'PARENT_CHAIN_RPC', 'offchainlabs/chain-actions'], + stderr: 'bad', + exitCode: 17, + }); + expect(error.message).not.toContain('my-secret-key'); + expect(error.argv.join(' ')).not.toContain('my-secret-key'); + }); +});