@toa.io/operations 1.0.0-alpha.287 → 1.0.0-alpha.289

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.
@@ -10,6 +10,7 @@ export class Registry {
10
10
 
11
11
  #process
12
12
 
13
+ /** @type {toa.deployment.images.Image[]} */
13
14
  #images = []
14
15
 
15
16
  /** @type {Promise<string> | undefined} */
@@ -36,18 +37,16 @@ export class Registry {
36
37
  async prepare(root) {
37
38
  const path = await workspace.create('images', root)
38
39
 
39
- await Promise.all(this.#images.map((image) => image.prepare(path)))
40
+ await Promise.all(this.#all().map((image) => image.prepare(path)))
40
41
 
41
42
  return path
42
43
  }
43
44
 
44
45
  async build() {
45
- await this.prepare()
46
46
  await this.#run(false)
47
47
  }
48
48
 
49
49
  async push() {
50
- await this.prepare()
51
50
  await this.#run(true)
52
51
  }
53
52
 
@@ -69,24 +68,57 @@ export class Registry {
69
68
  }
70
69
 
71
70
  /**
72
- * Every image is probed at once, and what is missing is built concurrently: a build is
73
- * bound by the registry it uploads to, not by the runner, so one at a time leaves both idle.
71
+ * Every image and what it is laid over.
72
+ *
73
+ * @returns {toa.deployment.images.Image[]}
74
+ */
75
+ #all() {
76
+ return this.#images.flatMap((image) =>
77
+ image.dependencies === undefined ? [image] : [image.dependencies, image]
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Every image is probed at once, and what is missing is prepared and built concurrently:
83
+ * a build is bound by the registry it uploads to, not by the runner, so one at a time
84
+ * leaves both idle. What an image is laid over is built before it.
74
85
  *
75
86
  * @param {boolean} push
76
87
  * @returns {Promise<void>}
77
88
  */
78
89
  async #run(push) {
90
+ // a push builds on the container driver, whose bootstrap is spent while the probes are out
91
+ if (push) this.#ensureBuilder().catch(() => undefined)
92
+
93
+ const images = this.#all()
94
+
79
95
  const existing = await Promise.all(
80
- this.#images.map((image) => this.exists(image.reference))
96
+ images.map((image) => this.exists(image.reference))
81
97
  )
82
98
 
83
- const missing = this.#images.filter((image, index) => {
99
+ const missing = images.filter((image, index) => {
84
100
  if (existing[index]) console.log('Image already exists, skipping:', image.reference)
85
101
 
86
102
  return !existing[index]
87
103
  })
88
104
 
89
- await pool(missing, (image) => this.#build(image, push))
105
+ if (missing.length === 0) return
106
+
107
+ const path = await workspace.create('images')
108
+
109
+ await Promise.all(missing.map((image) => image.prepare(path)))
110
+
111
+ const bases = new Set(this.#images.map((image) => image.dependencies))
112
+ const build = (image) => this.#build(image, push)
113
+
114
+ await pool(
115
+ missing.filter((image) => bases.has(image)),
116
+ build
117
+ )
118
+ await pool(
119
+ missing.filter((image) => !bases.has(image)),
120
+ build
121
+ )
90
122
  }
91
123
 
92
124
  /**
@@ -102,7 +134,7 @@ export class Registry {
102
134
 
103
135
  args.push('--tag', image.reference, image.context)
104
136
 
105
- if (this.#registry.build?.arguments !== undefined) {
137
+ if (image.arguments && this.#registry.build?.arguments !== undefined) {
106
138
  for (const arg of this.#registry.build.arguments)
107
139
  args.push('--build-arg', `${arg}=${process.env[arg]}`)
108
140
  }
@@ -111,9 +143,10 @@ export class Registry {
111
143
 
112
144
  if (platforms !== null) args.push('--platform', platforms.join(','))
113
145
 
114
- // the container driver is what builds for a platform the runner is not; for a single
115
- // platform it costs a buildkit image to pull and buys nothing
116
- if (platforms !== null && platforms.length > 1) {
146
+ // a push takes the container driver: its registry exporter is what lays an image over a
147
+ // base it never pulled. A load takes the daemon's own, unless it builds for a platform
148
+ // the runner is not, which the daemon's cannot
149
+ if (push || (platforms !== null && platforms.length > 1)) {
117
150
  args.push('--builder', await this.#ensureBuilder())
118
151
 
119
152
  // the driver that produces attestations is this one, and nothing reads them;
@@ -127,7 +160,12 @@ export class Registry {
127
160
  }
128
161
 
129
162
  async exists(tag) {
130
- const args = ['manifest', 'inspect', tag]
163
+ const args = ['manifest', 'inspect']
164
+
165
+ // a registry on this machine speaks plain HTTP, which the probe has to be told
166
+ if (LOCAL.test(tag)) args.push('--insecure')
167
+
168
+ args.push(tag)
131
169
 
132
170
  try {
133
171
  await this.#process.execute('docker', args, { silently: true })
@@ -155,11 +193,16 @@ export class Registry {
155
193
  silently: true
156
194
  })
157
195
  } catch {
196
+ // on the host's network, a registry the host reaches as `localhost` is reached
158
197
  await this.#process.execute('docker', [
159
198
  'buildx',
160
199
  'create',
161
200
  '--name',
162
201
  BUILDER,
202
+ '--driver',
203
+ 'docker-container',
204
+ '--driver-opt',
205
+ 'network=host',
163
206
  '--bootstrap'
164
207
  ])
165
208
  }
@@ -189,5 +232,8 @@ async function pool(items, work) {
189
232
 
190
233
  const BUILDER = 'toa'
191
234
 
235
+ /** A reference into a registry on this machine, by any of the names it goes by. */
236
+ const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|host\.docker\.internal)(:\d+)?\//
237
+
192
238
  /** How many builds run at once. Past this the uploads contend for the same uplink. */
193
239
  const CONCURRENCY = 3
@@ -27,9 +27,9 @@ beforeEach(() => {
27
27
  }
28
28
 
29
29
  factory = /** @type {toa.deployment.images.Factory} */ {
30
- composition: () => createImage('composition-mono'),
30
+ composition: () => createImage('composition-mono', true),
31
31
  service: () => createImage('extension-realtime'),
32
- mono: () => createImage('mono')
32
+ mono: () => createImage('mono', true)
33
33
  }
34
34
  })
35
35
 
@@ -54,6 +54,10 @@ it('should reuse named builder across images', async () => {
54
54
  'create',
55
55
  '--name',
56
56
  'toa',
57
+ '--driver',
58
+ 'docker-container',
59
+ '--driver-opt',
60
+ 'network=host',
57
61
  '--bootstrap'
58
62
  ])
59
63
 
@@ -62,7 +66,7 @@ it('should reuse named builder across images', async () => {
62
66
  args[0] === '--context=default' && args[1] === 'buildx' && args[2] === 'build'
63
67
  )
64
68
 
65
- assert.strictEqual(builds.length, 2)
69
+ assert.strictEqual(builds.length, 3)
66
70
 
67
71
  for (const {
68
72
  arguments: [, args]
@@ -107,7 +111,7 @@ it('should not export a build cache', async () => {
107
111
  ({ arguments: [, args] }) => args[0] === '--context=default' && args[2] === 'build'
108
112
  )
109
113
 
110
- assert.strictEqual(builds.length, 2)
114
+ assert.strictEqual(builds.length, 3)
111
115
 
112
116
  for (const {
113
117
  arguments: [, args]
@@ -172,8 +176,114 @@ it('should probe every image before building any', async () => {
172
176
  const build = calls.indexOf('--context=default')
173
177
  const probed = calls.slice(0, build).filter((argument) => argument === 'manifest')
174
178
 
175
- // both probes are spent before the first build starts, rather than one before each
176
- assert.strictEqual(probed.length, 2)
179
+ // every probe is spent before the first build starts, rather than one before each:
180
+ // the composition, what it is laid over, and the service
181
+ assert.strictEqual(probed.length, 3)
182
+ })
183
+
184
+ it('should build what an image is laid over before the image', async () => {
185
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
186
+
187
+ registry.composition(/** @type {any} */ ({}))
188
+ registry.service('.', /** @type {any} */ ({}))
189
+
190
+ await registry.build()
191
+
192
+ const tags = builds().map((args) => args[args.indexOf('--tag') + 1])
193
+ const [composition] = images
194
+
195
+ assert.strictEqual(tags.length, 3)
196
+ assert.ok(
197
+ tags.indexOf(composition.dependencies.reference) < tags.indexOf(composition.reference)
198
+ )
199
+ })
200
+
201
+ it('should lay the image over what already exists', async () => {
202
+ process.execute = mock.fn(async (cmd, args) => {
203
+ if (args[0] === 'manifest' && !args[2].includes(':deps-'))
204
+ throw new Error('manifest unknown')
205
+
206
+ return ''
207
+ })
208
+
209
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
210
+
211
+ registry.composition(/** @type {any} */ ({}))
212
+
213
+ await registry.build()
214
+
215
+ const tags = builds().map((args) => args[args.indexOf('--tag') + 1])
216
+ const [composition] = images
217
+
218
+ assert.deepStrictEqual(tags, [composition.reference])
219
+ assert.strictEqual(composition.dependencies.prepare.mock.callCount(), 0)
220
+ assert.strictEqual(composition.prepare.mock.callCount(), 1)
221
+ })
222
+
223
+ it('should prepare only what is missing', async () => {
224
+ process.execute = mock.fn(async () => '')
225
+
226
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
227
+
228
+ registry.composition(/** @type {any} */ ({}))
229
+ registry.service('.', /** @type {any} */ ({}))
230
+
231
+ await registry.build()
232
+
233
+ for (const image of images) assert.strictEqual(image.prepare.mock.callCount(), 0)
234
+ })
235
+
236
+ it('should pass build arguments to the images that read them', async () => {
237
+ const registry = createRegistry({
238
+ base: 'example.com/reg',
239
+ platforms: ['linux/amd64'],
240
+ build: { arguments: ['TOKEN'] }
241
+ })
242
+
243
+ registry.composition(/** @type {any} */ ({}))
244
+ registry.service('.', /** @type {any} */ ({}))
245
+
246
+ await registry.build()
247
+
248
+ const [composition] = images
249
+
250
+ for (const args of builds()) {
251
+ const tag = args[args.indexOf('--tag') + 1]
252
+ const reads = tag === composition.dependencies.reference
253
+
254
+ assert.strictEqual(args.includes('--build-arg'), reads, tag)
255
+ }
256
+ })
257
+
258
+ it('should push on the container builder', async () => {
259
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
260
+
261
+ registry.composition(/** @type {any} */ ({}))
262
+
263
+ await registry.push()
264
+
265
+ for (const args of builds()) {
266
+ assert.ok(args.includes('--push'))
267
+ assert.strictEqual(args[args.indexOf('--builder') + 1], 'toa')
268
+ assert.ok(args.includes('--provenance=false'))
269
+ }
270
+ })
271
+
272
+ it('should not create the builder for a push when nothing is missing', async () => {
273
+ process.execute = mock.fn(async (cmd, args) => {
274
+ if (args[0] === 'buildx' && args[1] === 'inspect')
275
+ throw new Error('builder not found')
276
+
277
+ return ''
278
+ })
279
+
280
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
281
+
282
+ registry.composition(/** @type {any} */ ({}))
283
+
284
+ await registry.push()
285
+
286
+ assert.strictEqual(builds().length, 0)
177
287
  })
178
288
 
179
289
  it('should use default builder when platforms is null', async () => {
@@ -187,14 +297,14 @@ it('should use default builder when platforms is null', async () => {
187
297
  ({ arguments: [, args] }) => args[0] === '--context=default' && args[2] === 'build'
188
298
  )
189
299
 
190
- assert.strictEqual(builds.length, 1)
300
+ assert.strictEqual(builds.length, 2)
191
301
 
192
- const {
302
+ for (const {
193
303
  arguments: [, args]
194
- } = builds[0]
195
-
196
- assert.strictEqual(args[args.indexOf('--builder') + 1], 'default')
197
- assert.ok(!args.includes('--platform'))
304
+ } of builds) {
305
+ assert.strictEqual(args[args.indexOf('--builder') + 1], 'default')
306
+ assert.ok(!args.includes('--platform'))
307
+ }
198
308
  })
199
309
 
200
310
  it('should skip build when image already exists', async () => {
@@ -226,6 +336,34 @@ it('should skip build when image already exists', async () => {
226
336
  )
227
337
  })
228
338
 
339
+ it('should probe a local registry as insecure', async () => {
340
+ process.execute = mock.fn(async () => '')
341
+
342
+ const registry = createRegistry({ base: 'localhost:5000', platforms: null })
343
+
344
+ factory.composition = () => {
345
+ const image = createImage('composition-mono', true)
346
+
347
+ image.reference = 'localhost:5000/acme/composition-mono:abcdef12'
348
+ image.dependencies.reference = 'localhost:5000/acme/composition-mono:deps-12345678'
349
+
350
+ return image
351
+ }
352
+
353
+ registry.composition(/** @type {any} */ ({}))
354
+
355
+ await registry.build()
356
+
357
+ const probes = process.execute.mock.calls
358
+ .filter(({ arguments: [, args] }) => args[0] === 'manifest')
359
+ .map(({ arguments: [, args] }) => args)
360
+
361
+ assert.strictEqual(probes.length, 2)
362
+
363
+ for (const args of probes)
364
+ assert.deepStrictEqual(args.slice(0, 3), ['manifest', 'inspect', '--insecure'])
365
+ })
366
+
229
367
  /**
230
368
  * @param {object} [registry]
231
369
  * @returns {Registry}
@@ -236,16 +374,34 @@ function createRegistry(registry = {}) {
236
374
 
237
375
  /**
238
376
  * @param {string} name
377
+ * @param {boolean} [bundle] laid over an image of its dependencies
239
378
  * @returns {toa.deployment.images.Image}
240
379
  */
241
- function createImage(name) {
380
+ function createImage(name, bundle = false) {
242
381
  const image = /** @type {toa.deployment.images.Image} */ {
243
382
  reference: `example.com/reg/acme/${name}:abcdef12`,
244
383
  context: `/tmp/${name}`,
245
384
  prepare: mock.fn(async () => `/tmp/${name}`)
246
385
  }
247
386
 
387
+ if (bundle)
388
+ image.dependencies = /** @type {toa.deployment.images.Image} */ {
389
+ reference: `example.com/reg/acme/${name}:deps-12345678`,
390
+ context: `/tmp/dependencies/${name}`,
391
+ arguments: true,
392
+ prepare: mock.fn(async () => `/tmp/dependencies/${name}`)
393
+ }
394
+
248
395
  images.push(image)
249
396
 
250
397
  return image
251
398
  }
399
+
400
+ /** @returns {string[][]} the arguments of every build run */
401
+ function builds() {
402
+ return process.execute.mock.calls
403
+ .filter(
404
+ ({ arguments: [, args] }) => args[0] === '--context=default' && args[2] === 'build'
405
+ )
406
+ .map(({ arguments: [, args] }) => args)
407
+ }
package/src/process.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execa } from 'execa'
1
+ import { spawn } from 'node:child_process'
2
2
 
3
3
  /**
4
4
  * @implements {toa.operations.Process}
@@ -7,15 +7,59 @@ export class Process {
7
7
  async execute(cmd, args, options = {}) {
8
8
  console.log('toa>', cmd, args.join(' '))
9
9
 
10
- const command = execa(cmd, args)
10
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
11
+ const stdout = []
12
+ const stderr = []
13
+
14
+ child.stdout.on('data', (chunk) => stdout.push(chunk))
15
+ child.stderr.on('data', (chunk) => stderr.push(chunk))
11
16
 
12
17
  if (options.silently !== true) {
13
- command.stdout.pipe(process.stdout)
14
- command.stderr.pipe(process.stderr)
18
+ child.stdout.pipe(process.stdout)
19
+ child.stderr.pipe(process.stderr)
15
20
  }
16
21
 
17
- const result = await command
22
+ await exit(child, cmd, args, () => Buffer.concat(stderr).toString())
18
23
 
19
- return result.stdout
24
+ return Buffer.concat(stdout).toString().replace(/\n$/, '')
20
25
  }
21
26
  }
27
+
28
+ /**
29
+ * Runs a command on this process's own streams, and answers once it has exited.
30
+ *
31
+ * @param {string} cmd
32
+ * @param {string[]} args
33
+ * @returns {Promise<void>}
34
+ */
35
+ export async function run(cmd, args) {
36
+ console.log('toa>', cmd, args.join(' '))
37
+
38
+ await exit(spawn(cmd, args, { stdio: 'inherit' }), cmd, args)
39
+ }
40
+
41
+ /**
42
+ * @param {import('node:child_process').ChildProcess} child
43
+ * @param {string} cmd
44
+ * @param {string[]} args
45
+ * @param {() => string} stderr
46
+ * @returns {Promise<void>}
47
+ */
48
+ const exit = (child, cmd, args, stderr = () => '') =>
49
+ new Promise((resolve, reject) => {
50
+ child.once('error', reject)
51
+
52
+ child.once('close', (code, signal) => {
53
+ if (code === 0) return resolve()
54
+
55
+ const output = stderr()
56
+ const reason = signal === null ? `exit code ${code}` : signal
57
+
58
+ reject(
59
+ new Error(
60
+ `Command failed with ${reason}: ${cmd} ${args.join(' ')}` +
61
+ (output === '' ? '' : '\n' + output.trimEnd())
62
+ )
63
+ )
64
+ })
65
+ })
@@ -5,8 +5,6 @@ import { Process } from './process.js'
5
5
 
6
6
  describe('execute', () => {
7
7
  it('should answer what the command wrote', async () => {
8
- // `execa` is a named export, and calling the module namespace instead answers
9
- // `execa is not a function` — where only a deployment would have found out
10
8
  const output = await new Process().execute(
11
9
  'node',
12
10
  ['-e', 'process.stdout.write("ok")'],
@@ -16,9 +14,20 @@ describe('execute', () => {
16
14
  assert.strictEqual(output, 'ok')
17
15
  })
18
16
 
19
- it('should reject what the command failed at', async () => {
17
+ it('should reject what the command failed at, with what it wrote', async () => {
20
18
  await assert.rejects(
21
- new Process().execute('node', ['-e', 'process.exit(1)'], { silently: true })
19
+ new Process().execute(
20
+ 'node',
21
+ ['-e', 'process.stderr.write("broken"); process.exit(2)'],
22
+ { silently: true }
23
+ ),
24
+ (error) => /exit code 2: node/.test(error.message) && /broken/.test(error.message)
25
+ )
26
+ })
27
+
28
+ it('should reject a command that does not exist', async () => {
29
+ await assert.rejects(
30
+ new Process().execute('toa-no-such-command', [], { silently: true })
22
31
  )
23
32
  })
24
33
  })
@@ -3,12 +3,27 @@ declare namespace toa.deployment.images {
3
3
  readonly reference: string
4
4
  readonly context: string
5
5
 
6
+ /** The image this one is laid over, built and pushed as an image of its own. */
7
+ readonly dependencies?: Image
8
+
9
+ /** Whether the build reads `registry.build.arguments`. */
10
+ readonly arguments?: boolean
11
+
6
12
  name: string
7
13
 
8
14
  tag(): void
9
15
 
10
16
  prepare(root: string): Promise<string>
11
17
  }
18
+
19
+ /** Components in one image, laid over their dependencies. */
20
+ export interface Bundle extends Image {
21
+ readonly image?: string
22
+ readonly components: any[]
23
+
24
+ conflict(): string
25
+ }
12
26
  }
13
27
 
14
28
  export type Image = toa.deployment.images.Image
29
+ export type Bundle = toa.deployment.images.Bundle