Skip to content

Commit

Permalink
Merge pull request #326 from snyk/feat/update-dockerfile-base-image
Browse files Browse the repository at this point in the history
feat: update dockerfile with new base image
  • Loading branch information
ahmed-agabani-snyk committed Mar 2, 2021
2 parents 231f1ec + deda807 commit 883996e
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 1 deletion.
2 changes: 2 additions & 0 deletions lib/dockerfile/index.ts
Expand Up @@ -7,6 +7,7 @@ import {
getPackagesFromDockerfile,
instructionDigest,
} from "./instruction-parser";
import { updateDockerfileBaseImageName } from "./instruction-updater";
import { DockerFileAnalysis } from "./types";

export {
Expand All @@ -15,6 +16,7 @@ export {
instructionDigest,
getPackagesFromDockerfile,
getDockerfileBaseImageName,
updateDockerfileBaseImageName,
DockerFileAnalysis,
};

Expand Down
94 changes: 94 additions & 0 deletions lib/dockerfile/instruction-updater.ts
@@ -0,0 +1,94 @@
import { DockerfileParser } from "dockerfile-ast";
import { EOL } from "os";
import { getDockerfileBaseImageName } from "./instruction-parser";
import {
UpdateDockerfileBaseImageNameErrorCode,
UpdateDockerfileBaseImageNameResult,
} from "./types";

export { updateDockerfileBaseImageName };

/**
* Updates the image name of the last from stage, after resolving all aliases
* @param contents Contents of the Dockerfile to update
* @param newBaseImageName New base image name Dockerfile contents should be updated to
*/
function updateDockerfileBaseImageName(
contents: string,
newBaseImageName: string,
): UpdateDockerfileBaseImageNameResult {
const dockerfile = DockerfileParser.parse(contents);

const currentBaseImageName = getDockerfileBaseImageName(dockerfile);

if (currentBaseImageName === undefined) {
return {
contents,
error: {
code: UpdateDockerfileBaseImageNameErrorCode.BASE_IMAGE_NAME_NOT_FOUND,
},
};
}

const fromRanges = dockerfile
.getFROMs()
.filter((from) => from.getImage() === currentBaseImageName)
.map((from) => from.getImageRange()!);

const argRanges = dockerfile
.getARGs()
.filter((arg) => arg.getProperty()?.getValue() === currentBaseImageName)
.map((arg) => arg.getProperty()?.getValueRange()!);

const ranges = fromRanges.concat(argRanges);

if (ranges.length === 0) {
/**
* This happens when the image is split over multiple FROM and ARG statements
* making it difficult to update Dockerfiles that fall into these edge cases.
* e.g.:
* ARG REPO=repo
* ARG TAG=tag
* FROM ${REPO}:${TAG}
*/
return {
contents,
error: {
code: UpdateDockerfileBaseImageNameErrorCode.BASE_IMAGE_NAME_FRAGMENTED,
},
};
}

const lines = contents.split(EOL);

for (const range of ranges) {
const lineNumber = range.start.line;
const start = range.start.character;
const end = range.end.character;

const content = lines[lineNumber];
const updated =
content.substring(0, start) + newBaseImageName + content.substring(end);
lines[lineNumber] = updated;
}

const updatedContents = lines.join(EOL);
const updatedDockerfile = DockerfileParser.parse(updatedContents);

if (
dockerfile.getInstructions().length !==
updatedDockerfile.getInstructions().length
) {
return {
contents,
error: {
code:
UpdateDockerfileBaseImageNameErrorCode.DOCKERFILE_GENERATION_FAILED,
},
};
}

return {
contents: updatedContents,
};
}
15 changes: 15 additions & 0 deletions lib/dockerfile/types.ts
Expand Up @@ -15,3 +15,18 @@ export interface DockerFileLayers {
instruction: string;
};
}

export interface UpdateDockerfileBaseImageNameResult {
contents: string;
error?: UpdateDockerfileBaseImageNameError;
}

export interface UpdateDockerfileBaseImageNameError {
code: UpdateDockerfileBaseImageNameErrorCode;
}

export enum UpdateDockerfileBaseImageNameErrorCode {
BASE_IMAGE_NAME_FRAGMENTED = "BASE_IMAGE_NAME_FRAGMENTED",
BASE_IMAGE_NAME_NOT_FOUND = "BASE_IMAGE_NAME_NOT_FOUND",
DOCKERFILE_GENERATION_FAILED = "DOCKERFILE_GENERATION_FAILED",
}
105 changes: 104 additions & 1 deletion test/lib/dockerfile/index.spec.ts
@@ -1,6 +1,10 @@
import { DockerfileParser } from "dockerfile-ast";
import { EOL } from "os";
import { getDockerfileBaseImageName } from "../../../lib/dockerfile";
import {
getDockerfileBaseImageName,
updateDockerfileBaseImageName,
} from "../../../lib/dockerfile";
import { UpdateDockerfileBaseImageNameErrorCode } from "../../../lib/dockerfile/types";

