@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.
- 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 +10 -8
- package/relay/main.ts +2 -2
- package/relay/relay.ts +1 -0
- package/relay/token.ts +34 -5
- package/relay/wisp.ts +37 -5
- 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,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
|
+
}
|