@toa.io/operations 1.0.0-alpha.297 → 1.0.0-alpha.299

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.299](https://github.com/toa-io/toa/compare/v1.0.0-alpha.298...v1.0.0-alpha.299) (2026-09-10)
7
+
8
+ **Note:** Version bump only for package @toa.io/operations
9
+
10
+
11
+
12
+
13
+
14
+ # [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)
15
+
16
+ ### Features
17
+
18
+ * **operations:** tag a deploy with the environment so registries can prune ([7247c35](https://github.com/toa-io/toa/commit/7247c35f33fb34dcfb3ffa04b196c14bfb3d5620))
19
+
20
+
6
21
  # [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)
7
22
 
8
23
  **Note:** Version bump only for package @toa.io/operations
@@ -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.297",
3
+ "version": "1.0.0-alpha.299",
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.297",
33
+ "@toa.io/norm": "1.0.0-alpha.299",
34
+ "oxc-parser": "0.149.0",
34
35
  "paseto": "4.0.0"
35
36
  },
36
- "gitHead": "52959bba83817238725f50553d45afde1f231af1"
37
+ "gitHead": "2ba95b16f2cf06f82d6ff8d436761e843e5e4b43"
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 () => {
@@ -144,6 +144,6 @@ describe('a service image', () => {
144
144
 
145
145
  const dockerfile = await readFile(join(context, 'Dockerfile'), 'utf8')
146
146
 
147
- assert.ok(dockerfile.includes('npm i --prefix /toa --omit=dev --legacy-peer-deps $(cat .packages)'))
147
+ assert.ok(dockerfile.includes('npm i --prefix /toa --omit=dev $(cat .packages)'))
148
148
  })
149
149
  })
@@ -14,12 +14,12 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
14
14
  # component rather than beside the extension: an application that runs none of them, and the
15
15
  # runtime image every application is built on, carry nothing for them
16
16
  RUN --mount=type=cache,target=/root/.npm,sharing=locked \
17
- for entry in components/*; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev --legacy-peer-deps); fi; done
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
19
  # and beside Toa as well, because what an extension imports on a component's behalf — a storage
20
20
  # provider's SDK — is imported from where the extension is, which is `/toa` and not here
21
21
  RUN --mount=type=cache,target=/root/.npm,sharing=locked \
22
- if [ -s .packages ]; then npm i --prefix /toa --omit=dev --legacy-peer-deps $(cat .packages); fi
22
+ if [ -s .packages ]; then npm i --prefix /toa --omit=dev $(cat .packages); fi
23
23
 
24
24
  # no USER: the runtime drops to `node` itself, and only a process that started as root can
25
25
  # close its environment under /proc — see runtime/runtime/bin/toa
@@ -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
  }