Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
chrmarti committed Oct 27, 2022
1 parent f4da653 commit 04eaabe
Show file tree
Hide file tree
Showing 2 changed files with 163 additions and 22 deletions.
100 changes: 78 additions & 22 deletions src/spec-node/dockerfileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,23 @@ const findFromLines = new RegExp(/^(?<line>\s*FROM.*)/, 'gm');
const parseFromLine = /FROM\s+(?<platform>--platform=\S+\s+)?(?<image>\S+)(\s+[Aa][Ss]\s+(?<label>[^\s]+))?/;

const fromStatement = /^\s*FROM\s+(?<platform>--platform=\S+\s+)?(?<image>\S+)(\s+[Aa][Ss]\s+(?<label>[^\s]+))?/m;
const userStatements = /^\s*USER\s+(?<user>\S+)/gm;
const argStatementsWithValue = /^\s*ARG\s+(?<name>\S+)=("(?<value1>\S+)"|(?<value2>\S+))/gm;
const argEnvUserStatements = /^\s*(?<instruction>ARG|ENV|USER)\s+(?<name>[^\s=]+)(=("(?<value1>\S+)"|(?<value2>\S+)))?/gm;
const directives = /^\s*#\s*(?<name>\S+)\s*=\s*(?<value>.+)/;
const variables = /\$\{?(?<variable>[a-zA-Z0-9_]+)\}?/g;

export interface Dockerfile {
preamble: {
version: string | undefined;
directives: Record<string, string>;
args: Record<string, string>;
instructions: Instruction[];
};
stages: Stage[];
stagesByLabel: Record<string, Stage>;
}

export interface Stage {
from: From;
args: Record<string, string>;
users: string[];
instructions: Instruction[];
}

