@toa.io/operations 1.0.0-alpha.34 → 1.0.0-alpha.341

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 (78) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/image/runtime.Dockerfile +20 -0
  3. package/package.json +10 -10
  4. package/readme.md +224 -0
  5. package/src/deployment/.deployment/.describe/components.js +1 -5
  6. package/src/deployment/.deployment/.describe/compositions.js +26 -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/fold.js +42 -0
  10. package/src/deployment/.deployment/.describe/fold.test.js +108 -0
  11. package/src/deployment/.deployment/.describe/index.js +4 -13
  12. package/src/deployment/.deployment/.describe/mounts.js +17 -0
  13. package/src/deployment/.deployment/.describe/resources.js +129 -0
  14. package/src/deployment/.deployment/.describe/resources.test.js +129 -0
  15. package/src/deployment/.deployment/.describe/services.js +32 -6
  16. package/src/deployment/.deployment/.describe/services.test.js +78 -0
  17. package/src/deployment/.deployment/.describe/variables.js +5 -9
  18. package/src/deployment/.deployment/declare.js +1 -5
  19. package/src/deployment/.deployment/describe.js +168 -10
  20. package/src/deployment/.deployment/index.js +3 -9
  21. package/src/deployment/.deployment/merge.js +66 -6
  22. package/src/deployment/.deployment/merge.test.js +133 -0
  23. package/src/deployment/chart/templates/components.configmap.yaml +13 -0
  24. package/src/deployment/chart/templates/components.yaml +4 -3
  25. package/src/deployment/chart/templates/compositions.yaml +109 -4
  26. package/src/deployment/chart/templates/mono.yaml +199 -0
  27. package/src/deployment/chart/templates/services.yaml +97 -7
  28. package/src/deployment/chart/values.yaml +23 -1
  29. package/src/deployment/composition.js +8 -6
  30. package/src/deployment/deployment.js +175 -35
  31. package/src/deployment/drain.js +70 -0
  32. package/src/deployment/drain.test.js +64 -0
  33. package/src/deployment/factory.js +178 -37
  34. package/src/deployment/images/bundle.js +93 -0
  35. package/src/deployment/images/composition.Dockerfile +9 -17
  36. package/src/deployment/images/composition.js +9 -56
  37. package/src/deployment/images/dependencies.Dockerfile +23 -0
  38. package/src/deployment/images/dependencies.js +171 -0
  39. package/src/deployment/images/dependencies.test.js +271 -0
  40. package/src/deployment/images/factory.js +31 -15
  41. package/src/deployment/images/format.js +66 -0
  42. package/src/deployment/images/format.test.js +150 -0
  43. package/src/deployment/images/image.fixtures.js +17 -19
  44. package/src/deployment/images/image.js +79 -38
  45. package/src/deployment/images/image.test.js +11 -5
  46. package/src/deployment/images/index.js +1 -5
  47. package/src/deployment/images/mono.Dockerfile +11 -0
  48. package/src/deployment/images/mono.js +15 -0
  49. package/src/deployment/images/publish.js +91 -0
  50. package/src/deployment/images/runtime-base.test.js +97 -0
  51. package/src/deployment/images/service.Dockerfile +17 -5
  52. package/src/deployment/images/service.js +63 -14
  53. package/src/deployment/index.js +1 -5
  54. package/src/deployment/operator.js +25 -16
  55. package/src/deployment/operator.test.js +98 -7
  56. package/src/deployment/registry.js +210 -46
  57. package/src/deployment/registry.test.js +539 -0
  58. package/src/deployment/secrets.js +92 -0
  59. package/src/deployment/secrets.test.js +180 -0
  60. package/src/deployment/service.js +4 -6
  61. package/src/deployment/workspace.js +13 -8
  62. package/src/index.js +3 -2
  63. package/src/process.js +51 -13
  64. package/src/process.test.js +33 -0
  65. package/types/_deployment/composition.d.ts +2 -3
  66. package/types/_deployment/dependency.d.ts +15 -7
  67. package/types/_deployment/deployment.d.ts +10 -8
  68. package/types/_deployment/factory.d.ts +2 -4
  69. package/types/_deployment/images/factory.d.ts +6 -8
  70. package/types/_deployment/images/image.d.ts +16 -1
  71. package/types/_deployment/images/registry.d.ts +8 -11
  72. package/types/_deployment/operator.d.ts +10 -9
  73. package/types/_deployment/registry.d.ts +7 -4
  74. package/types/_deployment/service.d.ts +4 -3
  75. package/types/dependency.ts +79 -0
  76. package/types/index.ts +1 -0
  77. package/types/dependency.d.ts +0 -39
  78. package/types/index.d.ts +0 -1
