@threadedstack/wmd-web 0.1.17 → 0.1.18

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.
@@ -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 }