@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
@@ -3,9 +3,16 @@ compositions:
3
3
  - name: todos
4
4
  image: localhost:5000/composition-todos:0.0.0
5
5
  replicas: 2
6
+ mounts:
7
+ - name: mount-name
8
+ path: /storage
9
+ claim: storage-pvc
6
10
  components:
7
11
  - todos-tasks
8
12
  - todos-stats
13
+ resources:
14
+ cpu: [100m, 1]
15
+ memory: [100Mi, 1Gi]
9
16
  variables:
10
17
  - name: TOA_CONFIGURATION_TODOS_TASKS
11
18
  value: foo
@@ -18,17 +25,32 @@ compositions:
18
25
  replicas: 3
19
26
  components:
20
27
  - users-users
21
- # TODO: create component services only if sync binding is being used
28
+ # the extensions whose services this composition runs in its own pod
29
+ services:
30
+ - '@toa.io/extensions.exposition'
31
+ # each of those, as its own Service selects it
32
+ hosted:
33
+ - exposition-gateway
34
+ # the ports they bind
35
+ backends:
36
+ - port: 8000
37
+ path: /
22
38
  components:
23
39
  - todos-tasks
24
40
  - todos-stats
25
41
  - users-users
26
42
  services:
43
+ # a service a composition runs carries `workload`, and gets no deployment of its own —
44
+ # its Service and Ingress stay, and select that composition's pods
27
45
  - name: resources-gateway
28
46
  image: localhost:5000/resources-gateway:0.0.0
29
47
  port: 8000
30
48
  replicas: 2
49
+ resources:
50
+ cpu: [100m, 1]
51
+ memory: [100Mi, 1Gi]
31
52
  ingress:
53
+ default: true
32
54
  hosts: [dummies.toa.io]
33
55
  class: alb
34
56
  annotations:
