@threadedstack/wmd-web 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/RELEASE.json +39 -54
- package/guest/arm64/Image +2 -2
- package/guest/arm64/Image-4k +1 -1
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +4 -4
- package/relay/hosted.ts +27 -11
- package/relay/main.ts +3 -3
- package/src/boot.js +25 -0
- package/src/build/builder.js +791 -0
- package/src/build/context.js +349 -0
- package/src/build/dockerfile.js +1055 -0
- package/src/build/dockerignore.js +270 -0
- package/src/build/fast.js +1187 -0
- package/src/build/inspect.js +78 -0
- package/src/build/pin.js +6 -0
- package/src/build/tar.js +304 -0
- package/src/host/ctl.js +17 -0
- package/src/host/disk-map.js +330 -0
- package/src/host/disk-worker.js +125 -0
- package/src/host/disk.js +219 -0
- package/src/host/wmd-imports.js +3 -3
- package/src/image-files.js +2 -1
- package/src/registry/hub.js +113 -44
- package/src/registry/load-tar.js +473 -0
- package/src/registry/pull.js +5 -3
- package/src/worker.js +2 -0
- package/wmdpack.wasm +0 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
// The no-RUN fast path (docs/plans/web-buildkit-impl.md 5.5): a Dockerfile of only FROM, COPY (from the context),
|
|
2
|
+
// ENV, ARG, LABEL, WORKDIR, USER, EXPOSE, ENTRYPOINT, CMD, STOPSIGNAL, VOLUME and HEALTHCHECK is built in the page:
|
|
3
|
+
// the base is pulled, each COPY (and a WORKDIR that makes a folder) becomes one layer tar written the way BuildKit's
|
|
4
|
+
// file operation writes it, each tar is converted to a pack, and the config and history are written as the
|
|
5
|
+
// dockerfile frontend writes them.
|
|
6
|
+
//
|
|
7
|
+
// const e = fastEligible(text, { args, target }) // {ok: true, plan} | {ok: false, why}
|
|
8
|
+
// const r = await fastBuild({ plan, context, pull, converter, platform, args, target, onEvent })
|
|
9
|
+
// // r: {index, packs, config, history, path: 'fast', ...}; index and packs are what pullImage returns (layers top first)
|
|
10
|
+
//
|
|
11
|
+
// Everything the plan does not list goes to BuildKit, decided before any work (fastEligible) or, for what only the
|
|
12
|
+
// pulled base shows (ONBUILD in its config, the account files /etc/passwd and /etc/group), by fastBuild throwing
|
|
13
|
+
// FastRefused. Any doubt is a refusal: a build that falls back costs time, a build that differs costs correctness.
|
|
14
|
+
//
|
|
15
|
+
// BuildKit sources read for this port (third_party/buildkit, v0.33.0 + tonistiigi/fsutil copy/): dockerfile2llb
|
|
16
|
+
// (dispatchCopy, dispatchWorkdir, dispatch*), solver/llbsolver/file (docopy, mkdir), ops/user_unix.go (readUser),
|
|
17
|
+
// fsutil copy.Copy (prepareTargetDir, copyDirectory, MkdirAll).
|
|
18
|
+
//
|
|
19
|
+
// Context: {list, read} (list(): entries or an async iterable of {path, type: 'file'|'dir'|'symlink', mode, mtime
|
|
20
|
+
// (seconds), target}; read(path): bytes), a tar (Uint8Array), or {[path]: Uint8Array|string}. A source with no modes
|
|
21
|
+
// gives 0644 files and 0755 folders (5.6). All entries are 0:0 on arrival, as BuildKit's local source maps them.
|
|
22
|
+
|
|
23
|
+
import { Overlay } from '../fs/overlay.js'
|
|
24
|
+
import { Pack, bytesSource } from '../fs/pack.js'
|
|
25
|
+
import { FsError } from '../fs/errno.js'
|
|
26
|
+
import { ImageFiles } from '../image-files.js'
|
|
27
|
+
import { createConverter } from '../registry/converter.js'
|
|
28
|
+
import { sha256 } from '../registry/hub.js'
|
|
29
|
+
import { CONVERTER } from '../registry/pull.js'
|
|
30
|
+
import { IMAGE_SCHEMA, parsePlatform, parseRef, platformString } from '../registry/ref.js'
|
|
31
|
+
import {
|
|
32
|
+
DockerfileError, Env, FallbackError, expandWord, goDurationString, goQuote, goQuoteList, parseDockerfile,
|
|
33
|
+
} from './dockerfile.js'
|
|
34
|
+
import { matcher, parseDockerignore } from './dockerignore.js'
|
|
35
|
+
import { ContextError, packContext } from './context.js'
|
|
36
|
+
import { decodeText, scanTarBytes, tarWriter } from './tar.js'
|
|
37
|
+
|
|
38
|
+
/** The base or the build needs BuildKit: fastBuild stopped before returning an image. */
|
|
39
|
+
export class FastRefused extends FallbackError {
|
|
40
|
+
constructor(why) {
|
|
41
|
+
super(why)
|
|
42
|
+
this.name = 'FastRefused'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HISTORY_COMMENT = 'buildkit.dockerfile.v0'
|
|
47
|
+
const DEFAULT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
48
|
+
const DEFAULT_SHELL = ['/bin/sh', '-c']
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- paths (Go's path, filepath, system.NormalizePath)
|
|
51
|
+
|
|
52
|
+
/** path.Clean for a rooted path: '/a//b/../c/' -> '/a/c'. */
|
|
53
|
+
function clean(p) {
|
|
54
|
+
const out = []
|
|
55
|
+
for (const s of p.split('/')) {
|
|
56
|
+
if (s === '' || s === '.') continue
|
|
57
|
+
if (s === '..') out.pop()
|
|
58
|
+
else out.push(s)
|
|
59
|
+
}
|
|
60
|
+
return `/${out.join('/')}`
|
|
61
|
+
}
|
|
62
|
+
const parts = (abs) => clean(abs).split('/').filter(Boolean)
|
|
63
|
+
const join = (a, b) => clean(`${a}/${b}`)
|
|
64
|
+
const dirname = (abs) => {
|
|
65
|
+
const c = clean(abs)
|
|
66
|
+
return c.slice(0, c.lastIndexOf('/')) || '/'
|
|
67
|
+
}
|
|
68
|
+
const basename = (abs) => {
|
|
69
|
+
const c = clean(abs)
|
|
70
|
+
return c === '/' ? '/' : c.slice(c.lastIndexOf('/') + 1)
|
|
71
|
+
}
|
|
72
|
+
const tarName = (abs) => clean(abs).slice(1)
|
|
73
|
+
|
|
74
|
+
/** system.NormalizePath(parent, newPath, 'linux', keepSlash). */
|
|
75
|
+
function normalizePath(parent, newPath, keepSlash) {
|
|
76
|
+
let par = parent === '' ? '/' : parent
|
|
77
|
+
if (!par.startsWith('/')) par = clean(`/${par}`)
|
|
78
|
+
const orig = newPath
|
|
79
|
+
let p = newPath === '' ? par : newPath
|
|
80
|
+
if (!p.startsWith('/')) p = clean(`${par}/${p}`)
|
|
81
|
+
if (keepSlash) {
|
|
82
|
+
if (orig.endsWith('/') && !p.endsWith('/')) p += '/'
|
|
83
|
+
else if (orig.endsWith('/.')) {
|
|
84
|
+
if (p !== '/') p += '/'
|
|
85
|
+
p += '.'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return p
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** system.NormalizeWorkdir(current, wd): an absolute workdir is kept as written. */
|
|
92
|
+
export function normalizeWorkdir(current, wd) {
|
|
93
|
+
return normalizePath(current, wd, false)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolves symlinks in every component of an absolute path as fs.RootPath does (the last one too when followLast):
|
|
98
|
+
* absolute targets restart at the root, '..' stops at the root, a missing component keeps the rest as written.
|
|
99
|
+
* lstat(abs) -> {type, target}|null.
|
|
100
|
+
*/
|
|
101
|
+
function realPath(path, lstat, followLast = true) {
|
|
102
|
+
let ps = parts(path)
|
|
103
|
+
const out = []
|
|
104
|
+
let hops = 0
|
|
105
|
+
for (let i = 0; i < ps.length; i++) {
|
|
106
|
+
const c = ps[i]
|
|
107
|
+
const cand = `/${[...out, c].join('/')}`
|
|
108
|
+
const st = lstat(cand)
|
|
109
|
+
if (!st) {
|
|
110
|
+
out.push(c, ...ps.slice(i + 1))
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
if (st.type === 'symlink' && (i < ps.length - 1 || followLast)) {
|
|
114
|
+
if (++hops > 255) throw new FastRefused(`too many levels of symbolic links at ${cand}`)
|
|
115
|
+
const t = st.target
|
|
116
|
+
const next = t.startsWith('/') ? clean(t) : clean(`/${out.join('/')}/${t}`)
|
|
117
|
+
ps = [...next.split('/').filter(Boolean), ...ps.slice(i + 1)]
|
|
118
|
+
out.length = 0
|
|
119
|
+
i = -1
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
out.push(c)
|
|
123
|
+
}
|
|
124
|
+
return `/${out.join('/')}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- filepath.Match and wildcard sources
|
|
128
|
+
|
|
129
|
+
const regexEsc = (c) => c.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
130
|
+
|
|
131
|
+
/** filepath.Match as a RegExp: '*' and '?' stop at '/', '[a-z]' and '[^a-z]' classes, '\\x' escapes. null when malformed. */
|
|
132
|
+
function globRegExp(pat) {
|
|
133
|
+
const cs = Array.from(pat)
|
|
134
|
+
let re = '^'
|
|
135
|
+
for (let i = 0; i < cs.length; i++) {
|
|
136
|
+
const c = cs[i]
|
|
137
|
+
if (c === '*') re += '[^/]*'
|
|
138
|
+
else if (c === '?') re += '[^/]'
|
|
139
|
+
else if (c === '\\') {
|
|
140
|
+
if (i + 1 >= cs.length) return null
|
|
141
|
+
re += regexEsc(cs[++i])
|
|
142
|
+
} else if (c === '[') {
|
|
143
|
+
let j = i + 1
|
|
144
|
+
let cls = '['
|
|
145
|
+
if (cs[j] === '^') {
|
|
146
|
+
cls += '^'
|
|
147
|
+
j++
|
|
148
|
+
}
|
|
149
|
+
let n = 0
|
|
150
|
+
for (; j < cs.length && (cs[j] !== ']' || n === 0); j++, n++) {
|
|
151
|
+
if (cs[j] === '\\') {
|
|
152
|
+
if (j + 1 >= cs.length) return null
|
|
153
|
+
cls += regexEsc(cs[++j])
|
|
154
|
+
} else if (cs[j] === '-') cls += '-'
|
|
155
|
+
else cls += regexEsc(cs[j])
|
|
156
|
+
}
|
|
157
|
+
if (j >= cs.length) return null
|
|
158
|
+
re += `${cls}]`
|
|
159
|
+
i = j
|
|
160
|
+
} else re += regexEsc(c)
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return new RegExp(`${re}$`, 'u')
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const hasWildcard = (name) => {
|
|
170
|
+
for (let i = 0; i < name.length; i++) {
|
|
171
|
+
const c = name[i]
|
|
172
|
+
if (c === '\\') i++
|
|
173
|
+
else if (c === '*' || c === '?' || c === '[') return true
|
|
174
|
+
}
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** fsutil splitWildcards: the folder part before the first component with a wildcard, and the rest. */
|
|
179
|
+
function splitWildcards(p) {
|
|
180
|
+
const ps = clean(`/${p}`).split('/') // '' first
|
|
181
|
+
const p1 = []
|
|
182
|
+
const p2 = []
|
|
183
|
+
let found = false
|
|
184
|
+
for (let c of ps) {
|
|
185
|
+
if (!found && hasWildcard(c)) found = true
|
|
186
|
+
if (c === '') c = '/'
|
|
187
|
+
if (!found) p1.push(c)
|
|
188
|
+
else p2.push(c)
|
|
189
|
+
}
|
|
190
|
+
const j = (a) => clean(a.join('/'))
|
|
191
|
+
return { d1: j(p1), d2: p2.join('/') }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------- the context
|
|
195
|
+
|
|
196
|
+
/** A folder of the build context, indexed: path ('/a/b', '/' the root) -> {type, mode, mtime, target, read}. */
|
|
197
|
+
class Context {
|
|
198
|
+
constructor() {
|
|
199
|
+
this.map = new Map([['/', { type: 'dir', mode: 0o755, mtime: null, implicit: true }]])
|
|
200
|
+
this.kids = new Map()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
add(path, e) {
|
|
204
|
+
const p = clean(path)
|
|
205
|
+
if (p === '/') {
|
|
206
|
+
// the root's mtime is not carried into BuildKit's local source (its snapshot root is made at transfer time): the build time
|
|
207
|
+
if (e.type === 'dir') this.map.set('/', { ...e, mtime: null, implicit: true })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
for (let d = dirname(p); d !== '/'; d = dirname(d)) {
|
|
211
|
+
if (!this.map.has(d)) this.map.set(d, { type: 'dir', mode: 0o755, mtime: null, implicit: true })
|
|
212
|
+
}
|
|
213
|
+
this.map.set(p, e)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
seal() {
|
|
217
|
+
this.kids = new Map()
|
|
218
|
+
for (const p of this.map.keys()) {
|
|
219
|
+
if (p === '/') continue
|
|
220
|
+
const d = dirname(p)
|
|
221
|
+
if (!this.kids.has(d)) this.kids.set(d, [])
|
|
222
|
+
this.kids.get(d).push(basename(p))
|
|
223
|
+
}
|
|
224
|
+
for (const l of this.kids.values()) l.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
225
|
+
return this
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lstat(p) {
|
|
229
|
+
return this.map.get(clean(p)) || null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
children(p) {
|
|
233
|
+
return this.kids.get(clean(p)) || []
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A copy without what .dockerignore patterns drop (a folder stays when something under it stays). */
|
|
237
|
+
filtered(ignoredFn) {
|
|
238
|
+
const keep = new Set(['/'])
|
|
239
|
+
for (const p of this.map.keys()) {
|
|
240
|
+
if (p === '/') continue
|
|
241
|
+
if (ignoredFn(p.slice(1))) continue
|
|
242
|
+
for (let d = p; d !== '/'; d = dirname(d)) keep.add(d)
|
|
243
|
+
}
|
|
244
|
+
const out = new Context()
|
|
245
|
+
for (const [p, e] of this.map) if (keep.has(p)) out.map.set(p, e)
|
|
246
|
+
return out.seal()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const TYPE_NAMES = { file: 'file', f: 'file', '0': 'file', '7': 'file', dir: 'dir', directory: 'dir', d: 'dir', '5': 'dir', symlink: 'symlink', link: 'symlink', l: 'symlink', '2': 'symlink' }
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The context as an indexed Context. Every source kind goes through context.js packContext (file source, plain object,
|
|
254
|
+
* tar; its limits, path checks and .dockerignore filtering for file sources and objects) and the tar it returns is read
|
|
255
|
+
* here, so modes and mtimes are the builder's. A tar passes through packContext unfiltered; applyDockerignore filters it.
|
|
256
|
+
*/
|
|
257
|
+
async function loadContext(input, { dockerfileName, limits, omitDockerfile } = {}) {
|
|
258
|
+
let tar
|
|
259
|
+
try {
|
|
260
|
+
;({ tar } = await packContext(input, { dockerfileName, limits, ...(omitDockerfile ? { dockerfile: '' } : {}) }))
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof ContextError) throw new FastRefused(e.message)
|
|
263
|
+
throw e
|
|
264
|
+
}
|
|
265
|
+
const ctx = new Context()
|
|
266
|
+
const norm = (p) => clean(`/${String(p).replace(/^(\.\/)+/, '')}`)
|
|
267
|
+
const entry = (raw, readFn) => {
|
|
268
|
+
const type = TYPE_NAMES[raw.type] || (raw.isDir ? 'dir' : raw.target || raw.linkname ? 'symlink' : 'file')
|
|
269
|
+
const mode = (raw.mode ?? (type === 'dir' ? 0o755 : 0o644)) & 0o7777
|
|
270
|
+
let mtime = raw.mtime ?? 0
|
|
271
|
+
if (mtime instanceof Date) mtime = Math.floor(mtime.getTime() / 1000)
|
|
272
|
+
const e = { type, mode, mtime: Number(mtime) }
|
|
273
|
+
if (type === 'symlink') e.target = raw.target ?? raw.linkname
|
|
274
|
+
if (type === 'file') e.read = readFn
|
|
275
|
+
return e
|
|
276
|
+
}
|
|
277
|
+
const byName = new Map()
|
|
278
|
+
const hard = []
|
|
279
|
+
for await (const t of scanTarBytes(tar)) {
|
|
280
|
+
const p = norm(t.name)
|
|
281
|
+
// the layer writer reads a file named .wh.* as a whiteout; BuildKit's copy would write it literally and the diff reads it as one
|
|
282
|
+
if (p.split('/').some(c => c.startsWith('.wh.'))) throw new FastRefused(`the context holds ${JSON.stringify(t.name)}, a name that means a whiteout in a layer`)
|
|
283
|
+
if (t.type === '1') {
|
|
284
|
+
hard.push([p, t])
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (!(t.type in TYPE_NAMES) && t.type !== '\0') throw new FastRefused(`the context holds a special file (${JSON.stringify(t.name)}, tar type ${JSON.stringify(t.type)})`)
|
|
288
|
+
const data = tar.subarray(t.offset, t.offset + (t.type === '0' || t.type === '7' ? t.size : 0))
|
|
289
|
+
const e = entry({ type: t.type === '\0' ? '0' : t.type, mode: t.mode, mtime: t.mtime, target: t.linkname }, () => data)
|
|
290
|
+
byName.set(p, e)
|
|
291
|
+
ctx.add(p, e)
|
|
292
|
+
}
|
|
293
|
+
for (const [p, t] of hard) {
|
|
294
|
+
const tgt = byName.get(norm(t.linkname))
|
|
295
|
+
if (!tgt || tgt.type !== 'file') throw new FastRefused(`the context has a hard link to ${JSON.stringify(t.linkname)} that is not a file in it`)
|
|
296
|
+
ctx.add(p, { ...entry({ type: '0', mode: t.mode, mtime: t.mtime }, tgt.read), hard: true })
|
|
297
|
+
}
|
|
298
|
+
return ctx.seal()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function applyDockerignore(ctx, dockerfileName) {
|
|
302
|
+
for (const name of [`${dockerfileName}.dockerignore`, '.dockerignore']) {
|
|
303
|
+
const e = ctx.lstat(`/${name}`)
|
|
304
|
+
if (e && e.type === 'file') {
|
|
305
|
+
const text = decodeText(await e.read())
|
|
306
|
+
const patterns = parseDockerignore(text)
|
|
307
|
+
if (patterns.length === 0) return ctx
|
|
308
|
+
const m = matcher(patterns)
|
|
309
|
+
return ctx.filtered((p) => m.ignored(p, ctx.lstat(`/${p}`)?.type === 'dir'))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return ctx
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------- accounts (ops/user_unix.go readUser)
|
|
316
|
+
|
|
317
|
+
// moby/sys/user ParsePasswdFilter / ParseGroupFilter: lines are trimmed, a blank line is skipped (a group file also skips
|
|
318
|
+
// '#' lines), fields missing from a short line stay at their zero value, and a number that does not parse is 0.
|
|
319
|
+
const goAtoi = (x) => (x !== undefined && /^[+-]?\d+$/.test(x) ? Number(x) : 0)
|
|
320
|
+
const goTrim = (x) => x.replace(/^[\t\n\v\f\r \u0085\u00a0]+|[\t\n\v\f\r \u0085\u00a0]+$/g, '')
|
|
321
|
+
function parsePasswd(text) {
|
|
322
|
+
const out = []
|
|
323
|
+
for (const raw of text.split('\n')) {
|
|
324
|
+
const line = goTrim(raw)
|
|
325
|
+
if (line === '') continue
|
|
326
|
+
const f = line.split(':')
|
|
327
|
+
out.push({ name: f[0], uid: goAtoi(f[2]), gid: goAtoi(f[3]) })
|
|
328
|
+
}
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
function parseGroup(text) {
|
|
332
|
+
const out = []
|
|
333
|
+
for (const raw of text.split('\n')) {
|
|
334
|
+
const line = goTrim(raw)
|
|
335
|
+
if (line === '' || line[0] === '#') continue
|
|
336
|
+
const f = line.split(':')
|
|
337
|
+
out.push({ name: f[0], gid: goAtoi(f[2]) })
|
|
338
|
+
}
|
|
339
|
+
return out
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const parseUID = (s) => {
|
|
343
|
+
if (s === 'root') return 0
|
|
344
|
+
if (!/^[+-]?\d+$/.test(s)) return null
|
|
345
|
+
const n = Number(s)
|
|
346
|
+
return n >= -(2 ** 31) && n < 2 ** 31 ? n : null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* llb.WithUser(spec) read against the account files: "name", "uid", "name:group", "uid:gid". A number is the uid AND the
|
|
351
|
+
* gid (no group); a name takes its passwd uid and gid; a name that is not in the file is 0 (0:0), with no error; a
|
|
352
|
+
* group name is looked up in /etc/group and left alone when absent.
|
|
353
|
+
* @returns {{uid: number, gid: number}}
|
|
354
|
+
*/
|
|
355
|
+
export function readUserSpec(spec, { passwd = '', group = '' } = {}) {
|
|
356
|
+
const [u, g] = spec.includes(':') ? [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)] : [spec, null]
|
|
357
|
+
const us = { uid: 0, gid: 0 }
|
|
358
|
+
const uid = parseUID(u)
|
|
359
|
+
if (uid !== null) us.uid = us.gid = uid
|
|
360
|
+
else {
|
|
361
|
+
const row = parsePasswd(passwd).find((r) => r.name === u)
|
|
362
|
+
if (row) {
|
|
363
|
+
us.uid = row.uid
|
|
364
|
+
us.gid = row.gid
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (g !== null) {
|
|
368
|
+
// "app:" and "7:" leave the group empty: WithUser marshals it as ByID 0, so the gid is 0 (client/llb/fileop.go)
|
|
369
|
+
if (g === '') us.gid = 0
|
|
370
|
+
else {
|
|
371
|
+
const gid = parseUID(g)
|
|
372
|
+
if (gid !== null) us.gid = gid
|
|
373
|
+
else {
|
|
374
|
+
const row = parseGroup(group).find((r) => r.name === g)
|
|
375
|
+
if (row) us.gid = row.gid
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return us
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** An owner the layer tar can hold (BuildKit marshals ids as uint32): FastRefused otherwise, before any work is done. */
|
|
383
|
+
function checkOwner(o, what) {
|
|
384
|
+
for (const k of ['uid', 'gid']) {
|
|
385
|
+
if (!Number.isInteger(o[k]) || o[k] < 0 || o[k] > 0xffffffff) throw new FastRefused(`${what}: ${k} ${o[k]} is not an id a layer can hold`)
|
|
386
|
+
}
|
|
387
|
+
return o
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const specNeedsAccounts = (spec) => {
|
|
391
|
+
const [u, g] = spec.includes(':') ? [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)] : [spec, null]
|
|
392
|
+
return parseUID(u) === null || (g !== null && parseUID(g) === null)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------- eligibility
|
|
396
|
+
|
|
397
|
+
const PLATFORM_ARGS = ['BUILDPLATFORM', 'BUILDOS', 'BUILDOSVERSION', 'BUILDARCH', 'BUILDVARIANT', 'TARGETPLATFORM', 'TARGETOS', 'TARGETOSVERSION', 'TARGETARCH', 'TARGETVARIANT', 'TARGETSTAGE']
|
|
398
|
+
const SIGNALS = new Set([
|
|
399
|
+
'HUP', 'INT', 'QUIT', 'ILL', 'TRAP', 'ABRT', 'BUS', 'FPE', 'KILL', 'USR1', 'SEGV', 'USR2', 'PIPE', 'ALRM', 'TERM', 'STKFLT', 'CHLD', 'CONT', 'STOP',
|
|
400
|
+
'TSTP', 'TTIN', 'TTOU', 'URG', 'XCPU', 'XFSZ', 'VTALRM', 'PROF', 'WINCH', 'IO', 'PWR', 'SYS',
|
|
401
|
+
])
|
|
402
|
+
const validSignal = (s) => /^\d+$/.test(s) ? Number(s) >= 0 && Number(s) <= 64 : SIGNALS.has(s.replace(/^SIG/, '')) || /^(SIG)?RTM(IN|AX)(\+\d+)?$/.test(s)
|
|
403
|
+
|
|
404
|
+
/** The predefined global args (defaultArgs): BUILDPLATFORM... for a build and target of the one platform. */
|
|
405
|
+
function globalArgsFor(platform, targetName) {
|
|
406
|
+
const p = parsePlatform(platform)
|
|
407
|
+
const s = platformString(p)
|
|
408
|
+
const env = new Env([
|
|
409
|
+
['BUILDPLATFORM', s], ['BUILDOS', p.os], ['BUILDOSVERSION', ''], ['BUILDARCH', p.architecture], ['BUILDVARIANT', p.variant],
|
|
410
|
+
['TARGETPLATFORM', s], ['TARGETOS', p.os], ['TARGETOSVERSION', ''], ['TARGETARCH', p.architecture], ['TARGETVARIANT', p.variant],
|
|
411
|
+
['TARGETSTAGE', targetName || 'default'],
|
|
412
|
+
])
|
|
413
|
+
return env
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A word BuildKit expands: checks, without values, that it expands at all (a modifier or a bad quote refuses). */
|
|
417
|
+
function checkWord(word, what) {
|
|
418
|
+
try {
|
|
419
|
+
expandWord(word, { get: () => '' })
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (e instanceof FallbackError) throw new FastRefused(`${what}: ${e.why}`)
|
|
422
|
+
throw new FastRefused(`${what}: ${e.message}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const REMOTE_SRC = /^(https?:\/\/|git@|git:\/\/|ssh:\/\/|ftp:\/\/|github\.com\/)/
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Decides before any work. {ok: true, plan} | {ok: false, why}. `args`: build args; `target`: the target stage (default the
|
|
430
|
+
* last); `platform`: the build's (default linux/arm64).
|
|
431
|
+
*/
|
|
432
|
+
export function fastEligible(text, { args = {}, target = '', platform = 'linux/arm64' } = {}) {
|
|
433
|
+
const no = (why) => ({ ok: false, why })
|
|
434
|
+
let df
|
|
435
|
+
try {
|
|
436
|
+
df = parseDockerfile(text)
|
|
437
|
+
} catch (e) {
|
|
438
|
+
if (e instanceof DockerfileError) return no(`the Dockerfile does not parse (${e.message}); BuildKit reports it`)
|
|
439
|
+
throw e
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
// directives.go discardShebang: a leading `#!` line is skipped before `# syntax=` is read, so the frontend may be another one
|
|
443
|
+
if (/^\uFEFF?#!/.test(text)) return no('a #! line before the Dockerfile (BuildKit reads the syntax directive after it)')
|
|
444
|
+
for (const name of Object.keys(df.directives)) return no(`the parser directive # ${name}= (BuildKit's frontend decides what it means)`)
|
|
445
|
+
if (df.metaArgs.length) return no('ARG before FROM')
|
|
446
|
+
for (const k of Object.keys(args)) {
|
|
447
|
+
if (PLATFORM_ARGS.includes(k)) return no(`the build arg ${k} (it changes the platform BuildKit builds for)`)
|
|
448
|
+
if (k === 'SOURCE_DATE_EPOCH' || k.startsWith('BUILDKIT_')) return no(`the build arg ${k} (a BuildKit control)`)
|
|
449
|
+
}
|
|
450
|
+
let want
|
|
451
|
+
try {
|
|
452
|
+
want = parsePlatform(platform)
|
|
453
|
+
} catch (e) {
|
|
454
|
+
return no(`the platform: ${e.message}`)
|
|
455
|
+
}
|
|
456
|
+
const stage = pickStage(df, target)
|
|
457
|
+
if (!stage.stage) return no(stage.why)
|
|
458
|
+
const st = stage.stage
|
|
459
|
+
const idx = stage.index
|
|
460
|
+
|
|
461
|
+
// FROM: BuildKit expands the FROM of every stage, reachable or not (convert.go buildDispatchStates)
|
|
462
|
+
for (const s2 of df.stages) if (s2.baseName.includes('$')) return no('a variable in FROM')
|
|
463
|
+
const base = st.baseName
|
|
464
|
+
if (base.toLowerCase() === 'scratch') return no('FROM scratch')
|
|
465
|
+
for (let j = 0; j < idx; j++) if (df.stages[j].name && df.stages[j].name === base) return no(`FROM <earlier stage> (${base})`)
|
|
466
|
+
try {
|
|
467
|
+
parseRef(base)
|
|
468
|
+
} catch (e) {
|
|
469
|
+
return no(`FROM ${base}: ${e.message}`)
|
|
470
|
+
}
|
|
471
|
+
if (st.platform) {
|
|
472
|
+
const g = globalArgsFor(platform, st.name || (df.stages[df.stages.length - 1].name))
|
|
473
|
+
let v
|
|
474
|
+
try {
|
|
475
|
+
v = expandWord(st.platform, g)
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return no(`FROM --platform=${st.platform}: ${e.message}`)
|
|
478
|
+
}
|
|
479
|
+
let p
|
|
480
|
+
try {
|
|
481
|
+
p = parsePlatform(v)
|
|
482
|
+
} catch {
|
|
483
|
+
return no(`FROM --platform=${st.platform}: not a platform`)
|
|
484
|
+
}
|
|
485
|
+
if (platformString(p) !== platformString(want)) return no(`FROM --platform=${v} is not the build's platform`)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const steps = []
|
|
489
|
+
for (const c of st.commands) {
|
|
490
|
+
const where = `line ${c.line}`
|
|
491
|
+
switch (c.type) {
|
|
492
|
+
case 'run': return no(`RUN (${where})`)
|
|
493
|
+
case 'add': return no(`ADD (${where})`)
|
|
494
|
+
case 'shell': return no(`SHELL (${where})`)
|
|
495
|
+
case 'onbuild': return no(`ONBUILD (${where})`)
|
|
496
|
+
case 'maintainer': return no(`MAINTAINER (${where})`)
|
|
497
|
+
case 'copy': {
|
|
498
|
+
if (c.from) return no(`COPY --from (${where})`)
|
|
499
|
+
if (c.link) return no(`COPY --link (${where})`)
|
|
500
|
+
if (c.parents) return no(`COPY --parents (${where})`)
|
|
501
|
+
if (c.exclude.length) return no(`COPY --exclude (${where})`)
|
|
502
|
+
if (c.contents.length) return no(`a heredoc in COPY (${where})`)
|
|
503
|
+
for (const s of c.sources) if (REMOTE_SRC.test(s)) return no(`COPY of a URL or git source (${where})`)
|
|
504
|
+
for (const w of [...c.sources, c.dest, c.chown, c.chmod]) checkWord(w, `COPY (${where})`)
|
|
505
|
+
if (c.chmod) {
|
|
506
|
+
let m
|
|
507
|
+
try {
|
|
508
|
+
m = expandWord(c.chmod, { get: () => '' })
|
|
509
|
+
} catch {
|
|
510
|
+
m = null
|
|
511
|
+
}
|
|
512
|
+
if (m !== null && c.chmod === m && (!/^[0-7]+$/.test(m) || parseInt(m, 8) > 0o7777)) return no(`COPY --chmod=${m} is not an octal mode of 0..07777 (${where})`)
|
|
513
|
+
}
|
|
514
|
+
if (c.chown && !c.chown.includes('$')) {
|
|
515
|
+
// numeric ids go into a layer tar as they are: only 0..4294967295 can be held (BuildKit marshals uint32)
|
|
516
|
+
const [uu, gg] = c.chown.includes(':') ? [c.chown.slice(0, c.chown.indexOf(':')), c.chown.slice(c.chown.indexOf(':') + 1)] : [c.chown, '']
|
|
517
|
+
for (const x of [uu, gg]) if (/^[+-]?\d+$/.test(x) && (Number(x) < 0 || Number(x) > 0xffffffff)) return no(`COPY --chown=${c.chown}: an id outside 0..4294967295 (${where})`)
|
|
518
|
+
}
|
|
519
|
+
steps.push(c)
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
case 'env':
|
|
523
|
+
case 'label':
|
|
524
|
+
for (const p of c.pairs) {
|
|
525
|
+
checkWord(p.key, `${c.type.toUpperCase()} (${where})`)
|
|
526
|
+
checkWord(p.value, `${c.type.toUpperCase()} (${where})`)
|
|
527
|
+
}
|
|
528
|
+
steps.push(c)
|
|
529
|
+
break
|
|
530
|
+
case 'arg':
|
|
531
|
+
for (const a of c.args) if (a.value !== null) checkWord(a.value, `ARG (${where})`)
|
|
532
|
+
steps.push(c)
|
|
533
|
+
break
|
|
534
|
+
case 'workdir':
|
|
535
|
+
checkWord(c.path, `WORKDIR (${where})`)
|
|
536
|
+
steps.push(c)
|
|
537
|
+
break
|
|
538
|
+
case 'user':
|
|
539
|
+
checkWord(c.user, `USER (${where})`)
|
|
540
|
+
steps.push(c)
|
|
541
|
+
break
|
|
542
|
+
case 'volume':
|
|
543
|
+
for (const v of c.volumes) checkWord(v, `VOLUME (${where})`)
|
|
544
|
+
steps.push(c)
|
|
545
|
+
break
|
|
546
|
+
case 'stopsignal':
|
|
547
|
+
checkWord(c.signal, `STOPSIGNAL (${where})`)
|
|
548
|
+
steps.push(c)
|
|
549
|
+
break
|
|
550
|
+
case 'expose':
|
|
551
|
+
for (const p of c.ports) checkWord(p, `EXPOSE (${where})`)
|
|
552
|
+
steps.push(c)
|
|
553
|
+
break
|
|
554
|
+
case 'cmd':
|
|
555
|
+
case 'entrypoint':
|
|
556
|
+
if (c.cmdline.length === 0) return no(`an empty ${c.type.toUpperCase()} (${where})`)
|
|
557
|
+
steps.push(c)
|
|
558
|
+
break
|
|
559
|
+
case 'healthcheck':
|
|
560
|
+
steps.push(c)
|
|
561
|
+
break
|
|
562
|
+
default:
|
|
563
|
+
return no(`${c.type.toUpperCase()} (${where})`)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const lastName = df.stages[df.stages.length - 1].name
|
|
567
|
+
const plan = {
|
|
568
|
+
from: { ref: base, platform: platformString(want) },
|
|
569
|
+
stage: { name: st.name, index: idx },
|
|
570
|
+
targetName: target || lastName || '',
|
|
571
|
+
platform: platformString(want),
|
|
572
|
+
steps,
|
|
573
|
+
args: { ...args },
|
|
574
|
+
escape: df.escape,
|
|
575
|
+
}
|
|
576
|
+
return { ok: true, plan }
|
|
577
|
+
} catch (e) {
|
|
578
|
+
if (e instanceof FallbackError) return no(e.why)
|
|
579
|
+
throw e
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function pickStage(df, target) {
|
|
584
|
+
if (!df.stages.length) return { why: 'the Dockerfile has no stages' }
|
|
585
|
+
if (!target) return { stage: df.stages[df.stages.length - 1], index: df.stages.length - 1 }
|
|
586
|
+
const t = target.toLowerCase()
|
|
587
|
+
const i = df.stages.findLastIndex((s) => s.name === t) // addState: with a name given twice the last stage is the one named
|
|
588
|
+
if (i < 0) return { why: `the target stage ${JSON.stringify(target)} is not in the Dockerfile` }
|
|
589
|
+
return { stage: df.stages[i], index: i }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------- the image under construction
|
|
593
|
+
|
|
594
|
+
const packCache = new WeakMap()
|
|
595
|
+
function openPack(bytes, name) {
|
|
596
|
+
let p = packCache.get(bytes)
|
|
597
|
+
if (!p) {
|
|
598
|
+
p = new Pack(bytesSource(bytes), name)
|
|
599
|
+
packCache.set(bytes, p)
|
|
600
|
+
}
|
|
601
|
+
return p
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The image as the steps so far made it: reads through the packs, top first. */
|
|
605
|
+
class Rootfs {
|
|
606
|
+
constructor(stack) {
|
|
607
|
+
this.files = new ImageFiles(new Overlay({ layers: stack.slice().reverse().map((l, i) => openPack(l.bytes, `layer ${i}`)) }))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** {type, mode, uid, gid, mtime, target?} without following the last component, or null. */
|
|
611
|
+
lstat(path) {
|
|
612
|
+
try {
|
|
613
|
+
const s = this.files.stat(path, false)
|
|
614
|
+
return s && { type: s.type, mode: s.mode, uid: s.uid, gid: s.gid, target: s.target, mtime: Number((s.mtime ?? 0n) / 1000000000n) }
|
|
615
|
+
} catch (e) {
|
|
616
|
+
if (e instanceof FsError) return null
|
|
617
|
+
throw e
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
text(path) {
|
|
622
|
+
try {
|
|
623
|
+
return this.files.readText(path)
|
|
624
|
+
} catch (e) {
|
|
625
|
+
if (e instanceof FsError) return null
|
|
626
|
+
throw e
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** The files one instruction writes, over the image: a Map path -> entry in the order they go in the tar. */
|
|
632
|
+
class Pending {
|
|
633
|
+
constructor(rootfs, now) {
|
|
634
|
+
this.rootfs = rootfs
|
|
635
|
+
this.map = new Map()
|
|
636
|
+
this.now = now
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
lstat(path) {
|
|
640
|
+
const p = clean(path)
|
|
641
|
+
if (p === '/') return { type: 'dir', mode: 0o755 }
|
|
642
|
+
return this.map.get(p) || this.rootfs.lstat(p)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** os.Stat: follows symlinks (through the image) to what they name; null when it is missing. */
|
|
646
|
+
stat(path) {
|
|
647
|
+
const real = realPath(path, (p) => this.lstat(p), true)
|
|
648
|
+
const s = this.lstat(real)
|
|
649
|
+
return s && s.type === 'symlink' ? null : s
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** fsutil MkdirAll: every missing folder is made with perm and the owner; existing ones are left alone. */
|
|
653
|
+
mkdirAll(path, perm, owner) {
|
|
654
|
+
const ps = parts(path)
|
|
655
|
+
let cur = ''
|
|
656
|
+
for (const c of ps) {
|
|
657
|
+
cur = `${cur}/${c}`
|
|
658
|
+
const s = this.lstat(cur)
|
|
659
|
+
if (s) {
|
|
660
|
+
if (s.type === 'dir') continue
|
|
661
|
+
if (s.type === 'symlink') {
|
|
662
|
+
const t = this.stat(cur)
|
|
663
|
+
if (t && t.type === 'dir') continue
|
|
664
|
+
}
|
|
665
|
+
throw new FastRefused(`${cur} is not a directory`)
|
|
666
|
+
}
|
|
667
|
+
// os.Mkdir with a raw int mode keeps the permission bits only: no setuid, setgid or sticky on a folder made here
|
|
668
|
+
this.map.set(cur, { type: 'dir', mode: perm & 0o777, uid: owner?.uid ?? 0, gid: owner?.gid ?? 0, mtime: this.now, created: true })
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The part of fsutil copy.Copy that decides what a COPY writes, for one resolved source. */
|
|
674
|
+
function copyOne({ ctx, pending, src, dest, owner, mode, now, pathMtimeRoot }) {
|
|
675
|
+
// dest: cleanPath() of the destination as dispatchCopy built it ('/app/', '/app', '/app/.')
|
|
676
|
+
const slash = dest.lastIndexOf('/')
|
|
677
|
+
const d = dest.slice(0, slash + 1)
|
|
678
|
+
const f = dest.slice(slash + 1)
|
|
679
|
+
const ensure = f !== '' && f !== '.' ? d : dest
|
|
680
|
+
const perm = mode ?? 0o755
|
|
681
|
+
const lst = (p) => pending.lstat(p)
|
|
682
|
+
pending.mkdirAll(realPath(ensure, lst, true), perm, owner)
|
|
683
|
+
const dst = realPath(clean(dest), lst, true)
|
|
684
|
+
|
|
685
|
+
const srcFollowed = realPath(src, (p) => ctx.lstat(p), true)
|
|
686
|
+
const fiSrc = ctx.lstat(srcFollowed)
|
|
687
|
+
if (!fiSrc) throw new FastRefused(`${src}: not found in the build context`)
|
|
688
|
+
const fiDest = pending.stat(dst)
|
|
689
|
+
let destPath = dst
|
|
690
|
+
if (fiSrc.type !== 'dir' && fiDest && fiDest.type === 'dir') destPath = join(dst, basename(src))
|
|
691
|
+
let target = dirname(destPath)
|
|
692
|
+
if (fiSrc.type === 'dir' && !fiDest) target = destPath
|
|
693
|
+
pending.mkdirAll(target, perm, owner)
|
|
694
|
+
|
|
695
|
+
const own = (e) => ({ uid: owner?.uid ?? 0, gid: owner?.gid ?? 0, ...e })
|
|
696
|
+
const copyNode = (sp, tp, overwrite) => {
|
|
697
|
+
const fi = ctx.lstat(sp)
|
|
698
|
+
const tfi = pending.lstat(tp)
|
|
699
|
+
if (fi.type !== 'dir') {
|
|
700
|
+
if (tfi && tfi.type === 'dir') throw new FastRefused(`cannot replace to directory ${tp} with file`)
|
|
701
|
+
const common = { mode: (mode ?? fi.mode) & 0o7777, mtime: fi.mtime ?? 0 }
|
|
702
|
+
if (fi.hard) throw new FastRefused(`${sp} is a hard link in the context; the layer would hold a link, which this path does not write`)
|
|
703
|
+
if (fi.type === 'file') pending.map.set(tp, own({ type: 'file', from: sp, ...common }))
|
|
704
|
+
else pending.map.set(tp, own({ type: 'symlink', target: fi.target, mtime: fi.mtime ?? 0 }))
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
// a folder
|
|
708
|
+
let created = false
|
|
709
|
+
if (!tfi) {
|
|
710
|
+
created = true
|
|
711
|
+
} else if (tfi.type !== 'dir') {
|
|
712
|
+
throw new FastRefused(`cannot copy to non-directory: ${tp}`)
|
|
713
|
+
}
|
|
714
|
+
const mt = fi.mtime ?? (fi.implicit ? now : 0)
|
|
715
|
+
if (created) pending.map.set(tp, own({ type: 'dir', mode: (mode ?? fi.mode) & 0o7777, mtime: mt, created: true }))
|
|
716
|
+
else if (overwrite) pending.map.set(tp, own({ type: 'dir', mode: (mode ?? fi.mode) & 0o7777, mtime: mt }))
|
|
717
|
+
else {
|
|
718
|
+
// the top folder of the source: left as it is, only its time is restored (to the source's) when this COPY made it
|
|
719
|
+
const mine = pending.map.get(tp)
|
|
720
|
+
if (mine) mine.mtime = fi.mtime ?? pathMtimeRoot
|
|
721
|
+
// a folder of the image: copy.go restores its time to the source's (restoreFileTimestamp); mode and owner stay
|
|
722
|
+
else if (tfi.type === 'dir' && tp !== '/') pending.map.set(tp, { type: 'dir', mode: tfi.mode, uid: tfi.uid, gid: tfi.gid, mtime: fi.mtime ?? pathMtimeRoot })
|
|
723
|
+
}
|
|
724
|
+
for (const name of ctx.children(sp)) copyNode(join(sp, name), join(tp, name), true)
|
|
725
|
+
}
|
|
726
|
+
copyNode(srcFollowed, destPath, false)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* What a layer holds: the entries the instruction wrote, and every existing folder above them (overlayfs copies an ancestor
|
|
731
|
+
* up when something is written below it): with the base's mode and owner, and the build time when the folder gained a new
|
|
732
|
+
* direct child, else the base's own mtime. Parents come before children.
|
|
733
|
+
*/
|
|
734
|
+
function layerEntries(pending) {
|
|
735
|
+
const out = new Map(pending.map)
|
|
736
|
+
const gains = new Set()
|
|
737
|
+
for (const [p, e] of pending.map) if (e.created || !pending.rootfs.lstat(p)) gains.add(dirname(p))
|
|
738
|
+
for (const p of pending.map.keys()) {
|
|
739
|
+
for (let d = dirname(p); d !== '/'; d = dirname(d)) {
|
|
740
|
+
if (out.has(d)) continue
|
|
741
|
+
const st = pending.rootfs.lstat(d)
|
|
742
|
+
if (st?.type !== 'dir') continue
|
|
743
|
+
out.set(d, { type: 'dir', mode: st.mode, uid: st.uid, gid: st.gid, mtime: gains.has(d) ? pending.now : st.mtime })
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const key = (p) => p.split('/')
|
|
747
|
+
return [...out].sort(([a], [b]) => {
|
|
748
|
+
const x = key(a)
|
|
749
|
+
const y = key(b)
|
|
750
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1
|
|
751
|
+
return x.length - y.length
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** The tar of a Pending: parents before children, paths without the leading '/'. */
|
|
756
|
+
async function pendingTar(pending, ctx) {
|
|
757
|
+
const tw = tarWriter()
|
|
758
|
+
for (const [path, e] of layerEntries(pending)) {
|
|
759
|
+
const name = tarName(path)
|
|
760
|
+
const o = { mode: e.mode, mtime: e.mtime, uid: e.uid, gid: e.gid }
|
|
761
|
+
if (e.type === 'dir') tw.dir(name, o)
|
|
762
|
+
else if (e.type === 'symlink') tw.symlink(name, e.target, { mtime: e.mtime, uid: e.uid, gid: e.gid })
|
|
763
|
+
else tw.file(name, await ctx.lstat(e.from).read(), o)
|
|
764
|
+
}
|
|
765
|
+
return tw.finish()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------- history lines (what BuildKit writes in created_by)
|
|
769
|
+
|
|
770
|
+
const goKV = (p) => `${p.key}=${p.value}`
|
|
771
|
+
function healthString(h) {
|
|
772
|
+
const dur = (n) => goDurationString(n || 0)
|
|
773
|
+
return `{Test:[${h.Test.join(' ')}] Interval:${dur(h.Interval)} Timeout:${dur(h.Timeout)} StartPeriod:${dur(h.StartPeriod)} StartInterval:${dur(h.StartInterval)} Retries:${h.Retries || 0}}`
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const nowIso = (sec) => new Date(sec * 1000).toISOString().replace('.000Z', 'Z')
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------- fastBuild
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* @param {object} o
|
|
782
|
+
* @param {object} o.plan from fastEligible
|
|
783
|
+
* @param {*} o.context what packContext takes: a file source {list, stat, read}, {[path]: bytes|string}, or a tar
|
|
784
|
+
* (Uint8Array, ArrayBuffer, Blob); .dockerignore is applied as the builder does
|
|
785
|
+
* @param {(ref: string) => Promise<{index: object, packs: Uint8Array[], rawConfig: object}>} o.pull the base, as pullImage returns it
|
|
786
|
+
* (packs top first); rawConfig (the base's OCI image config JSON: history, rootfs.diff_ids, config.OnBuild...) is required:
|
|
787
|
+
* without it the build is refused (FastRefused) rather than guessing ONBUILD triggers or diff ids
|
|
788
|
+
* @param {object} o.converter a converter from createConverter, or wmdpack.wasm (URL, bytes, Module)
|
|
789
|
+
* @param {string} [o.platform] must be the plan's (and the base's)
|
|
790
|
+
* @param {object} [o.args] build args
|
|
791
|
+
* @param {string} [o.target]
|
|
792
|
+
* @param {(e: object) => void} [o.onEvent] {t:'pull'|'step'|'convert'|'done', ...}
|
|
793
|
+
* @param {number} [o.now] seconds (default the clock): the mtime of folders BuildKit makes itself, and `created`
|
|
794
|
+
* @param {boolean} [o.omitDockerfile] the Dockerfile text was given apart from the context: leave the context's own Dockerfile out, as the builder does
|
|
795
|
+
* @param {string} [o.dockerfile] the Dockerfile's name in the context, for <name>.dockerignore (default 'Dockerfile')
|
|
796
|
+
* @param {string} [o.ref] the image's name for index.ref
|
|
797
|
+
* @param {boolean} [o.baseInConverter] the converter already holds the base's packs
|
|
798
|
+
* @param {boolean} [o.keepTars] return the layer tars (r.tars) too
|
|
799
|
+
*/
|
|
800
|
+
export async function fastBuild(o) {
|
|
801
|
+
const t0 = performance.now()
|
|
802
|
+
const { plan, pull, onEvent = () => {} } = o
|
|
803
|
+
if (!plan?.steps) throw new TypeError('fastBuild: pass the plan from fastEligible')
|
|
804
|
+
if (typeof pull !== 'function') throw new TypeError('fastBuild: pass pull(ref)')
|
|
805
|
+
if (!o.converter) throw new TypeError('fastBuild: pass converter')
|
|
806
|
+
const platform = platformString(parsePlatform(o.platform || plan.platform))
|
|
807
|
+
if (platform !== plan.platform) throw new FastRefused(`the plan is for ${plan.platform}, not ${platform}`)
|
|
808
|
+
const args = o.args ?? plan.args ?? {}
|
|
809
|
+
const now = Math.floor(o.now ?? Date.now() / 1000)
|
|
810
|
+
const created = nowIso(now)
|
|
811
|
+
const escape = plan.escape || '\\'
|
|
812
|
+
|
|
813
|
+
onEvent({ t: 'pull', ref: plan.from.ref })
|
|
814
|
+
const base = await pull(plan.from.ref)
|
|
815
|
+
if (!base?.index?.layers?.length) throw new FastRefused(`the base ${plan.from.ref} has no layers`)
|
|
816
|
+
if (base.index.platform && base.index.platform !== platform) throw new FastRefused(`the base is ${base.index.platform}, not ${platform}`)
|
|
817
|
+
|
|
818
|
+
const full = base.rawConfig
|
|
819
|
+
if (!full || typeof full !== 'object' || !Array.isArray(full.rootfs?.diff_ids)) {
|
|
820
|
+
throw new FastRefused(`the base ${plan.from.ref} has no raw OCI config (rootfs.diff_ids, history, ONBUILD)`)
|
|
821
|
+
}
|
|
822
|
+
const cfg = structuredClone(full.config || {})
|
|
823
|
+
if (cfg.OnBuild && cfg.OnBuild.length) throw new FastRefused('ONBUILD in the base image config')
|
|
824
|
+
delete cfg.OnBuild
|
|
825
|
+
if (cfg.WorkingDir && !cfg.WorkingDir.startsWith('/')) cfg.WorkingDir = normalizeWorkdir('/', cfg.WorkingDir)
|
|
826
|
+
const baseHistory = full.history ? structuredClone(full.history) : []
|
|
827
|
+
// the base's layers, bottom first
|
|
828
|
+
const stack = base.index.layers.map((l, i) => ({ pack: l.pack, size: l.size, layer: l.layer, bytes: base.packs[i] })).reverse()
|
|
829
|
+
const diffIds = [...full.rootfs.diff_ids]
|
|
830
|
+
if (diffIds.length !== stack.length) throw new FastRefused('the base config lists a different number of layers than its manifest')
|
|
831
|
+
|
|
832
|
+
let ctx = await loadContext(o.context, { dockerfileName: o.dockerfile || 'Dockerfile', limits: o.limits, omitDockerfile: !!o.omitDockerfile })
|
|
833
|
+
ctx = await applyDockerignore(ctx, o.dockerfile || 'Dockerfile')
|
|
834
|
+
|
|
835
|
+
const ownConverter = typeof o.converter.convert !== 'function'
|
|
836
|
+
const conv = ownConverter ? await createConverter(o.converter) : o.converter
|
|
837
|
+
const newHistory = []
|
|
838
|
+
const tars = []
|
|
839
|
+
let built = 0
|
|
840
|
+
let layerBytes = 0
|
|
841
|
+
let convertMs = 0
|
|
842
|
+
try {
|
|
843
|
+
if (!o.baseInConverter || ownConverter) for (let i = stack.length - 1; i >= 0; i--) await conv.add(stack[i].pack, stack[i].bytes)
|
|
844
|
+
|
|
845
|
+
// PATH is always set; the state's env is the config's plus ARGs and ENVs, in order
|
|
846
|
+
cfg.Env = cfg.Env ? [...cfg.Env] : []
|
|
847
|
+
if (!cfg.Env.some((e) => e.split('=')[0] === 'PATH')) cfg.Env.push(`PATH=${DEFAULT_PATH}`)
|
|
848
|
+
const env = new Env(cfg.Env.map((e) => (e.includes('=') ? [e.slice(0, e.indexOf('=')), e.slice(e.indexOf('=') + 1)] : [e, ''])))
|
|
849
|
+
const globals = globalArgsFor(platform, plan.targetName)
|
|
850
|
+
let cmdSeen = false
|
|
851
|
+
let accountsDirty = false
|
|
852
|
+
let rootfs = new Rootfs(stack)
|
|
853
|
+
const accounts = () => ({ passwd: rootfs.text('/etc/passwd') || '', group: rootfs.text('/etc/group') || '' })
|
|
854
|
+
const expand = (w, what) => {
|
|
855
|
+
try {
|
|
856
|
+
return expandWord(w, env, { escape })
|
|
857
|
+
} catch (e) {
|
|
858
|
+
throw new FastRefused(`${what}: ${e.message}`)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const history = (createdBy, layer) => newHistory.push({ created: created, created_by: createdBy, comment: HISTORY_COMMENT, ...(layer ? {} : { empty_layer: true }) })
|
|
862
|
+
|
|
863
|
+
const total = plan.steps.length
|
|
864
|
+
for (let i = 0; i < total; i++) {
|
|
865
|
+
const c = plan.steps[i]
|
|
866
|
+
onEvent({ t: 'step', i: i + 1, total, text: c.original, state: 'start' })
|
|
867
|
+
let pendingMap = null
|
|
868
|
+
let createdBy
|
|
869
|
+
switch (c.type) {
|
|
870
|
+
case 'env': {
|
|
871
|
+
// convert.go dispatch: every pair is expanded against the env from before the instruction, then all are set
|
|
872
|
+
let msg = 'ENV'
|
|
873
|
+
const kvs = c.pairs.map((p) => [expand(p.key, 'ENV'), expand(p.value, 'ENV')])
|
|
874
|
+
for (const [k, v] of kvs) {
|
|
875
|
+
msg += ` ${k}=${v}`
|
|
876
|
+
env.set(k, v)
|
|
877
|
+
const j = cfg.Env.findIndex((e) => e.split('=')[0] === k)
|
|
878
|
+
if (j >= 0) cfg.Env[j] = `${k}=${v}`
|
|
879
|
+
else cfg.Env.push(`${k}=${v}`)
|
|
880
|
+
}
|
|
881
|
+
createdBy = msg
|
|
882
|
+
break
|
|
883
|
+
}
|
|
884
|
+
case 'label': {
|
|
885
|
+
cfg.Labels ||= {}
|
|
886
|
+
let msg = 'LABEL'
|
|
887
|
+
for (const p of c.pairs) {
|
|
888
|
+
const k = expand(p.key, 'LABEL')
|
|
889
|
+
const v = expand(p.value, 'LABEL')
|
|
890
|
+
cfg.Labels[k] = v
|
|
891
|
+
msg += ` ${k}=${v}`
|
|
892
|
+
}
|
|
893
|
+
createdBy = msg
|
|
894
|
+
break
|
|
895
|
+
}
|
|
896
|
+
case 'arg': {
|
|
897
|
+
const strs = []
|
|
898
|
+
for (const a of c.args) {
|
|
899
|
+
const given = Object.prototype.hasOwnProperty.call(args, a.key)
|
|
900
|
+
let v = a.value
|
|
901
|
+
if (v === null && !given && globals.has(a.key)) v = globals.get(a.key)
|
|
902
|
+
if (given) v = String(args[a.key])
|
|
903
|
+
else if (a.value !== null) v = expand(a.value, 'ARG')
|
|
904
|
+
if (v !== null) env.set(a.key, v)
|
|
905
|
+
strs.push(v !== null ? `${a.key}=${v}` : a.key)
|
|
906
|
+
}
|
|
907
|
+
createdBy = `ARG ${strs.join(' ')}`
|
|
908
|
+
break
|
|
909
|
+
}
|
|
910
|
+
case 'workdir': {
|
|
911
|
+
const wd = normalizeWorkdir(cfg.WorkingDir || '', expand(c.path, 'WORKDIR'))
|
|
912
|
+
cfg.WorkingDir = wd
|
|
913
|
+
createdBy = `WORKDIR ${wd}`
|
|
914
|
+
if (wd !== '/') {
|
|
915
|
+
const pend = new Pending(rootfs, now)
|
|
916
|
+
let owner = null
|
|
917
|
+
if (cfg.User) {
|
|
918
|
+
if (specNeedsAccounts(cfg.User) && accountsDirty) throw new FastRefused('WORKDIR as a named USER after a COPY that wrote /etc/passwd or /etc/group')
|
|
919
|
+
owner = checkOwner(readUserSpec(cfg.User, accounts()), `USER ${cfg.User}`)
|
|
920
|
+
}
|
|
921
|
+
const real = realPath(clean(wd), (p) => pend.lstat(p), true)
|
|
922
|
+
pend.mkdirAll(real, 0o755, owner)
|
|
923
|
+
// the layer is made even when the folder is there already: MkdirAll then changes nothing (no chown either), so it is empty
|
|
924
|
+
pendingMap = pend
|
|
925
|
+
}
|
|
926
|
+
break
|
|
927
|
+
}
|
|
928
|
+
case 'user': {
|
|
929
|
+
cfg.User = expand(c.user, 'USER')
|
|
930
|
+
createdBy = `USER ${cfg.User}`
|
|
931
|
+
break
|
|
932
|
+
}
|
|
933
|
+
case 'volume': {
|
|
934
|
+
cfg.Volumes ||= {}
|
|
935
|
+
const vs = c.volumes.map((v) => expand(v, 'VOLUME'))
|
|
936
|
+
for (const v of vs) {
|
|
937
|
+
if (v === '') throw new FastRefused('VOLUME specified can not be an empty string')
|
|
938
|
+
cfg.Volumes[v] = {}
|
|
939
|
+
}
|
|
940
|
+
createdBy = `VOLUME [${vs.join(' ')}]`
|
|
941
|
+
break
|
|
942
|
+
}
|
|
943
|
+
case 'stopsignal': {
|
|
944
|
+
const sig = expand(c.signal, 'STOPSIGNAL')
|
|
945
|
+
if (!validSignal(sig)) throw new FastRefused(`STOPSIGNAL ${sig}: not a signal this port knows`)
|
|
946
|
+
cfg.StopSignal = sig
|
|
947
|
+
createdBy = `STOPSIGNAL ${sig}`
|
|
948
|
+
break
|
|
949
|
+
}
|
|
950
|
+
case 'expose': {
|
|
951
|
+
const ports = []
|
|
952
|
+
for (const p of c.ports) {
|
|
953
|
+
const ws = []
|
|
954
|
+
expandWord(p, env, { escape, words: ws })
|
|
955
|
+
for (const w of ws) ports.push(...parsePorts(w))
|
|
956
|
+
}
|
|
957
|
+
cfg.ExposedPorts ||= {}
|
|
958
|
+
for (const p of ports) cfg.ExposedPorts[p] = {}
|
|
959
|
+
createdBy = `EXPOSE [${ports.join(' ')}]`
|
|
960
|
+
break
|
|
961
|
+
}
|
|
962
|
+
case 'cmd':
|
|
963
|
+
case 'entrypoint': {
|
|
964
|
+
let line = c.cmdline
|
|
965
|
+
if (c.shell) line = [...(cfg.Shell?.length ? cfg.Shell : DEFAULT_SHELL), c.cmdline.join(' ')]
|
|
966
|
+
if (c.type === 'cmd') {
|
|
967
|
+
cfg.Cmd = line
|
|
968
|
+
cfg.ArgsEscaped = true
|
|
969
|
+
cmdSeen = true
|
|
970
|
+
} else {
|
|
971
|
+
cfg.Entrypoint = line
|
|
972
|
+
if (!cmdSeen) delete cfg.Cmd
|
|
973
|
+
}
|
|
974
|
+
createdBy = `${c.type.toUpperCase()} ${goQuoteList(line)}`
|
|
975
|
+
break
|
|
976
|
+
}
|
|
977
|
+
case 'healthcheck': {
|
|
978
|
+
cfg.Healthcheck = { ...c.health }
|
|
979
|
+
createdBy = `HEALTHCHECK ${healthString(c.health)}`
|
|
980
|
+
break
|
|
981
|
+
}
|
|
982
|
+
case 'copy': {
|
|
983
|
+
const srcs = c.sources.map((s) => expand(s, 'COPY'))
|
|
984
|
+
const destArg = expand(c.dest, 'COPY')
|
|
985
|
+
const chown = expand(c.chown, 'COPY')
|
|
986
|
+
const chmodS = expand(c.chmod, 'COPY')
|
|
987
|
+
let mode = null
|
|
988
|
+
if (chmodS !== '') {
|
|
989
|
+
if (!/^[0-7]+$/.test(chmodS) || parseInt(chmodS, 8) > 0o7777) throw new FastRefused(`COPY --chmod=${chmodS}: not an octal mode (0..07777)`)
|
|
990
|
+
mode = parseInt(chmodS, 8)
|
|
991
|
+
}
|
|
992
|
+
let owner = null
|
|
993
|
+
if (chown !== '') {
|
|
994
|
+
if (specNeedsAccounts(chown) && accountsDirty) throw new FastRefused(`COPY --chown=${chown} after a COPY that wrote /etc/passwd or /etc/group`)
|
|
995
|
+
owner = checkOwner(readUserSpec(chown, accounts()), `COPY --chown=${chown}`)
|
|
996
|
+
}
|
|
997
|
+
// pathRelativeToWorkingDir: '.' and '' are './' (a folder), a relative path is under the workdir
|
|
998
|
+
const wdir = cfg.WorkingDir || '/'
|
|
999
|
+
const dest = cleanDest(destArg.startsWith('/') ? normalizePath('/', destArg, true) : normalizePath(wdir, destArg === '.' || destArg === '' ? './' : destArg, true))
|
|
1000
|
+
const pend = new Pending(rootfs, now)
|
|
1001
|
+
const cleanSrcs = srcs.map((s) => normalizePath('/', s, false))
|
|
1002
|
+
for (const s of cleanSrcs) {
|
|
1003
|
+
const { d1, d2 } = splitWildcards(s)
|
|
1004
|
+
let list
|
|
1005
|
+
if (d2 === '') list = [d1]
|
|
1006
|
+
else {
|
|
1007
|
+
list = resolveWildcards(ctx, d1, d2)
|
|
1008
|
+
if (list.length === 0) continue // AllowEmptyWildcard
|
|
1009
|
+
}
|
|
1010
|
+
for (const m of list) copyOne({ ctx, pending: pend, src: m, dest, owner, mode, now, pathMtimeRoot: ctx.lstat('/')?.mtime ?? now })
|
|
1011
|
+
}
|
|
1012
|
+
let msg = 'COPY'
|
|
1013
|
+
if (c.chown) msg += ` --chown=${chown}`
|
|
1014
|
+
if (c.chmod) msg += ` --chmod=${chmodS}`
|
|
1015
|
+
for (const s of srcs) msg += ` ${s}`
|
|
1016
|
+
msg += ` ${destArg}`
|
|
1017
|
+
createdBy = `${msg} # buildkit`
|
|
1018
|
+
pendingMap = pend // a COPY that matched nothing still makes its (empty) layer
|
|
1019
|
+
if ([...pend.map.keys()].some((p) => p === '/etc/passwd' || p === '/etc/group')) accountsDirty = true
|
|
1020
|
+
break
|
|
1021
|
+
}
|
|
1022
|
+
default:
|
|
1023
|
+
throw new FastRefused(`${c.type.toUpperCase()} is not part of the fast path`)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (pendingMap) {
|
|
1027
|
+
const tar = await pendingTar(pendingMap, ctx)
|
|
1028
|
+
if (o.keepTars) tars.push(tar.slice())
|
|
1029
|
+
const diff = await sha256(tar)
|
|
1030
|
+
layerBytes += tar.length
|
|
1031
|
+
const below = stack.map((l) => l.pack).reverse()
|
|
1032
|
+
const r = await conv.convert(tar, below)
|
|
1033
|
+
convertMs += r.ms || 0
|
|
1034
|
+
stack.push({ pack: r.sha, size: r.size, layer: diff, bytes: r.bytes })
|
|
1035
|
+
diffIds.push(diff)
|
|
1036
|
+
built++
|
|
1037
|
+
rootfs = new Rootfs(stack)
|
|
1038
|
+
onEvent({ t: 'convert', i: i + 1, pack: r.sha, size: r.size, ms: r.ms })
|
|
1039
|
+
history(createdBy, true)
|
|
1040
|
+
} else history(createdBy, false)
|
|
1041
|
+
onEvent({ t: 'step', i: i + 1, total, text: c.original, state: 'done', layer: !!pendingMap })
|
|
1042
|
+
}
|
|
1043
|
+
} finally {
|
|
1044
|
+
if (ownConverter) conv.close()
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// the image config, as the dockerfile frontend and the exporter write it
|
|
1048
|
+
const MAP_FIELDS = new Set(['ExposedPorts', 'Volumes', 'Labels'])
|
|
1049
|
+
const cfgOut = {}
|
|
1050
|
+
for (const k of ['User', 'ExposedPorts', 'Env', 'Entrypoint', 'Cmd', 'Volumes', 'WorkingDir', 'Labels', 'StopSignal', 'ArgsEscaped', 'Healthcheck', 'Shell']) {
|
|
1051
|
+
const v = cfg[k]
|
|
1052
|
+
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) continue
|
|
1053
|
+
// Go's omitempty leaves out an empty map too (ExposedPorts, Volumes, Labels); Healthcheck is a pointer and stays
|
|
1054
|
+
if (MAP_FIELDS.has(k) && typeof v === 'object' && Object.keys(v).length === 0) continue
|
|
1055
|
+
cfgOut[k] = v
|
|
1056
|
+
}
|
|
1057
|
+
const config = {
|
|
1058
|
+
created,
|
|
1059
|
+
architecture: base.index.architecture || full.architecture || parsePlatform(platform).architecture,
|
|
1060
|
+
os: base.index.os || full.os || 'linux',
|
|
1061
|
+
config: cfgOut,
|
|
1062
|
+
rootfs: { type: 'layers', diff_ids: diffIds },
|
|
1063
|
+
history: [...baseHistory, ...newHistory],
|
|
1064
|
+
}
|
|
1065
|
+
// only what BuildKit's typed image (DockerOCIImage) has fields for: docker_version, container, container_config and the rest are dropped
|
|
1066
|
+
for (const k of ['author', 'variant', 'os.version', 'os.features']) if (full[k] !== undefined) config[k] = full[k]
|
|
1067
|
+
for (const k of Object.keys(config)) if (config[k] === undefined) delete config[k]
|
|
1068
|
+
|
|
1069
|
+
const top = stack.slice().reverse()
|
|
1070
|
+
const index = {
|
|
1071
|
+
schema: IMAGE_SCHEMA,
|
|
1072
|
+
ref: o.ref || '',
|
|
1073
|
+
platform,
|
|
1074
|
+
index: null,
|
|
1075
|
+
manifest: null,
|
|
1076
|
+
os: config.os,
|
|
1077
|
+
architecture: config.architecture,
|
|
1078
|
+
// from the config as written: empty fields are left out there (Go's omitempty), as BuildKit's are
|
|
1079
|
+
config: {
|
|
1080
|
+
Env: cfgOut.Env || null,
|
|
1081
|
+
Cmd: cfgOut.Cmd || null,
|
|
1082
|
+
Entrypoint: cfgOut.Entrypoint || null,
|
|
1083
|
+
WorkingDir: cfgOut.WorkingDir || '',
|
|
1084
|
+
User: cfgOut.User || '',
|
|
1085
|
+
ExposedPorts: cfgOut.ExposedPorts || null,
|
|
1086
|
+
Labels: cfgOut.Labels || null,
|
|
1087
|
+
Volumes: cfgOut.Volumes || null,
|
|
1088
|
+
StopSignal: cfgOut.StopSignal || '',
|
|
1089
|
+
Healthcheck: cfgOut.Healthcheck || null,
|
|
1090
|
+
},
|
|
1091
|
+
layers: top.map((x) => ({ pack: x.pack, size: x.size, layer: x.layer })),
|
|
1092
|
+
converter: CONVERTER,
|
|
1093
|
+
pulled: created,
|
|
1094
|
+
stats: {
|
|
1095
|
+
totalMs: Math.round(performance.now() - t0),
|
|
1096
|
+
layerBytes,
|
|
1097
|
+
downloadBytes: 0,
|
|
1098
|
+
downloadMs: 0,
|
|
1099
|
+
convertMs,
|
|
1100
|
+
fileBytes: 0,
|
|
1101
|
+
wasmBytes: 0,
|
|
1102
|
+
packBytes: top.reduce((a, x) => a + x.size, 0),
|
|
1103
|
+
cachedLayers: stack.length - built,
|
|
1104
|
+
builtLayers: built,
|
|
1105
|
+
},
|
|
1106
|
+
}
|
|
1107
|
+
onEvent({ t: 'done', layers: built })
|
|
1108
|
+
const result = { index, packs: top.map((x) => x.bytes), config, history: config.history, path: 'fast' }
|
|
1109
|
+
if (o.keepTars) result.tars = tars
|
|
1110
|
+
return result
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** filepath-style cleanPath of the backend: Join('/', s), keeping a trailing '/' and a trailing '/.'. */
|
|
1114
|
+
function cleanDest(s) {
|
|
1115
|
+
const c = clean(s)
|
|
1116
|
+
if (s.endsWith('/.')) return `${c === '/' ? '' : c}/.`
|
|
1117
|
+
if (s.endsWith('/') && c !== '/') return `${c}/`
|
|
1118
|
+
return c
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** fsutil resolveWildcards: walks base lexically, matching each path relative to it; a matched folder is not entered. */
|
|
1122
|
+
function resolveWildcards(ctx, base, comp) {
|
|
1123
|
+
const re = globRegExp(comp)
|
|
1124
|
+
if (!re) throw new FastRefused(`COPY: malformed pattern ${JSON.stringify(comp)}`)
|
|
1125
|
+
const baseReal = realPath(base, (p) => ctx.lstat(p), true)
|
|
1126
|
+
const out = []
|
|
1127
|
+
const walk = (dir, rel) => {
|
|
1128
|
+
for (const name of ctx.children(dir)) {
|
|
1129
|
+
const p = join(dir, name)
|
|
1130
|
+
const r = rel ? `${rel}/${name}` : name
|
|
1131
|
+
const e = ctx.lstat(p)
|
|
1132
|
+
if (re.test(r)) {
|
|
1133
|
+
out.push(p)
|
|
1134
|
+
continue // SkipDir for folders; files have no children
|
|
1135
|
+
}
|
|
1136
|
+
if (e.type === 'dir') walk(p, r)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (ctx.lstat(baseReal)?.type === 'dir') walk(baseReal, '')
|
|
1140
|
+
return out
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** fsutil/convert_expose parsePort for one word: "80", "80/udp", "8000-8002", "ip:host:container/proto" -> ["80/tcp", ...]. */
|
|
1144
|
+
function parsePorts(raw) {
|
|
1145
|
+
const ps = raw.split(':')
|
|
1146
|
+
let hostPort = ''
|
|
1147
|
+
let containerPort
|
|
1148
|
+
if (ps.length === 1) containerPort = ps[0]
|
|
1149
|
+
else if (ps.length === 2) [hostPort, containerPort] = ps
|
|
1150
|
+
else {
|
|
1151
|
+
containerPort = ps[ps.length - 1]
|
|
1152
|
+
hostPort = ps[ps.length - 2]
|
|
1153
|
+
}
|
|
1154
|
+
const si = containerPort.indexOf('/')
|
|
1155
|
+
const portPart = si < 0 ? containerPort : containerPort.slice(0, si)
|
|
1156
|
+
let proto = si < 0 ? '' : containerPort.slice(si + 1)
|
|
1157
|
+
if (portPart === '') throw new FastRefused(`EXPOSE ${raw}: no port specified`)
|
|
1158
|
+
if (!['', 'tcp', 'udp', 'sctp'].includes(proto.toLowerCase())) throw new FastRefused(`EXPOSE ${raw}: invalid proto ${proto}`)
|
|
1159
|
+
if (proto === '') proto = 'tcp'
|
|
1160
|
+
const range = (s) => {
|
|
1161
|
+
const dash = s.indexOf('-')
|
|
1162
|
+
const num = (x) => {
|
|
1163
|
+
if (!/^[+-]?\d+$/.test(x)) throw new FastRefused(`EXPOSE ${raw}: invalid port ${JSON.stringify(x)}`)
|
|
1164
|
+
const n = Number(x)
|
|
1165
|
+
if (n < 0 || n > 65535) throw new FastRefused(`EXPOSE ${raw}: value out of range (0-65535)`)
|
|
1166
|
+
return n
|
|
1167
|
+
}
|
|
1168
|
+
if (dash < 0) return [num(s), num(s)]
|
|
1169
|
+
const a = s.slice(0, dash)
|
|
1170
|
+
const b = s.slice(dash + 1)
|
|
1171
|
+
if (a === b) return [num(a), num(a)]
|
|
1172
|
+
const [x, y] = [num(a), num(b)]
|
|
1173
|
+
if (y < x) throw new FastRefused(`EXPOSE ${raw}: invalid port range`)
|
|
1174
|
+
return [x, y]
|
|
1175
|
+
}
|
|
1176
|
+
if (ps.length > 2) throw new FastRefused(`EXPOSE ${raw}: an address in EXPOSE`)
|
|
1177
|
+
const [s, e] = range(portPart)
|
|
1178
|
+
if (hostPort) {
|
|
1179
|
+
const [hs, he] = range(hostPort)
|
|
1180
|
+
if (e - s !== he - hs && e !== s) throw new FastRefused(`EXPOSE ${raw}: invalid ranges for container and host ports`)
|
|
1181
|
+
}
|
|
1182
|
+
const out = []
|
|
1183
|
+
for (let p = s; p <= e; p++) out.push(`${p}/${proto.toLowerCase()}`)
|
|
1184
|
+
return out
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
export const __test = { clean, realPath, globRegExp, splitWildcards, normalizePath, cleanDest, parsePorts, goQuote }
|