@toa.io/operations 1.0.0-alpha.26 → 1.0.0-alpha.262

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/image/runtime.Dockerfile +4 -0
  3. package/package.json +8 -8
  4. package/readme.md +16 -0
  5. package/src/deployment/.deployment/.describe/compositions.js +7 -2
  6. package/src/deployment/.deployment/.describe/mounts.js +24 -0
  7. package/src/deployment/.deployment/.describe/services.js +28 -1
  8. package/src/deployment/.deployment/.describe/services.test.js +61 -0
  9. package/src/deployment/.deployment/.describe/variables.js +6 -5
  10. package/src/deployment/.deployment/describe.js +74 -5
  11. package/src/deployment/.deployment/merge.js +41 -1
  12. package/src/deployment/.deployment/merge.test.js +74 -0
  13. package/src/deployment/chart/templates/compositions.yaml +70 -0
  14. package/src/deployment/chart/templates/mono.yaml +177 -0
  15. package/src/deployment/chart/templates/services.yaml +71 -4
  16. package/src/deployment/chart/values.yaml +11 -1
  17. package/src/deployment/composition.js +1 -0
  18. package/src/deployment/deployment.js +6 -2
  19. package/src/deployment/factory.js +24 -8
  20. package/src/deployment/images/composition.Dockerfile +2 -2
  21. package/src/deployment/images/composition.js +16 -6
  22. package/src/deployment/images/factory.js +12 -3
  23. package/src/deployment/images/image.js +16 -7
  24. package/src/deployment/images/mono.Dockerfile +18 -0
  25. package/src/deployment/images/mono.js +75 -0
  26. package/src/deployment/images/runtime-base.test.js +90 -0
  27. package/src/deployment/images/service.Dockerfile +3 -3
  28. package/src/deployment/operator.js +4 -0
  29. package/src/deployment/registry.js +64 -16
  30. package/src/deployment/registry.test.js +175 -0
  31. package/types/_deployment/images/image.d.ts +4 -2
  32. package/types/_deployment/registry.d.ts +9 -7
  33. package/types/dependency.ts +69 -0
  34. package/types/dependency.d.ts +0 -39
  35. /package/types/{index.d.ts → index.ts} +0 -0
@@ -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
+ })
@@ -1,14 +1,14 @@
1
- FROM node:20.9.0-alpine3.18
1
+ FROM {{build.image}}
2
2
 
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 .
@@ -50,6 +50,10 @@ class Operator {
50
50
  variables () {
51
51
  return this.#deployment.variables()
52
52
  }
53
+
54
+ tags () {
55
+ return this.#registry.tags()
56
+ }
53
57
  }
54
58
 
55
59
  /** @type {toa.deployment.installation.Options} */
@@ -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
@@ -29,6 +35,10 @@ class Registry {
29
35
  return this.#create('service', path, service)
30
36
  }
31
37
 
