@toa.io/operations 1.0.0-alpha.29 → 1.0.0-alpha.290

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 (75) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/image/runtime.Dockerfile +4 -0
  3. package/package.json +8 -10
  4. package/readme.md +93 -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 +103 -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 +125 -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.yaml +1 -1
  24. package/src/deployment/chart/templates/compositions.yaml +96 -4
  25. package/src/deployment/chart/templates/mono.yaml +182 -0
  26. package/src/deployment/chart/templates/services.yaml +82 -7
  27. package/src/deployment/chart/values.yaml +23 -1
  28. package/src/deployment/composition.js +6 -6
  29. package/src/deployment/deployment.js +55 -25
  30. package/src/deployment/drain.js +70 -0
  31. package/src/deployment/drain.test.js +64 -0
  32. package/src/deployment/factory.js +121 -32
  33. package/src/deployment/images/bundle.js +82 -0
  34. package/src/deployment/images/composition.Dockerfile +8 -16
  35. package/src/deployment/images/composition.js +9 -56
  36. package/src/deployment/images/dependencies.Dockerfile +16 -0
  37. package/src/deployment/images/dependencies.js +141 -0
  38. package/src/deployment/images/dependencies.test.js +214 -0
  39. package/src/deployment/images/factory.js +31 -15
  40. package/src/deployment/images/format.js +52 -0
  41. package/src/deployment/images/format.test.js +74 -0
  42. package/src/deployment/images/image.fixtures.js +13 -13
  43. package/src/deployment/images/image.js +77 -38
  44. package/src/deployment/images/image.test.js +11 -5
  45. package/src/deployment/images/index.js +1 -5
  46. package/src/deployment/images/mono.Dockerfile +11 -0
  47. package/src/deployment/images/mono.js +15 -0
  48. package/src/deployment/images/publish.js +93 -0
  49. package/src/deployment/images/runtime-base.test.js +97 -0
  50. package/src/deployment/images/service.Dockerfile +5 -4
  51. package/src/deployment/images/service.js +13 -13
  52. package/src/deployment/index.js +1 -5
  53. package/src/deployment/operator.js +13 -13
  54. package/src/deployment/operator.test.js +8 -7
  55. package/src/deployment/registry.js +165 -47
  56. package/src/deployment/registry.test.js +407 -0
  57. package/src/deployment/service.js +4 -6
  58. package/src/deployment/workspace.js +13 -8
  59. package/src/index.js +1 -3
  60. package/src/process.js +51 -13
  61. package/src/process.test.js +33 -0
  62. package/types/_deployment/composition.d.ts +1 -3
  63. package/types/_deployment/dependency.d.ts +13 -7
  64. package/types/_deployment/deployment.d.ts +3 -7
  65. package/types/_deployment/factory.d.ts +2 -4
  66. package/types/_deployment/images/factory.d.ts +6 -8
  67. package/types/_deployment/images/image.d.ts +16 -1
  68. package/types/_deployment/images/registry.d.ts +8 -11
  69. package/types/_deployment/operator.d.ts +7 -9
  70. package/types/_deployment/registry.d.ts +4 -4
  71. package/types/_deployment/service.d.ts +4 -3
  72. package/types/dependency.ts +79 -0
  73. package/types/index.ts +1 -0
  74. package/types/dependency.d.ts +0 -39
  75. package/types/index.d.ts +0 -1
