Files
Jamie Tanna 56983c3fdb refactor(manager/dockerfile): share the RUN command parsing (#46299)
As part of future changes, we want to extract system package managers'
packages in `Dockerfile`s more generally.

Instead of an `apk`-specific file, we can introduce a shared file for
extracting commands from `RUN`s.

From here, it can then be used by `apk` and follow-up tools by providing
the `names` to look for.

Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
2026-09-18 08:25:59 +00:00

218 lines
6.1 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { logger } from '../../../logger/index.ts';
import { parseRunCommands } from './run-command.ts';
const escapeChar = '\\\\';
describe('modules/manager/dockerfile/run-command', () => {
describe('parseRunCommands()', () => {
it('ignores instructions which are not RUN', () => {
expect(parseRunCommands('FROM alpine:3.21', escapeChar, ['apk'])).toEqual(
[],
);
expect(
parseRunCommands('CMD ["apk", "add", "bash"]', escapeChar, ['apk']),
).toEqual([]);
expect(
parseRunCommands('ENV APK_VERSION=2.14.6', escapeChar, ['apk']),
).toEqual([]);
});
it('ignores a RUN instruction which does not mention the command', () => {
expect(
parseRunCommands('RUN apt-get install -y bash', escapeChar, ['apk']),
).toEqual([]);
});
it('ignores a command which merely mentions the name', () => {
expect(
parseRunCommands('RUN rm -rf /var/cache/apk/*', escapeChar, ['apk']),
).toEqual([]);
});
it('returns the arguments of the command', () => {
expect(
parseRunCommands('RUN apk add --no-cache bash=5.2.37-r2', escapeChar, [
'apk',
]),
).toEqual([['add', '--no-cache', 'bash=5.2.37-r2']]);
});
it('returns no arguments for a bare command', () => {
expect(parseRunCommands('RUN apk', escapeChar, ['apk'])).toEqual([[]]);
});
it('matches any of the given command names', () => {
expect(
parseRunCommands('RUN apt-get install -y curl', escapeChar, [
'apt',
'apt-get',
]),
).toEqual([['install', '-y', 'curl']]);
expect(
parseRunCommands('RUN apt install -y curl', escapeChar, [
'apt',
'apt-get',
]),
).toEqual([['install', '-y', 'curl']]);
});
it('matches a command given by its path', () => {
expect(
parseRunCommands('RUN /sbin/apk add bash', escapeChar, ['apk']),
).toEqual([['add', 'bash']]);
});
it('matches a command prefixed by variable assignments', () => {
expect(
parseRunCommands('RUN DEBUG=1 apk add bash', escapeChar, ['apk']),
).toEqual([['add', 'bash']]);
});
it.each`
wrapper
${'sudo'}
${'chroot /mnt'}
${'timeout 30'}
${'env DEBIAN_FRONTEND=noninteractive'}
`('matches a command run through $wrapper', ({ wrapper }) => {
expect(
parseRunCommands(`RUN ${wrapper} apk add bash`, escapeChar, ['apk']),
).toEqual([['add', 'bash']]);
});
it('skips the RUN flags and the ONBUILD prefix', () => {
expect(
parseRunCommands(
'ONBUILD RUN --mount=type=cache,target=/var/cache/apk --network=default apk add bash',
escapeChar,
['apk'],
),
).toEqual([['add', 'bash']]);
});
it('strips quotes from the arguments', () => {
expect(
parseRunCommands(
`RUN apk add "nginx=1.26.2-r0" 'curl=8.12.1-r1'`,
escapeChar,
['apk'],
),
).toEqual([['add', 'nginx=1.26.2-r0', 'curl=8.12.1-r1']]);
});
it('joins line continuations', () => {
const instruction = codeBlock`
RUN apk add --no-cache \\
bash=5.2.37-r2 \\
curl=8.12.1-r1`;
expect(parseRunCommands(instruction, escapeChar, ['apk'])).toEqual([
['add', '--no-cache', 'bash=5.2.37-r2', 'curl=8.12.1-r1'],
]);
});
it('supports the backtick escape character', () => {
expect(
parseRunCommands('RUN apk add `\n bash=5.2.37-r2', '`', ['apk']),
).toEqual([['add', 'bash=5.2.37-r2']]);
});
it('handles CRLF line continuations', () => {
expect(
parseRunCommands('RUN apk add \\\r\n bash=5.2.37-r2', escapeChar, [
'apk',
]),
).toEqual([['add', 'bash=5.2.37-r2']]);
});
it('drops Dockerfile comments inside a line continuation', () => {
const instruction = codeBlock`
RUN apk add --no-cache \\
# bash is needed by our entrypoint
bash=5.2.37-r2`;
expect(parseRunCommands(instruction, escapeChar, ['apk'])).toEqual([
['add', '--no-cache', 'bash=5.2.37-r2'],
]);
});
it('stops at a shell comment', () => {
expect(
parseRunCommands(
'RUN apk add bash=5.2.37-r2 # apk add curl=8.12.1-r1',
escapeChar,
['apk'],
),
).toEqual([['add', 'bash=5.2.37-r2']]);
});
it.each`
separator
${'&&'}
${'||'}
${';'}
${'|'}
${'&'}
`('splits the commands on $separator', ({ separator }) => {
expect(
parseRunCommands(
`RUN apk update ${separator} apk add bash`,
escapeChar,
['apk'],
),
).toEqual([['update'], ['add', 'bash']]);
});
it.each`
separator
${'('}
${')'}
${'{'}
${'}'}
`('splits the commands on $separator', ({ separator }) => {
expect(
parseRunCommands(
`RUN apk update ${separator} apk add bash`,
escapeChar,
['apk'],
),
).toEqual([['update'], ['add', 'bash']]);
});
it('splits on a separator which is not preceded by whitespace', () => {
expect(
parseRunCommands(
'RUN for iter in {1..10}; do apk add bash; done',
escapeChar,
['apk'],
),
).toEqual([['add', 'bash']]);
});
it('returns each invocation in the order they were run', () => {
expect(
parseRunCommands(
'RUN apk add bash && apk del .build-deps && apk add curl',
escapeChar,
['apk'],
),
).toEqual([
['add', 'bash'],
['del', '.build-deps'],
['add', 'curl'],
]);
});
it('handles a command it cannot tokenize', () => {
expect(
parseRunCommands('RUN apk add "bash=5.2.37-r2', escapeChar, ['apk']),
).toEqual([]);
expect(logger.debug).toHaveBeenCalledWith(
{ err: expect.anything(), command: 'apk add "bash=5.2.37-r2' },
'Failed to tokenize Dockerfile RUN command',
);
});
});
});