describe("base image parsing", () => {
it.each`
Expand Down Expand Up @@ -32,3 +36,102 @@ describe("base image parsing", () => {
).toBeDefined();
});
});

describe("base image updating", () => {
describe("single stage", () => {
it.each`
scenario | content | expected
${"basic"} | ${"FROM repo"} | ${"FROM repo:tag0"}
${"with alias"} | ${"FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE"}
${"with arg"} | ${"ARG IMAGE=repo:tag1" + EOL + "FROM ${IMAGE}"} | ${"ARG IMAGE=repo:tag0" + EOL + "FROM ${IMAGE}"}
${"with tag"} | ${"FROM repo:tag1"} | ${"FROM repo:tag0"}
`("updates base image: $scenario", ({ content, expected }) => {
const result = updateDockerfileBaseImageName(content, "repo:tag0");
expect(result.error).toBeUndefined();
expect(result.contents).toBe(expected);
});
});

describe("multi stage", () => {
it.each`
scenario | content | expected
${"basic"} | ${"FROM repo:tag2" + EOL + "FROM repo"} | ${"FROM repo:tag2" + EOL + "FROM repo:tag0"}
${"with tag"} | ${"FROM repo:tag2" + EOL + "FROM repo:tag1"} | ${"FROM repo:tag2" + EOL + "FROM repo:tag0"}
${"duplicate"} | ${"FROM repo" + EOL + "FROM repo"} | ${"FROM repo:tag0" + EOL + "FROM repo:tag0"}
${"duplicate with tag"} | ${"FROM repo:tag1" + EOL + "FROM repo:tag1"} | ${"FROM repo:tag0" + EOL + "FROM repo:tag0"}
${"with arg"} | ${"ARG IMAGE=repo:tag1" + EOL + "FROM repo:tag2" + EOL + "FROM ${IMAGE}"} | ${"ARG IMAGE=repo:tag0" + EOL + "FROM repo:tag2" + EOL + "FROM ${IMAGE}"}
${"with non related arg"} | ${"ARG IMAGE=repo:tag1" + EOL + "FROM ${IMAGE}" + EOL + "FROM repo:tag2"} | ${"ARG IMAGE=repo:tag1" + EOL + "FROM ${IMAGE}" + EOL + "FROM repo:tag0"}
${"with duplicate related arg"} | ${"ARG IMAGE=repo:tag1" + EOL + "FROM repo:tag1" + EOL + "FROM ${IMAGE}"} | ${"ARG IMAGE=repo:tag0" + EOL + "FROM repo:tag0" + EOL + "FROM ${IMAGE}"}
`("updates base image: $scenario", ({ content, expected }) => {
const result = updateDockerfileBaseImageName(content, "repo:tag0");
expect(result.error).toBeUndefined();
expect(result.contents).toBe(expected);
});
});

describe("case sensitivity", () => {
it.each`
scenario | content | expected
${"lowercase"} | ${"from repo:tag1 as base"} | ${"from repo:tag0 as base"}
${"uppercase"} | ${"FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE"}
${"mixed case"} | ${"fRoM repo:tag1 aS bAsE"} | ${"fRoM repo:tag0 aS bAsE"}
`("updates base image: $scenario", ({ content, expected }) => {
const result = updateDockerfileBaseImageName(content, "repo:tag0");
expect(result.error).toBeUndefined();
expect(result.contents).toBe(expected);
});
});

describe("comments", () => {
it.each`
scenario | content | expected
${"before"} | ${"#FROM repo:tag1 AS BASE" + EOL + "FROM repo:tag1 AS BASE"} | ${"#FROM repo:tag1 AS BASE" + EOL + "FROM repo:tag0 AS BASE"}
${"after"} | ${"FROM repo:tag1 AS BASE" + EOL + "#FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE" + EOL + "#FROM repo:tag1 AS BASE"}
`("does not update comment: $scenario", ({ content, expected }) => {
const result = updateDockerfileBaseImageName(content, "repo:tag0");
expect(result.error).toBeUndefined();
expect(result.contents).toBe(expected);
});
});

describe("whitespace", () => {
it.each`
scenario | content | expected
${"between from and image"} | ${"FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE"}
${"between image and as"} | ${"FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE"}
${"between as and alias"} | ${"FROM repo:tag1 AS BASE"} | ${"FROM repo:tag0 AS BASE"}
`("does not update comment: $scenario", ({ content, expected }) => {
const result = updateDockerfileBaseImageName(content, "repo:tag0");
expect(result.error).toBeUndefined();
expect(result.contents).toBe(expected);
});
});

describe("unsupported cases", () => {
it.each`
scenario | content
${"${REPO}:${TAG}"} | ${"ARG REPO=repo" + EOL + "ARG TAG=tag" + EOL + "FROM ${REPO}:${TAG}"}
${"${REPO}:${MAJOR}.${MINOR}.${PATCH}-${FLAVOR}"} | ${"ARG REPO=repo" + EOL + "ARG MAJOR=1" + EOL + "ARG MINOR=0" + EOL + "ARG PATCH=0" + EOL + "ARG FLAVOR=slim" + EOL + "FROM ${REPO}:${MAJOR}.${MINOR}.${PATCH}-${SLIM}"}
`("does not update: $scenario", ({ content }) => {
const result = updateDockerfileBaseImageName(content, "image:tag0");
expect(result.error.code).toBe(
UpdateDockerfileBaseImageNameErrorCode.BASE_IMAGE_NAME_FRAGMENTED,
);
expect(result.contents).toBe(content);
});

it.each`
scenario | content
${"malformed ARG"} | ${"ARG_X IMAGE=repo:tag" + EOL + "FROM ${IMAGE}"}
${"malformed FROM"} | ${"FROM_X image:tag"}
${"missing ARG"} | ${"FROM ${IMAGE}"}
${"missing FROM"} | ${"#FROM image:tag"}
`("does not update: $scenario", ({ content }) => {
const result = updateDockerfileBaseImageName(content, "image:tag0");
expect(result.error.code).toBe(
UpdateDockerfileBaseImageNameErrorCode.BASE_IMAGE_NAME_NOT_FOUND,
);
expect(result.contents).toBe(content);
});
});
});

0 comments on commit 883996e

Please sign in to comment.