@toa.io/operations 1.0.0-alpha.296 → 1.0.0-alpha.298

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/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.0.0-alpha.298](https://github.com/toa-io/toa/compare/v1.0.0-alpha.297...v1.0.0-alpha.298) (2026-09-08)
7
+
8
+ ### Features
9
+
10
+ * **operations:** tag a deploy with the environment so registries can prune ([7247c35](https://github.com/toa-io/toa/commit/7247c35f33fb34dcfb3ffa04b196c14bfb3d5620))
11
+
12
+
13
+ # [1.0.0-alpha.297](https://github.com/toa-io/toa/compare/v1.0.0-alpha.296...v1.0.0-alpha.297) (2026-09-08)
14
+
15
+ **Note:** Version bump only for package @toa.io/operations
16
+
17
+
18
+
19
+
20
+
6
21
  # [1.0.0-alpha.296](https://github.com/toa-io/toa/compare/v1.0.0-alpha.295...v1.0.0-alpha.296) (2026-09-08)
7
22
 
8
23
  ### Bug Fixes
@@ -7,12 +7,8 @@ ARG VERSION
7
7
  WORKDIR /toa
8
8
 
9
9
  # mounted rather than written: npm's cache is a hundred megabytes, and a layer keeps whatever
10
- # the command left behind.
11
- # `--legacy-peer-deps` installs no peer this does not ask for: `promex`, `reretry` and
12
- # `matchacho` declare `typescript` a peer without marking it optional, and nothing here imports
13
- # it. Every other peer in the closure is a dependency of the runtime's already. Drop the flag
14
- # once those three are published with `peerDependenciesMeta`.
10
+ # the command left behind
15
11
  RUN --mount=type=cache,target=/root/.npm,sharing=locked \
16
- npm i @toa.io/runtime@${VERSION} --omit=dev --legacy-peer-deps
12
+ npm i @toa.io/runtime@${VERSION} --omit=dev
17
13
 
18
14
  ENV PATH=/toa/node_modules/.bin:$PATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.296",
3
+ "version": "1.0.0-alpha.298",
4
4
  "type": "module",
5
5
  "description": "Toa Deployment",
6
6
  "homepage": "https://toa.io",
@@ -30,8 +30,9 @@
30
30
  "dependencies": {
31
31
  "@toa.io/generic": "1.0.0-alpha.292",
32
32
  "@toa.io/kubernetes": "1.0.0-alpha.293",
33
- "@toa.io/norm": "1.0.0-alpha.296",
33
+ "@toa.io/norm": "1.0.0-alpha.298",
34
+ "oxc-parser": "0.149.0",
34
35
  "paseto": "4.0.0"
35
36
  },
36
- "gitHead": "d0fd4f9d13483f763acd9dd2b3c3eb8cb8856524"
37
+ "gitHead": "334fad8726adfa7507462c33b509ce7db9db853f"
37
38
  }
package/readme.md CHANGED
@@ -79,7 +79,10 @@ downloads nor uploads the dependencies again. The same holds for `mono`.
79
79
  Beside each component's sources the layer carries `manifest.toa.json`, the manifest as this
80
80
  build normalised it. A composition reads it instead of normalising again, so it loads no
81
81
  bridge to read what a module declares; a workspace has no such file and is read as it always
82
- was. What a build cannot express there a prototype in a directory of the application's own
82
+ was. A component an extension ships has none either its package is published long before any
83
+ image is built — and is read from the digest `@toa.io/definitions` carries, which a deploy has
84
+ always read. So nothing that runs reads a module's source, and `oxc-parser` is installed only
85
+ where a manifest is normalised: a workspace and a deploy, both through `@toa.io/operations`. What a build cannot express there — a prototype in a directory of the application's own
83
86
  rather than in a package — fails the build, where before it failed the container.
84
87
 
85
88
  A pushed image builds on the `toa` container builder, whose registry exporter is what lays
@@ -175,3 +178,46 @@ a secret containing required credentials can be specified using `registry.creden
175
178
  registry:
176
179
  credentials: docker-credentials-secret-name
177
180
  ```
181
+
182
+ #### Toa images
183
+
184
+ Each release publishes `ghcr.io/toa-io/runtime:<version>` and, beside it, `:alpha` or `:latest`.
185
+ The extension services share that version:
186
+
187
+ ```
188
+ ghcr.io/toa-io/extension-exposition-gateway:1.0.0-alpha.285
189
+ ghcr.io/toa-io/extension-realtime-streams:1.0.0-alpha.285
190
+ ghcr.io/toa-io/extension-introspection-explorer:1.0.0-alpha.285
191
+ ghcr.io/toa-io/extension-configuration-values:1.0.0-alpha.285
192
+ ```
193
+
194
+ Untagged manifests are deleted. A prerelease older than 90 days, and not among the last 30, is
195
+ deleted with the four service images of that version. `:alpha`, `:latest`, and a version without
196
+ a pre-release suffix stay.
197
+
198
+ An application still on a deleted prerelease cannot pull `registry.services: published` and cannot
199
+ rebuild `FROM` that runtime. Images already in `registry.base` keep running. Stay inside the
200
+ window, or pin a graduated release.
201
+
202
+ #### Userspace images
203
+
204
+ `toa deploy production` writes two tags on the same composition image (`mono` and a service
205
+ `registry.services: build` built are the same):
206
+
207
+ - `<name>:<hash>` — what the chart pins. A hash of the runtime and the sources. `toa push` writes
208
+ this tag and nothing else.
209
+ - `<name>:production` — moved onto that image. The next production deploy moves it again.
210
+
211
+ `deps-<hash>` is not tagged with the environment. Do not name an environment eight hex digits, or
212
+ `deps-` plus eight: a retention job would treat it as a content tag.
213
+
214
+ A job the application runs deletes every `<hash>` and `deps-<hash>` older than 7 days, except the
215
+ last 5, except the image `:production` (or `:staging`, or `:latest`) currently names. Then:
216
+
217
+ ```shell
218
+ $ doctl registries garbage-collection start --include-untagged-manifests <registry>
219
+ ```
220
+
221
+ Rollback is only those 7 days, those last 5, and whatever the environment tag still names. Turn
222
+ the job on after the first deploy that writes that tag. The first garbage collection returns the
223
+ space the old tags held, and blocks pushes while it runs.
@@ -61,7 +61,7 @@ export class Factory {
61
61
  this.#image
62
62
  )
63
63
 
64
- return new Operator(deployment, this.#registry)
64
+ return new Operator(deployment, this.#registry, this.#context.environment)
65
65
  }
66
66
 
67
67
  registry() {
@@ -19,7 +19,5 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
19
19
  # what the extensions need for what these components declare: installed beside the extension
20
20
  # that reads the declaration, in Toa's own install, because that is where Node resolves an
21
21
  # extension's imports from — and one copy for the composition rather than one per component
22
- # `--legacy-peer-deps` for the reason the base image installs with it: what is added here
23
- # re-resolves the tree, and a peer nothing imports comes back with it — 23 MB of `typescript`
24
22
  RUN --mount=type=cache,target=/root/.npm,sharing=locked \
25
- if [ -s .packages ]; then npm i --prefix /toa --omit=dev --legacy-peer-deps $(cat .packages); fi
23
+ if [ -s .packages ]; then npm i --prefix /toa --omit=dev $(cat .packages); fi
@@ -176,9 +176,7 @@ describe('prepare', () => {
176
176
 
177
177
  const dockerfile = await readFile(join(context, 'Dockerfile'), 'utf8')
178
178
 
179
- assert.ok(
180
- dockerfile.includes('npm i --prefix /toa --omit=dev --legacy-peer-deps $(cat .packages)')
181
- )
179
+ assert.ok(dockerfile.includes('npm i --prefix /toa --omit=dev $(cat .packages)'))
182
180
  })
183
181
 
184
182
  it('should hold what a service the composition runs brings', async () => {
@@ -111,3 +111,39 @@ describe('normalized', () => {
111
111
  assert.strictEqual(read.locator.id, 'todos.tasks')
112
112
  })
113
113
  })
114
+
115
+ describe('a service image', () => {
116
+ it('installs what its components declare beside Toa as well', async () => {
117
+ const { Service } = await import('./service.js')
118
+
119
+ const path = join(root, 'extension')
120
+ const component = join(path, 'components', 'octets')
121
+
122
+ await mkdir(component, { recursive: true })
123
+ await writeFile(join(path, 'package.json'), JSON.stringify({ name: 'ext', version: '1' }))
124
+ await writeFile(
125
+ join(component, 'package.json'),
126
+ JSON.stringify({ dependencies: { cloudinary: '2.11.0', jose: '6.2.10' } })
127
+ )
128
+
129
+ const service = new Service('', { version: '1' }, {}, path, {
130
+ group: 'g',
131
+ name: 'n',
132
+ version: '1'
133
+ })
134
+
135
+ service.reference = 'probe'
136
+
137
+ const context = await service.prepare(join(root, 'service'))
138
+
139
+ // what an extension imports on a component's behalf is imported from where Toa is
140
+ assert.strictEqual(
141
+ await readFile(join(context, '.packages'), 'utf8'),
142
+ 'cloudinary@2.11.0\njose@6.2.10'
143
+ )
144
+
145
+ const dockerfile = await readFile(join(context, 'Dockerfile'), 'utf8')
146
+
147
+ assert.ok(dockerfile.includes('npm i --prefix /toa --omit=dev $(cat .packages)'))
148
+ })
149
+ })
@@ -16,6 +16,11 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
16
16
  RUN --mount=type=cache,target=/root/.npm,sharing=locked \
17
17
  for entry in components/*; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done
18
18
 
19
+ # and beside Toa as well, because what an extension imports on a component's behalf — a storage
20
+ # provider's SDK — is imported from where the extension is, which is `/toa` and not here
21
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
22
+ if [ -s .packages ]; then npm i --prefix /toa --omit=dev $(cat .packages); fi
23
+
19
24
  # no USER: the runtime drops to `node` itself, and only a process that started as root can
20
25
  # close its environment under /proc — see runtime/runtime/bin/toa
21
26
  CMD toa serve .
@@ -1,8 +1,9 @@
1
1
  import { createRequire } from 'node:module'
2
2
  import { join, dirname } from 'node:path'
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
4
 
4
5
  import { Image } from './image.js'
5
- import { cp } from 'node:fs/promises'
6
+ import { cp, writeFile } from 'node:fs/promises'
6
7
 
7
8
  // a service is named the way a package is, and its directory is where it lives
8
9
  const require = createRequire(import.meta.url)
@@ -60,11 +61,46 @@ export class Service extends Image {
60
61
  const context = await super.prepare(root)
61
62
 
62
63
  await cp(this.#path, context, { recursive: true })
64
+ await writeFile(join(context, PACKAGES), this.#packages().join('\n'))
63
65
 
64
66
  return context
65
67
  }
68
+
69
+ /**
70
+ * What the components this service runs declare, as `name@version`.
71
+ *
72
+ * Each is installed beside its component, where the component's own modules import it, and
73
+ * beside Toa, where an extension that imports on a component's behalf is — a storage
74
+ * provider's SDK is imported by `@toa.io/extensions.storages`, not by the component that
75
+ * declares the storage.
76
+ *
77
+ * @returns {string[]}
78
+ */
79
+ #packages() {
80
+ const directory = join(this.#path, 'components')
81
+
82
+ if (!existsSync(directory)) return []
83
+
84
+ /** @type {Record<string, string>} */
85
+ const packages = {}
86
+
87
+ for (const label of readdirSync(directory)) {
88
+ const manifest = join(directory, label, 'package.json')
89
+
90
+ if (!existsSync(manifest)) continue
91
+
92
+ Object.assign(packages, JSON.parse(readFileSync(manifest, 'utf8')).dependencies)
93
+ }
94
+
95
+ return Object.entries(packages)
96
+ .map(([name, version]) => `${name}@${version}`)
97
+ .sort()
98
+ }
66
99
  }
67
100
 
101
+ /** Where the install reads what the components need, one `name@version` per line. */
102
+ const PACKAGES = '.packages'
103
+
68
104
  /**
69
105
  * Where the extension is installed, which is what its image is built from. A deploy install
70
106
  * carries what an extension declares and not the extension, so a service is built only where
@@ -7,13 +7,18 @@ export class Operator {
7
7
  /** @type {toa.deployment.Registry} */
8
8
  #registry
9
9
 
10
+ /** @type {string | undefined} */
11
+ #environment
12
+
10
13
  /**
11
- * @param deployment {toa.deployment.Deployment}
12
- * @param registry {toa.deployment.Registry}
14
+ * @param {toa.deployment.Deployment} deployment
15
+ * @param {toa.deployment.Registry} registry
16
+ * @param {string} [environment]
13
17
  */
14
- constructor(deployment, registry) {
18
+ constructor(deployment, registry, environment) {
15
19
  this.#deployment = deployment
16
20
  this.#registry = registry
21
+ this.#environment = environment
17
22
  }
18
23
 
19
24
  async export(path) {
@@ -36,6 +41,7 @@ export class Operator {
36
41
  options = Object.assign({}, OPTIONS, options)
37
42
 
38
43
  await Promise.all([this.export(), this.push()])
44
+ await this.#registry.alias(this.#environment)
39
45
  await this.#deployment.install(options)
40
46
  }
41
47
 
@@ -43,3 +43,52 @@ describe('env', () => {
43
43
  assert.deepStrictEqual(output, variables)
44
44
  })
45
45
  })
46
+
47
+ describe('install', () => {
48
+ /** @type {toa.deployment.Deployment} */
49
+ let deployment
50
+
51
+ /** @type {toa.deployment.Registry} */
52
+ let registry
53
+
54
+ beforeEach(() => {
55
+ deployment = /** @type {toa.deployment.Deployment} */ {
56
+ export: mock.fn(async () => {}),
57
+ install: mock.fn(async () => {})
58
+ }
59
+ registry = /** @type {toa.deployment.Registry} */ {
60
+ push: mock.fn(async () => {}),
61
+ alias: mock.fn(async () => {})
62
+ }
63
+ })
64
+
65
+ it('should tag the environment after a push', async () => {
66
+ const order = []
67
+
68
+ registry.push = mock.fn(async () => {
69
+ order.push('push')
70
+ })
71
+ registry.alias = mock.fn(async () => {
72
+ order.push('alias')
73
+ })
74
+ deployment.install = mock.fn(async () => {
75
+ order.push('install')
76
+ })
77
+
78
+ operator = new Operator(deployment, registry, 'production')
79
+
80
+ await operator.install()
81
+
82
+ assert.deepStrictEqual(order, ['push', 'alias', 'install'])
83
+ assert.deepStrictEqual(registry.alias.mock.calls[0].arguments, ['production'])
84
+ })
85
+
86
+ it('should not tag the environment on push', async () => {
87
+ operator = new Operator(deployment, registry, 'production')
88
+
89
+ await operator.push()
90
+
91
+ assert.strictEqual(registry.push.mock.callCount(), 1)
92
+ assert.strictEqual(registry.alias.mock.callCount(), 0)
93
+ })
94
+ })
@@ -50,6 +50,24 @@ export class Registry {
50
50
  await this.#run(true)
51
51
  }
52
52
 
53
+ /**
54
+ * A second tag on each workload image, the environment, so a retention job can see what
55
+ * a deploy left running. `deps-*` is not in `#images`.
56
+ *
57
+ * @param {string} [environment]
58
+ * @returns {Promise<void>}
59
+ */
60
+ async alias(environment) {
61
+ if (
62
+ typeof environment !== 'string' ||
63
+ environment === '' ||
64
+ CONTENT_TAG.test(environment)
65
+ )
66
+ return
67
+
68
+ await Promise.all(this.#images.map((image) => this.#alias(image, environment)))
69
+ }
70
+
53
71
  tags() {
54
72
  return this.#images.map((image) => image.reference)
55
73
  }
@@ -159,6 +177,22 @@ export class Registry {
159
177
  await this.#process.execute('docker', args)
160
178
  }
161
179
 
180
+ /**
181
+ * @param {toa.deployment.images.Image} image
182
+ * @param {string} environment
183
+ * @returns {Promise<void>}
184
+ */
185
+ async #alias(image, environment) {
186
+ const target = retag(image.reference, environment)
187
+ const args = ['buildx', 'imagetools', 'create', '--tag', target]
188
+
189
+ if (LOCAL.test(image.reference)) args.push('--insecure')
190
+
191
+ args.push(image.reference)
192
+
193
+ await this.#process.execute('docker', args)
194
+ }
195
+
162
196
  async exists(tag) {
163
197
  const args = ['manifest', 'inspect']
164
198
 
@@ -230,8 +264,20 @@ async function pool(items, work) {
230
264
  await Promise.all(workers)
231
265
  }
232
266
 
267
+ /**
268
+ * @param {string} reference
269
+ * @param {string} tag
270
+ * @returns {string}
271
+ */
272
+ function retag(reference, tag) {
273
+ return reference.slice(0, reference.lastIndexOf(':') + 1) + tag
274
+ }
275
+
233
276
  const BUILDER = 'toa'
234
277
 
278
+ /** An environment named this way is a content tag, and must not be moved as one. */
279
+ const CONTENT_TAG = /^(deps-)?[0-9a-f]{8}$/
280
+
235
281
  /** A reference into a registry on this machine, by any of the names it goes by. */
236
282
  const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|host\.docker\.internal)(:\d+)?\//
237
283
 
@@ -15,16 +15,7 @@ let images
15
15
 
16
16
  beforeEach(() => {
17
17
  images = []
18
- process = /** @type {toa.operations.Process} */ {
19
- execute: mock.fn(async (cmd, args) => {
20
- if (args[0] === 'manifest') throw new Error('manifest unknown')
21
-
22
- if (args[0] === 'buildx' && args[1] === 'inspect')
23
- throw new Error('builder not found')
24
-
25
- return ''
26
- })
27
- }
18
+ process = execute()
28
19
 
29
20
  factory = /** @type {toa.deployment.images.Factory} */ {
30
21
  composition: () => createImage('composition-mono', true),
@@ -336,25 +327,115 @@ it('should skip build when image already exists', async () => {
336
327
  )
337
328
  })
338
329
 
330
+ it('should tag the environment on each workload image', async () => {
331
+ const proc = execute()
332
+ const registry = createRegistry(
333
+ { base: 'example.com/reg', platforms: ['linux/amd64'] },
334
+ proc
335
+ )
336
+
337
+ registry.composition(/** @type {any} */ ({}))
338
+ registry.service('.', /** @type {any} */ ({}))
339
+
340
+ await registry.alias('production')
341
+
342
+ const aliases = imagetools(proc)
343
+ const tags = aliases.map((args) => args[args.indexOf('--tag') + 1]).sort()
344
+ const sources = aliases.map((args) => args.at(-1)).sort()
345
+
346
+ assert.strictEqual(aliases.length, 2)
347
+ assert.deepStrictEqual(tags, [
348
+ 'example.com/reg/acme/composition-mono:production',
349
+ 'example.com/reg/acme/extension-realtime:production'
350
+ ])
351
+ assert.deepStrictEqual(sources, [
352
+ 'example.com/reg/acme/composition-mono:abcdef12',
353
+ 'example.com/reg/acme/extension-realtime:abcdef12'
354
+ ])
355
+ })
356
+
357
+ it('should not tag deps images with the environment', async () => {
358
+ const proc = execute()
359
+ const registry = createRegistry(
360
+ { base: 'example.com/reg', platforms: ['linux/amd64'] },
361
+ proc
362
+ )
363
+
364
+ registry.composition(/** @type {any} */ ({}))
365
+
366
+ await registry.alias('production')
367
+
368
+ const aliases = imagetools(proc)
369
+
370
+ assert.strictEqual(aliases.length, 1)
371
+ assert.ok(!aliases[0].some((arg) => String(arg).includes(':deps-')))
372
+ })
373
+
374
+ it('should not tag the environment on push', async () => {
375
+ const proc = execute()
376
+ const registry = createRegistry(
377
+ { base: 'example.com/reg', platforms: ['linux/amd64'] },
378
+ proc
379
+ )
380
+
381
+ registry.composition(/** @type {any} */ ({}))
382
+
383
+ await registry.push()
384
+
385
+ assert.deepStrictEqual(imagetools(proc), [])
386
+ })
387
+
388
+ it('should not move a content tag as the environment', async () => {
389
+ const proc = execute()
390
+ const registry = createRegistry(
391
+ { base: 'example.com/reg', platforms: ['linux/amd64'] },
392
+ proc
393
+ )
394
+
395
+ registry.composition(/** @type {any} */ ({}))
396
+
397
+ await registry.alias('abcdef12')
398
+ await registry.alias('deps-12345678')
399
+
400
+ assert.deepStrictEqual(imagetools(proc), [])
401
+ })
402
+
403
+ it('should not tag without an environment', async () => {
404
+ const proc = execute()
405
+ const registry = createRegistry(
406
+ { base: 'example.com/reg', platforms: ['linux/amd64'] },
407
+ proc
408
+ )
409
+
410
+ registry.composition(/** @type {any} */ ({}))
411
+
412
+ await registry.alias()
413
+ await registry.alias('')
414
+
415
+ assert.deepStrictEqual(imagetools(proc), [])
416
+ })
417
+
339
418
  it('should probe a local registry as insecure', async () => {
340
- process.execute = mock.fn(async () => '')
419
+ const proc = execute()
341
420
 
342
- const registry = createRegistry({ base: 'localhost:5000', platforms: null })
421
+ proc.execute = mock.fn(async () => '')
343
422
 
344
- factory.composition = () => {
345
- const image = createImage('composition-mono', true)
423
+ const image = createImage('composition-mono', true)
346
424
 
347
- image.reference = 'localhost:5000/acme/composition-mono:abcdef12'
348
- image.dependencies.reference = 'localhost:5000/acme/composition-mono:deps-12345678'
425
+ image.reference = 'localhost:5000/acme/composition-mono:abcdef12'
426
+ image.dependencies.reference = 'localhost:5000/acme/composition-mono:deps-12345678'
349
427
 
350
- return image
351
- }
428
+ const registry = new Registry(
429
+ { base: 'localhost:5000', platforms: null },
430
+ { composition: () => image, service: factory.service, mono: factory.mono },
431
+ proc
432
+ )
352
433
 
353
434
  registry.composition(/** @type {any} */ ({}))
354
435
 
355
436
  await registry.build()
356
437
 
357
- const probes = process.execute.mock.calls
438
+ const probes = proc.execute.mock.calls
358
439
  .filter(({ arguments: [, args] }) => args[0] === 'manifest')
359
440
  .map(({ arguments: [, args] }) => args)
360
441
 
@@ -364,12 +445,56 @@ it('should probe a local registry as insecure', async () => {
364
445
  assert.deepStrictEqual(args.slice(0, 3), ['manifest', 'inspect', '--insecure'])
365
446
  })
366
447
 
448
+ it('should tag a local registry as insecure', async () => {
449
+ const proc = execute()
450
+ const image = createImage('composition-mono', true)
451
+
452
+ image.reference = 'localhost:5000/acme/composition-mono:abcdef12'
453
+ image.dependencies.reference = 'localhost:5000/acme/composition-mono:deps-12345678'
454
+
455
+ const registry = new Registry(
456
+ { base: 'localhost:5000', platforms: null },
457
+ { composition: () => image, service: factory.service, mono: factory.mono },
458
+ proc
459
+ )
460
+
461
+ registry.composition(/** @type {any} */ ({}))
462
+
463
+ await registry.alias('production')
464
+
465
+ assert.deepStrictEqual(imagetools(proc), [
466
+ [
467
+ 'buildx',
468
+ 'imagetools',
469
+ 'create',
470
+ '--tag',
471
+ 'localhost:5000/acme/composition-mono:production',
472
+ '--insecure',
473
+ 'localhost:5000/acme/composition-mono:abcdef12'
474
+ ]
475
+ ])
476
+ })
477
+
367
478
  /**
368
479
  * @param {object} [registry]
369
480
  * @returns {Registry}
370
481
  */
371
- function createRegistry(registry = {}) {
372
- return new Registry(registry, factory, process)
482
+ function createRegistry(registry = {}, proc = process) {
483
+ return new Registry(registry, factory, proc)
484
+ }
485
+
486
+ /** @returns {toa.operations.Process} */
487
+ function execute() {
488
+ return /** @type {toa.operations.Process} */ {
489
+ execute: mock.fn(async (cmd, args) => {
490
+ if (args[0] === 'manifest') throw new Error('manifest unknown')
491
+
492
+ if (args[0] === 'buildx' && args[1] === 'inspect')
493
+ throw new Error('builder not found')
494
+
495
+ return ''
496
+ })
497
+ }
373
498
  }
374
499
 
375
500
  /**
@@ -397,6 +522,13 @@ function createImage(name, bundle = false) {
397
522
  return image
398
523
  }
399
524
 
525
+ /** @returns {string[][]} the arguments of every imagetools run */
526
+ function imagetools(proc = process) {
527
+ return proc.execute.mock.calls
528
+ .filter(({ arguments: [, args] }) => args[0] === 'buildx' && args[1] === 'imagetools')
529
+ .map(({ arguments: [, args] }) => args)
530
+ }
531
+
400
532
  /** @returns {string[][]} the arguments of every build run */
401
533
  function builds() {
402
534
  return process.execute.mock.calls
@@ -14,6 +14,9 @@ declare namespace toa.deployment {
14
14
 
15
15
  push(): Promise<void>
16
16
 
17
+ /** Point each workload image at `environment` (`:production`). `deps-*` is not tagged. */
18
+ alias(environment?: string): Promise<void>
19
+
17
20
  tags(): string[]
18
21
  }
19
22
  }