@toa.io/operations 1.0.0-alpha.28 → 1.0.0-alpha.280

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 (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/image/runtime.Dockerfile +4 -0
  3. package/package.json +10 -10
  4. package/readme.md +16 -0
  5. package/src/deployment/.deployment/.describe/components.js +1 -5
  6. package/src/deployment/.deployment/.describe/compositions.js +8 -7
  7. package/src/deployment/.deployment/.describe/dependencies.js +1 -6
  8. package/src/deployment/.deployment/.describe/events.js +63 -0
  9. package/src/deployment/.deployment/.describe/index.js +4 -13
  10. package/src/deployment/.deployment/.describe/mounts.js +20 -0
  11. package/src/deployment/.deployment/.describe/resources.js +33 -0
  12. package/src/deployment/.deployment/.describe/services.js +28 -5
  13. package/src/deployment/.deployment/.describe/services.test.js +61 -0
  14. package/src/deployment/.deployment/.describe/variables.js +6 -9
  15. package/src/deployment/.deployment/declare.js +1 -5
  16. package/src/deployment/.deployment/describe.js +133 -9
  17. package/src/deployment/.deployment/index.js +3 -9
  18. package/src/deployment/.deployment/merge.js +46 -6
  19. package/src/deployment/.deployment/merge.test.js +72 -0
  20. package/src/deployment/chart/templates/compositions.yaml +70 -0
  21. package/src/deployment/chart/templates/mono.yaml +181 -0
  22. package/src/deployment/chart/templates/services.yaml +75 -4
  23. package/src/deployment/chart/values.yaml +11 -1
  24. package/src/deployment/composition.js +2 -5
  25. package/src/deployment/deployment.js +19 -12
  26. package/src/deployment/factory.js +50 -28
  27. package/src/deployment/images/composition.Dockerfile +2 -2
  28. package/src/deployment/images/composition.js +26 -22
  29. package/src/deployment/images/factory.js +15 -10
  30. package/src/deployment/images/format.js +48 -0
  31. package/src/deployment/images/format.test.js +74 -0
  32. package/src/deployment/images/image.fixtures.js +11 -11
  33. package/src/deployment/images/image.js +20 -22
  34. package/src/deployment/images/image.test.js +6 -5
  35. package/src/deployment/images/index.js +1 -5
  36. package/src/deployment/images/mono.Dockerfile +18 -0
  37. package/src/deployment/images/mono.js +72 -0
  38. package/src/deployment/images/runtime-base.test.js +91 -0
  39. package/src/deployment/images/service.Dockerfile +3 -3
  40. package/src/deployment/images/service.js +8 -8
  41. package/src/deployment/index.js +1 -5
  42. package/src/deployment/operator.js +6 -6
  43. package/src/deployment/operator.test.js +8 -7
  44. package/src/deployment/registry.js +66 -22
  45. package/src/deployment/registry.test.js +177 -0
  46. package/src/deployment/service.js +1 -5
  47. package/src/deployment/workspace.js +13 -8
  48. package/src/index.js +1 -3
  49. package/src/process.js +2 -8
  50. package/src/process.test.js +20 -0
  51. package/types/_deployment/composition.d.ts +1 -1
  52. package/types/_deployment/dependency.d.ts +3 -1
  53. package/types/_deployment/deployment.d.ts +3 -3
  54. package/types/_deployment/factory.d.ts +2 -2
  55. package/types/_deployment/images/factory.d.ts +2 -2
  56. package/types/_deployment/images/image.d.ts +4 -2
  57. package/types/_deployment/images/registry.d.ts +2 -2
  58. package/types/_deployment/operator.d.ts +2 -2
  59. package/types/_deployment/registry.d.ts +10 -8
  60. package/types/_deployment/service.d.ts +4 -1
  61. package/types/dependency.ts +73 -0
  62. package/types/index.ts +1 -0
  63. package/types/dependency.d.ts +0 -39
  64. package/types/index.d.ts +0 -1
@@ -1,23 +1,15 @@
1
- 'use strict'
1
+ import { join, posix } from 'node:path'
2
+ import { readFile as read, writeFile as write } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
2
4
 
3
- const {
4
- join,
5
- posix
6
- } = require('node:path')
7
- const {
8
- readFile: read,
9
- writeFile: write
10
- } = require('node:fs/promises')
11
- const { createHash } = require('node:crypto')
12
-
13
- const { overwrite } = require('@toa.io/generic')
14
- const { mkdir } = require('node:fs/promises')
5
+ import { overwrite } from '@toa.io/generic'
6
+ import { mkdir } from 'node:fs/promises'
15
7
 
16
8
  /**
17
9
  * @implements {toa.deployment.images.Image}
18
10
  * @abstract
19
11
  */
20
- class Image {
12
+ export class Image {
21
13
  context
22
14
  reference
23
15
  dockerfile
@@ -26,9 +18,7 @@ class Image {
26
18
  #registry
27
19
  #runtime
28
20
  #values = {
29
- build: {
30
- image: 'node:20.9.0-alpine3.18'
31
- }
21
+ build: {}
32
22
  }
33
23
 
34
24
  constructor (scope, runtime, registry) {
@@ -54,6 +44,8 @@ class Image {
54
44
 
55
45
  get base () {}
56
46
 
47
+ get run () {}
48
+
57
49
  async prepare (root) {
58
50
  if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
59
51
 
@@ -65,7 +57,7 @@ class Image {
65
57
 
66
58
  const template = await read(this.dockerfile, 'utf-8')
67
59
  const contents = template.replace(/{{(\S{1,32})}}/g, (_, key) => this.#value(key))
68
- const ignore = 'Dockerfile'
60
+ const ignore = ['Dockerfile', '**/node_modules'].join('\r\n')
69
61
 
70
62
  await write(join(path, 'Dockerfile'), contents)
71
63
  await write(join(path, '.dockerignore'), ignore)
@@ -77,13 +69,19 @@ class Image {
77
69
 
78
70
  #setValues () {
79
71
  this.#values.runtime = this.#runtime
80
- this.#values.build = overwrite(this.#values.build, this.#registry.build)
72
+ this.#values.build = overwrite({
73
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
74
+ }, this.#registry.build)
81
75
 
82
76
  const image = this.base
83
77
 
84
- if (image !== undefined) {
78
+ if (image !== undefined)
85
79
  this.#values.build.image = image
86
- }
80
+
81
+ const run = this.run
82
+
83
+ if (run !== undefined)
84
+ this.#values.build.run = (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
87
85
 
88
86
  if (this.#values.build.arguments !== undefined) this.#values.build.arguments = createArguments(this.#values.build.arguments)
89
87
  if (this.#values.build.run !== undefined) this.#values.build.run = createRunCommands(this.#values.build.run)
@@ -121,4 +119,4 @@ function createArguments (variables) {
121
119
  return args.join('\n')
122
120
  }
123
121
 
124
- exports.Image = Image
122
+ export const RUNTIME_IMAGE = 'ghcr.io/toa-io/runtime'
@@ -1,7 +1,8 @@
1
- 'use strict'
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
2
3
 
3
- const fixtures = require('./image.fixtures')
4
- const { generate } = require('randomstring')
4
+ import * as fixtures from './image.fixtures.js'
5
+ import { generate } from 'randomstring'
5
6
 
6
7
  /** @type {toa.deployment.images.Image} */
7
8
  let instance
@@ -13,11 +14,11 @@ beforeEach(() => {
13
14
  it('should assign url', () => {
14
15
  instance.tag()
15
16
 
16
- expect(instance.reference).toEqual(`${fixtures.registry.base}/${fixtures.scope}/${fixtures.name}:${fixtures.version}`)
17
+ assert.deepStrictEqual(instance.reference, `${fixtures.registry.base}/${fixtures.scope}/${fixtures.name}:${fixtures.version}`)
17
18
  })
18
19
 
19
20
  describe('prepare', () => {
20
21
  it('should throw error if no dockerfile specified', async () => {
21
- await expect(instance.prepare(generate())).rejects.toThrow(/Dockerfile isn't specified/)
22
+ await assert.rejects(instance.prepare(generate()), (error) => /Dockerfile isn't specified/.test(error.message))
22
23
  })
23
24
  })
@@ -1,5 +1 @@
1
- 'use strict'
2
-
3
- const { Factory } = require('./factory')
4
-
5
- exports.Factory = Factory
1
+ export { Factory } from './factory.js'
@@ -0,0 +1,18 @@
1
+ FROM {{build.image}}
2
+
3
+ {{build.arguments}}
4
+
5
+ ENV NODE_ENV=production
6
+ RUN if [ "{{runtime.registry}}" != "" ]; then npm set registry {{runtime.registry}}; fi
7
+ RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
8
+
9
+ WORKDIR /composition
10
+ COPY --chown=node:node . /composition
11
+
12
+ {{build.run}}
13
+
14
+ RUN --mount=type=cache,target=/root/.npm \
15
+ for entry in *; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done
16
+
17
+ USER node
18
+ CMD toa mono *
@@ -0,0 +1,72 @@
1
+ import { join } from 'node:path'
2
+ import fs from 'fs-extra'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import { Image } from './image.js'
6
+ import { declare } from './format.js'
7
+
8
+ export class Mono extends Image {
9
+ dockerfile = join(import.meta.dirname, 'mono.Dockerfile')
10
+
11
+ #image
12
+ #components
13
+
14
+ constructor (scope, runtime, registry, composition) {
15
+ super(scope, runtime, registry)
16
+
17
+ this.#image = composition.image
18
+ this.#components = composition.components
19
+ }
20
+
21
+ get name () {
22
+ return 'mono'
23
+ }
24
+
25
+ get version () {
26
+ const hash = createHash('sha256')
27
+
28
+ for (const component of this.#components) {
29
+ hash.update(component.locator.id)
30
+ hash.update(component.version)
31
+ }
32
+
33
+ return hash.digest('hex').slice(0, 8)
34
+ }
35
+
36
+ get base () {
37
+ if (this.#image !== undefined) return this.#image
38
+
39
+ const images = new Set(this.#components.map((component) => component.build?.image))
40
+
41
+ if (images.size > 1)
42
+ throw new Error('Mono deployment requires different base images for its components. Specify base image for the composition in the context.')
43
+
44
+ return images.values().next().value
45
+ }
46
+
47
+ get run () {
48
+ const commands = []
49
+
50
+ for (const component of this.#components) {
51
+ const run = component.build?.run
52
+
53
+ if (run !== undefined)
54
+ commands.push(run)
55
+ }
56
+
57
+ return commands.join('\n')
58
+ }
59
+
60
+ async prepare (root) {
61
+ const context = await super.prepare(root)
62
+
63
+ for (const component of this.#components) {
64
+ const target = join(context, component.locator.label)
65
+
66
+ await fs.copy(component.path, target)
67
+ await declare(component.path, target, component.locator.label)
68
+ }
69
+
70
+ return context
71
+ }
72
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { join } from 'node:path'
5
+ import { mkdtemp, readFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ import { Image, RUNTIME_IMAGE } from './image.js'
9
+
10
+ const compositionDockerfile = join(import.meta.dirname, 'composition.Dockerfile')
11
+ const serviceDockerfile = join(import.meta.dirname, 'service.Dockerfile')
12
+
13
+ class TestImage extends Image {
14
+ dockerfile
15
+
16
+ #name
17
+ #base
18
+
19
+ constructor (runtime, registry, dockerfile, name = 'test', base) {
20
+ super('acme', runtime, registry)
21
+
22
+ this.dockerfile = dockerfile
23
+ this.#name = name
24
+ this.#base = base
25
+ }
26
+
27
+ get name () {
28
+ return this.#name
29
+ }
30
+
31
+ get version () {
32
+ return 'abcdef12'
33
+ }
34
+
35
+ get base () {
36
+ return this.#base
37
+ }
38
+ }
39
+
40
+ describe('runtime base image', () => {
41
+ /** @type {string} */
42
+ let root
43
+
44
+ beforeEach(async () => {
45
+ root = await mkdtemp(join(tmpdir(), 'toa-runtime-base-test'))
46
+ })
47
+
48
+ it('should default build.image to version-pinned GHCR runtime image', async () => {
49
+ const runtime = { version: '1.0.0-alpha.232' }
50
+ const image = new TestImage(runtime, {}, compositionDockerfile)
51
+
52
+ const path = await image.prepare(root)
53
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
54
+
55
+ assert.ok(dockerfile.includes(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.232`))
56
+ assert.doesNotMatch(dockerfile, /npm i -g @toa\.io\/runtime/)
57
+ })
58
+
59
+ it('should not install runtime in service Dockerfile template', async () => {
60
+ const runtime = { version: '1.0.0-alpha.99' }
61
+ const image = new TestImage(runtime, {}, serviceDockerfile, 'service-test')
62
+
63
+ const path = await image.prepare(root)
64
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
65
+
66
+ assert.ok(dockerfile.includes(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.99`))
67
+ assert.doesNotMatch(dockerfile, /npm i -g @toa\.io\/runtime/)
68
+ })
69
+
70
+ it('should allow registry.build.image override', async () => {
71
+ const runtime = { version: '1.0.0-alpha.232' }
72
+ const registry = { build: { image: 'node:24.14.0-alpine3.22' } }
73
+ const image = new TestImage(runtime, registry, compositionDockerfile)
74
+
75
+ const path = await image.prepare(root)
76
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
77
+
78
+ assert.ok(dockerfile.includes('FROM node:24.14.0-alpine3.22'))
79
+ assert.ok(!(dockerfile.includes(RUNTIME_IMAGE)))
80
+ })
81
+
82
+ it('should allow composition.image override via base', async () => {
83
+ const runtime = { version: '1.0.0-alpha.232' }
84
+ const image = new TestImage(runtime, {}, compositionDockerfile, 'mono', 'custom.example/base:1')
85
+
86
+ const path = await image.prepare(root)
87
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
88
+
89
+ assert.ok(dockerfile.includes('FROM custom.example/base:1'))
90
+ })
91
+ })
@@ -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 .
@@ -1,12 +1,14 @@
1
- 'use strict'
1
+ import { createRequire } from 'node:module'
2
+ import { join, dirname } from 'node:path'
2
3
 
3
- const { join, dirname } = require('node:path')
4
+ import { Image } from './image.js'
5
+ import fs from 'fs-extra'
4
6
 
5
- const { Image } = require('./image')
6
- const fs = require('fs-extra')
7
+ // a service is named the way a package is, and its directory is where it lives
8
+ const require = createRequire(import.meta.url)
7
9
 
8
- class Service extends Image {
9
- dockerfile = join(__dirname, 'service.Dockerfile')
10
+ export class Service extends Image {
11
+ dockerfile = join(import.meta.dirname, 'service.Dockerfile')
10
12
 
11
13
  /**
12
14
  * Used by Dockerfile
@@ -70,5 +72,3 @@ class Service extends Image {
70
72
  const find = (reference) => {
71
73
  return dirname(require.resolve(join(reference, 'package.json')))
72
74
  }
73
-
74
- exports.Service = Service
@@ -1,5 +1 @@
1
- 'use strict'
2
-
3
- const { Factory } = require('./factory')
4
-
5
- exports.Factory = Factory
1
+ export { Factory } from './factory.js'
@@ -1,8 +1,6 @@
1
- 'use strict'
1
+ import * as workspace from './workspace.js'
2
2
 
3
- const workspace = require('./workspace')
4
-
5
- class Operator {
3
+ export class Operator {
6
4
  /** @type {toa.deployment.Deployment} */
7
5
  #deployment
8
6
 
@@ -50,9 +48,11 @@ class Operator {
50
48
  variables () {
51
49
  return this.#deployment.variables()
52
50
  }
51
+
52
+ tags () {
53
+ return this.#registry.tags()
54
+ }
53
55
  }
54
56
 
55
57
  /** @type {toa.deployment.installation.Options} */
56
58
  const OPTIONS = { wait: false }
57
-
58
- exports.Operator = Operator
@@ -1,10 +1,11 @@
1
- 'use strict'
1
+ import { describe, it, beforeEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
2
3
 
3
- const { Operator } = require('../../src/deployment/operator')
4
- const { generate } = require('randomstring')
4
+ import { Operator } from '../../src/deployment/operator.js'
5
+ import { generate } from 'randomstring'
5
6
 
6
7
  it('should be', async () => {
7
- expect(Operator).toBeInstanceOf(Function)
8
+ assert.ok(Operator instanceof Function)
8
9
  })
9
10
 
10
11
  /** @type {toa.deployment.Operator} */
@@ -25,7 +26,7 @@ describe('env', () => {
25
26
  it('should be', async () => {
26
27
  operator = new Operator(deployment, registry)
27
28
 
28
- expect(operator.variables).toBeInstanceOf(Function)
29
+ assert.ok(operator.variables instanceof Function)
29
30
  })
30
31
 
31
32
  it('should return variables', async () => {
@@ -33,12 +34,12 @@ describe('env', () => {
33
34
 
34
35
  deployment.variables =
35
36
  /** @type {typeof toa.deployment.Operator.variables} */
36
- jest.fn(() => variables)
37
+ mock.fn(() => variables)
37
38
 
38
39
  operator = new Operator(deployment, registry)
39
40
 
40
41
  const output = operator.variables()
41
42
 
42
- expect(output).toStrictEqual(variables)
43
+ assert.deepStrictEqual(output, variables)
43
44
  })
44
45
  })
@@ -1,12 +1,12 @@
1
- 'use strict'
2
-
3
- const workspace = require('./workspace')
4
- const { newid } = require('@toa.io/generic')
1
+ import { posix } from 'node:path'
2
+ import * as workspace from './workspace.js'
5
3
 
6
4
  /**
7
5
  * @implements {toa.deployment.Registry}
8
6
  */
9
- class Registry {
7
+ export class Registry {
8
+ #scope
9
+
10
10
  #registry
11
11
 
12
12
  #factory
@@ -15,7 +15,11 @@ class Registry {
15
15
 
16
16
  #images = []
17
17
 
18
- constructor (registry, factory, process) {
18
+ /** @type {string | undefined} */
19
+ #builder
20
+
21
+ constructor (scope, registry, factory, process) {
22
+ this.#scope = scope
19
23
  this.#registry = registry
20
24
  this.#factory = factory
21
25
  this.#process = process
@@ -29,6 +33,10 @@ class Registry {
29
33
  return this.#create('service', path, service)
30
34
  }
31
35
 
36
+ mono (composition) {
37
+ return this.#create('mono', composition)
38
+ }
39
+
32
40
  async prepare (root) {
33
41
  const path = await workspace.create('images', root)
34
42
 
@@ -40,9 +48,8 @@ class Registry {
40
48
  async build () {
41
49
  await this.prepare()
42
50
 
43
- for (const image of this.#images) {
51
+ for (const image of this.#images)
44
52
  await this.#build(image)
45
- }
46
53
  }
47
54
 
48
55
  async push () {
@@ -51,8 +58,12 @@ class Registry {
51
58
  for (const image of this.#images) await this.#push(image)
52
59
  }
53
60
 
61
+ tags () {
62
+ return this.#images.map((image) => image.reference)
63
+ }
64
+
54
65
  /**
55
- * @param {'composition' | 'service'} type
66
+ * @param {'composition' | 'service' | 'mono'} type
56
67
  * @param {...any} args
57
68
  * @returns {toa.deployment.images.Image}
58
69
  */
@@ -70,13 +81,17 @@ class Registry {
70
81
  * @returns {Promise<void>}
71
82
  */
72
83
  async #build (image, push = false) {
84
+ if (await this.exists(image.reference)) {
85
+ console.log('Image already exists, skipping:', image.reference)
86
+ return
87
+ }
88
+
73
89
  const args = ['--context=default', 'buildx', 'build']
74
90
 
75
- if (push) {
91
+ if (push)
76
92
  args.push('--push')
77
- } else {
93
+ else
78
94
  args.push('--load')
79
- }
80
95
 
81
96
  args.push('--tag', image.reference, image.context)
82
97
 
@@ -88,14 +103,15 @@ class Registry {
88
103
 
89
104
  if (multiarch) {
90
105
  const platform = this.#registry.platforms.join(',')
91
- const builder = await this.#createBuilder()
106
+ const builder = await this.#ensureBuilder()
92
107
 
93
108
  args.push('--platform', platform)
94
109
  args.push('--builder', builder)
95
110
 
96
- } else {
111
+ if (this.#registry.base !== undefined)
112
+ this.#appendCache(args)
113
+ } else
97
114
  args.push('--builder', 'default')
98
- }
99
115
 
100
116
  args.push('--progress', 'plain')
101
117
 
@@ -106,16 +122,44 @@ class Registry {
106
122
  await this.#build(image, true)
107
123
  }
108
124
 
109
- async #createBuilder () {
110
- const name = `toa-${newid()}`
111
- const create = `buildx create --name ${name} --bootstrap --use`.split(' ')
125
+ async exists (tag) {
126
+ const args = ['manifest', 'inspect', tag]
127
+
128
+ try {
129
+ await this.#process.execute('docker', args, { silently: true })
130
+ } catch (error) {
131
+ console.log(error.message)
132
+
133
+ return false
134
+ }
135
+
136
+ return true
137
+ }
138
+
139
+ async #ensureBuilder () {
140
+ if (this.#builder !== undefined)
141
+ return this.#builder
142
+
143
+ try {
144
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], { silently: true })
145
+ } catch {
146
+ await this.#process.execute('docker', ['buildx', 'create', '--name', BUILDER, '--bootstrap'])
147
+ }
112
148
 
113
- await this.#process.execute('docker', create)
149
+ this.#builder = BUILDER
114
150
 
115
- return name
151
+ return BUILDER
152
+ }
153
+
154
+ /**
155
+ * @param {string[]} args
156
+ */
157
+ #appendCache (args) {
158
+ const ref = posix.join(this.#registry.base, this.#scope, 'buildcache')
159
+
160
+ args.push('--cache-from', `type=registry,ref=${ref}`)
161
+ args.push('--cache-to', `type=registry,ref=${ref},mode=max,image-manifest=true`)
116
162
  }
117
163
  }
118
164
 
119
165
  const BUILDER = 'toa'
120
-
121
- exports.Registry = Registry