@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,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,102 @@ 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)
44
+ })
45
+ })
46
+
47
+ describe('install', () => {
48
+ /** @type {toa.deployment.Deployment} */
49
+ let deployment
50
+
51
+ /** @type {toa.deployment.Registry} */
52
+ let registry
53
+
54
+ beforeEach(() => {
55
+ deployment = /** @type {toa.deployment.Deployment} */ {
56
+ export: mock.fn(async () => {}),
57
+ verify: mock.fn(async () => {}),
58
+ install: mock.fn(async () => {})
59
+ }
60
+ registry = /** @type {toa.deployment.Registry} */ {
61
+ push: mock.fn(async () => {}),
62
+ alias: mock.fn(async () => {})
63
+ }
64
+ })
65
+
66
+ it('should verify the secrets before anything is built', async () => {
67
+ const order = []
68
+
69
+ deployment.verify = mock.fn(async () => {
70
+ order.push('verify')
71
+ })
72
+ deployment.export = mock.fn(async () => {
73
+ order.push('export')
74
+ })
75
+ registry.push = mock.fn(async () => {
76
+ order.push('push')
77
+ })
78
+
79
+ operator = new Operator(deployment, registry, 'production')
80
+
81
+ await operator.install({ namespace: 'acme' })
82
+
83
+ // export and push race each other, and both wait for the cluster to have been read
84
+ assert.strictEqual(order[0], 'verify')
85
+ assert.deepStrictEqual(order.slice(1).sort(), ['export', 'push'])
86
+ assert.deepStrictEqual(deployment.verify.mock.calls[0].arguments, [
87
+ { wait: false, namespace: 'acme' }
88
+ ])
89
+ })
90
+
91
+ it('should build nothing where the secrets are not deployed', async () => {
92
+ deployment.verify = mock.fn(async () => {
93
+ throw new Error('Secrets are not deployed: toa-mongodb.default/username')
94
+ })
95
+
96
+ operator = new Operator(deployment, registry, 'production')
97
+
98
+ await assert.rejects(operator.install(), /Secrets are not deployed/)
99
+
100
+ assert.strictEqual(deployment.export.mock.callCount(), 0)
101
+ assert.strictEqual(registry.push.mock.callCount(), 0)
102
+ assert.strictEqual(registry.alias.mock.callCount(), 0)
103
+ assert.strictEqual(deployment.install.mock.callCount(), 0)
104
+ })
105
+
106
+ it('should tag the environment after a push', async () => {
107
+ const order = []
108
+
109
+ registry.push = mock.fn(async () => {
110
+ order.push('push')
111
+ })
112
+ registry.alias = mock.fn(async () => {
113
+ order.push('alias')
114
+ })
115
+ deployment.install = mock.fn(async () => {
116
+ order.push('install')
117
+ })
118
+
119
+ operator = new Operator(deployment, registry, 'production')
120
+
121
+ await operator.install()
122
+
123
+ assert.deepStrictEqual(order, ['push', 'alias', 'install'])
124
+ assert.deepStrictEqual(registry.alias.mock.calls[0].arguments, ['production'])
125
+ })
126
+
127
+ it('should not tag the environment on push', async () => {
128
+ operator = new Operator(deployment, registry, 'production')
129
+
130
+ await operator.push()
131
+
132
+ assert.strictEqual(registry.push.mock.callCount(), 1)
133
+ assert.strictEqual(registry.alias.mock.callCount(), 0)
43
134
  })
44
135
  })
@@ -1,62 +1,83 @@
1
- 'use strict'
2
-
3
- const workspace = require('./workspace')
4
- const { newid } = require('@toa.io/generic')
1
+ import * as workspace from './workspace.js'
5
2
 
6
3
  /**
7
4
  * @implements {toa.deployment.Registry}
8
5
  */
