First attempt at Dockerfile hashing
This commit is contained in:
parent
6ac8623216
commit
0b60ad12d1
8
Makefile
8
Makefile
|
@ -28,10 +28,8 @@ image:
|
||||||
@: $${I}
|
@: $${I}
|
||||||
ifeq ($(I),composite)
|
ifeq ($(I),composite)
|
||||||
node tools/build-composite-image.js
|
node tools/build-composite-image.js
|
||||||
else ifeq ($(I),app)
|
|
||||||
docker build . -f docker/$(I)/Dockerfile -t riju:$(I)
|
|
||||||
else
|
else
|
||||||
docker build . -f docker/$(I)/Dockerfile -t riju:$(I) --pull
|
docker build . -f docker/$(I)/Dockerfile -t riju:$(I)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: script
|
.PHONY: script
|
||||||
|
@ -118,6 +116,10 @@ test:
|
||||||
|
|
||||||
### Fetch artifacts from registries
|
### Fetch artifacts from registries
|
||||||
|
|
||||||
|
.PHONY: pull-base
|
||||||
|
pull-base:
|
||||||
|
docker pull ubuntu:rolling
|
||||||
|
|
||||||
.PHONY: pull
|
.PHONY: pull
|
||||||
pull:
|
pull:
|
||||||
@: $${I} $${DOCKER_REPO}
|
@: $${I} $${DOCKER_REPO}
|
||||||
|
|
|
@ -7,12 +7,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.12.10",
|
"@babel/core": "^7.12.10",
|
||||||
"@babel/preset-env": "^7.12.11",
|
"@babel/preset-env": "^7.12.11",
|
||||||
|
"@balena/dockerignore": "^1.0.2",
|
||||||
"async-lock": "^1.2.6",
|
"async-lock": "^1.2.6",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"commander": "^6.2.1",
|
"commander": "^6.2.1",
|
||||||
"css-loader": "^5.0.1",
|
"css-loader": "^5.0.1",
|
||||||
"debounce": "^1.2.0",
|
"debounce": "^1.2.0",
|
||||||
|
"docker-file-parser": "^1.0.5",
|
||||||
"ejs": "^3.1.5",
|
"ejs": "^3.1.5",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-ws": "^4.0.0",
|
"express-ws": "^4.0.0",
|
||||||
|
|
|
@ -0,0 +1,202 @@
|
||||||
|
import crypto from "crypto";
|
||||||
|
import fsBase, { promises as fs } from "fs";
|
||||||
|
import nodePath from "path";
|
||||||
|
import process from "process";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
import dockerfileParser from "docker-file-parser";
|
||||||
|
import dockerignore from "@balena/dockerignore";
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import { runCommand } from "./util.js";
|
||||||
|
|
||||||
|
// Given a string like "runtime" that identifies the relevant
|
||||||
|
// Dockerfile, read it from disk and parse it into a list of commands.
|
||||||
|
async function parseDockerfile(name) {
|
||||||
|
const contents = await fs.readFile(`docker/${name}/Dockerfile`, "utf-8");
|
||||||
|
return dockerfileParser.parse(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .dockerignore from disk and parse it into a dockerignore
|
||||||
|
// object that can be used to filter paths.
|
||||||
|
async function parseDockerignore() {
|
||||||
|
const contents = await fs.readFile(".dockerignore", "utf-8");
|
||||||
|
return dockerignore().add(contents.trim().split("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a path (file or directory) relative to the repo root, read it
|
||||||
|
// and any children recursively. Return an array including the given
|
||||||
|
// path as well as any children, in lexicographic order by path
|
||||||
|
// segment. The array contains objects with the following properties:
|
||||||
|
//
|
||||||
|
// * path: string, always starts with passed-in path
|
||||||
|
// * type: string, 'file' or 'symlink' or 'directory'
|
||||||
|
// * executable: boolean (only for type: 'file')
|
||||||
|
// * hash: string, SHA-1 of contents (only for type: 'file')
|
||||||
|
// * target: string, target of symlink (only for type: 'symlink')
|
||||||
|
async function listFiles(path) {
|
||||||
|
const stat = await fs.lstat(path);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const dirents = await fs.readdir(path, { withFileTypes: true });
|
||||||
|
const subpaths = await Promise.all(
|
||||||
|
_.sortBy(dirents, "name").map(async (dirent) => {
|
||||||
|
return await listFiles(nodePath.join(path, dirent.name));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return [{ path, type: "directory" }, ...subpaths.flat()];
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
const executable =
|
||||||
|
(stat.mode &
|
||||||
|
(fsBase.constants.S_IXUSR |
|
||||||
|
fsBase.constants.S_IXGRP |
|
||||||
|
fsBase.constants.S_IXOTH)) !=
|
||||||
|
0;
|
||||||
|
const contents = await fs.readFile(path, "utf-8");
|
||||||
|
const hash = crypto.createHash("sha1").update(contents).digest("hex");
|
||||||
|
return [{ path, type: "file", executable, hash }];
|
||||||
|
} else if (stat.isSymbolicLink()) {
|
||||||
|
const target = await fs.readlink(path);
|
||||||
|
return [{ path, type: "symlink", target }];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a Dockerfile name like "packaging", read all the necessary
|
||||||
|
// files from disk and then convert the Dockerfile into a JavaScript
|
||||||
|
// object which includes all relevant build context. The idea is that
|
||||||
|
// whenever the Dockerfile would need to be rebuilt, something would
|
||||||
|
// change in this object, and when irrelevant things change, this
|
||||||
|
// object does not change.
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
//
|
||||||
|
// * remote: fetch Riju image digests from registry instead of local
|
||||||
|
// index
|
||||||
|
async function encodeDockerfile(name, opts) {
|
||||||
|
const { remote } = opts || {};
|
||||||
|
const dockerfile = await parseDockerfile(name);
|
||||||
|
const ignore = await parseDockerignore();
|
||||||
|
return await Promise.all(
|
||||||
|
dockerfile.map(async ({ name, args, error }) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const step = { name, args };
|
||||||
|
outOfSwitch: switch (name) {
|
||||||
|
case "ADD":
|
||||||
|
throw new Error(
|
||||||
|
`in Dockerfile ${name}, unsupported ADD directive used`
|
||||||
|
);
|
||||||
|
case "COPY":
|
||||||
|
let nonOptionArgs = args;
|
||||||
|
while (
|
||||||
|
nonOptionArgs.length > 0 &&
|
||||||
|
nonOptionArgs[0].startsWith("--")
|
||||||
|
) {
|
||||||
|
if (nonOptionArgs[0].startsWith("--from")) {
|
||||||
|
break outOfSwitch;
|
||||||
|
}
|
||||||
|
nonOptionArgs = nonOptionArgs.slice(1);
|
||||||
|
}
|
||||||
|
const sources = args.slice(0, nonOptionArgs.length - 1);
|
||||||
|
const target = args[nonOptionArgs.length - 1];
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const char of "*?[^]\\") {
|
||||||
|
if (source.includes(char)) {
|
||||||
|
throw new Error(
|
||||||
|
`in Dockerfile ${name}, COPY source ${source} uses unsupported wildcard`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step.context = await Promise.all(
|
||||||
|
sources.map(async (source) =>
|
||||||
|
(await listFiles(source)).filter(
|
||||||
|
(entry) => !ignore.ignores(entry.path)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "FROM":
|
||||||
|
if (typeof args !== "string") {
|
||||||
|
throw new Error("got unexpected non-string for FROM args");
|
||||||
|
}
|
||||||
|
let image = args.split(" ")[0];
|
||||||
|
let [repo, tag] = image.split(":");
|
||||||
|
if (repo === "riju" && remote) {
|
||||||
|
repo = process.env.DOCKER_REPO;
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error("$DOCKER_REPO not set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image = `${repo}:${tag}`;
|
||||||
|
if (remote) {
|
||||||
|
const tags = (
|
||||||
|
await runCommand(
|
||||||
|
`skopeo list-tags "docker://${repo}" | jq -r '.Tags[]'`,
|
||||||
|
{ getStdout: true }
|
||||||
|
)
|
||||||
|
).stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n");
|
||||||
|
if (tags.includes(tag)) {
|
||||||
|
step.digest = (
|
||||||
|
await runCommand(
|
||||||
|
`skopeo inspect docker://${image} | jq -r .Digest`,
|
||||||
|
{ getStdout: true }
|
||||||
|
)
|
||||||
|
).stdout.trim();
|
||||||
|
} else {
|
||||||
|
step.digest = "none";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
step.digest =
|
||||||
|
(
|
||||||
|
await runCommand(
|
||||||
|
`docker images --no-trunc --quiet "${image}"`,
|
||||||
|
{ getStdout: true }
|
||||||
|
)
|
||||||
|
).stdout.trim() || "none";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command-line arguments, run main functionality, and exit.
|
||||||
|
async function main() {
|
||||||
|
const program = new Command();
|
||||||
|
program
|
||||||
|
.arguments("<name>")
|
||||||
|
.storeOptionsAsProperties(false)
|
||||||
|
.option("--debug", "output Dockerfile internal representation, unhashed")
|
||||||
|
.option("--remote", "fetch image digests from remote registry");
|
||||||
|
program.parse(process.argv);
|
||||||
|
if (program.args.length !== 1) {
|
||||||
|
program.help();
|
||||||
|
}
|
||||||
|
const [name] = program.args;
|
||||||
|
const { debug, remote } = program.opts();
|
||||||
|
const encoding = await encodeDockerfile(name, { remote });
|
||||||
|
if (debug) {
|
||||||
|
console.log(JSON.stringify(encoding, null, 2));
|
||||||
|
} else {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash("sha1")
|
||||||
|
.update(JSON.stringify(encoding))
|
||||||
|
.digest("hex");
|
||||||
|
console.log(hash);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === url.fileURLToPath(import.meta.url)) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
10
yarn.lock
10
yarn.lock
|
@ -814,6 +814,11 @@
|
||||||
lodash "^4.17.19"
|
lodash "^4.17.19"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
|
"@balena/dockerignore@^1.0.2":
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d"
|
||||||
|
integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==
|
||||||
|
|
||||||
"@discoveryjs/json-ext@^0.5.0":
|
"@discoveryjs/json-ext@^0.5.0":
|
||||||
version "0.5.2"
|
version "0.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752"
|
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752"
|
||||||
|
@ -1794,6 +1799,11 @@ diffie-hellman@^5.0.0:
|
||||||
miller-rabin "^4.0.0"
|
miller-rabin "^4.0.0"
|
||||||
randombytes "^2.0.0"
|
randombytes "^2.0.0"
|
||||||
|
|
||||||
|
docker-file-parser@^1.0.5:
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/docker-file-parser/-/docker-file-parser-1.0.5.tgz#0081cf34d0cda620f0a539f8f8595da36e8bbb84"
|
||||||
|
integrity sha512-/pwpZWCVEonetpoEdvHUCH9KtfL/N96X0VVWjvKu/tQGqSE4RgR82kFcTG89xxp1J2LILzJy4ilyUfHpt2FBEQ==
|
||||||
|
|
||||||
domain-browser@^1.1.1:
|
domain-browser@^1.1.1:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
||||||
|
|
Loading…
Reference in New Issue