@@ -1,18 +1,20 @@
1
- 'use strict'
2
-
3
- class Composition {
1
+ export class Composition {
4
2
  name
5
3
  image
6
4
  /** @type {string[]} */
7
5
  components
8
6
 
9
- constructor (composition, image) {
7
+ constructor(composition, image) {
10
8
  this.name = composition.name
11
9
  this.image = image.reference
12
10
  this.components = composition.components.map(component)
11
+ this.resources = composition.resources
12
+
13
+ if (composition.replicas !== undefined) this.replicas = composition.replicas
14
+
15
+ // the extensions whose services this composition runs, as `TOA_SERVICES` for the process
16
+ if (composition.services !== undefined) this.services = composition.services
13
17
  }
14
18
  }
15
19
 
16
20
  const component = (component) => component.locator.label
17
-
18
- exports.Composition = Composition
@@ -1,90 +1,230 @@
1
- 'use strict'
1
+ import { join } from 'node:path'
2
+ import { writeFile as write } from 'node:fs/promises'
3
+ import { yaml as jsyaml } from '@toa.io/generic'
4
+ import { cp } from 'node:fs/promises'
5
+ import { shortcuts } from '@toa.io/norm'
2
6
 
3
- const { join } = require('node:path')
4
- const { writeFile: write } = require('node:fs/promises')
5
- const { dump } = require('@toa.io/yaml')
6
- const fs = require('fs-extra')
7
+ import { merge, declare, describe } from './.deployment/index.js'
8
+ import { drain } from './drain.js'
9
+ import * as secrets from './secrets.js'
7
10
 
8
- const { merge, declare, describe } = require('./.deployment')
9
-
10
- class Deployment {
11
+ export class Deployment {
11
12
  #chart
12
13
  #values
14
+ #keyed
13
15
  #process
14
16
  #target
15
17
 
16
- constructor (context, compositions, dependencies, process) {
18
+ constructor(context, compositions, dependencies, process, image) {
17
19
  const dependency = merge(dependencies)
18
20
 
19
21
  this.#chart = declare(context, dependency)
20
- this.#values = describe(context, compositions, dependency)
22
+ this.#values = describe(context, compositions, dependency, image)
23
+ this.#keyed = dependency.variables
21
24
  this.#process = process
22
25
  }
23
26
 
24
- async export (target) {
27
+ async export(target) {
28
+ // a context is read for a local run as well — `toa env` builds one of these and asks it
29
+ // for variables — and such a context needs no chart and states no version. So this is
30
+ // asked for where a chart is written, and not where a context is read. Without it Helm
31
+ // answers `chart.metadata.version is required`, naming nothing, by which point a deploy
32
+ // has built and pushed every image.
33
+ if (this.#chart.version === undefined)
34
+ throw new Error(
35
+ `Context '${this.#chart.name}' declares no version, which is what its chart is ` +
36
+ 'versioned by. Declare it in context.toa.yaml.'
37
+ )
38
+
25
39
  const chart = dump(this.#chart)
26
40
  const values = dump(this.#values)
27
41
 
28
42
  await Promise.all([
29
43
  write(join(target, 'Chart.yaml'), chart),
30
44
  write(join(target, 'values.yaml'), values),
31
- fs.copy(TEMPLATES, join(target, 'templates'))
45
+ cp(TEMPLATES, join(target, 'templates'), { recursive: true })
32
46
  ])
33
47
 
34
48
  this.#target = target
35
49
  }
36
50
 
37
- async install (options) {
51
+ /**
52
+ * Refuses a deploy the cluster is not ready for: a secret a workload reads that is not there
53
+ * leaves its pod uncreated, which is seen only once every image has been built and pushed.
54
+ *
55
+ * @param {toa.deployment.installation.Options} options
56
+ * @returns {Promise<void>}
57
+ */
58
+ async verify(options) {
59
+ const references = secrets.references(this.variables(), this.#values.credentials)
60
+
61
+ await secrets.verify(this.#process, references, options)
62
+ }
63
+
64
+ async install(options) {
38
65
  if (options.target) this.#target = options.target
39
- if (this.#target === undefined) throw new Error('Deployment hasn\'t been exported')
66
+ if (this.#target === undefined) throw new Error("Deployment hasn't been exported")
40
67
 
41
68
  const args = []
42
69
 
43
70
  if (options.namespace !== undefined) args.push('-n', options.namespace)
44
- if (options.wait === true) args.push('--wait')
71
+ // an upgrade waited for and failed — or timed out — is rolled back, rather than left pending:
72
+ // a pending release refuses every upgrade after it
73
+ if (options.wait === true) args.push('--wait', '--atomic')
74
+ if (options.timeout !== undefined) args.push('--timeout', options.timeout)
75
+
76
+ await this.#update()
77
+ await this.#process.execute('helm', [
78
+ 'upgrade',
79
+ this.#chart.name,
80
+ '-i',
81
+ ...args,
82
+ this.#target
83
+ ])
45
84
 
46
- await this.#process.execute('helm', ['dependency', 'update', this.#target])
47
- await this.#process.execute('helm', ['upgrade', this.#chart.name, '-i', ...args, this.#target])
85
+ // ready is not done: the replicas replaced are still draining when helm answers
86
+ if (options.wait === true) await drain(this.#process, options)
48
87
  }
49
88
 
50
- async template (options) {
51
- if (this.#target === undefined) throw new Error('Deployment hasn\'t been exported')
89
+ async template(options) {
90
+ if (this.#target === undefined) throw new Error("Deployment hasn't been exported")
52
91
 
53
- await this.#process.execute('helm', ['dependency', 'update', this.#target], { silently: true })
92
+ await this.#update({ silently: true })
54
93
 
55
94
  const args = []
56
95
 
57
96
  if (options.namespace !== undefined) args.push('-n', options.namespace)
58
97
 
59
- return await this.#process.execute('helm',
98
+ return await this.#process.execute(
99
+ 'helm',
60
100
  ['template', this.#chart.name, ...args, this.#target],
61
- { silently: true })
101
+ { silently: true }
102
+ )
103
+ }
104
+
105
+ /**
106
+ * A chart with no subcharts has nothing to resolve, and the call still reaches out for
107
+ * the repositories it would have read.
108
+ *
109
+ * @param {object} [options]
110
+ * @returns {Promise<void>}
111
+ */
112
+ async #update(options = {}) {
113
+ if (this.#chart.dependencies.length === 0) return
114
+
115
+ await this.#process.execute('helm', ['dependency', 'update', this.#target], options)
116
+ }
117
+
118
+ /**
119
+ * @param {{ components?: string[], services?: string[] }} [options]
120
+ * @returns {toa.deployment.dependency.Variable[]}
121
+ */
122
+ variables(options = {}) {
123
+ if (options.components === undefined && options.services === undefined) {
124
+ const variables = []
125
+ const used = new Set()
126
+
127
+ addVariables(this.#values.compositions, variables, used)
128
+ addVariables(this.#values.services, variables, used)
129
+
130
+ if (this.#values.mono !== undefined)
131
+ addVariables([this.#values.mono], variables, used)
132
+
133
+ return variables
134
+ }
135
+
136
+ return this.#select(options.components, options.services)
62
137
  }
63
138
 
64
- variables () {
139
+ /**
140
+ * @param {string[] | undefined} components
141
+ * @param {string[] | undefined} services
142
+ * @returns {toa.deployment.dependency.Variable[]}
143
+ */
144
+ #select(components, services) {
145
+ const labels = labelsOf(this.#values, this.#keyed)
146
+ const keys = new Set(['global'])
147
+ const selected = []
148
+
149
+ for (const token of components ?? []) keys.add(matchComponent(token, labels))
150
+
151
+ for (const token of services ?? []) {
152
+ const service = matchService(token, this.#values.services)
153
+
154
+ selected.push(service)
155
+
156
+ for (const label of service.components ?? []) keys.add(label)
157
+ }
158
+
65
159
  const variables = []
66
160
  const used = new Set()
67
161
 
68
- addVariables(this.#values.compositions, variables, used)
69
- addVariables(this.#values.services, variables, used)
162
+ for (const key of keys) append(this.#keyed[key], variables, used)
163
+
164
+ for (const service of selected) append(service.variables, variables, used)
70
165
 
71
166
  return variables
72
167
  }
73
168
  }
74
169
 
75
- function addVariables (list, variables, used = new Set()) {
76
- for (const item of list) {
77
- if (item.variables === undefined) continue
170
+ function addVariables(list, variables, used = new Set()) {
171
+ if (list === undefined) return
78
172
 
79
- for (const variable of item.variables) {
80
- if (used.has(variable.name)) continue
173
+ for (const item of list) append(item.variables, variables, used)
174
+ }
81
175
 
82
- variables.push(variable)
83
- used.add(variable.name)
84
- }
176
+ function append(set, variables, used) {
177
+ if (set === undefined) return
178
+
179
+ for (const variable of set) {
180
+ if (used.has(variable.name)) continue
181
+
182
+ variables.push(variable)
183
+ used.add(variable.name)
85
184
  }
86
185
  }
87
186
 
88
- const TEMPLATES = join(__dirname, 'chart/templates')
187
+ function labelsOf(values, keyed) {
188
+ const labels = new Set(values.components ?? [])
89
189
 
90
- exports.Deployment = Deployment
190
+ for (const service of values.services ?? [])
191
+ for (const label of service.components ?? []) labels.add(label)
192
+
193
+ for (const key of Object.keys(keyed ?? {})) if (key !== 'global') labels.add(key)
194
+
195
+ return labels
196
+ }
197
+
198
+ function matchComponent(token, labels) {
199
+ const candidates = [token, token.replaceAll('.', '-').toLowerCase()]
200
+
201
+ if (!token.includes('.')) candidates.push('default-' + token.toLowerCase())
202
+
203
+ for (const candidate of candidates) if (labels.has(candidate)) return candidate
204
+
205
+ throw new Error(`Component '${token}' is not in the context`)
206
+ }
207
+
208
+ function matchService(token, services) {
209
+ const resolved = shortcuts.resolve(token)
210
+
211
+ const service = (services ?? []).find((service) => {
212
+ if (service.name === token || service.group === token) return true
213
+
214
+ for (const [alias, pkg] of Object.entries(shortcuts.SHORTCUTS))
215
+ if (resolved === pkg && service.group === alias) return true
216
+
217
+ return false
218
+ })
219
+
220
+ if (service === undefined) throw new Error(`Service '${token}' is not in the context`)
221
+
222
+ return service
223
+ }
224
+
225
+ const TEMPLATES = join(import.meta.dirname, 'chart/templates')
226
+
227
+ function dump(object) {
228
+ // js-yaml writes plain objects only, and the values carry locators and images
229
+ return jsyaml.dump(JSON.parse(JSON.stringify(object)), { noRefs: true, lineWidth: -1 })
230
+ }
@@ -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
+ }