@@ -0,0 +1,70 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises'
2
+
3
+ /**
4
+ * Waits until no pod of the application is still terminating. `helm --wait` answers once the
5
+ * replacements are ready, while the replicas they replace are still draining, and a request
6
+ * made then may land on either.
7
+ *
8
+ * @param {toa.operations.Process} process
9
+ * @param {toa.deployment.installation.Options} options
10
+ * @returns {Promise<void>}
11
+ */
12
+ export async function drain(process, options) {
13
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT
14
+ const deadline = Date.now() + duration(timeout)
15
+ const args = ['get', 'pods', '-o', 'json']
16
+
17
+ if (options.namespace !== undefined) args.push('-n', options.namespace)
18
+
19
+ let announced = false
20
+
21
+ for (;;) {
22
+ const output = await process.execute('kubectl', args, { silently: true })
23
+ const pods = JSON.parse(output).items.filter(terminating)
24
+
25
+ if (pods.length === 0) return
26
+
27
+ if (Date.now() >= deadline)
28
+ throw new Error(`${pods.length} replaced pod(s) still terminating after ${timeout}`)
29
+
30
+ if (!announced) {
31
+ console.log(`Waiting for ${pods.length} replaced pod(s) to terminate`)
32
+ announced = true
33
+ }
34
+
35
+ await sleep(INTERVAL)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * A pod of the application on its way out. The chart labels every pod it renders under `toa/`,
41
+ * and nothing else in the namespace is waited for.
42
+ */
43
+ const terminating = (pod) =>
44
+ pod.metadata.deletionTimestamp !== undefined &&
45
+ Object.keys(pod.metadata.labels ?? {}).some((label) => label.startsWith('toa/'))
46
+
47
+ /**
48
+ * A helm duration, `12m` or `1h30m` or `90s`, in milliseconds.
49
+ *
50
+ * @param {string} value
51
+ * @returns {number}
52
+ */
53
+ export function duration(value) {
54
+ const matches = value.matchAll(/(\d+)(h|ms|m|s)/g)
55
+
56
+ let total = 0
57
+
58
+ for (const [, amount, unit] of matches) total += Number(amount) * UNITS[unit]
59
+
60
+ if (total === 0) throw new Error(`'${value}' is not a duration`)
61
+
62
+ return total
63
+ }
64
+
65
+ const UNITS = { h: 3_600_000, m: 60_000, s: 1_000, ms: 1 }
66
+
67
+ /** helm's own default for `--timeout` */
68
+ const DEFAULT_TIMEOUT = '5m'
69
+
70
+ const INTERVAL = 3_000
@@ -0,0 +1,64 @@
1
+ import { it, beforeEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { drain, duration } from './drain.js'
5
+
6
+ /** @type {Array<object[]>} what each poll answers */
7
+ let polls
8
+
9
+ /** @type {toa.operations.Process} */
10
+ let process
11
+
12
+ beforeEach(() => {
13
+ polls = []
14
+ process = /** @type {toa.operations.Process} */ {
15
+ execute: mock.fn(async () => JSON.stringify({ items: polls.shift() ?? [] }))
16
+ }
17
+ })
18
+
19
+ it('should answer once no pod of the application is terminating', async () => {
20
+ polls.push([pod('composition-a', true), pod('composition-b')], [pod('composition-b')])
21
+
22
+ await drain(process, { namespace: 'acme', timeout: '1m' })
23
+
24
+ assert.strictEqual(process.execute.mock.callCount(), 2)
25
+
26
+ const [, args, options] = process.execute.mock.calls[0].arguments
27
+
28
+ assert.deepStrictEqual(args, ['get', 'pods', '-o', 'json', '-n', 'acme'])
29
+ assert.deepStrictEqual(options, { silently: true })
30
+ })
31
+
32
+ it('should not wait for a pod that is not the application', async () => {
33
+ polls.push([pod('redis-0', true, {})])
34
+
35
+ await drain(process, {})
36
+
37
+ assert.strictEqual(process.execute.mock.callCount(), 1)
38
+ })
39
+
40
+ it('should give up at the timeout', async () => {
41
+ polls.push([pod('composition-a', true)], [pod('composition-a', true)])
42
+
43
+ await assert.rejects(drain(process, { timeout: '1ms' }), /still terminating after 1ms/)
44
+ })
45
+
46
+ it('should read a helm duration', () => {
47
+ assert.strictEqual(duration('12m'), 720_000)
48
+ assert.strictEqual(duration('1h30m'), 5_400_000)
49
+ assert.strictEqual(duration('90s'), 90_000)
50
+ assert.throws(() => duration('soon'), /not a duration/)
51
+ })
52
+
53
+ /**
54
+ * @param {string} name
55
+ * @param {boolean} [terminating]
56
+ * @param {object} [labels]
57
+ */
58
+ function pod(name, terminating = false, labels = { 'toa/composition': 'a' }) {
59
+ const metadata = { name, labels }
60
+
61
+ if (terminating) metadata.deletionTimestamp = '2026-01-01T00:00:00Z'
62
+
63
+ return { metadata }
64
+ }
@@ -1,67 +1,121 @@
1
- 'use strict'
2
-
3
- const { context: load } = require('@toa.io/norm')
4
- const { Process } = require('../process')
5
- const { Operator } = require('./operator')
6
- const { Factory: ImagesFactory } = require('./images')
7
- const { Deployment } = require('./deployment')
8
- const { Registry } = require('./registry')
9
- const { Composition } = require('./composition')
10
- const { Service } = require('./service')
11
-
12
- class Factory {
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { context as load } from '@toa.io/norm'
6
+ import { Process } from '../process.js'
7
+ import { Operator } from './operator.js'
8
+ import { Factory as ImagesFactory } from './images/index.js'
9
+ import { Deployment } from './deployment.js'
10
+
11
+ // a dependency is resolved the way a package is
12
+ const require = createRequire(import.meta.url)
13
+ import { Registry } from './registry.js'
14
+ import { Composition } from './composition.js'
15
+ import { Service } from './service.js'
16
+
17
+ export class Factory {
13
18
  #context
19
+ #mono
14
20
  #compositions
15
21
  #dependencies
16
22
  #registry
17
23
  #process
18
- #extensionComponents = []
24
+ #image
19
25
 
20
- constructor (context) {
26
+ /** Which compositions run a given extension's service, rather than deploying it on its
27
+ * own. A service is stateless and already runs several replicas, so several compositions
28
+ * running one are replicas of it, behind the one Service that selects them all.
29
+ * @type {Map<string, string[]>} */
30
+ #claims
31
+
32
+ constructor(context, options = {}) {
21
33
  this.#context = context
34
+ this.#mono = options.mono === true
22
35
  this.#process = new Process()
23
36
 
24
- const imagesFactory = new ImagesFactory(context.name, context.runtime, context.registry)
37
+ const imagesFactory = new ImagesFactory(
38
+ context.name,
39
+ context.runtime,
40
+ context.registry
41
+ )
25
42
 
26
43
  this.#registry = new Registry(context.registry, imagesFactory, this.#process)
27
- this.#compositions = context.compositions.map((composition) => this.#composition(composition))
44
+ this.#claims = claims(context)
28
45
  this.#dependencies = this.#getDependencies()
46
+ this.#compositions = []
47
+
48
+ if (this.#mono)
49
+ this.#image = this.#registry.mono({
50
+ components: context.components
51
+ })
52
+ else
53
+ this.#compositions = context.compositions.map((composition) =>
54
+ this.#composition(composition)
55
+ )
29
56
  }
30
57
 
31
- operator () {
32
- const deployment = new Deployment(this.#context, this.#compositions, this.#dependencies, this.#process)
58
+ async operator() {
59
+ const deployment = new Deployment(
60
+ this.#context,
61
+ this.#compositions,
62
+ // the constructor cannot await, so what it started is settled here
63
+ await this.#dependencies,
64
+ this.#process,
65
+ this.#image
66
+ )
33
67
 
34
68
  return new Operator(deployment, this.#registry)
35
69
  }
36
70
 
37
- registry () {
71
+ registry() {
38
72
  return this.#registry
39
73
  }
40
74
 
41
- #composition (composition) {
75
+ #composition(composition) {
42
76
  const image = this.#registry.composition(composition)
43
77
 
44
78
  return new Composition(composition, image)
45
79
  }
46
80
 
47
- #getDependencies () {
81
+ async #getDependencies() {
48
82
  /** @type {toa.deployment.Dependency[]} */
49
83
  const dependencies = []
50
84
 
51
85
  if (this.#context.dependencies === undefined) return dependencies
52
86
 
87
+ /** the claimed references that turned out to contribute a service */
88
+ const contributing = new Set()
89
+
53
90
  for (const [reference, instances] of Object.entries(this.#context.dependencies)) {
54
- const dependency = this.#getDependency(reference, instances)
91
+ const dependency = await this.#getDependency(reference, instances)
92
+
93
+ if (dependency === undefined) continue
94
+
95
+ if (dependency.services?.length > 0) contributing.add(reference)
55
96
 
56
- if (dependency !== undefined) dependencies.push(dependency)
97
+ dependencies.push(dependency)
57
98
  }
58
99
 
100
+ // this is the first point where every `deployment()` has run, so it is the first
101
+ // point where a reference that yields nothing can be told from one that yields a service
102
+ for (const [reference, compositions] of this.#claims)
103
+ if (!contributing.has(reference))
104
+ throw new Error(
105
+ `Composition '${compositions[0]}' lists '${reference}', ` +
106
+ 'which contributes no service.'
107
+ )
108
+
59
109
  return dependencies
60
110
  }
61
111
 
62
- #getDependency (path, instances) {
63
- const module = require(path)
64
- const pkg = require(path + '/package.json')
112
+ async #getDependency(reference, instances) {
113
+ // a dependency may be named the way a package is or written as a directory,
114
+ // and a module is loaded by file
115
+ const module = await import(pathToFileURL(require.resolve(reference)).href)
116
+ const pkg = JSON.parse(
117
+ readFileSync(require.resolve(join(reference, 'package.json')), 'utf8')
118
+ )
65
119
 
66
120
  if (module.deployment === undefined) return
67
121
 
@@ -70,8 +124,17 @@ class Factory {
70
124
  /** @type {toa.deployment.dependency.Declaration} */
71
125
  const dependency = module.deployment(instances, annotation)
72
126
 
127
+ // mono claims every service, including one an extension added since this context was
128
+ // written, so its claim is the wildcard rather than a list
129
+ const workload = this.#mono ? [MONO] : this.#claims.get(reference)
130
+
73
131
  /** @type {toa.deployment.Service[]} */
74
- const services = dependency.services?.map((service) => this.#service(path, service))
132
+ const services = dependency.services?.map((service) =>
133
+ workload === undefined
134
+ ? this.#service(reference, service) // its own deployment, its own image
135
+ : // named the way `Service` would name it, since it skips that wrapper
136
+ { ...service, name: `${service.group}-${service.name}`, workload }
137
+ )
75
138
 
76
139
  return { ...dependency, services }
77
140
  }
@@ -81,17 +144,43 @@ class Factory {
81
144
  * @param service {toa.deployment.dependency.Service}
82
145
  * @returns {Service}
83
146
  */
84
- #service (path, service) {
85
- const image = this.#registry.service(path, service)
147
+ #service(path, service) {
148
+ // an extension that publishes its service's image is taken at its word: nothing is
149
+ // added to the registry, so nothing is prepared, probed, built or pushed for it
150
+ const published =
151
+ service.image !== undefined && this.#context.registry?.services === PUBLISHED
152
+
153
+ const image = published
154
+ ? { reference: `${service.image}:${this.#context.runtime.version}` }
155
+ : this.#registry.service(path, service)
86
156
 
87
157
  return new Service(service, image)
88
158
  }
89
159
 
90
- static async create (path, environment) {
160
+ static async create(path, environment, options = {}) {
91
161
  const context = await load(path, environment)
92
162
 
93
- return new Factory(context)
163
+ return new Factory(context, options)
94
164
  }
95
165
  }
96
166
 
97
- exports.Factory = Factory
167
+ const MONO = 'mono'
168
+
169
+ const PUBLISHED = 'published'
170
+
171
+ /**
172
+ * @param {toa.norm.Context} context
173
+ * @returns {Map<string, string[]>}
174
+ */
175
+ function claims(context) {
176
+ const map = new Map()
177
+
178
+ for (const composition of context.compositions ?? [])
179
+ for (const reference of composition.services ?? []) {
180
+ if (!map.has(reference)) map.set(reference, [])
181
+
182
+ map.get(reference).push(composition.name)
183
+ }
184
+
185
+ return map
186
+ }
@@ -0,0 +1,82 @@
1
+ import { basename, join } from 'node:path'
2
+ import { cp } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import { Image } from './image.js'
6
+ import { Dependencies } from './dependencies.js'
7
+ import { declare } from './format.js'
8
+
9
+ /**
10
+ * Components in one image: their sources laid over their dependencies, which are an image
11
+ * of their own (see `Dependencies`). What is built here is the sources layer alone.
12
+ *
13
+ * @implements {toa.deployment.images.Bundle}
14
+ * @abstract
15
+ */
16
+ export class Bundle extends Image {
17
+ /** @type {Dependencies} */
18
+ dependencies
19
+
20
+ /** @type {string | undefined} */
21
+ image
22
+
23
+ /** @type {toa.norm.Component[]} */
24
+ components
25
+
26
+ constructor(scope, runtime, registry, composition) {
27
+ super(scope, runtime, registry)
28
+
29
+ this.image = composition.image
30
+ this.components = composition.components
31
+ this.dependencies = new Dependencies(scope, runtime, registry, this)
32
+ }
33
+
34
+ tag() {
35
+ this.dependencies.tag()
36
+ super.tag()
37
+ }
38
+
39
+ get version() {
40
+ const hash = createHash('sha256')
41
+
42
+ for (const component of this.components) {
43
+ hash.update(component.locator.id)
44
+ hash.update(component.version)
45
+ }
46
+
47
+ // laid over a different base is a different image, whatever the sources
48
+ hash.update(this.dependencies.version)
49
+
50
+ return hash.digest('hex').slice(0, 8)
51
+ }
52
+
53
+ get base() {
54
+ return this.dependencies.reference
55
+ }
56
+
57
+ /**
58
+ * What to say when the components ask for different base images.
59
+ *
60
+ * @abstract
61
+ * @returns {string}
62
+ */
63
+ conflict() {
64
+ throw new Error('Not implemented')
65
+ }
66
+
67
+ async prepare(root) {
68
+ const context = await super.prepare(root)
69
+
70
+ for (const component of this.components) {
71
+ const target = join(context, component.locator.label)
72
+
73
+ // what was installed in the workspace is not what the image installs
74
+ await cp(component.path, target, { recursive: true, filter: sources })
75
+ await declare(component.path, target, component.locator.label)
76
+ }
77
+
78
+ return context
79
+ }
80
+ }
81
+
82
+ const sources = (path) => basename(path) !== 'node_modules'
@@ -1,19 +1,11 @@
1
1
  FROM {{build.image}}
2
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
- RUN npm i -g @toa.io/runtime@{{runtime.version}} --omit=dev
9
-
10
- WORKDIR /composition
11
- COPY --chown=node:node . /composition
12
-
13
- {{build.run}}
14
-
15
- # 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
17
-
18
- USER node
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
19
11
  CMD toa compose *
@@ -1,70 +1,23 @@
1
- 'use strict'
1
+ import { join } from 'node:path'
2
2
 
3
- const { join } = require('node:path')
4
- const fs = require('fs-extra')
5
- const { createHash } = require('node:crypto')
3
+ import { Bundle } from './bundle.js'
6
4
 
7
- const { Image } = require('./image')
8
-
9
- class Composition extends Image {
10
- dockerfile = join(__dirname, 'composition.Dockerfile')
5
+ export class Composition extends Bundle {
6
+ dockerfile = join(import.meta.dirname, 'composition.Dockerfile')
11
7
 
12
8
  #name
13
- #image
14
- #components
15
9
 
16
- constructor (scope, runtime, registry, composition) {
17
- super(scope, runtime, registry)
10
+ constructor(scope, runtime, registry, composition) {
11
+ super(scope, runtime, registry, composition)
18
12
 
19
13
  this.#name = composition.name
20
- this.#image = composition.image
21
- this.#components = composition.components
22
14
  }
23
15
 
24
- get name () {
16
+ get name() {
25
17
  return 'composition-' + this.#name
26
18
  }
27
19
 
28
- get version () {
29
- const hash = createHash('sha256')
30
-
31
- for (const component of this.#components) {
32
- hash.update(component.locator.id)
33
- hash.update(component.version)
34
- }
35
-
36
- return hash.digest('hex').slice(0, 8)
37
- }
38
-
39
- get base () {
40
- if (this.#image !== undefined) {
41
- return this.#image
42
- }
43
-
44
- let image = null
45
-
46
- for (const component of this.#components) {
47
- const value = component.build?.image
48
-
49
- if (image !== null && image !== value) {
50
- throw new Error(`Composition '${this.#name}' requires different base images for its components. Specify base image for the composition in the context.`)
51
- }
52
-
53
- image = value
54
- }
55
-
56
- return image ?? undefined
57
- }
58
-
59
- async prepare (root) {
60
- const context = await super.prepare(root)
61
-
62
- for (const component of this.#components) {
63
- await fs.copy(component.path, join(context, component.locator.label))
64
- }
65
-
66
- return context
20
+ conflict() {
21
+ return `Composition '${this.#name}' requires different base images for its components. Specify base image for the composition in the context.`
67
22
  }
68
23
  }
69
-
70
- exports.Composition = Composition
@@ -0,0 +1,16 @@
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
+ # the context holds the manifests alone, so this is every dependency and none of the sources
15
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
16
+ for entry in *; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done