@@ -1,61 +1,90 @@
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 { MAP } from '@toa.io/definitions'
7
+ import { mkdir } from 'node:fs/promises'
15
8
 
16
9
  /**
17
10
  * @implements {toa.deployment.images.Image}
18
11
  * @abstract
19
12
  */
20
- class Image {
13
+ export class Image {
21
14
  context
22
15
  reference
23
16
  dockerfile
24
17
 
18
+ /**
19
+ * The image this one is laid over, built and pushed as an image of its own.
20
+ * Undefined stands on the base alone, which is what a service does.
21
+ *
22
+ * @type {toa.deployment.images.Image | undefined}
23
+ */
24
+ dependencies
25
+
26
+ /** Whether the build reads `registry.build.arguments`. */
27
+ arguments = false
28
+
25
29
  #scope
26
30
  #registry
27
31
  #runtime
28
32
  #values = {
29
- build: {
30
- image: 'node:20.9.0-alpine3.18'
31
- }
33
+ build: {}
32
34
  }
33
35
 
34
- constructor (scope, runtime, registry) {
36
+ constructor(scope, runtime, registry) {
35
37
  this.#scope = scope
36
38
  this.#registry = registry
37
39
  this.#runtime = runtime
38
40
  }
39
41
 
40
- tag () {
42
+ tag() {
43
+ this.reference = posix.join(
44
+ this.#registry.base ?? '',
45
+ this.#scope,
46
+ `${this.name}:${this.digest()}`
47
+ )
48
+ }
49
+
50
+ /**
51
+ * What follows the colon: the runtime and the image's own version, digested.
52
+ *
53
+ * @returns {string}
54
+ */
55
+ digest() {
41
56
  const hash = createHash('sha256')
42
57
 
43
58
  hash.update(this.#runtime.version)
44
59
  hash.update(this.version)
45
60
 
46
- const tag = hash.digest('hex').slice(0, 8)
61
+ return hash.digest('hex').slice(0, 8)
62
+ }
47
63
 
48
- this.reference = posix.join(this.#registry.base ?? '', this.#scope, `${this.name}:${tag}`)
64
+ /** @returns {string | undefined} */
65
+ get name() {
66
+ return undefined
49
67
  }
50
68
 
51
- get name () {}
69
+ /** @returns {string | undefined} */
70
+ get version() {
71
+ return undefined
72
+ }
52
73
 
53
- get version () {}
74
+ /** The image to build `FROM`. Undefined takes the runtime's, which is what a service does.
75
+ * @returns {string | undefined} */
76
+ get base() {
77
+ return undefined
78
+ }
54
79
 
55
- get base () {}
80
+ /** Build commands to add. Undefined adds none, which is what a service does.
81
+ * @returns {string | undefined} */
82
+ get run() {
83
+ return undefined
84
+ }
56
85
 
57
- async prepare (root) {
58
- if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
86
+ async prepare(root) {
87
+ if (this.dockerfile === undefined) throw new Error("Dockerfile isn't specified")
59
88
 
60
89
  this.#setValues()
61
90
 
@@ -65,7 +94,7 @@ class Image {
65
94
 
66
95
  const template = await read(this.dockerfile, 'utf-8')
67
96
  const contents = template.replace(/{{(\S{1,32})}}/g, (_, key) => this.#value(key))
68
- const ignore = 'Dockerfile'
97
+ const ignore = ['Dockerfile', '**/node_modules'].join('\r\n')
69
98
 
70
99
  await write(join(path, 'Dockerfile'), contents)
71
100
  await write(join(path, '.dockerignore'), ignore)
@@ -75,32 +104,44 @@ class Image {
75
104
  return path
76
105
  }
77
106
 
78
- #setValues () {
107
+ #setValues() {
108
+ this.#values.map = { file: MAP }
79
109
  this.#values.runtime = this.#runtime
80
- this.#values.build = overwrite(this.#values.build, this.#registry.build)
110
+ this.#values.build = overwrite(
111
+ {
112
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
113
+ },
114
+ this.#registry.build
115
+ )
81
116
 
82
117
  const image = this.base
83
118
 
84
- if (image !== undefined) {
85
- this.#values.build.image = image
86
- }
119
+ if (image !== undefined) this.#values.build.image = image
120
+
121
+ const run = this.run
122
+
123
+ if (run !== undefined)
124
+ this.#values.build.run =
125
+ (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
87
126
 
88
- if (this.#values.build.arguments !== undefined) this.#values.build.arguments = createArguments(this.#values.build.arguments)
89
- if (this.#values.build.run !== undefined) this.#values.build.run = createRunCommands(this.#values.build.run)
127
+ if (this.#values.build.arguments !== undefined)
128
+ this.#values.build.arguments = createArguments(this.#values.build.arguments)
129
+ if (this.#values.build.run !== undefined)
130
+ this.#values.build.run = createRunCommands(this.#values.build.run)
90
131
  }
91
132
 
92
133
  /**
93
134
  * @param key {string}
94
135
  * @returns {string}
95
136
  */
96
- #value (key) {
137
+ #value(key) {
97
138
  const [source, property] = key.split('.')
98
139
 
99
140
  return this.#values[source]?.[property] ?? ''
100
141
  }
101
142
  }
102
143
 
103
- function createRunCommands (input) {
144
+ function createRunCommands(input) {
104
145
  const lines = input.split('\n')
105
146
 
106
147
  return lines.reduce((commands, command) => {
@@ -110,7 +151,7 @@ function createRunCommands (input) {
110
151
  }, '')
111
152
  }
112
153
 
113
- function createArguments (variables) {
154
+ function createArguments(variables) {
114
155
  const args = []
115
156
 
116
157
  for (const variable of variables) {
@@ -121,4 +162,4 @@ function createArguments (variables) {
121
162
  return args.join('\n')
122
163
  }
123
164
 
124
- exports.Image = Image
165
+ 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,16 @@ 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(
18
+ instance.reference,
19
+ `${fixtures.registry.base}/${fixtures.scope}/${fixtures.name}:${fixtures.version}`
20
+ )
17
21
  })
18
22
 
19
23
  describe('prepare', () => {
20
24
  it('should throw error if no dockerfile specified', async () => {
21
- await expect(instance.prepare(generate())).rejects.toThrow(/Dockerfile isn't specified/)
25
+ await assert.rejects(instance.prepare(generate()), (error) =>
26
+ /Dockerfile isn't specified/.test(error.message)
27
+ )
22
28
  })
23
29
  })
@@ -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,11 @@
1
+ FROM {{build.image}}
2
+
3
+ # the one instruction that touches the filesystem, and linked: the base is not pulled to lay
4
+ # the sources over it, and a push carries this layer and a manifest, not the base's layers.
5
+ # Owned by root like the dependencies beside them: a linked layer knows no user by name,
6
+ # and the runtime reads its sources, it does not write them
7
+ COPY --link . /composition
8
+
9
+ # no USER: the runtime drops to `node` itself, and only a process that started as root can
10
+ # close its environment under /proc — see runtime/runtime/bin/toa
11
+ CMD toa mono * --map {{map.file}}
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { Bundle } from './bundle.js'
4
+
5
+ export class Mono extends Bundle {
6
+ dockerfile = join(import.meta.dirname, 'mono.Dockerfile')
7
+
8
+ get name() {
9
+ return 'mono'
10
+ }
11
+
12
+ conflict() {
13
+ return 'Mono deployment requires different base images for its components. Specify base image for the composition in the context.'
14
+ }
15
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises'
2
+ import { readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { definition } from '@toa.io/norm'
6
+
7
+ import { run } from '../../process.js'
8
+ import { Service } from './service.js'
9
+
10
+ /**
11
+ * Builds and pushes the image an extension ships for its service, so that an application
12
+ * with `registry.services: published` takes it instead of building one.
13
+ *
14
+ * The image is built from the package as npm publishes it, not from the workspace: what
15
+ * `files` leaves out is not in the tarball, and an application builds from the tarball.
16
+ *
17
+ * @param {string} workspace path to the extension's directory in this repository
18
+ * @param {string} runtime the runtime version, which is the tag and the base image
19
+ * @param {string[]} platforms
20
+ * @returns {Promise<string>} the reference pushed
21
+ */
22
+ export async function publish(workspace, runtime, platforms) {
23
+ const directory = resolve(workspace)
24
+ const manifest = read(join(directory, 'package.json'))
25
+
26
+ const { image } = (await definition(manifest.name)).module
27
+
28
+ if (image === undefined)
29
+ throw new Error(
30
+ `'${manifest.name}' publishes no service image: it exports no 'image'`
31
+ )
32
+
33
+ const root = await mkdtemp(join(tmpdir(), 'toa-publish'))
34
+
35
+ await writeFile(join(root, 'package.json'), '{"private":true}')
36
+ await run('npm', [
37
+ 'i',
38
+ '--prefix',
39
+ root,
40
+ '--omit=dev',
41
+ `${manifest.name}@${manifest.version}`
42
+ ])
43
+
44
+ const path = join(root, 'node_modules', manifest.name)
45
+
46
+ const service = new Service(SCOPE, { version: runtime }, {}, path, {
47
+ ...name(image),
48
+ version: manifest.version
49
+ })
50
+
51
+ service.reference = `${image}:${runtime}`
52
+
53
+ await service.prepare(await mkdtemp(join(tmpdir(), 'toa-images')))
54
+
55
+ await run('docker', [
56
+ 'buildx',
57
+ 'build',
58
+ '--platform',
59
+ platforms.join(','),
60
+ '--tag',
61
+ service.reference,
62
+ '--provenance=false',
63
+ '--push',
64
+ service.context
65
+ ])
66
+
67
+ return service.reference
68
+ }
69
+
70
+ /** The repository's last segment is what `Service` names an image, so it reads back. */
71
+ function name(image) {
72
+ const match = /\/extension-([^-/]+)-([^/]+)$/.exec(image)
73
+
74
+ if (match === null)
75
+ throw new Error(`'${image}' is not named 'extension-<group>-<name>'`)
76
+
77
+ return { group: match[1], name: match[2] }
78
+ }
79
+
80
+ const read = (path) => JSON.parse(readFileSync(path, 'utf8'))
81
+
82
+ // the reference is assigned rather than derived from a scope
83
+ const SCOPE = ''
84
+
85
+ const PLATFORMS = 'linux/amd64,linux/arm64'
86
+
87
+ if (import.meta.main) {
88
+ const [workspace, runtime, platforms = PLATFORMS] = process.argv.slice(2)
89
+
90
+ console.log(await publish(workspace, runtime, platforms.split(',')))
91
+ }
@@ -0,0 +1,97 @@
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 dependenciesDockerfile = join(import.meta.dirname, 'dependencies.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, {}, dependenciesDockerfile)
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.22.1-alpine3.24' } }
73
+ const image = new TestImage(runtime, registry, dependenciesDockerfile)
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.22.1-alpine3.24'))
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(
85
+ runtime,
86
+ {},
87
+ dependenciesDockerfile,
88
+ 'mono',
89
+ 'custom.example/base:1'
90
+ )
91
+
92
+ const path = await image.prepare(root)
93
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
94
+
95
+ assert.ok(dockerfile.includes('FROM custom.example/base:1'))
96
+ })
97
+ })
@@ -1,14 +1,26 @@
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,sharing=locked \
11
+ npm i --omit=dev
12
12
 
13
- USER node
14
- CMD toa serve .
13
+ # a component of the extension's own declares what it imports, and it is installed beside the
14
+ # component rather than beside the extension: an application that runs none of them, and the
15
+ # runtime image every application is built on, carry nothing for them
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); fi; done
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
+
24
+ # no USER: the runtime drops to `node` itself, and only a process that started as root can
25
+ # close its environment under /proc — see runtime/runtime/bin/toa
26
+ CMD toa serve . --map {{map.file}}
@@ -1,12 +1,15 @@
1
- 'use strict'
1
+ import { createRequire } from 'node:module'
2
+ import { join, dirname } from 'node:path'
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
4
 
3
- const { join, dirname } = require('node:path')
5
+ import { Image } from './image.js'
6
+ import { cp, writeFile } from 'node:fs/promises'
4
7
 
5
- const { Image } = require('./image')
6
- const fs = require('fs-extra')
8
+ // a service is named the way a package is, and its directory is where it lives
9
+ const require = createRequire(import.meta.url)
7
10
 
8
- class Service extends Image {
9
- dockerfile = join(__dirname, 'service.Dockerfile')
11
+ export class Service extends Image {
12
+ dockerfile = join(import.meta.dirname, 'service.Dockerfile')
10
13
 
11
14
  /**
12
15
  * Used by Dockerfile
@@ -35,7 +38,7 @@ class Service extends Image {
35
38
  * @param {string} reference
36
39
  * @param {toa.deployment.dependency.Service} service
37
40
  */
38
- constructor (scope, runtime, registry, reference, service) {
41
+ constructor(scope, runtime, registry, reference, service) {
39
42
  super(scope, runtime, registry)
40
43
 
41
44
  this.service = service.name
@@ -46,29 +49,75 @@ class Service extends Image {
46
49
  this.#version = service.version
47
50
  }
48
51
 
49
- get name () {
52
+ get name() {
50
53
  return 'extension-' + this.#group + '-' + this.#name
51
54
  }
52
55
 
53
- get version () {
56
+ get version() {
54
57
  return this.#version
55
58
  }
56
59
 
57
- async prepare (root) {
60
+ async prepare(root) {
58
61
  const context = await super.prepare(root)
59
62
 
60
- await fs.copy(this.#path, context)
63
+ await cp(this.#path, context, { recursive: true })
64
+ await writeFile(join(context, PACKAGES), this.#packages().join('\n'))
61
65
 
62
66
  return context
63
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
+ }
64
99
  }
65
100
 
101
+ /** Where the install reads what the components need, one `name@version` per line. */
102
+ const PACKAGES = '.packages'
103
+
66
104
  /**
105
+ * Where the extension is installed, which is what its image is built from. A deploy install
106
+ * carries what an extension declares and not the extension, so a service is built only where
107
+ * the runtime is installed beside the deployment library; elsewhere its image is published.
108
+ *
67
109
  * @param {string} reference
68
110
  * @returns {string}
69
111
  */
70
112
  const find = (reference) => {
71
- return dirname(require.resolve(join(reference, 'package.json')))
113
+ try {
114
+ return dirname(require.resolve(join(reference, 'package.json')))
115
+ } catch (error) {
116
+ throw new Error(
117
+ `'${reference}' is not installed, and \`registry.services: build\` builds its image ` +
118
+ 'from where it is: install the runtime beside @toa.io/operations, or take the ' +
119
+ 'image the release publishes with `registry.services: published`',
120
+ { cause: error }
121
+ )
122
+ }
72
123
  }
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,24 +1,27 @@
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
 
9
7
  /** @type {toa.deployment.Registry} */
10
8
  #registry
11
9
 
10
+ /** @type {string | undefined} */
11
+ #environment
12
+
12
13
  /**
13
- * @param deployment {toa.deployment.Deployment}
14
- * @param registry {toa.deployment.Registry}
14
+ * @param {toa.deployment.Deployment} deployment
15
+ * @param {toa.deployment.Registry} registry
16
+ * @param {string} [environment]
15
17
  */
16
- constructor (deployment, registry) {
18
+ constructor(deployment, registry, environment) {
17
19
  this.#deployment = deployment
18
20
  this.#registry = registry
21
+ this.#environment = environment
19
22
  }
20
23
 
21
- async export (path) {
24
+ async export(path) {
22
25
  const target = await workspace.create('deployment', path)
23
26
 
24
27
  await this.#deployment.export(target)
@@ -26,33 +29,39 @@ class Operator {
26
29
  return target
27
30
  }
28
31
 
29
- async prepare (path) {
32
+ async prepare(path) {
30
33
  return await this.#registry.prepare(path)
31
34
  }
32
35
 
33
- async push () {
36
+ async push() {
34
37
  await this.#registry.push()
35
38
  }
36
39
 
37
- async install (options = {}) {
40
+ async install(options = {}) {
38
41
  options = Object.assign({}, OPTIONS, options)
39
42
 
43
+ // nothing is built, pushed or applied where the cluster cannot run what this deploys
44
+ await this.#deployment.verify(options)
45
+
40
46
  await Promise.all([this.export(), this.push()])
47
+ await this.#registry.alias(this.#environment)
41
48
  await this.#deployment.install(options)
42
49
  }
43
50
 
44
- async template (options = {}) {
51
+ async template(options = {}) {
45
52
  await this.export()
46
53
 
47
54
  return await this.#deployment.template(options)
48
55
  }
49
56
 
50
- variables () {
51
- return this.#deployment.variables()
57
+ variables(options) {
58
+ return this.#deployment.variables(options)
59
+ }
60
+
61
+ tags() {
62
+ return this.#registry.tags()
52
63
  }
53
64
  }
54
65
 
55
66
  /** @type {toa.deployment.installation.Options} */
56
67
  const OPTIONS = { wait: false }
57
-
58
- exports.Operator = Operator