38
+ mono (composition) {
39
+ return this.#create('mono', composition)
40
+ }
41
+
32
42
  async prepare (root) {
33
43
  const path = await workspace.create('images', root)
34
44
 
@@ -40,9 +50,8 @@ class Registry {
40
50
  async build () {
41
51
  await this.prepare()
42
52
 
43
- for (const image of this.#images) {
53
+ for (const image of this.#images)
44
54
  await this.#build(image)
45
- }
46
55
  }
47
56
 
48
57
  async push () {
@@ -51,8 +60,12 @@ class Registry {
51
60
  for (const image of this.#images) await this.#push(image)
52
61
  }
53
62
 
63
+ tags () {
64
+ return this.#images.map((image) => image.reference)
65
+ }
66
+
54
67
  /**
55
- * @param {'composition' | 'service'} type
68
+ * @param {'composition' | 'service' | 'mono'} type
56
69
  * @param {...any} args
57
70
  * @returns {toa.deployment.images.Image}
58
71
  */
@@ -70,13 +83,17 @@ class Registry {
70
83
  * @returns {Promise<void>}
71
84
  */
72
85
  async #build (image, push = false) {
86
+ if (await this.exists(image.reference)) {
87
+ console.log('Image already exists, skipping:', image.reference)
88
+ return
89
+ }
90
+
73
91
  const args = ['--context=default', 'buildx', 'build']
74
92
 
75
- if (push) {
93
+ if (push)
76
94
  args.push('--push')
77
- } else {
95
+ else
78
96
  args.push('--load')
79
- }
80
97
 
81
98
  args.push('--tag', image.reference, image.context)
82
99
 
@@ -88,14 +105,15 @@ class Registry {
88
105
 
89
106
  if (multiarch) {
90
107
  const platform = this.#registry.platforms.join(',')
91
- const builder = await this.#createBuilder()
108
+ const builder = await this.#ensureBuilder()
92
109
 
93
110
  args.push('--platform', platform)
94
111
  args.push('--builder', builder)
95
112
 
96
- } else {
113
+ if (this.#registry.base !== undefined)
114
+ this.#appendCache(args)
115
+ } else
97
116
  args.push('--builder', 'default')
98
- }
99
117
 
100
118
  args.push('--progress', 'plain')
101
119
 
@@ -106,13 +124,43 @@ class Registry {
106
124
  await this.#build(image, true)
107
125
  }
108
126
 
109
- async #createBuilder () {
110
- const name = `toa-${newid()}`
111
- const create = `buildx create --name ${name} --bootstrap --use`.split(' ')
127
+ async exists (tag) {
128
+ const args = ['manifest', 'inspect', tag]
129
+
130
+ try {
131
+ await this.#process.execute('docker', args, { silently: true })
132
+ } catch (error) {
133
+ console.log(error.message)
134
+
135
+ return false
136
+ }
137
+
138
+ return true
139
+ }
140
+
141
+ async #ensureBuilder () {
142
+ if (this.#builder !== undefined)
143
+ return this.#builder
144
+
145
+ try {
146
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], { silently: true })
147
+ } catch {
148
+ await this.#process.execute('docker', ['buildx', 'create', '--name', BUILDER, '--bootstrap'])
149
+ }
150
+
151
+ this.#builder = BUILDER
152
+
153
+ return BUILDER
154
+ }
112
155
 
113
- await this.#process.execute('docker', create)
156
+ /**
157
+ * @param {string[]} args
158
+ */
159
+ #appendCache (args) {
160
+ const ref = posix.join(this.#registry.base, this.#scope, 'buildcache')
114
161
 
115
- return name
162
+ args.push('--cache-from', `type=registry,ref=${ref}`)
163
+ args.push('--cache-to', `type=registry,ref=${ref},mode=max,image-manifest=true`)
116
164
  }
117
165
  }
118
166
 
@@ -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
+ }
@@ -4,9 +4,11 @@ declare namespace toa.deployment.images {
4
4
  readonly reference: string
5
5
  readonly context: string
6
6
 
7
- tag(): void
7
+ name: string
8
8
 
9
- prepare(root: string): Promise<string>
9
+ tag (): void
10
+
11
+ prepare (root: string): Promise<string>
10
12
  }
11
13
 
12
14
  }
@@ -1,19 +1,21 @@
1
1
  import type * as _norm from '@toa.io/norm/types'
2
2
  import type * as _dependency from './dependency'
3
- import type * as _image from "./images/image"
3
+ import type * as _image from './images/image'
4
4
 
5
5
  declare namespace toa.deployment {
6
-
6
+
7
7
  interface Registry {
8
- composition(composition: _norm.Composition): _image.Image
8
+ composition (composition: _norm.Composition): _image.Image
9
+
10
+ service (path: string, service: _dependency.Service): _image.Image
9
11
 
10
- service(path: string, service: _dependency.Service): _image.Image
12
+ prepare (path: string): Promise<string>
11
13
 
12
- prepare(path: string): Promise<string>
14
+ build (): Promise<void>
13
15
 
14
- build(): Promise<void>
16
+ push (): Promise<void>
15
17
 
16
- push(): Promise<void>
18
+ tags (): string[]
17
19
  }
18
20
 
19
21
  }
@@ -0,0 +1,69 @@
1
+ import type { Manifest } from '@toa.io/norm'
2
+ import type { Locator } from '@toa.io/core'
3
+
4
+ export interface Service {
5
+ group: string
6
+ name: string
7
+ version: string
8
+ port?: number
9
+ ingress?: Ingress
10
+ resources?: Resources
11
+ variables?: Variable[]
12
+ components?: string[]
13
+ probe?: Probe | false
14
+ }
15
+
16
+ export interface Variable {
17
+ name: string
18
+ value?: string
19
+ secret?: {
20
+ name: string
21
+ key: string
22
+ optional?: boolean
23
+ }
24
+ }
25
+
26
+ export interface Instance<T> {
27
+ locator: Locator
28
+ manifest: T
29
+ component: Manifest
30
+ }
31
+
32
+ export type Instances<T> = Array<Instance<T>>
33
+
34
+ export type Variables = Record<'global' | string, Variable[]>
35
+ export type Mounts = Record<'global' | string, Mount[]>
36
+
37
+ export interface Dependency {
38
+ services?: Service[]
39
+ variables?: Variables
40
+ mounts?: Mounts
41
+ /** Default probe for compositions and services without their own probe. `false` disables. */
42
+ probe?: Probe | false
43
+ }
44
+
45
+ interface Ingress {
46
+ /** Path prefix this service claims on the host. Defaults to `/`. */
47
+ path?: string
48
+ default?: boolean
49
+ hosts?: string[]
50
+ class?: string
51
+ annotations?: object
52
+ }
53
+
54
+ export interface Probe {
55
+ port: number
56
+ path: string
57
+ delay?: number
58
+ }
59
+
60
+ interface Mount {
61
+ name: string
62
+ path: string
63
+ claim: string
64
+ }
65
+
66
+ export interface Resources {
67
+ cpu: string[]
68
+ memory: string[]
69
+ }
@@ -1,39 +0,0 @@
1
- export type Service = {
2
- group: string
3
- name: string
4
- version: string
5
- port: number
6
- ingress: Ingress
7
- variables: Variable[]
8
- components?: string[]
9
- probe?: Probe
10
- }
11
-
12
- export type Variable = {
13
- name: string
14
- value?: string
15
- secret?: {
16
- name: string,
17
- key: string
18
- optional?: boolean
19
- }
20
- }
21
-
22
- export type Variables = Record<'global' | string, Variable[]>
23
-
24
- export type Dependency = {
25
- services?: Service[]
26
- variables?: Variables
27
- }
28
-
29
- type Ingress = {
30
- hosts: string[]
31
- class?: string
32
- annotations?: object
33
- }
34
-
35
- interface Probe {
36
- port: number
37
- path: string
38
- delay?: number
39
- }
File without changes