@toa.io/operations 1.0.0-alpha.232 → 1.0.0-alpha.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ FROM node:24.14.0-alpine3.22
2
+
3
+ ARG VERSION
4
+ RUN npm i -g @toa.io/runtime@${VERSION} --omit=dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.232",
3
+ "version": "1.0.0-alpha.234",
4
4
  "description": "Toa Deployment",
5
5
  "homepage": "https://toa.io",
6
6
  "author": {
@@ -34,5 +34,5 @@
34
34
  "execa": "5.1.1",
35
35
  "fs-extra": "11.1.1"
36
36
  },
37
- "gitHead": "a9c61fad587aa8f0dcd0e4db39901fc778d5bfa4"
37
+ "gitHead": "0a102917aca0115eadc2410f0a7836e3e40096a0"
38
38
  }
package/readme.md CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ### Container Registry
6
6
 
7
+ Deploy images default to `FROM ghcr.io/toa-io/runtime:<runtime.version>`
8
+ (published with each Toa release). The base image already has `@toa.io/runtime`
9
+ installed; composition/service Dockerfiles only install component dependencies.
10
+
11
+ To use a custom base image, set `registry.build.image` (or `composition.image`).
12
+ That image must provide the `toa` CLI, or install it via `registry.build.run`:
13
+
14
+ ```yaml
15
+ # context.toa.yaml
16
+
17
+ registry:
18
+ build:
19
+ image: node:24.14.0-alpine3.22
20
+ run: npm i -g @toa.io/runtime --omit=dev
21
+ ```
22
+
7
23
  #### Build Options
8
24
 