export interface From {
Expand All @@ -40,15 +39,20 @@ export interface From {
label?: string;
}

export interface Instruction {
instruction: string;
name: string;
value: string | undefined;
}

export function extractDockerfile(dockerfile: string): Dockerfile {
const fromStatementsAhead = /(?=^\s*FROM)/gm;
const parts = dockerfile.split(fromStatementsAhead);
const preambleStr = fromStatementsAhead.test(parts[0] || '') ? '' : parts.shift()!;
const stageStrs = parts;
const stages = stageStrs.map(stageStr => ({
from: fromStatement.exec(stageStr)?.groups as unknown as From || { image: 'unknown' },
args: extractArgs(stageStr),
users: [...stageStr.matchAll(userStatements)].map(m => m.groups!.user),
instructions: extractInstructions(stageStr),
}));
const directives = extractDirectives(preambleStr);
const versionMatch = directives.syntax && /^(?:docker.io\/)?docker\/dockerfile(?::(?<version>\S+))?/.exec(directives.syntax) || undefined;
Expand All @@ -57,7 +61,7 @@ export function extractDockerfile(dockerfile: string): Dockerfile {
preamble: {
version,
directives,
args: extractArgs(preambleStr),
instructions: extractInstructions(preambleStr),
},
stages,
stagesByLabel: stages.reduce((obj, stage) => {
Expand All @@ -78,10 +82,11 @@ export function findUserStatement(dockerfile: Dockerfile, buildArgs: Record<stri
}
seen.add(stage);

if (stage.users.length) {
return replaceArgs(stage.users[stage.users.length - 1], { ...stage.args, ...buildArgs });
const i = findLastIndex(stage.instructions, i => i.instruction === 'USER');
if (i !== -1) {
return replaceVariables(dockerfile, buildArgs, stage.instructions[i].name, stage, i);
}
const image = replaceArgs(stage.from.image, { ...dockerfile.preamble.args, ...buildArgs });
const image = replaceVariables(dockerfile, buildArgs, stage.from.image, dockerfile.preamble, dockerfile.preamble.instructions.length);
stage = dockerfile.stagesByLabel[image];
}
return undefined;
Expand All @@ -96,7 +101,7 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record<string,
}
seen.add(stage);

const image = replaceArgs(stage.from.image, { ...dockerfile.preamble.args, ...buildArgs });
const image = replaceVariables(dockerfile, buildArgs, stage.from.image, dockerfile.preamble, dockerfile.preamble.instructions.length);
const nextStage = dockerfile.stagesByLabel[image];
if (!nextStage) {
return image;
Expand All @@ -121,19 +126,70 @@ function extractDirectives(preambleStr: string) {
return map;
}

function extractArgs(stageStr: string) {
return [...stageStr.matchAll(argStatementsWithValue)]
.reduce((obj, match) => {
function extractInstructions(stageStr: string) {
return [...stageStr.matchAll(argEnvUserStatements)]
.map(match => {
const groups = match.groups!;
obj[groups.name] = groups.value1 || groups.value2;
return obj;
}, {} as Record<string, string>);
return {
instruction: groups.instruction,
name: groups.name,
value: groups.value1 || groups.value2,
};
});
}

function replaceVariables(dockerfile: Dockerfile, buildArgs: Record<string, string>, str: string, stage: { from?: From; instructions: Instruction[] }, beforeInstructionIndex: number) {
return [...str.matchAll(variables)]
.map(match => {
const variable = match.groups!.variable;
const value = findValue(dockerfile, buildArgs, variable, stage, beforeInstructionIndex);
return {
begin: match.index!,
end: match.index! + match[0].length,
value,
};
}).reverse()
.reduce((str, { begin, end, value }) => str.substring(0, begin) + value + str.substring(end), str);
}

function findValue(dockerfile: Dockerfile, buildArgs: Record<string, string>, variable: string, stage: { from?: From; instructions: Instruction[] }, beforeInstructionIndex: number): string | undefined {
let considerArg = true;
const seen = new Set<typeof stage>();
while (true) {
if (seen.has(stage)) {
return undefined;
}
seen.add(stage);

const i = findLastIndex(stage.instructions, i => i.name === variable && (i.instruction === 'ENV' || (considerArg && typeof (buildArgs[i.name] ?? i.value) === 'string')), beforeInstructionIndex - 1);
if (i !== -1) {
const instruction = stage.instructions[i];
if (instruction.instruction === 'ENV') {
return replaceVariables(dockerfile, buildArgs, instruction.value!, stage, i);
}
if (instruction.instruction === 'ARG') {
return replaceVariables(dockerfile, buildArgs, buildArgs[instruction.name] ?? instruction.value, stage, i);
}
}

if (!stage.from) {
return undefined;
}

const image = replaceVariables(dockerfile, buildArgs, stage.from.image, dockerfile.preamble, dockerfile.preamble.instructions.length);
stage = dockerfile.stagesByLabel[image] || dockerfile.preamble;
beforeInstructionIndex = stage.instructions.length;
considerArg = stage === dockerfile.preamble;
}
}

function replaceArgs(str: string, args: Record<string, string>) {
return Object.keys(args)
.sort((a, b) => b.length - a.length) // Sort by length to replace longest first.
.reduce((current, arg) => current.replace(new RegExp(`\\$${arg}|\\$\\{${arg}\\}`, 'g'), args[arg]), str);
function findLastIndex<T>(array: T[], predicate: (value: T, index: number, obj: T[]) => boolean, position = array.length - 1): number {
for (let i = position; i >= 0; i--) {
if (predicate(array[i], i, array)) {
return i;
}
}
return -1;
}

// not expected to be called externally (exposed for testing)
Expand Down
85 changes: 85 additions & 0 deletions src/test/dockerfileUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,91 @@ USER user4
const image = findUserStatement(extracted, {}, 'stage2');
assert.strictEqual(image, 'user3_2');
});

it('ARG after ENV', async () => {
const dockerfile = `
FROM debian
ENV USERNAME=user1
ARG USERNAME=user2
USER \${USERNAME}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user2');
});

it('ARG after ENV in preceding stage', async () => {
const dockerfile = `
FROM debian as one
ENV USERNAME=user1
ARG USERNAME=user2
FROM one as two
USER \${USERNAME}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user1');
});

it('ARG in preamble', async () => {
const dockerfile = `
ARG USERNAME=user1
FROM debian
USER \${USERNAME}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user1');
});

it('unbound ARG after ENV', async () => {
const dockerfile = `
FROM debian
ENV USERNAME=user1
ARG USERNAME
USER \${USERNAME}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user1');
});

it('ENV after ARG', async () => {
const dockerfile = `
FROM debian
ARG USERNAME=user1
ENV USERNAME=user2
USER \${USERNAME}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user2');
});

it('ENV set from ARG', async () => {
const dockerfile = `
FROM debian
ARG USERNAME1=user1
ENV USERNAME2=\${USERNAME1}
USER \${USERNAME2}
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'user1');
});

it('multiple variables', async () => {
const dockerfile = `
FROM debian
ARG USERNAME1=user1
ENV USERNAME2=user2
USER A\${USERNAME1}A\${USERNAME2}A
`;
const extracted = extractDockerfile(dockerfile);
const user = findUserStatement(extracted, {}, undefined);
assert.strictEqual(user, 'Auser1Auser2A');
});
});

describe('supportsBuildContexts', () => {
Expand Down

0 comments on commit 04eaabe

Please sign in to comment.