@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,791 @@
1
+ // Dockerfile builds in the browser with BuildKit (docs/plans/web-buildkit-impl.md, D-547).
2
+ //
3
+ // createBuilder boots one builder machine: moby's BuildKit image plus wmd's start script (web/builder/), as a
4
+ // privileged container with the guest network on a relay and BuildKit's storage on a disk in OPFS
5
+ // (host/disk.js). build() sends the context as a tar into the builder's memory (/run/ctx), runs buildctl, reads
6
+ // the image back as a Docker tar on buildctl's stdout and converts it with loadImageTar. A Dockerfile with no RUN
7
+ // is built in the page instead (fast.js), unless fast: false.
8
+
9
+ import { bootWmd } from '../boot.js'
10
+ import { MAIN_PROC_ID, containerBundle, fillRunRequest } from '../host/ctl.js'
11
+ import { DiskError, deleteDisk, openDisk, sweepDisks } from '../host/disk.js'
12
+ import { p9MountOptions } from '../host/p9mount.js'
13
+ import { loadImageTar } from '../registry/load-tar.js'
14
+ import { packContext, readTarFile } from './context.js'
15
+ import { BUILDER_PINS } from './pin.js'
16
+ import { FastRefused, fastBuild, fastEligible } from './fast.js'
17
+
18
+ export const ARCHES = {
19
+ arm64: {
20
+ platform: 'linux/arm64',
21
+ machine: ['--arch', 'a64', '--board', 'arm64'],
22
+ kernel: 'guest/Image',
23
+ console: ['console=ttyAMA0', 'earlycon=pl011,0x9000000'],
24
+ },
25
+ amd64: {
26
+ platform: 'linux/amd64',
27
+ machine: ['--arch', 'x86', '--board', 'x86'],
28
+ kernel: 'guest/bzImage',
29
+ console: ['console=ttyS0,115200', 'earlyprintk=serial,ttyS0', 'tsc=reliable', 'no_timer_check', 'virtio_mmio.device=4K@0xd0000000:5'],
30
+ },
31
+ }
32
+ const P9_PORT = 564
33
+ const CONTROL_PORT = 8
34
+ const NET_PORT = 9
35
+ const MAC = '02:00:00:00:00:01'
36
+ const LOG_LINES = 2000
37
+ const LOG_TAIL = 64 << 10
38
+ const SAY = 'wmd-buildkitd: '
39
+ const ROOM_WATCH_MS = 1000 // how often a build checks /bk's free room
40
+ const FULL_MIB = 64 // /bk with less free than this during a failed build: the build failed for want of room
41
+ const enc = new TextEncoder()
42
+ /** host/jit.js's line when the engine's code pool is full (D-515). */
43
+ export const POOL_FULL_RE = /wmd-web: jit: the engine's code pool is full/
44
+
45
+ export class BuildError extends Error {
46
+ constructor(message, { step = null, tail = '', reason = null } = {}) {
47
+ super(message)
48
+ this.name = 'BuildError'
49
+ this.step = step
50
+ this.tail = tail
51
+ this.reason = reason // 'no-space' | 'quota' | 'disk' | 'timeout' | 'pin' | 'secret' | null
52
+ }
53
+ }
54
+ export class BuildCancelled extends Error {
55
+ constructor(message = 'the build was cancelled') {
56
+ super(message)
57
+ this.name = 'BuildCancelled'
58
+ }
59
+ }
60
+ export class BuildRefused extends Error {
61
+ constructor(feature, message) {
62
+ super(message)
63
+ this.name = 'BuildRefused'
64
+ this.feature = feature
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The builder's no-RUN fast path (fast.js): (dockerfile, req, onEvent) -> {index, packs, name} for a Dockerfile it can
70
+ * build in the page, else null (the caller then runs BuildKit). `req.keepTars` also returns the new layers' tars (tars). Declining emits {t:'fast', ok:false, why}; a
71
+ * FastRefused from the build (a base with ONBUILD, a pull with no rawConfig, a context path BuildKit would handle
72
+ * differently...) does too, with refused: true. Without `pull` (no registry fetch in the page) it always declines.
73
+ */
74
+ export function makeFast({ pull, converter, platform }) {
75
+ return async (dockerfile, req, onEvent) => {
76
+ const decline = (why, extra) => {
77
+ onEvent({ t: 'fast', ok: false, why, ...extra })
78
+ return null
79
+ }
80
+ const e = fastEligible(dockerfile, { args: req.args, target: req.target, platform })
81
+ if (!e.ok) return decline(e.why)
82
+ if (!pull) return decline('no pull')
83
+ try {
84
+ const r = await fastBuild({
85
+ plan: e.plan,
86
+ context: req.context || {},
87
+ pull,
88
+ converter,
89
+ platform,
90
+ args: req.args,
91
+ target: req.target,
92
+ limits: req.limits,
93
+ omitDockerfile: req.dockerfile != null,
94
+ ref: req.tag || '',
95
+ keepTars: !!req.keepTars,
96
+ onEvent: ev => ev.t !== 'done' && onEvent(ev), // runBuild reports the one 'done'
97
+ })
98
+ return { index: r.index, packs: r.packs, name: req.tag || null, ...(r.tars ? { tars: r.tars } : {}) }
99
+ } catch (err) {
100
+ if (err instanceof FastRefused) return decline(err.message, { refused: true })
101
+ throw err
102
+ }
103
+ }
104
+ }
105
+
106
+ /** A namespace names a disk: lowercase letters, digits, '_' and '-', at most 64; 'tmp-' is the builder's own. */
107
+ export function checkNamespace(ns) {
108
+ if (typeof ns !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(ns) || /^tmp-/.test(ns)) {
109
+ throw new TypeError(`bad disk namespace ${JSON.stringify(ns)}: lowercase letters, digits, '_' and '-', at most 64, not starting with 'tmp-'`)
110
+ }
111
+ return ns
112
+ }
113
+
114
+ /** A registry host (name[:port]) as buildkitd.toml takes it. */
115
+ function checkHost(h) {
116
+ if (!/^[A-Za-z0-9.-]+(:\d{1,5})?$/.test(h)) throw new TypeError(`bad registry host ${JSON.stringify(h)}`)
117
+ return h
118
+ }
119
+
120
+ /** The features a page cannot give BuildKit, refused before any work (plan section 4); null when none. */
121
+ export function refusals(dockerfile, req = {}) {
122
+ if (req.ssh || /--mount=(?:[^\s]*,)?type=ssh\b/i.test(dockerfile)) {
123
+ return new BuildRefused('ssh', 'RUN --mount=type=ssh needs an SSH agent, and a page has none; pass the key as a secret (RUN --mount=type=secret) instead')
124
+ }
125
+ if (req.output) return new BuildRefused('output', 'the browser builder gives the image, its Docker tar (tar: true) and a push (push: {name}); other outputs are not supported')
126
+ return null
127
+ }
128
+
129
+ async function sha256Hex(bytes) {
130
+ const d = await crypto.subtle.digest('SHA-256', bytes)
131
+ return [...new Uint8Array(d)].map(x => x.toString(16).padStart(2, '0')).join('')
132
+ }
133
+
134
+ /** Base64 of a string's UTF-8 bytes (btoa takes Latin-1 only). */
135
+ export function b64utf8(str) {
136
+ const u8 = new TextEncoder().encode(String(str))
137
+ let bin = ''
138
+ for (let i = 0; i < u8.length; i += 0x8000) bin += String.fromCharCode(...u8.subarray(i, i + 0x8000))
139
+ return btoa(bin)
140
+ }
141
+
142
+ const hexOfBytes = u8 => [...u8].map(x => x.toString(16).padStart(2, '0')).join('')
143
+
144
+ /** JSON with sorted keys: the same value always gives the same text. */
145
+ function canonicalJSON(v) {
146
+ if (Array.isArray(v)) return `[${v.map(canonicalJSON).join(',')}]`
147
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => `${JSON.stringify(k)}:${canonicalJSON(v[k])}`).join(',')}}`
148
+ return JSON.stringify(v) ?? 'null'
149
+ }
150
+
151
+ const REF_COMPONENT = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/
152
+ const REF_HOST = /^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*(?::\d{1,5})?$/
153
+ const REF_TAG = /^\w[\w.-]{0,127}$/
154
+
155
+ /** An image reference by docker's grammar ([host[:port]/]path[:tag][@sha256:hex]); anything else is a TypeError. */
156
+ export function checkRef(ref, what = 'image reference') {
157
+ const bad = () => new TypeError(`bad ${what} ${JSON.stringify(String(ref).slice(0, 200))}: [registry[:port]/]lowercase/path[:tag][@sha256:<64 hex>]`)
158
+ if (typeof ref !== 'string' || !ref || ref.length > 255) throw bad()
159
+ let rest = ref
160
+ const at = rest.indexOf('@')
161
+ if (at >= 0) {
162
+ if (!/^sha256:[0-9a-f]{64}$/.test(rest.slice(at + 1))) throw bad()
163
+ rest = rest.slice(0, at)
164
+ }
165
+ const colon = rest.lastIndexOf(':')
166
+ if (colon > rest.lastIndexOf('/')) {
167
+ if (!REF_TAG.test(rest.slice(colon + 1))) throw bad()
168
+ rest = rest.slice(0, colon)
169
+ }
170
+ const parts = rest.split('/')
171
+ if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':') || parts[0] === 'localhost')) {
172
+ if (!REF_HOST.test(parts[0])) throw bad()
173
+ parts.shift()
174
+ }
175
+ if (!parts.length || !parts.every(c => REF_COMPONENT.test(c))) throw bad()
176
+ return ref
177
+ }
178
+
179
+ /** Secrets as {id, name, text}: ids by /^[A-Za-z0-9_.-]{1,128}$/, the env name from the id's hex, values as UTF-8 text without NUL. */
180
+ function checkSecrets(secrets) {
181
+ const out = []
182
+ for (const [sid, val] of Object.entries(secrets || {})) {
183
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(sid)) throw new TypeError(`bad secret id ${JSON.stringify(sid.slice(0, 200))}: letters, digits, '_', '.' and '-', at most 128`)
184
+ let text
185
+ if (typeof val === 'string') text = val
186
+ else {
187
+ try {
188
+ text = new TextDecoder('utf-8', { fatal: true }).decode(val)
189
+ } catch {
190
+ throw new BuildError(`secret ${sid} is not valid UTF-8; secrets travel as environment values`, { reason: 'secret' })
191
+ }
192
+ }
193
+ if (text.includes('\0')) throw new TypeError(`secret ${sid} holds a NUL byte; secrets travel as environment values`)
194
+ out.push({ id: sid, name: `WMD_SECRET_${hexOfBytes(enc.encode(sid))}`, text })
195
+ }
196
+ return out
197
+ }
198
+
199
+ /** The packs against the index's layer digests (top first, both): a mismatch is a BuildError 'pin'. */
200
+ async function checkPacks(img, what) {
201
+ const layers = img?.index?.layers || []
202
+ const packs = img?.packs
203
+ const bad = why => new BuildError(`${what}: ${why}`, { reason: 'pin' })
204
+ if (!Array.isArray(packs) || packs.length !== layers.length) throw bad(`${packs?.length} packs for ${layers.length} layers in its index`)
205
+ for (let i = 0; i < packs.length; i++) {
206
+ const want = layers[i]?.pack
207
+ if (!(packs[i] instanceof Uint8Array)) throw bad(`pack ${i} is not bytes`)
208
+ const got = `sha256:${await sha256Hex(packs[i])}`
209
+ if (got !== want) throw bad(`pack ${i} is ${got}, its index says ${want}`)
210
+ }
211
+ return img
212
+ }
213
+
214
+ /** The digest a builder's state directory is named by: the index's own, else the hash of its canonical JSON. */
215
+ async function indexDigest(index) {
216
+ for (const d of [index?.manifest, index?.index]) if (typeof d === 'string' && /^sha256:[0-9a-f]{64}$/.test(d)) return d.slice(7)
217
+ return sha256Hex(enc.encode(canonicalJSON(index ?? null)))
218
+ }
219
+
220
+ async function bytesOf(x, fetchFn) {
221
+ if (x instanceof Uint8Array) return x
222
+ if (x instanceof ArrayBuffer) return new Uint8Array(x)
223
+ if (typeof Blob !== 'undefined' && x instanceof Blob) return new Uint8Array(await x.arrayBuffer())
224
+ const r = await fetchFn(x)
225
+ if (!r.ok) throw new Error(`${x}: ${r.status} ${r.statusText}`)
226
+ return new Uint8Array(await r.arrayBuffer())
227
+ }
228
+
229
+ /**
230
+ * The builder image as {index, packs}: given as such, or as {tar, sha256} (a `docker save` tar of the image,
231
+ * URL or bytes, checked against sha256 before it is read). With a store, the converted image is kept under
232
+ * `builder:<sha256>`.
233
+ */
234
+ async function builderImage(image, { converter, platform, archName, fetchFn, store }) {
235
+ if (image?.index && image?.packs) {
236
+ await checkPacks(image, 'the builder image')
237
+ return image
238
+ }
239
+ if (!image?.tar || !/^[0-9a-f]{64}$/.test(image.sha256 || '')) throw new TypeError('image: {index, packs} or {tar, sha256}')
240
+ if (image.unpinned !== true && image.sha256 !== BUILDER_PINS[archName]?.sha256) {
241
+ throw new BuildError(`the builder image sha256:${image.sha256} is not the one this wmd-web pins for ${archName} (sha256:${BUILDER_PINS[archName]?.sha256}); image.unpinned: true allows another, for tests and development`, { reason: 'pin' })
242
+ }
243
+ const key = `builder:${image.sha256}`
244
+ if (store?.get) {
245
+ const hit = await store.get(key).catch(() => null)
246
+ if (hit?.index && hit?.packs) return checkPacks(hit, 'the builder image from the store')
247
+ }
248
+ const tar = await bytesOf(image.tar, fetchFn)
249
+ const got = await sha256Hex(tar)
250
+ if (got !== image.sha256) throw new BuildError(`the builder image tar is sha256:${got}, not the pinned sha256:${image.sha256}`, { reason: 'pin' })
251
+ const r = await loadImageTar(tar, { converter, platform })
252
+ if (store?.put) await store.put(key, { index: r.index, packs: r.packs }).catch(() => {})
253
+ return r
254
+ }
255
+
256
+ /** BuildKit's rawjson progress (one SolveStatus per line) into step events and log lines. */
257
+ export function progressReader({ onStep, onLog }) {
258
+ const vertexes = new Map()
259
+ let buf = ''
260
+ const dec = new TextDecoder()
261
+ return {
262
+ vertexes,
263
+ push(text) {
264
+ buf += text
265
+ let i
266
+ while ((i = buf.indexOf('\n')) >= 0) {
267
+ const line = buf.slice(0, i)
268
+ buf = buf.slice(i + 1)
269
+ let s = null
270
+ if (line.startsWith('{')) {
271
+ try {
272
+ s = JSON.parse(line)
273
+ } catch {}
274
+ }
275
+ if (!s) {
276
+ if (line.trim()) onLog(line, null)
277
+ continue
278
+ }
279
+ for (const v of s.vertexes || []) {
280
+ const old = vertexes.get(v.digest) || {}
281
+ const cur = { ...old, ...v }
282
+ const status = cur.error ? 'error' : cur.completed ? 'done' : cur.started ? 'running' : 'waiting'
283
+ if (status !== old._status) {
284
+ cur._status = status
285
+ onStep({ t: 'step', name: cur.name, status, cached: !!cur.cached, error: cur.error || null, digest: v.digest })
286
+ } else cur._status = old._status
287
+ vertexes.set(v.digest, cur)
288
+ }
289
+ for (const l of s.logs || []) {
290
+ let data = ''
291
+ try {
292
+ data = dec.decode(Uint8Array.from(atob(l.data || ''), c => c.charCodeAt(0)))
293
+ } catch {}
294
+ const name = vertexes.get(l.vertex)?.name || null
295
+ for (const part of data.split('\n')) if (part) onLog(part, name)
296
+ }
297
+ }
298
+ },
299
+ }
300
+ }
301
+
302
+ /** Log lines kept for the result: the first LOG_LINES lines, and always the last LOG_TAIL bytes. */
303
+ export function logKeeper(maxLines = LOG_LINES, tailBytes = LOG_TAIL) {
304
+ const lines = []
305
+ let tail = ''
306
+ let dropped = 0
307
+ return {
308
+ add(line) {
309
+ if (lines.length < maxLines) lines.push(line)
310
+ else dropped++
311
+ tail = (tail + line + '\n').slice(-tailBytes)
312
+ },
313
+ get lines() {
314
+ return dropped ? [...lines, `[${dropped} lines left out; the last ${tailBytes >> 10} KiB follow]`, ...tail.split('\n').filter(Boolean)] : [...lines]
315
+ },
316
+ get tail() {
317
+ return tail
318
+ },
319
+ }
320
+ }
321
+
322
+ function aborted(signal) {
323
+ if (!signal) return new Promise(() => {})
324
+ return new Promise((_, rej) => {
325
+ if (signal.aborted) rej(new BuildCancelled())
326
+ signal.addEventListener('abort', () => rej(new BuildCancelled()), { once: true })
327
+ })
328
+ }
329
+
330
+ /**
331
+ * Boots a builder machine.
332
+ * @param {object} o
333
+ * @param {string|Uint8Array} o.vmUrl vm.wasm (as bootWmd)
334
+ * @param {object} o.files guest files for o.arch: {'guest/Image' (arm64) or 'guest/bzImage' (amd64), 'guest/initramfs.cpio.gz'}
335
+ * @param {'arm64'|'amd64'} [o.arch]
336
+ * @param {object} o.image {index, packs} or {tar, sha256} (web/builder: `make web-builder`); a tar's sha256 must be
337
+ * BUILDER_PINS[arch].sha256 (pin.js) unless image.unpinned === true (tests, development);
338
+ * the packs of an {index, packs} or a store hit must match the index's layer digests
339
+ * @param {{url: string, protocol?: 'wisp'}} o.relay the guest's network
340
+ * @param {number} [o.ram] MiB (default 1536)
341
+ * @param {number} [o.cpus] default 2
342
+ * @param {{namespace: string, mib?: number}|false} o.disk BuildKit's storage in OPFS (default 10240 MiB); false: memory
343
+ * @param {string|Uint8Array} o.converter wmdpack.wasm
344
+ * @param {object} [o.store] PackStore {get, put}: the converted builder image and built packs go there
345
+ * @param {Function} [o.pull] ref -> {index, packs, rawConfig} for the fast path's base images (pullImage; the fast path
346
+ * is off without it, and a pull with no rawConfig is refused to BuildKit)
347
+ * @param {string[]} [o.httpRegistries] registry hosts BuildKit reaches over plain http
348
+ * @param {string[]} [o.registryMirrors] Docker Hub mirrors (host[:port], in order) BuildKit tries before docker.io; list a plain-http one
349
+ * in httpRegistries too
350
+ * @param {Record<string,string>} [o.registryCAs] host -> PEM: CAs BuildKit checks that registry's certificate against
351
+ * @param {Function} [o.onEvent]
352
+ * @param {AbortSignal} [o.signal]
353
+ * @param {object} [o.boot] extra bootWmd options (tests)
354
+ */
355
+ export async function createBuilder(o) {
356
+ const archName = o.arch || 'arm64'
357
+ const arch = ARCHES[archName]
358
+ if (!arch) throw new TypeError(`arch ${o.arch}: arm64 or amd64`)
359
+ if (o.disk !== false) checkNamespace(o.disk?.namespace)
360
+ if (!o.relay?.url) throw new TypeError('relay: {url}: the builder pulls base images through it')
361
+ const fetchFn = o.fetch || globalThis.fetch.bind(globalThis)
362
+ const emit = ev => {
363
+ try {
364
+ o.onEvent?.(ev)
365
+ } catch {}
366
+ }
367
+ const ram = o.ram || 1536
368
+ const cpus = o.cpus || 2
369
+ const t0 = performance.now()
370
+ o.signal?.throwIfAborted()
371
+
372
+ const image = await builderImage(o.image, { converter: o.converter, platform: arch.platform, archName, fetchFn, store: o.store })
373
+ const digest = o.image?.tar ? o.image.sha256 : await indexDigest(image.index)
374
+
375
+ // the disk: wmd-builder-<namespace>-<arch>, or a temporary one when another tab holds it
376
+ let disk = null
377
+ if (o.disk !== false) {
378
+ const mib = o.disk.mib || 10240
379
+ const base = `wmd-builder-${o.disk.namespace}-${archName}`
380
+ const swept = await sweepDisks('wmd-builder-tmp-').catch(() => [])
381
+ if (swept.length) emit({ t: 'disk', reason: 'swept', names: swept })
382
+ try {
383
+ disk = await openDisk({ name: base, mib, onEvent: emit })
384
+ } catch (e) {
385
+ if (!(e instanceof DiskError) || e.reason !== 'locked') throw e
386
+ const tmp = `wmd-builder-tmp-${crypto.randomUUID().slice(0, 8)}-${archName}`
387
+ emit({ t: 'disk', reason: 'locked', message: `${base} is open in another tab; this builder uses ${tmp}, deleted on close` })
388
+ disk = await openDisk({ name: tmp, mib, onEvent: emit })
389
+ disk.temporary = true
390
+ }
391
+ emit({ t: 'disk', reason: disk.created ? 'new' : 'open', name: disk.name, mib: disk.mib, persisted: disk.persisted })
392
+ }
393
+
394
+ const env = [
395
+ ...(image.index.config?.Env || ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']),
396
+ `WMD_BUILDER_DIGEST=${digest}`,
397
+ ...(o.httpRegistries?.length ? [`WMD_BK_HTTP=${o.httpRegistries.map(checkHost).join(' ')}`] : []),
398
+ ...(o.registryMirrors?.length ? [`WMD_BK_MIRRORS=${o.registryMirrors.map(checkHost).join(',')}`] : []),
399
+ ...(o.registryCAs && Object.keys(o.registryCAs).length ? [`WMD_BK_CA=${Object.entries(o.registryCAs).map(([h, pem]) => `${checkHost(h)}=${b64utf8(pem)}`).join(' ')}`] : []),
400
+ ]
401
+ const argv = ['/usr/local/bin/wmd-buildkitd']
402
+ const b = containerBundle({ args: argv, env, cwd: '/', hostname: 'buildkitsandbox', networkDisabled: false, ip: '192.168.127.2', gateway: '192.168.127.1', privileged: true })
403
+ const append = [...arch.console, 'rdinit=/sbin/init', 'loglevel=4', 'norandmaps', 'wmd.init', 'wmd.p9tag=wmdroot', 'wmd.control=vport', 'wmd.exit'].join(' ')
404
+ const args = [
405
+ '--machine', ...arch.machine, '--mode', 'jit', '--cpus', String(cpus), '--ram', String(ram),
406
+ '--kernel', `/g/${arch.kernel}`, '--initrd', '/g/guest/initramfs.cpio.gz', '--append', append,
407
+ '--net', String(NET_PORT), '--mac', MAC,
408
+ '--p9', String(P9_PORT), '--p9-tag', 'wmdroot', '--control', String(CONTROL_PORT),
409
+ ]
410
+ const services = [
411
+ { port: P9_PORT, kind: '9p', layers: image.packs, at: 'rootfs', files: b.files },
412
+ { port: CONTROL_PORT, kind: 'control', yamux: true },
413
+ { port: NET_PORT, kind: 'net', relay: { protocol: 'wisp', ...o.relay } },
414
+ ]
415
+ let stderr = ''
416
+ let slowSaid = false
417
+ emit({ t: 'boot', ram, cpus, arch: archName })
418
+ let vm
419
+ try {
420
+ vm = await bootWmd({
421
+ vmUrl: o.vmUrl,
422
+ files: o.files,
423
+ args,
424
+ ...(ram > 1536 ? { flatRam: true } : {}),
425
+ ...(disk ? { disk } : {}),
426
+ onStderr: s => {
427
+ stderr = (stderr + s).slice(-16384)
428
+ // WebKit: the engine's code pool filled, so new code waits and the guest runs slower (D-515; plan 5.10)
429
+ if (!slowSaid && POOL_FULL_RE.test(s)) {
430
+ slowSaid = true
431
+ emit({ t: 'slow', reason: 'webkit-code-space', message: "the browser's code space is full: new code runs in the interpreter, so this build is slower" })
432
+ }
433
+ },
434
+ channels: { services, collect: [{ port: P9_PORT, path: 'bundle/exitcode' }] },
435
+ ...(o.boot || {}),
436
+ })
437
+ } catch (e) {
438
+ await disk?.close().catch(() => {})
439
+ throw e
440
+ }
441
+ const ended = vm.exited
442
+ const ctl = vm.control
443
+ const stop = async () => {
444
+ try {
445
+ vm.stop()
446
+ } catch {}
447
+ await Promise.race([ended, new Promise(r => setTimeout(r, 10_000))])
448
+ await disk?.close().catch(() => {})
449
+ if (disk?.temporary) await deleteDisk(disk.name).catch(() => {})
450
+ }
451
+
452
+ // the start script's own lines on the main process's output
453
+ let mainOut = ''
454
+ let readyRes
455
+ let readyRej
456
+ const ready = new Promise((res, rej) => ((readyRes = res), (readyRej = rej)))
457
+ ready.catch(() => {})
458
+ const mainLog = logKeeper(400, 16 << 10)
459
+ const dec = new TextDecoder()
460
+ const onMain = u8 => {
461
+ mainOut += dec.decode(u8, { stream: true })
462
+ let i
463
+ while ((i = mainOut.indexOf('\n')) >= 0) {
464
+ const line = mainOut.slice(0, i)
465
+ mainOut = mainOut.slice(i + 1)
466
+ mainLog.add(line)
467
+ if (!line.startsWith(SAY)) continue
468
+ const [what, ...rest] = line.slice(SAY.length).split(' ')
469
+ if (what === 'ready') readyRes(rest[0])
470
+ else if (what === 'disk') emit({ t: 'disk', reason: rest[0], mib: Number(rest[1]) })
471
+ else if (what === 'tmpfs') emit({ t: 'disk', reason: 'tmpfs', mib: Number(rest[0]) })
472
+ else if (what === 'error') readyRej(new Error(`the builder did not start: ${rest.join(' ')}\n${mainLog.tail.slice(-3000)}`))
473
+ }
474
+ }
475
+ try {
476
+ await Promise.race([ctl.waitSession(600_000), ended.then(r => Promise.reject(new Error(`the builder machine ended before its control session: ${r.code} ${r.reason}\n${stderr.slice(-2000)}`)))])
477
+ const main = ctl.run(fillRunRequest({ id: MAIN_PROC_ID, args: argv, env, cwd: '/' }, { p9opts: p9MountOptions() }), { onStdout: onMain, onStderr: onMain })
478
+ main.exited.then(r => readyRej(new Error(`the builder's start script ended (${r.code}) before buildkitd answered\n${mainLog.tail.slice(-3000)}`)))
479
+ await Promise.race([ready, ended.then(r => Promise.reject(new Error(`the builder machine ended: ${r.code} ${r.reason}`))), aborted(o.signal)])
480
+ } catch (e) {
481
+ await stop()
482
+ throw e
483
+ }
484
+ emit({ t: 'ready', ms: Math.round(performance.now() - t0), platform: arch.platform })
485
+
486
+ let seq = 0
487
+ let busy = false
488
+ let closed = false
489
+ /** One process in the builder: {code, out (text, or bytes when binary), err}. */
490
+ const inGuest = (cmd, { stdin = null, env: xenv = [], binary = false, onStderr = null, signal = null, id = `b${++seq}` } = {}) => {
491
+ const chunks = []
492
+ let size = 0
493
+ let out = ''
494
+ let err = ''
495
+ const d1 = new TextDecoder()
496
+ const d2 = new TextDecoder()
497
+ const p = ctl.startExec({ id, args: cmd, env: [...env, ...xenv], cwd: '/' }, {
498
+ stdin: !!stdin,
499
+ onStdout: u8 => {
500
+ if (binary) {
501
+ chunks.push(u8.slice())
502
+ size += u8.length
503
+ } else out = (out + d1.decode(u8, { stream: true })).slice(-(1 << 20))
504
+ },
505
+ onStderr: u8 => {
506
+ const s = d2.decode(u8, { stream: true })
507
+ if (onStderr) onStderr(s)
508
+ else err = (err + s).slice(-(1 << 20))
509
+ },
510
+ })
511
+ let finished = false
512
+ let killTimer = null
513
+ let abandon = null
514
+ const abandoned = new Promise(res => (abandon = res))
515
+ // an abort sends SIGINT; a process still running 5 s later gets SIGKILL, and 2 s after that it is given up on
516
+ const kill = () => {
517
+ if (finished) return
518
+ ctl.kill({ id, sig: 'SIGINT' }).catch(() => {})
519
+ killTimer = setTimeout(() => {
520
+ if (finished) return
521
+ ctl.kill({ id, sig: 'SIGKILL' }).catch(() => {})
522
+ killTimer = setTimeout(() => abandon({ code: null }), 2000)
523
+ }, 5000)
524
+ }
525
+ if (signal) {
526
+ if (signal.aborted) kill()
527
+ else signal.addEventListener('abort', kill, { once: true })
528
+ }
529
+ return (async () => {
530
+ let r
531
+ try {
532
+ if (stdin) {
533
+ await p.writeAsync(stdin)
534
+ p.closeStdin()
535
+ }
536
+ r = await Promise.race([p.exited, abandoned])
537
+ } finally {
538
+ finished = true
539
+ clearTimeout(killTimer)
540
+ signal?.removeEventListener('abort', kill)
541
+ }
542
+ if (!binary) return { code: r.code ?? null, out, err }
543
+ const bytes = new Uint8Array(size)
544
+ let at = 0
545
+ for (const c of chunks) {
546
+ bytes.set(c, at)
547
+ at += c.length
548
+ }
549
+ return { code: r.code ?? null, out: bytes, err }
550
+ })()
551
+ }
552
+ const sh = (cmd, opts) => inGuest(['/bin/sh', '-c', cmd], opts)
553
+
554
+ const builder = {
555
+ arch: archName,
556
+ platform: arch.platform,
557
+ disk,
558
+ vm,
559
+ converter: o.converter,
560
+ store: o.store || null,
561
+ pull: o.pull || null,
562
+ fast: makeFast({ pull: o.pull, converter: o.converter, platform: arch.platform }),
563
+ inGuest,
564
+ sh,
565
+ log: () => mainLog.lines,
566
+ /** Free MiB and free inodes on /bk. */
567
+ async room() {
568
+ const r = await sh("df -Pm /bk | awk 'NR==2{print $4}'; df -Pi /bk | awk 'NR==2{print $4, $2}'")
569
+ const [mib, inodes, total] = r.out.trim().split(/\s+/).map(Number)
570
+ return { mib, inodes, inodesTotal: total }
571
+ },
572
+ async build(req) {
573
+ if (closed) throw new Error('the builder is closed')
574
+ if (busy) throw new Error('one build at a time')
575
+ busy = true
576
+ try {
577
+ return await runBuild(builder, req, emit)
578
+ } finally {
579
+ busy = false
580
+ }
581
+ },
582
+ /** Stops the machine and closes the disk; {keep: false} also deletes the disk and its cache. */
583
+ async close({ keep = true } = {}) {
584
+ if (closed) return
585
+ closed = true
586
+ ctl.kill({ id: MAIN_PROC_ID, sig: 'SIGTERM' }).catch(() => {})
587
+ await Promise.race([ended, new Promise(r => setTimeout(r, 20_000))])
588
+ await stop()
589
+ if (!keep && disk && !disk.temporary) await deleteDisk(disk.name).catch(() => {})
590
+ },
591
+ ended,
592
+ }
593
+ o.signal?.addEventListener('abort', () => builder.close(), { once: true })
594
+ return builder
595
+ }
596
+
597
+ const shq = s => `'${String(s).replace(/'/g, `'\\''`)}'`
598
+
599
+ /** Keeps a finished build in o.store the way pullImage does: each pack under its digest, the index as JSON under image:<digest>. */
600
+ async function keepBuilt(store, index, packs) {
601
+ if (!store?.put) return
602
+ try {
603
+ const layers = index?.layers || []
604
+ for (let i = 0; i < packs.length; i++) if (layers[i]?.pack && packs[i] instanceof Uint8Array) await store.put(layers[i].pack, packs[i])
605
+ await store.put(`image:sha256:${await indexDigest(index)}`, enc.encode(JSON.stringify(index)))
606
+ } catch {}
607
+ }
608
+
609
+ async function runBuild(builder, req, emit) {
610
+ const t0 = performance.now()
611
+ const onEvent = ev => {
612
+ emit(ev)
613
+ try {
614
+ req.onEvent?.(ev)
615
+ } catch {}
616
+ }
617
+ req.signal?.throwIfAborted()
618
+ // everything that can be refused is, before any work
619
+ if (req.tag != null) checkRef(req.tag, 'tag')
620
+ if (req.push) checkRef(req.push.name, 'push name')
621
+ const secrets = checkSecrets(req.secrets)
622
+ const log = logKeeper()
623
+ // the Dockerfile: given, or read from the context
624
+ let dockerfile = req.dockerfile ?? null
625
+ if (dockerfile == null) {
626
+ const df = await readFromContext(req.context, 'Dockerfile')
627
+ if (df == null) throw new BuildError('no Dockerfile: give dockerfile, or put a Dockerfile in the context')
628
+ dockerfile = new TextDecoder().decode(df)
629
+ }
630
+ const refused = refusals(dockerfile, req)
631
+ if (refused) throw refused
632
+ if (req.platform && req.platform !== builder.platform) {
633
+ throw new BuildRefused('platform', `this builder builds ${builder.platform}; ${req.platform} needs a builder of that arch (createBuilder({arch}))`)
634
+ }
635
+ req.signal?.throwIfAborted()
636
+
637
+ // the fast path: a Dockerfile with no RUN, built in the page (fast.js)
638
+ if (req.fast !== false && builder.fast) {
639
+ const fp = await builder.fast(dockerfile, req, onEvent)
640
+ req.signal?.throwIfAborted()
641
+ if (fp) {
642
+ await keepBuilt(builder.store, fp.index, fp.packs)
643
+ const ms = Math.round(performance.now() - t0)
644
+ onEvent({ t: 'done', ms, path: 'fast' })
645
+ return { ...fp, log: log.lines, path: 'fast', ms, cached: 0 }
646
+ }
647
+ }
648
+
649
+ const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
650
+ const dir = `/run/ctx/${id}`
651
+ // req.signal and timeoutMs cover everything from here: the context, the upload, buildctl and the conversion
652
+ const ac = new AbortController()
653
+ const timer = req.timeoutMs ? setTimeout(() => ac.abort(new BuildError(`the build ran past ${req.timeoutMs} ms`, { reason: 'timeout' })), req.timeoutMs) : null
654
+ const onAbort = () => ac.abort(new BuildCancelled())
655
+ req.signal?.addEventListener('abort', onAbort, { once: true })
656
+ const check = () => {
657
+ if (ac.signal.aborted) throw ac.signal.reason
658
+ }
659
+ const diskMark = Array.isArray(builder.disk?.events) ? builder.disk.events.length : 0
660
+ try {
661
+ check()
662
+ const ctx = await packContext(req.context || {}, { ...(req.dockerfile != null ? { dockerfile } : {}), limits: req.limits })
663
+ check()
664
+ await ensureRoom(builder, onEvent, ac.signal)
665
+ check()
666
+ // BuildKit keeps the last context it was sent and reuses each file whose size and mtime did not change; contexts
667
+ // with made-up mtimes (a plain object has none) would then build with an earlier build's bytes
668
+ const fresh = await builder.inGuest(['buildctl', 'prune', '--filter', 'type==source.local'], { signal: ac.signal })
669
+ if (fresh.code !== 0) throw new BuildError(`dropping the last build's context: ${String(fresh.err || '').slice(-500)}`)
670
+ check()
671
+ const sig = { signal: ac.signal }
672
+ const put = await builder.inGuest(['/bin/sh', '-c', 'mkdir -p "$1/ctx" "$1/df" && /bin/tar -x -p --numeric-owner -C "$1/ctx"', 'sh', dir], { stdin: ctx.tar, ...sig })
673
+ check()
674
+ if (put.code !== 0) throw new BuildError(`sending the context: ${put.err.slice(-500)}`)
675
+ const dfPut = await builder.inGuest(['/bin/sh', '-c', 'cat > "$1/df/Dockerfile"', 'sh', dir], { stdin: enc.encode(dockerfile), ...sig })
676
+ check()
677
+ if (dfPut.code !== 0) throw new BuildError(`sending the Dockerfile: ${dfPut.err.slice(-500)}`)
678
+ // a .dockerignore in the context goes next to the Dockerfile too, where BuildKit reads it (a symlink only when it
679
+ // points inside the context, as the docker CLI reads it from there)
680
+ await builder.sh(`cd ${shq(`${dir}/ctx`)} && for f in .dockerignore Dockerfile.dockerignore; do [ -f "$f" ] || continue; t=$(readlink -f "$f") && case "$t" in "$(pwd -P)"/*) cp "$t" "../df/$f";; esac; done; true`, sig)
681
+ check()
682
+ if (req.push?.auth) {
683
+ const cfg = JSON.stringify({ auths: { [registryOf(req.push.name)]: { auth: b64utf8(`${req.push.auth.username}:${req.push.auth.password}`) } } })
684
+ const a = await builder.inGuest(['/bin/sh', '-c', 'mkdir -p "$1/auth" && umask 077 && cat > "$1/auth/config.json"', 'sh', dir], { stdin: enc.encode(cfg), ...sig })
685
+ check()
686
+ if (a.code !== 0) throw new BuildError('writing the push credentials')
687
+ }
688
+ const args = ['buildctl', 'build', '--progress=rawjson', '--frontend', 'dockerfile.v0',
689
+ '--local', `context=${dir}/ctx`, '--local', `dockerfile=${dir}/df`, '--opt', `platform=${builder.platform}`]
690
+ for (const [k, v] of Object.entries(req.args || {})) args.push('--opt', `build-arg:${k}=${v}`)
691
+ if (req.target) args.push('--opt', `target=${req.target}`)
692
+ if (req.noCache) args.push('--no-cache')
693
+ const xenv = []
694
+ for (const sec of secrets) {
695
+ args.push('--secret', `id=${sec.id},env=${sec.name}`)
696
+ xenv.push(`${sec.name}=${sec.text}`)
697
+ }
698
+ if (req.push?.auth) xenv.push(`DOCKER_CONFIG=${dir}/auth`)
699
+ args.push('--output', `type=docker,${req.tag ? `name=${req.tag},` : ''}dest=-`)
700
+ if (req.push) args.push('--output', `type=image,name=${req.push.name},push=true${req.push.insecure ? ',registry.insecure=true' : ''}`)
701
+ let lastStep = null
702
+ const errLines = [] // buildctl's own error lines (no step), not the steps' output
703
+ const pr = progressReader({
704
+ onStep: ev => {
705
+ if (ev.status === 'running' || ev.status === 'error') lastStep = ev.name
706
+ onEvent(ev)
707
+ },
708
+ onLog: (line, step) => {
709
+ if (!step && /^(?:error|ERROR)\b/.test(line)) errLines.push(line)
710
+ log.add(step ? `[${step}] ${line}` : line)
711
+ onEvent({ t: 'log', line, step })
712
+ },
713
+ })
714
+ onEvent({ t: 'build', id, files: ctx.files, bytes: ctx.bytes })
715
+ // the least free room /bk had during the build: a step that fills the disk fails with its own exit code, and
716
+ // BuildKit drops the step's files once it fails, so only a watch while it runs tells a full disk apart
717
+ let minMib = Infinity
718
+ let watching = false
719
+ const watch = setInterval(() => {
720
+ if (watching) return
721
+ watching = true
722
+ builder.room().then(x => Number.isFinite(x.mib) && (minMib = Math.min(minMib, x.mib)), () => {}).finally(() => (watching = false))
723
+ }, ROOM_WATCH_MS)
724
+ let r
725
+ try {
726
+ r = await builder.inGuest(args, { env: xenv, binary: true, onStderr: s => pr.push(s), signal: ac.signal, id: `build-${id}` })
727
+ } finally {
728
+ clearInterval(watch)
729
+ }
730
+ pr.push('\n')
731
+ // buildctl exited 0 before an abort landed: the build is done, and the result is returned
732
+ if (r.code !== 0) {
733
+ check()
734
+ const tail = log.tail
735
+ let reason = null
736
+ if (/no space left on device/i.test(errLines.join('\n')) || minMib < FULL_MIB) reason = 'no-space'
737
+ if (builder.disk?.events?.slice?.(diskMark).some(e => e.reason === 'quota')) reason = 'quota'
738
+ if (!reason && /input\/output error|read-only file system/i.test(tail)) reason = 'disk'
739
+ const failed = [...pr.vertexes.values()].find(v => v.error)
740
+ const why = failed?.error || tail.split('\n').filter(Boolean).pop() || `buildctl exited ${r.code}`
741
+ throw new BuildError(`the build failed${failed ? ` at ${failed.name}` : ''}: ${why.slice(0, 500)}`, { step: failed?.name || lastStep, tail, reason })
742
+ }
743
+ onEvent({ t: 'export', bytes: r.out.length })
744
+ const t1 = performance.now()
745
+ const img = await loadImageTar(r.out, { converter: builder.converter, platform: builder.platform })
746
+ onEvent({ t: 'convert', layers: img.packs.length, ms: Math.round(performance.now() - t1) })
747
+ await keepBuilt(builder.store, img.index, img.packs)
748
+ const cached = [...pr.vertexes.values()].filter(v => v.cached).length
749
+ const out = { index: img.index, packs: img.packs, name: img.name, log: log.lines, path: 'buildkit', ms: Math.round(performance.now() - t0), cached }
750
+ if (req.tar) out.tar = new Blob([r.out], { type: 'application/x-tar' })
751
+ onEvent({ t: 'done', ms: out.ms, cached, path: 'buildkit' })
752
+ return out
753
+ } finally {
754
+ clearTimeout(timer)
755
+ req.signal?.removeEventListener('abort', onAbort)
756
+ await builder.sh(`rm -rf ${shq(dir)}`).catch(() => {})
757
+ }
758
+ }
759
+
760
+ function registryOf(name) {
761
+ const first = String(name).split('/')[0]
762
+ return first.includes('.') || first.includes(':') || first === 'localhost' ? first : 'https://index.docker.io/v1/'
763
+ }
764
+
765
+ async function readFromContext(ctx, path) {
766
+ if (!ctx) return null
767
+ if (ctx instanceof Uint8Array || ctx instanceof ArrayBuffer || (typeof Blob !== 'undefined' && ctx instanceof Blob)) return readTarFile(ctx, path)
768
+ if (typeof ctx.read === 'function') return Promise.resolve(ctx.read(path)).catch(() => null)
769
+ const v = ctx[path]
770
+ return v == null ? null : typeof v === 'string' ? enc.encode(v) : v
771
+ }
772
+
773
+ /** Before a build: when /bk is short of space or inodes, prune BuildKit's cache and give the freed blocks back. */
774
+ async function ensureRoom(builder, onEvent, signal) {
775
+ const room = await builder.room()
776
+ const lowInodes = room.inodesTotal && room.inodes < room.inodesTotal * 0.1
777
+ if (room.mib >= 1024 && !lowInodes) return room
778
+ onEvent({ t: 'prune', mib: room.mib, inodes: room.inodes })
779
+ await builder.sh('buildctl prune --keep-storage 512 >/dev/null 2>&1; fstrim /bk >/dev/null 2>&1; sync', { signal })
780
+ return builder.room()
781
+ }
782
+
783
+ /** createBuilder + one build + close. */
784
+ export async function buildImage(o) {
785
+ const builder = await createBuilder(o)
786
+ try {
787
+ return await builder.build(o)
788
+ } finally {
789
+ await builder.close()
790
+ }
791
+ }