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

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 +449 -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,77 +1,162 @@
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 { context as load, definition } from '@toa.io/norm'
2
+ import { Process } from '../process.js'
3
+ import { Operator } from './operator.js'
4
+ import { Factory as ImagesFactory } from './images/index.js'
5
+ import { Deployment } from './deployment.js'
6
+
7
+ import { Registry } from './registry.js'
8
+ import { Composition } from './composition.js'
9
+ import { Service } from './service.js'
10
+
11
+ export class Factory {
13
12
  #context
13
+ #mono
14
14
  #compositions
15
15
  #dependencies
16
16
  #registry
17
17
  #process
18
- #extensionComponents = []
18
+ #image
19
+
20
+ /** Which compositions run a given extension's service, rather than deploying it on its
21
+ * own. A service is stateless and already runs several replicas, so several compositions
22
+ * running one are replicas of it, behind the one Service that selects them all.
23
+ * @type {Map<string, string[]>} */
24
+ #claims
25
+
26
+ /** what this context does not deploy, by package reference
27
+ * @type {Set<string>} */
28
+ #evicted
19
29
 
20
- constructor (context) {
30
+ /** the extensions some composition lists, whether or not that composition is deployed
31
+ * @type {Set<string>} */
32
+ #listed
33
+
34
+ constructor(context, options = {}) {
21
35
  this.#context = context
36
+ this.#mono = options.mono === true
22
37
  this.#process = new Process()
23
38
 
24
- const imagesFactory = new ImagesFactory(context.name, context.runtime, context.registry)
39
+ const imagesFactory = new ImagesFactory(
40
+ context.name,
41
+ context.runtime,
42
+ context.registry
43
+ )
44
+
45
+ const compositions = deployed(context.compositions)
25
46
 
26
47
  this.#registry = new Registry(context.registry, imagesFactory, this.#process)
27
- this.#compositions = context.compositions.map((composition) => this.#composition(composition))
48
+ this.#claims = claims(compositions)
49
+ this.#listed = listed(context.compositions)
50
+ this.#evicted = new Set(context.evicted?.services ?? [])
28
51
  this.#dependencies = this.#getDependencies()
52
+ this.#compositions = []
53
+
54
+ if (this.#mono)
55
+ this.#image = this.#registry.mono({
56
+ components: context.components.filter(managed),
57
+ // mono runs every service, so it installs what every one of them brings
58
+ packages: context.packages
59
+ })
60
+ else
61
+ this.#compositions = compositions.map((composition) =>
62
+ this.#composition(composition)
63
+ )
29
64
  }
30
65
 