9
25
  ```yaml
@@ -66,5 +66,12 @@ spec:
66
66
  claimName: {{ .claim }}
67
67
  {{- end }}
68
68
  {{- end }}
69
+ topologySpreadConstraints:
70
+ - maxSkew: 1
71
+ topologyKey: kubernetes.io/hostname
72
+ whenUnsatisfiable: ScheduleAnyway
73
+ labelSelector:
74
+ matchLabels:
75
+ toa.io/composition: {{ .name }}
69
76
  ---
70
77
  {{- end }}
@@ -73,6 +73,13 @@ spec:
73
73
  exec:
74
74
  command: [sleep, "5"]
75
75
  terminationGracePeriodSeconds: 45
76
+ topologySpreadConstraints:
77
+ - maxSkew: 1
78
+ topologyKey: kubernetes.io/hostname
79
+ whenUnsatisfiable: ScheduleAnyway
80
+ labelSelector:
81
+ matchLabels:
82
+ toa.io/service: extension-{{ .name }}
76
83
  {{- if .port }}
77
84
  ---
78
85
  apiVersion: v1
@@ -22,7 +22,7 @@ class Factory {
22
22
 
23
23
  const imagesFactory = new ImagesFactory(context.name, context.runtime, context.registry)
24
24
 
25
- this.#registry = new Registry(context.registry, imagesFactory, this.#process)
25
+ this.#registry = new Registry(context.name, context.registry, imagesFactory, this.#process)
26
26
  this.#compositions = context.compositions.map((composition) => this.#composition(composition))
27
27
  this.#dependencies = this.#getDependencies()
28
28
  }
@@ -5,7 +5,6 @@ FROM {{build.image}}
5
5
  ENV NODE_ENV=production
6
6
  RUN if [ "{{runtime.registry}}" != "" ]; then npm set registry {{runtime.registry}}; fi
7
7
  RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
8
- RUN npm i -g @toa.io/runtime@{{runtime.version}} --omit=dev
9
8
 
10
9
  WORKDIR /composition
11
10
  COPY --chown=node:node . /composition
@@ -13,7 +12,8 @@ COPY --chown=node:node . /composition
13
12
  {{build.run}}
14
13
 
15
14
  # run 'npm i' in each component
16
- RUN for entry in *; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
15
+ RUN --mount=type=cache,target=/root/.npm \
16
+ for entry in *; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
17
17
 
18
18
  USER node
19
19
  CMD toa compose *
@@ -26,9 +26,7 @@ class Image {
26
26
  #registry
27
27
  #runtime
28
28
  #values = {
29
- build: {
30
- image: 'node:24.14.0-alpine3.22'
31
- }
29
+ build: {}
32
30
  }
33
31
 
34
32
  constructor (scope, runtime, registry) {
@@ -79,7 +77,9 @@ class Image {
79
77
 
80
78
  #setValues () {
81
79
  this.#values.runtime = this.#runtime
82
- this.#values.build = overwrite(this.#values.build, this.#registry.build)
80
+ this.#values.build = overwrite({
81
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
82
+ }, this.#registry.build)
83
83
 
84
84
  const image = this.base
85
85
 
@@ -127,4 +127,7 @@ function createArguments (variables) {
127
127
  return args.join('\n')
128
128
  }
129
129
 
130
+ const RUNTIME_IMAGE = 'ghcr.io/toa-io/runtime'
131
+
130
132
  exports.Image = Image
133
+ exports.RUNTIME_IMAGE = RUNTIME_IMAGE
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+
3
+ const { join } = require('node:path')
4
+ const { readFile } = require('node:fs/promises')
5
+ const { directory } = require('@toa.io/filesystem')
6
+
7
+ const { Image, RUNTIME_IMAGE } = require('./image')
8
+
9
+ const compositionDockerfile = join(__dirname, 'composition.Dockerfile')
10
+ const serviceDockerfile = join(__dirname, 'service.Dockerfile')
11
+
12
+ class TestImage extends Image {
13
+ dockerfile
14
+
15
+ #name
16
+ #base
17
+
18
+ constructor (runtime, registry, dockerfile, name = 'test', base) {
19
+ super('acme', runtime, registry)
20
+
21
+ this.dockerfile = dockerfile
22
+ this.#name = name
23
+ this.#base = base
24
+ }
25
+
26
+ get name () {
27
+ return this.#name
28
+ }
29
+
30
+ get version () {
31
+ return 'abcdef12'
32
+ }
33
+
34
+ get base () {
35
+ return this.#base
36
+ }
37
+ }
38
+
39
+ describe('runtime base image', () => {
40
+ /** @type {string} */
41
+ let root
42
+
43
+ beforeEach(async () => {
44
+ root = await directory.temp('toa-runtime-base-test')
45
+ })
46
+
47
+ it('should default build.image to version-pinned GHCR runtime image', async () => {
48
+ const runtime = { version: '1.0.0-alpha.232' }
49
+ const image = new TestImage(runtime, {}, compositionDockerfile)
50
+
51
+ const path = await image.prepare(root)
52
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
53
+
54
+ expect(dockerfile).toContain(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.232`)
55
+ expect(dockerfile).not.toMatch(/npm i -g @toa\.io\/runtime/)
56
+ })
57
+
58
+ it('should not install runtime in service Dockerfile template', async () => {
59
+ const runtime = { version: '1.0.0-alpha.99' }
60
+ const image = new TestImage(runtime, {}, serviceDockerfile, 'service-test')
61
+
62
+ const path = await image.prepare(root)
63
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
64
+
65
+ expect(dockerfile).toContain(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.99`)
66
+ expect(dockerfile).not.toMatch(/npm i -g @toa\.io\/runtime/)
67
+ })
68
+
69
+ it('should allow registry.build.image override', async () => {
70
+ const runtime = { version: '1.0.0-alpha.232' }
71
+ const registry = { build: { image: 'node:24.14.0-alpine3.22' } }
72
+ const image = new TestImage(runtime, registry, compositionDockerfile)
73
+
74
+ const path = await image.prepare(root)
75
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
76
+
77
+ expect(dockerfile).toContain('FROM node:24.14.0-alpine3.22')
78
+ expect(dockerfile).not.toContain(RUNTIME_IMAGE)
79
+ })
80
+
81
+ it('should allow composition.image override via base', async () => {
82
+ const runtime = { version: '1.0.0-alpha.232' }
83
+ const image = new TestImage(runtime, {}, compositionDockerfile, 'mono', 'custom.example/base:1')
84
+
85
+ const path = await image.prepare(root)
86
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
87
+
88
+ expect(dockerfile).toContain('FROM custom.example/base:1')
89
+ })
90
+ })
@@ -3,12 +3,12 @@ FROM {{build.image}}
3
3
  ENV NODE_ENV=production
4
4
  RUN if [ "{{runtime.registry}}" != "" ]; then npm set registry {{runtime.registry}}; fi
5
5
  RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
6
- RUN npm i -g @toa.io/runtime@{{runtime.version}} --omit=dev
7
6
 
8
7
  WORKDIR /service
9
8
  COPY --chown=node:node . /service
10
9
 
11
- RUN npm i --omit=dev
10
+ RUN --mount=type=cache,target=/root/.npm \
11
+ npm i --omit=dev
12
12
 
13
13
  USER node
14
14
  CMD toa serve .
@@ -1,12 +1,14 @@
1
1
  'use strict'
2
2
 
3
+ const { posix } = require('node:path')
3
4
  const workspace = require('./workspace')
4
- const { newid } = require('@toa.io/generic')
5
5
 
6
6
  /**
7
7
  * @implements {toa.deployment.Registry}
8
8
  */
9
9
  class Registry {
10
+ #scope
11
+
10
12
  #registry
11
13
 
12
14
  #factory
@@ -15,7 +17,11 @@ class Registry {
15
17
 
16
18
  #images = []
17
19
 
18
- constructor (registry, factory, process) {
20
+ /** @type {string | undefined} */
21
+ #builder
22
+
23
+ constructor (scope, registry, factory, process) {
24
+ this.#scope = scope
19
25
  this.#registry = registry
20
26
  this.#factory = factory
21
27
  this.#process = process
@@ -40,9 +46,8 @@ class Registry {
40
46
  async build () {
41
47
  await this.prepare()
42
48
 
43
- for (const image of this.#images) {
49
+ for (const image of this.#images)
44
50
  await this.#build(image)
45
- }
46
51
  }
47
52
 
48
53
  async push () {
@@ -81,11 +86,10 @@ class Registry {
81
86
 
82
87
  const args = ['--context=default', 'buildx', 'build']
83
88
 
84
- if (push) {
89
+ if (push)
85
90
  args.push('--push')
86
- } else {
91
+ else
87
92
  args.push('--load')
88
- }
89
93
 
90
94
  args.push('--tag', image.reference, image.context)
91
95
 
@@ -97,13 +101,15 @@ class Registry {
97
101
 
98
102
  if (multiarch) {
99
103
  const platform = this.#registry.platforms.join(',')
100
- const builder = await this.#createBuilder()
104
+ const builder = await this.#ensureBuilder()
101
105
 
102
106
  args.push('--platform', platform)
103
107
  args.push('--builder', builder)
104
- } else {
108
+
109
+ if (this.#registry.base !== undefined)
110
+ this.#appendCache(args)
111
+ } else
105
112
  args.push('--builder', 'default')
106
- }
107
113
 
108
114
  args.push('--progress', 'plain')
109
115
 
@@ -128,13 +134,29 @@ class Registry {
128
134
  return true
129
135
  }
130
136
 
131
- async #createBuilder () {
132
- const name = `toa-${newid()}`
133
- const create = `buildx create --name ${name} --bootstrap --use`.split(' ')
137
+ async #ensureBuilder () {
138
+ if (this.#builder !== undefined)
139
+ return this.#builder
140
+
141
+ try {
142
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], { silently: true })
143
+ } catch {
144
+ await this.#process.execute('docker', ['buildx', 'create', '--name', BUILDER, '--bootstrap'])
145
+ }
134
146
 
135
- await this.#process.execute('docker', create)
147
+ this.#builder = BUILDER
148
+
149
+ return BUILDER
150
+ }
151
+
152
+ /**
153
+ * @param {string[]} args
154
+ */
155
+ #appendCache (args) {
156
+ const ref = posix.join(this.#registry.base, this.#scope, 'buildcache')
136
157
 
137
- return name
158
+ args.push('--cache-from', `type=registry,ref=${ref}`)
159
+ args.push('--cache-to', `type=registry,ref=${ref},mode=max,image-manifest=true`)
138
160
  }
139
161
  }
140
162
 
@@ -0,0 +1,175 @@
1
+ 'use strict'
2
+
3
+ const { Registry } = require('./registry')
4
+
5
+ /** @type {toa.operations.Process} */
6
+ let process
7
+
8
+ /** @type {toa.deployment.images.Factory} */
9
+ let factory
10
+
11
+ /** @type {toa.deployment.images.Image[]} */
12
+ let images
13
+
14
+ beforeEach(() => {
15
+ images = []
16
+ process = /** @type {toa.operations.Process} */ {
17
+ execute: jest.fn(async (cmd, args) => {
18
+ if (args[0] === 'manifest')
19
+ throw new Error('manifest unknown')
20
+
21
+ if (args[0] === 'buildx' && args[1] === 'inspect')
22
+ throw new Error('builder not found')
23
+
24
+ return ''
25
+ })
26
+ }
27
+
28
+ factory = /** @type {toa.deployment.images.Factory} */ {
29
+ composition: () => createImage('composition-mono'),
30
+ service: () => createImage('extension-realtime')
31
+ }
32
+ })
33
+
34
+ it('should reuse named builder across images', async () => {
35
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
36
+
37
+ registry.composition(/** @type {any} */ ({}))
38
+ registry.service('.', /** @type {any} */ ({}))
39
+
40
+ await registry.build()
41
+
42
+ const creates = process.execute.mock.calls.filter(([, args]) =>
43
+ args[0] === 'buildx' && args[1] === 'create')
44
+
45
+ expect(creates).toHaveLength(1)
46
+ expect(creates[0][1]).toEqual(['buildx', 'create', '--name', 'toa', '--bootstrap'])
47
+
48
+ const builds = process.execute.mock.calls.filter(([, args]) =>
49
+ args[0] === '--context=default' && args[1] === 'buildx' && args[2] === 'build')
50
+
51
+ expect(builds).toHaveLength(2)
52
+
53
+ for (const [, args] of builds) {
54
+ expect(args).toContain('--builder')
55
+ expect(args[args.indexOf('--builder') + 1]).toBe('toa')
56
+ }
57
+ })
58
+
59
+ it('should not create builder when it already exists', async () => {
60
+ process.execute = jest.fn(async (cmd, args) => {
61
+ if (args[0] === 'manifest')
62
+ throw new Error('manifest unknown')
63
+
64
+ return ''
65
+ })
66
+
67
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
68
+
69
+ registry.composition(/** @type {any} */ ({}))
70
+
71
+ await registry.build()
72
+
73
+ const creates = process.execute.mock.calls.filter(([, args]) =>
74
+ args[0] === 'buildx' && args[1] === 'create')
75
+
76
+ expect(creates).toHaveLength(0)
77
+ })
78
+
79
+ it('should add shared registry cache flags when base is set', async () => {
80
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
81
+
82
+ registry.composition(/** @type {any} */ ({}))
83
+ registry.service('.', /** @type {any} */ ({}))
84
+
85
+ await registry.build()
86
+
87
+ const builds = process.execute.mock.calls.filter(([, args]) =>
88
+ args[0] === '--context=default' && args[2] === 'build')
89
+
90
+ expect(builds).toHaveLength(2)
91
+
92
+ for (const [, args] of builds) {
93
+ expect(args).toContain('--cache-from')
94
+ expect(args).toContain('type=registry,ref=example.com/reg/acme/buildcache')
95
+ expect(args).toContain('--cache-to')
96
+ expect(args).toContain('type=registry,ref=example.com/reg/acme/buildcache,mode=max,image-manifest=true')
97
+ }
98
+ })
99
+
100
+ it('should omit cache flags when base is not set', async () => {
101
+ const registry = createRegistry({ platforms: ['linux/amd64'] })
102
+
103
+ registry.composition(/** @type {any} */ ({}))
104
+
105
+ await registry.build()
106
+
107
+ const builds = process.execute.mock.calls.filter(([, args]) =>
108
+ args[0] === '--context=default' && args[2] === 'build')
109
+
110
+ expect(builds).toHaveLength(1)
111
+
112
+ const [, args] = builds[0]
113
+
114
+ expect(args).not.toContain('--cache-from')
115
+ expect(args).not.toContain('--cache-to')
116
+ })
117
+
118
+ it('should use default builder when platforms is null', async () => {
119
+ const registry = createRegistry({ base: 'example.com/reg', platforms: null })
120
+
121
+ registry.composition(/** @type {any} */ ({}))
122
+
123
+ await registry.build()
124
+
125
+ const builds = process.execute.mock.calls.filter(([, args]) =>
126
+ args[0] === '--context=default' && args[2] === 'build')
127
+
128
+ expect(builds).toHaveLength(1)
129
+
130
+ const [, args] = builds[0]
131
+
132
+ expect(args[args.indexOf('--builder') + 1]).toBe('default')
133
+ expect(args).not.toContain('--cache-from')
134
+ expect(args).not.toContain('--platform')
135
+ })
136
+
137
+ it('should skip build when image already exists', async () => {
138
+ process.execute = jest.fn(async () => '')
139
+
140
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
141
+
142
+ registry.composition(/** @type {any} */ ({}))
143
+
144
+ await registry.build()
145
+
146
+ const builds = process.execute.mock.calls.filter(([, args]) =>
147
+ args[0] === '--context=default' && args[2] === 'build')
148
+
149
+ expect(builds).toHaveLength(0)
150
+ expect(process.execute).toHaveBeenCalledWith('docker', ['manifest', 'inspect', images[0].reference], { silently: true })
151
+ })
152
+
153
+ /**
154
+ * @param {object} [registry]
155
+ * @returns {Registry}
156
+ */
157
+ function createRegistry (registry = {}) {
158
+ return new Registry('acme', registry, factory, process)
159
+ }
160
+
161
+ /**
162
+ * @param {string} name
163
+ * @returns {toa.deployment.images.Image}
164
+ */
165
+ function createImage (name) {
166
+ const image = /** @type {toa.deployment.images.Image} */ {
167
+ reference: `example.com/reg/acme/${name}:abcdef12`,
168
+ context: `/tmp/${name}`,
169
+ prepare: jest.fn(async () => `/tmp/${name}`)
170
+ }
171
+
172
+ images.push(image)
173
+
174
+ return image
175
+ }