UNPKG

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