9
- class Registry {
6
+ export class Registry {
10
7
  #registry
11
8
 
12
9
  #factory
13
10
 
14
11
  #process
15
12
 
13
+ /** @type {toa.deployment.images.Image[]} */
16
14
  #images = []
17
15
 
18
- constructor (registry, factory, process) {
16
+ /** @type {Promise<string> | undefined} */
17
+ #builder
18
+
19
+ constructor(registry, factory, process) {
19
20
  this.#registry = registry
20
21
  this.#factory = factory
21
22
  this.#process = process
22
23
  }
23
24
 
24
- composition (composition) {
25
+ composition(composition) {
25
26
  return this.#create('composition', composition)
26
27
  }
27
28
 
28
- service (path, service) {
29
+ service(path, service) {
29
30
  return this.#create('service', path, service)
30
31
  }
31
32
 
32
- async prepare (root) {
33
+ mono(composition) {
34
+ return this.#create('mono', composition)
35
+ }
36
+
37
+ async prepare(root) {
33
38
  const path = await workspace.create('images', root)
34
39
 
35
- await Promise.all(this.#images.map((image) => image.prepare(path)))
40
+ await Promise.all(this.#all().map((image) => image.prepare(path)))
36
41
 
37
42
  return path
38
43
  }
39
44
 
40
- async build () {
41
- await this.prepare()
45
+ async build() {
46
+ await this.#run(false)
47
+ }
42
48
 
43
- for (const image of this.#images) {
44
- await this.#build(image)
45
- }
49
+ async push() {
50
+ await this.#run(true)
46
51
  }
47
52
 
48
- async push () {
49
- await this.prepare()
53
+ /**
54
+ * A second tag on each workload image, the environment, so a retention job can see what
55
+ * a deploy left running. `deps-*` is not in `#images`.
56
+ *
57
+ * @param {string} [environment]
58
+ * @returns {Promise<void>}
59
+ */
60
+ async alias(environment) {
61
+ if (
62
+ typeof environment !== 'string' ||
63
+ environment === '' ||
64
+ CONTENT_TAG.test(environment)
65
+ )
66
+ return
67
+
68
+ await Promise.all(this.#images.map((image) => this.#alias(image, environment)))
69
+ }
50
70
 
51
- for (const image of this.#images) await this.#push(image)
71
+ tags() {
72
+ return this.#images.map((image) => image.reference)
52
73
  }
53
74
 
54
75
  /**
55
- * @param {'composition' | 'service'} type
76
+ * @param {'composition' | 'service' | 'mono'} type
56
77
  * @param {...any} args
57
78
  * @returns {toa.deployment.images.Image}
58
79
  */
59
- #create (type, ...args) {
80
+ #create(type, ...args) {
60
81
  const image = this.#factory[type](...args)
61
82
 
62
83
  this.#images.push(image)
@@ -64,58 +85,201 @@ class Registry {
64
85
  return image
65
86
  }
66
87
 
88
+ /**
89
+ * Every image and what it is laid over.
90
+ *
91
+ * @returns {toa.deployment.images.Image[]}
92
+ */
93
+ #all() {
94
+ return this.#images.flatMap((image) =>
95
+ image.dependencies === undefined ? [image] : [image.dependencies, image]
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Every image is probed at once, and what is missing is prepared and built concurrently:
101
+ * a build is bound by the registry it uploads to, not by the runner, so one at a time
102
+ * leaves both idle. What an image is laid over is built before it.
103
+ *
104
+ * @param {boolean} push
105
+ * @returns {Promise<void>}
106
+ */
107
+ async #run(push) {
108
+ // a push builds on the container driver, whose bootstrap is spent while the probes are out
109
+ if (push) this.#ensureBuilder().catch(() => undefined)
110
+
111
+ const images = this.#all()
112
+
113
+ const existing = await Promise.all(
114
+ images.map((image) => this.exists(image.reference))
115
+ )
116
+
117
+ const missing = images.filter((image, index) => {
118
+ if (existing[index]) console.log('Image already exists, skipping:', image.reference)
119
+
120
+ return !existing[index]
121
+ })
122
+
123
+ if (missing.length === 0) return
124
+
125
+ const path = await workspace.create('images')
126
+
127
+ await Promise.all(missing.map((image) => image.prepare(path)))
128
+
129
+ const bases = new Set(this.#images.map((image) => image.dependencies))
130
+ const build = (image) => this.#build(image, push)
131
+
132
+ await pool(
133
+ missing.filter((image) => bases.has(image)),
134
+ build
135
+ )
136
+ await pool(
137
+ missing.filter((image) => !bases.has(image)),
138
+ build
139
+ )
140
+ }
141
+
67
142
  /**
68
143
  * @param {toa.deployment.images.Image} image
69
144
  * @param {boolean} [push]
70
145
  * @returns {Promise<void>}
71
146
  */
72
- async #build (image, push = false) {
147
+ async #build(image, push = false) {
73
148
  const args = ['--context=default', 'buildx', 'build']
74
149
 
75
- if (push) {
76
- args.push('--push')
77
- } else {
78
- args.push('--load')
79
- }
150
+ if (push) args.push('--push')
151
+ else args.push('--load')
80
152
 
81
153
  args.push('--tag', image.reference, image.context)
82
154
 
83
- const multiarch = this.#registry.platforms !== null
84
-
85
- if (this.#registry.build?.arguments !== undefined) {
86
- for (const arg of this.#registry.build.arguments) args.push('--build-arg', `${arg}=${process.env[arg]}`)
155
+ if (image.arguments && this.#registry.build?.arguments !== undefined) {
156
+ for (const arg of this.#registry.build.arguments)
157
+ args.push('--build-arg', `${arg}=${process.env[arg]}`)
87
158
  }
88
159
 
89
- if (multiarch) {
90
- const platform = this.#registry.platforms.join(',')
91
- const builder = await this.#createBuilder()
160
+ const platforms = this.#registry.platforms
92
161
 
93
- args.push('--platform', platform)
94
- args.push('--builder', builder)
162
+ if (platforms !== null) args.push('--platform', platforms.join(','))
95
163
 
96
- } else {
97
- args.push('--builder', 'default')
98
- }
164
+ // a push takes the container driver: its registry exporter is what lays an image over a
165
+ // base it never pulled. A load takes the daemon's own, unless it builds for a platform
166
+ // the runner is not, which the daemon's cannot
167
+ if (push || (platforms !== null && platforms.length > 1)) {
168
+ args.push('--builder', await this.#ensureBuilder())
169
+
170
+ // the driver that produces attestations is this one, and nothing reads them;
171
+ // each is a manifest list to export and push per image
172
+ args.push('--provenance=false')
173
+ } else args.push('--builder', 'default')
99
174
 
100
175
  args.push('--progress', 'plain')
101
176
 
102
177
  await this.#process.execute('docker', args)
103
178
  }
104
179
 
105
- async #push (image) {
106
- await this.#build(image, true)
180
+ /**
181
+ * @param {toa.deployment.images.Image} image
182
+ * @param {string} environment
183
+ * @returns {Promise<void>}
184
+ */
185
+ async #alias(image, environment) {
186
+ const target = retag(image.reference, environment)
187
+ const args = ['buildx', 'imagetools', 'create', '--tag', target]
188
+
189
+ if (LOCAL.test(image.reference)) args.push('--insecure')
190
+
191
+ args.push(image.reference)
192
+
193
+ await this.#process.execute('docker', args)
194
+ }
195
+
196
+ async exists(tag) {
197
+ const args = ['manifest', 'inspect']
198
+
199
+ // a registry on this machine speaks plain HTTP, which the probe has to be told
200
+ if (LOCAL.test(tag)) args.push('--insecure')
201
+
202
+ args.push(tag)
203
+
204
+ try {
205
+ await this.#process.execute('docker', args, { silently: true })
206
+ } catch (error) {
207
+ console.log(error.message)
208
+
209
+ return false
210
+ }
211
+
212
+ return true
107
213
  }
108
214
 
109
- async #createBuilder () {
110
- const name = `toa-${newid()}`
111
- const create = `buildx create --name ${name} --bootstrap --use`.split(' ')
215
+ /** @returns {Promise<string>} */
216
+ #ensureBuilder() {
217
+ // the promise is what is memoized, not its value: concurrent builds ask before any answers
218
+ this.#builder ??= this.#createBuilder()
112
219
 
113
- await this.#process.execute('docker', create)
220
+ return this.#builder
221
+ }
222
+
223
+ /** @returns {Promise<string>} */
224
+ async #createBuilder() {
225
+ try {
226
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], {
227
+ silently: true
228
+ })
229
+ } catch {
230
+ // on the host's network, a registry the host reaches as `localhost` is reached
231
+ await this.#process.execute('docker', [
232
+ 'buildx',
233
+ 'create',
234
+ '--name',
235
+ BUILDER,
236
+ '--driver',
237
+ 'docker-container',
238
+ '--driver-opt',
239
+ 'network=host',
240
+ '--bootstrap'
241
+ ])
242
+ }
114
243
 
115
- return name
244
+ return BUILDER
116
245
  }
117
246
  }
118
247
 
248
+ /**
249
+ * @template T
250
+ * @param {T[]} items
251
+ * @param {(item: T) => Promise<void>} work
252
+ * @returns {Promise<void>}
253
+ */
254
+ async function pool(items, work) {
255
+ const queue = items.slice()
256
+
257
+ const workers = Array.from(
258
+ { length: Math.min(CONCURRENCY, queue.length) },
259
+ async () => {
260
+ while (queue.length > 0) await work(queue.shift())
261
+ }
262
+ )
263
+
264
+ await Promise.all(workers)
265
+ }
266
+
267
+ /**
268
+ * @param {string} reference
269
+ * @param {string} tag
270
+ * @returns {string}
271
+ */
272
+ function retag(reference, tag) {
273
+ return reference.slice(0, reference.lastIndexOf(':') + 1) + tag
274
+ }
275
+
119
276
  const BUILDER = 'toa'
120
277
 
121
- exports.Registry = Registry
278
+ /** An environment named this way is a content tag, and must not be moved as one. */
279
+ const CONTENT_TAG = /^(deps-)?[0-9a-f]{8}$/
280
+
281
+ /** A reference into a registry on this machine, by any of the names it goes by. */
282
+ const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|host\.docker\.internal)(:\d+)?\//
283
+
284
+ /** How many builds run at once. Past this the uploads contend for the same uplink. */
285
+ const CONCURRENCY = 3