1 | ;
|
2 | var __importDefault = (this && this.__importDefault) || function (mod) {
|
3 | return (mod && mod.__esModule) ? mod : { "default": mod };
|
4 | };
|
5 | Object.defineProperty(exports, "__esModule", { value: true });
|
6 | const crypto_1 = __importDefault(require("crypto"));
|
7 | const fs_1 = __importDefault(require("fs"));
|
8 | const path_1 = __importDefault(require("path"));
|
9 | const dockerode_1 = __importDefault(require("dockerode"));
|
10 | // @ts-ignore
|
11 | const ndjson_1 = __importDefault(require("ndjson"));
|
12 | const docker_file_parser_1 = __importDefault(require("docker-file-parser"));
|
13 | const tar_fs_1 = __importDefault(require("tar-fs"));
|
14 | const zlib_1 = __importDefault(require("zlib"));
|
15 | /**
|
16 | * Builds Docker images from Dockerfiles
|
17 | *
|
18 | * Detect's the special `# dockter` comment and,
|
19 | * - sends the instructions prior to the comment to Docker to be built as normal and
|
20 | * - applies all following instructions into a single layer
|
21 | */
|
22 | class DockerBuilder {
|
23 | /**
|
24 | * Build a Docker image for a project
|
25 | *
|
26 | * @param dir The project directory
|
27 | * @param name The name to tag the image with
|
28 | * @param dockerfile The name of the Dockerfile within `dir` to use for the build
|
29 | */
|
30 | async build(dir, name, dockerfile = 'Dockerfile') {
|
31 | if (!name) {
|
32 | const hash = crypto_1.default.createHash('md5').update(dir).digest('hex');
|
33 | name = 'dockter-' + hash;
|
34 | }
|
35 | const content = fs_1.default.readFileSync(path_1.default.join(dir, dockerfile), 'utf8');
|
36 | let instructions = docker_file_parser_1.default.parse(content, { includeComments: true });
|
37 | // Collect all instructions prior to any `# dockter` comment into a
|
38 | // new Dockerfile and store remaining instructions for special handling.
|
39 | // Keep track of `WORKDIR` and `USER` instructions for consistent handling of those
|
40 | let workdir = '/';
|
41 | let user = 'root';
|
42 | let dockterize = false;
|
43 | let newContent = '';
|
44 | let index = 0;
|
45 | for (let instruction of instructions) {
|
46 | if (instruction.name === 'WORKDIR') {
|
47 | workdir = path_1.default.join(workdir, instruction.args);
|
48 | }
|
49 | else if (instruction.name === 'USER') {
|
50 | user = instruction.args;
|
51 | }
|
52 | else if (instruction.name === 'COMMENT') {
|
53 | const arg = instruction.args;
|
54 | if (arg.match(/^# *dockter/)) {
|
55 | instructions = instructions.slice(index + 1);
|
56 | dockterize = true;
|
57 | break;
|
58 | }
|
59 | }
|
60 | if (instruction.raw)
|
61 | newContent += instruction.raw + '\n';
|
62 | index += 1;
|
63 | }
|
64 | // If there was no # dockter comment then make sure there are no
|
65 | // 'extra' instructions
|
66 | if (!dockterize)
|
67 | instructions = [];
|
68 | // Pack the directory and replace the Dockerfile with the new one
|
69 | const tar = tar_fs_1.default.pack(dir, {
|
70 | ignore: name => {
|
71 | const relpath = path_1.default.relative(dir, name);
|
72 | // Ignore original Dockerfile
|
73 | // Ignore the special `snapshot` directory which exists when this
|
74 | // is run within a `pkg` binary and dir is `.`
|
75 | return relpath === 'Dockerfile' || relpath[0] === '.' || relpath === 'snapshot';
|
76 | },
|
77 | finalize: false,
|
78 | finish: pack => {
|
79 | // Add new Dockerfile
|
80 | pack.entry({ name: 'Dockerfile' }, newContent);
|
81 | pack.finalize();
|
82 | }
|
83 | });
|
84 | const targz = tar.pipe(zlib_1.default.createGzip());
|
85 | // The following line can be useful in debugging the
|
86 | // above tar stream generation
|
87 | // targz.pipe(fs.createWriteStream('/tmp/dockter-builder-debug-1.tar.gz'))
|
88 | const docker = new dockerode_1.default();
|
89 | const messages = [];
|
90 | const stream = await docker.buildImage(targz, {
|
91 | // Options to Docker ImageBuild operation
|
92 | // See https://docs.docker.com/engine/api/v1.37/#operation/ImageBuild
|
93 | t: name + ':system'
|
94 | });
|
95 | // The following catches errors from abovr and turns them into messages but does
|
96 | // nothing with them. It's commented out for now, so errors get thrown to console,
|
97 | // but will be reinstated when we determine the best place to attach these errors/messages
|
98 | /*
|
99 | .catch(error => {
|
100 | let line
|
101 | let message = error.message
|
102 | const match = message.match(/^\(HTTP code 400\) unexpected - Dockerfile parse error line (\d+): (.*)$/)
|
103 | if (match) {
|
104 | line = parseInt(match[1], 0)
|
105 | message = match[2]
|
106 | }
|
107 | messages.push({
|
108 | level: 'error',
|
109 | line: line,
|
110 | message: message
|
111 | })
|
112 | })
|
113 |
|
114 | // If there were any errors then return
|
115 | //if (!stream) return
|
116 | */
|
117 | // Wait for build to finish and record the id of the system layer
|
118 | let currentSystemLayer = await new Promise((resolve, reject) => {
|
119 | let id;
|
120 | stream.pipe(ndjson_1.default.parse()).on('data', (data) => {
|
121 | if (data.error) {
|
122 | messages.push({
|
123 | level: 'error',
|
124 | message: data.error
|
125 | });
|
126 | console.error(data.error);
|
127 | }
|
128 | else if (data.aux && data.aux.ID) {
|
129 | id = data.aux.ID;
|
130 | }
|
131 | else {
|
132 | // We could keep track of data that looks like this
|
133 | // {"stream":"Step 2/2 : RUN foo"}
|
134 | // to match any errors with lines in the Dockerfile content
|
135 | if (data.stream) {
|
136 | process.stderr.write(data.stream);
|
137 | }
|
138 | }
|
139 | });
|
140 | stream.on('end', () => resolve(id));
|
141 | stream.on('error', reject);
|
142 | });
|
143 | // Check for any error message
|
144 | const errors = messages.filter(message => message.level === 'error');
|
145 | if (errors.length)
|
146 | throw new Error(`There was an error when building the image: ${errors.map(error => error.message).join(',')}`);
|
147 | // Get information on the current
|
148 | const image = docker.getImage(name + ':latest');
|
149 | let appLayer;
|
150 | let lastSystemLayer;
|
151 | try {
|
152 | const imageInfo = await image.inspect();
|
153 | appLayer = imageInfo.Id;
|
154 | lastSystemLayer = imageInfo.Config.Labels && imageInfo.Config.Labels.systemLayer;
|
155 | }
|
156 | catch (error) {
|
157 | // No existing image, just continue
|
158 | }
|
159 | // If the foundation image has changed then use the new version,
|
160 | // otherwise use the existing one
|
161 | let layer;
|
162 | if (lastSystemLayer) {
|
163 | if (lastSystemLayer !== currentSystemLayer)
|
164 | layer = currentSystemLayer;
|
165 | else
|
166 | layer = appLayer;
|
167 | }
|
168 | else {
|
169 | layer = currentSystemLayer;
|
170 | }
|
171 | // Create a container from the layer and start it up
|
172 | let container = await docker.createContainer({
|
173 | Image: layer,
|
174 | Tty: true,
|
175 | Cmd: ['/bin/bash']
|
176 | });
|
177 | await container.start();
|
178 | // Handle the remaining instructions
|
179 | let count = 1;
|
180 | let changes = '';
|
181 | for (let instruction of instructions) {
|
182 | const step = `Dockter ${count}/${instructions.length} :`;
|
183 | switch (instruction.name) {
|
184 | case 'USER':
|
185 | user = instruction.args;
|
186 | break;
|
187 | case 'WORKDIR':
|
188 | workdir = path_1.default.join(workdir, instruction.args);
|
189 | break;
|
190 | case 'COPY':
|
191 | case 'ADD':
|
192 | // Add files/subdirs to the container
|
193 | const copy = instruction.args;
|
194 | const to = copy.pop();
|
195 | const pack = tar_fs_1.default.pack(dir, {
|
196 | // Set the destination of each file (last item in `COPY` command)
|
197 | map: function (header) {
|
198 | header.name = to;
|
199 | return header;
|
200 | },
|
201 | // Ignore any files in the directory that are not in the `COPY` list
|
202 | ignore: name => {
|
203 | const relativePath = path_1.default.relative(dir, name);
|
204 | return !copy.includes(relativePath);
|
205 | }
|
206 | });
|
207 | await container.putArchive(pack, { path: workdir });
|
208 | break;
|
209 | case 'RUN':
|
210 | // Execute code in the container
|
211 | const script = instruction.args;
|
212 | const exec = await container.exec({
|
213 | Cmd: ['bash', '-c', `${script}`],
|
214 | AttachStdout: true,
|
215 | AttachStderr: true,
|
216 | User: user,
|
217 | Tty: true
|
218 | });
|
219 | await exec.start();
|
220 | exec.output.pipe(process.stdout);
|
221 | // Wait until the exec has finished running, checking every 100ms
|
222 | while (true) {
|
223 | let status = await exec.inspect();
|
224 | if (status.Running === false)
|
225 | break;
|
226 | await new Promise(resolve => setTimeout(resolve, 100));
|
227 | }
|
228 | break;
|
229 | case 'CMD':
|
230 | // Dockerfile instructions to apply when committing the image
|
231 | changes += instruction.raw + '\n\n';
|
232 | break;
|
233 | case 'COMMENT':
|
234 | // Just ignore it!
|
235 | break;
|
236 | default:
|
237 | throw new Error(`Dockter can not yet handle a ${instruction.name} instruction. Put it before the # dockter comment in your Dockerfile.`);
|
238 | }
|
239 | count += 1;
|
240 | }
|
241 | // Create an image from the modified container
|
242 | const data = await container.commit({
|
243 | // Options to commit
|
244 | // See https://docs.docker.com/engine/api/v1.37/#operation/ImageCommit
|
245 | repo: name,
|
246 | comment: instructions.length > 0 ? 'Updated application layer' : 'No updates requested',
|
247 | changes,
|
248 | User: user,
|
249 | WorkingDir: workdir,
|
250 | Labels: {
|
251 | systemLayer: currentSystemLayer
|
252 | }
|
253 | });
|
254 | await container.stop();
|
255 | }
|
256 | }
|
257 | exports.default = DockerBuilder;
|