31
- operator () {
32
- const deployment = new Deployment(this.#context, this.#compositions, this.#dependencies, this.#process)
33
-
34
- return new Operator(deployment, this.#registry)
66
+ async operator() {
67
+ const deployment = new Deployment(
68
+ this.#context,
69
+ this.#compositions,
70
+ // the constructor cannot await, so what it started is settled here
71
+ await this.#dependencies,
72
+ this.#process,
73
+ this.#image
74
+ )
75
+
76
+ return new Operator(deployment, this.#registry, this.#context.environment)
35
77
  }
36
78
 
37
- registry () {
79
+ registry() {
38
80
  return this.#registry
39
81
  }
40
82
 
41
- #composition (composition) {
83
+ #composition(composition) {
42
84
  const image = this.#registry.composition(composition)
43
85
 
44
86
  return new Composition(composition, image)
45
87
  }
46
88
 
47
- #getDependencies () {
89
+ async #getDependencies() {
48
90
  /** @type {toa.deployment.Dependency[]} */
49
91
  const dependencies = []
50
92
 
51
93
  if (this.#context.dependencies === undefined) return dependencies
52
94
 
95
+ /** the claimed references that turned out to contribute a service */
96
+ const contributing = new Set()
97
+
53
98
  for (const [reference, instances] of Object.entries(this.#context.dependencies)) {
54
- const dependency = this.#getDependency(reference, instances)
99
+ const dependency = await this.#getDependency(reference, instances)
100
+
101
+ if (dependency === undefined) continue
55
102
 
56
- if (dependency !== undefined) dependencies.push(dependency)
103
+ if (dependency.services?.length > 0) contributing.add(reference)
104
+
105
+ dependencies.push(dependency)
57
106
  }
58
107
 
108
+ // this is the first point where every `deployment()` has run, so it is the first
109
+ // point where a reference that yields nothing can be told from one that yields a service
110
+ for (const [reference, compositions] of this.#claims)
111
+ if (!contributing.has(reference))
112
+ throw new Error(
113
+ `Composition '${compositions[0]}' lists '${reference}', ` +
114
+ 'which contributes no service.'
115
+ )
116
+
59
117
  return dependencies
60
118
  }
61
119
 
62
- #getDependency (path, instances) {
63
- const module = require(path)
64
- const pkg = require(path + '/package.json')
120
+ async #getDependency(reference, instances) {
121
+ const { name, module } = await definition(reference)
65
122
 
66
123
  if (module.deployment === undefined) return
67
124
 
68
- const annotation = this.#context.annotations?.[pkg.name]
125
+ // an evicted component is deployed by other means, and so is whatever only evicted
126
+ // components require; a service a composition lists is deployed for being listed
127
+ const managed = instances.filter(({ component }) => component.evicted !== true)
69
128
 
129
+ if (managed.length === 0 && instances.length > 0 && !this.#listed.has(reference))
130
+ return
131
+
132
+ const annotation = this.#context.annotations?.[name]
133
+
134
+ // every instance as well: what a definition keeps for all of its components, such as the
135
+ // configuration the values service serves, holds for an evicted one too
70
136
  /** @type {toa.deployment.dependency.Declaration} */
71
- const dependency = module.deployment(instances, annotation)
137
+ const dependency = module.deployment(managed, annotation, instances)
138
+
139
+ // mono claims every service, including one an extension added since this context was
140
+ // written, so its claim is the wildcard rather than a list
141
+ const workload = this.#mono ? [MONO] : this.#claims.get(reference)
142
+
143
+ // an evicted service is deployed nowhere, so nothing is prepared for it: no image, and
144
+ // no Deployment, Service or Ingress downstream
145
+ const declared = this.#evicted.has(reference) ? undefined : dependency.services
72
146
 
73
147
  /** @type {toa.deployment.Service[]} */
74
- const services = dependency.services?.map((service) => this.#service(path, service))
148
+ const services = declared?.map((service) =>
149
+ workload === undefined
150
+ ? this.#service(reference, service) // its own deployment, its own image
151
+ : // named the way `Service` would name it, since it skips that wrapper; and by the
152
+ // extension it is of, which the workload names to its process to be run
153
+ {
154
+ ...service,
155
+ name: `${service.group}-${service.name}`,
156
+ workload,
157
+ extension: reference
158
+ }
159
+ )
75
160
 
76
161
  return { ...dependency, services }
77
162
  }
@@ -81,17 +166,73 @@ class Factory {
81
166
  * @param service {toa.deployment.dependency.Service}
82
167
  * @returns {Service}
83
168
  */
84
- #service (path, service) {
85
- const image = this.#registry.service(path, service)
169
+ #service(path, service) {
170
+ // an extension that publishes its service's image is taken at its word: nothing is
171
+ // added to the registry, so nothing is prepared, probed, built or pushed for it
172
+ const published =
173
+ service.image !== undefined && this.#context.registry?.services === PUBLISHED
174
+
175
+ const image = published
176
+ ? { reference: `${service.image}:${this.#context.runtime.version}` }
177
+ : this.#registry.service(path, service)
86
178
 
87
179
  return new Service(service, image)
88
180
  }
89
181
 
90
- static async create (path, environment) {
91
- const context = await load(path, environment)
182
+ static async create(path, environment, options = {}) {
183
+ const context = await load(path, environment, options)
92
184
 
93
- return new Factory(context)
185
+ return new Factory(context, options)
94
186
  }
95
187
  }
96
188
 
97
- exports.Factory = Factory
189
+ const MONO = 'mono'
190
+
191
+ const PUBLISHED = 'published'
192
+
193
+ /**
194
+ * The compositions this context deploys: each without its evicted members, and none left with no
195
+ * members at all — the name it held is free again, and a service it listed falls back to a
196
+ * deployment of its own.
197
+ *
198
+ * @param {toa.norm.Composition[]} [compositions]
199
+ * @returns {toa.norm.Composition[]}
200
+ */
201
+ function deployed(compositions = []) {
202
+ return compositions
203
+ .map((composition) => ({
204
+ ...composition,
205
+ components: composition.components.filter(managed)
206
+ }))
207
+ .filter((composition) => composition.components.length > 0)
208
+ }
209
+
210
+ /** An evicted component is deployed by other means. */
211
+ function managed(component) {
212
+ return component.evicted !== true
213
+ }
214
+
215
+ /**
216
+ * @param {toa.norm.Composition[]} [compositions]
217
+ * @returns {Set<string>}
218
+ */
219
+ function listed(compositions = []) {
220
+ return new Set(compositions.flatMap((composition) => composition.services ?? []))
221
+ }
222
+
223
+ /**
224
+ * @param {toa.norm.Composition[]} compositions
225
+ * @returns {Map<string, string[]>}
226
+ */
227
+ function claims(compositions) {
228
+ const map = new Map()
229
+
230
+ for (const composition of compositions)
231
+ for (const reference of composition.services ?? []) {
232
+ if (!map.has(reference)) map.set(reference, [])
233
+
234
+ map.get(reference).push(composition.name)
235
+ }
236
+
237
+ return map
238
+ }
@@ -0,0 +1,93 @@
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, normalized } 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
+ /** What the services this workload runs install, which no component of it declares.
27
+ * @type {Record<string, string> | undefined} */
28
+ packages
29
+
30
+ constructor(scope, runtime, registry, composition) {
31
+ super(scope, runtime, registry)
32
+
33
+ this.image = composition.image
34
+ this.components = composition.components
35
+ this.packages = composition.packages
36
+ this.dependencies = new Dependencies(scope, runtime, registry, this)
37
+ }
38
+
39
+ tag() {
40
+ this.dependencies.tag()
41
+ super.tag()
42
+ }
43
+
44
+ get version() {
45
+ const hash = createHash('sha256')
46
+
47
+ for (const component of this.components) {
48
+ hash.update(component.locator.id)
49
+ hash.update(component.version)
50
+ }
51
+
52
+ // laid over a different base is a different image, whatever the sources
53
+ hash.update(this.dependencies.version)
54
+
55
+ return hash.digest('hex').slice(0, 8)
56
+ }
57
+
58
+ get base() {
59
+ return this.dependencies.reference
60
+ }
61
+
62
+ /**
63
+ * What to say when the components ask for different base images.
64
+ *
65
+ * @abstract
66
+ * @returns {string}
67
+ */
68
+ conflict() {
69
+ throw new Error('Not implemented')
70
+ }
71
+
72
+ async prepare(root) {
73
+ const context = await super.prepare(root)
74
+
75
+ for (const component of this.components) {
76
+ const target = join(context, component.locator.label)
77
+
78
+ // what was installed in the workspace is not what the image installs; a link is copied as
79
+ // the files it points to, which is the only way what it points to reaches the image at all
80
+ await cp(component.path, target, {
81
+ recursive: true,
82
+ filter: sources,
83
+ dereference: true
84
+ })
85
+ await declare(component.path, target, component.locator.label)
86
+ await normalized(component, target)
87
+ }
88
+
89
+ return context
90
+ }
91
+ }
92
+
93
+ 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
19
- CMD toa compose *
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 compose * --map {{map.file}}
@@ -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,23 @@
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
+ # `.packages` is a dotfile, so the glob does not reach it
16
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
17
+ for entry in *; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done
18
+
19
+ # what the extensions need for what these components declare: installed beside the extension
20
+ # that reads the declaration, in Toa's own install, because that is where Node resolves an
21
+ # extension's imports from — and one copy for the composition rather than one per component
22
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
23
+ if [ -s .packages ]; then npm i --prefix /toa --omit=dev $(cat .packages); fi
@@ -0,0 +1,171 @@
1
+ import { basename, join } from 'node:path'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ import { Image } from './image.js'
7
+
8
+ /**
9
+ * What a bundle's components depend on, installed on the base: the layers that weigh the
10
+ * most and change the least, so they are an image of their own. Its tag digests everything
11
+ * the install reads, and nothing else: a change to the sources leaves it where it is, and a
12
+ * bundle is laid over it without building or moving it again.
13
+ *
14
+ * @implements {toa.deployment.images.Image}
15
+ */
16
+ export class Dependencies extends Image {
17
+ dockerfile = join(import.meta.dirname, 'dependencies.Dockerfile')
18
+ arguments = true
19
+
20
+ /** @type {toa.deployment.images.Bundle} */
21
+ #owner
22
+
23
+ #runtime
24
+ #registry
25
+
26
+ /** @type {string | undefined} */
27
+ #version
28
+
29
+ /**
30
+ * @param {string} scope
31
+ * @param {toa.norm.context.Runtime} runtime
32
+ * @param {toa.norm.context.Registry} registry
33
+ * @param {toa.deployment.images.Bundle} owner the bundle laid over this image
34
+ */
35
+ constructor(scope, runtime, registry, owner) {
36
+ super(scope, runtime, registry)
37
+
38
+ this.#owner = owner
39
+ this.#runtime = runtime
40
+ this.#registry = registry
41
+ }
42
+
43
+ /** Same repository as the bundle: a tag apart, not a name apart. */
44
+ get name() {
45
+ return this.#owner.name
46
+ }
47
+
48
+ get version() {
49
+ if (this.#version !== undefined) return this.#version
50
+
51
+ const hash = createHash('sha256')
52
+ const build = this.#registry.build ?? {}
53
+
54
+ const inputs = [
55
+ this.#runtime.version,
56
+ this.#runtime.registry,
57
+ this.#runtime.proxy,
58
+ this.base,
59
+ build.image,
60
+ build.run,
61
+ this.run,
62
+ ...(build.arguments ?? [])
63
+ ]
64
+
65
+ for (const input of inputs) hash.update(input ?? '').update('\0')
66
+
67
+ for (const [label, files] of this.#manifests())
68
+ for (const file of files)
69
+ hash.update(label).update(basename(file)).update(readFileSync(file))
70
+
71
+ hash.update(this.#packages().join('\n'))
72
+
73
+ this.#version = hash.digest('hex').slice(0, 8)
74
+
75
+ return this.#version
76
+ }
77
+
78
+ digest() {
79
+ return PREFIX + this.version
80
+ }
81
+
82
+ get base() {
83
+ if (this.#owner.image !== undefined) return this.#owner.image
84
+
85
+ const images = new Set(
86
+ this.#owner.components.map((component) => component.build?.image)
87
+ )
88
+
89
+ if (images.size > 1) throw new Error(this.#owner.conflict())
90
+
91
+ return images.values().next().value
92
+ }
93
+
94
+ get run() {
95
+ const commands = []
96
+
97
+ for (const component of this.#owner.components) {
98
+ const run = component.build?.run
99
+
100
+ if (run !== undefined) commands.push(run)
101
+ }
102
+
103
+ return commands.join('\n')
104
+ }
105
+
106
+ async prepare(root) {
107
+ const context = await super.prepare(join(root, DIRECTORY))
108
+
109
+ for (const [label, files] of this.#manifests()) {
110
+ const target = join(context, label)
111
+
112
+ await mkdir(target, { recursive: true })
113
+
114
+ for (const file of files) await copyFile(file, join(target, basename(file)))
115
+ }
116
+
117
+ const packages = this.#packages()
118
+
119
+ // written whether or not there are any, so the file is one thing the Dockerfile reads
120
+ await writeFile(join(context, PACKAGES), packages.join('\n'))
121
+
122
+ return context
123
+ }
124
+
125
+ /**
126
+ * What the extensions install for what this workload runs, as `name@version`: what its
127
+ * components declare, and what the services it hosts bring. A component states its own
128
+ * dependencies in its manifest; this is what the packages that read its declaration need,
129
+ * which its manifest has no way to say.
130
+ *
131
+ * @returns {string[]}
132
+ */
133
+ #packages() {
134
+ /** @type {Record<string, string>} */
135
+ const packages = { ...this.#owner.packages }
136
+
137
+ for (const component of this.#owner.components)
138
+ Object.assign(packages, component.packages)
139
+
140
+ return Object.entries(packages)
141
+ .map(([name, version]) => `${name}@${version}`)
142
+ .sort()
143
+ }
144
+
145
+ /**
146
+ * The files the install reads, by component label, in a fixed order.
147
+ *
148
+ * @returns {Array<[string, string[]]>}
149
+ */
150
+ #manifests() {
151
+ const manifests = []
152
+
153
+ for (const component of this.#owner.components) {
154
+ const files = MANIFESTS.map((name) => join(component.path, name)).filter(existsSync)
155
+
156
+ if (files.length > 0) manifests.push([component.locator.label, files])
157
+ }
158
+
159
+ return manifests.sort(([a], [b]) => a.localeCompare(b))
160
+ }
161
+ }
162
+
163
+ const PREFIX = 'deps-'
164
+
165
+ /** Where the contexts land, apart from the bundles', whose names they share. */
166
+ const DIRECTORY = 'dependencies'
167
+
168
+ const MANIFESTS = ['package.json', 'package-lock.json']
169
+
170
+ /** Where the install reads what the extensions need, one `name@version` per line. */
171
+ const PACKAGES = '.packages'