@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.
- package/CHANGELOG.md +449 -0
- package/image/runtime.Dockerfile +20 -0
- package/package.json +10 -10
- package/readme.md +224 -0
- package/src/deployment/.deployment/.describe/components.js +1 -5
- package/src/deployment/.deployment/.describe/compositions.js +26 -7
- package/src/deployment/.deployment/.describe/dependencies.js +1 -6
- package/src/deployment/.deployment/.describe/events.js +63 -0
- package/src/deployment/.deployment/.describe/fold.js +42 -0
- package/src/deployment/.deployment/.describe/fold.test.js +108 -0
- package/src/deployment/.deployment/.describe/index.js +4 -13
- package/src/deployment/.deployment/.describe/mounts.js +17 -0
- package/src/deployment/.deployment/.describe/resources.js +129 -0
- package/src/deployment/.deployment/.describe/resources.test.js +129 -0
- package/src/deployment/.deployment/.describe/services.js +32 -6
- package/src/deployment/.deployment/.describe/services.test.js +78 -0
- package/src/deployment/.deployment/.describe/variables.js +5 -9
- package/src/deployment/.deployment/declare.js +1 -5
- package/src/deployment/.deployment/describe.js +168 -10
- package/src/deployment/.deployment/index.js +3 -9
- package/src/deployment/.deployment/merge.js +66 -6
- package/src/deployment/.deployment/merge.test.js +133 -0
- package/src/deployment/chart/templates/components.configmap.yaml +13 -0
- package/src/deployment/chart/templates/components.yaml +4 -3
- package/src/deployment/chart/templates/compositions.yaml +109 -4
- package/src/deployment/chart/templates/mono.yaml +199 -0
- package/src/deployment/chart/templates/services.yaml +97 -7
- package/src/deployment/chart/values.yaml +23 -1
- package/src/deployment/composition.js +8 -6
- package/src/deployment/deployment.js +175 -35
- package/src/deployment/drain.js +70 -0
- package/src/deployment/drain.test.js +64 -0
- package/src/deployment/factory.js +178 -37
- package/src/deployment/images/bundle.js +93 -0
- package/src/deployment/images/composition.Dockerfile +9 -17
- package/src/deployment/images/composition.js +9 -56
- package/src/deployment/images/dependencies.Dockerfile +23 -0
- package/src/deployment/images/dependencies.js +171 -0
- package/src/deployment/images/dependencies.test.js +271 -0
- package/src/deployment/images/factory.js +31 -15
- package/src/deployment/images/format.js +66 -0
- package/src/deployment/images/format.test.js +150 -0
- package/src/deployment/images/image.fixtures.js +17 -19
- package/src/deployment/images/image.js +79 -38
- package/src/deployment/images/image.test.js +11 -5
- package/src/deployment/images/index.js +1 -5
- package/src/deployment/images/mono.Dockerfile +11 -0
- package/src/deployment/images/mono.js +15 -0
- package/src/deployment/images/publish.js +91 -0
- package/src/deployment/images/runtime-base.test.js +97 -0
- package/src/deployment/images/service.Dockerfile +17 -5
- package/src/deployment/images/service.js +63 -14
- package/src/deployment/index.js +1 -5
- package/src/deployment/operator.js +25 -16
- package/src/deployment/operator.test.js +98 -7
- package/src/deployment/registry.js +210 -46
- package/src/deployment/registry.test.js +539 -0
- package/src/deployment/secrets.js +92 -0
- package/src/deployment/secrets.test.js +180 -0
- package/src/deployment/service.js +4 -6
- package/src/deployment/workspace.js +13 -8
- package/src/index.js +3 -2
- package/src/process.js +51 -13
- package/src/process.test.js +33 -0
- package/types/_deployment/composition.d.ts +2 -3
- package/types/_deployment/dependency.d.ts +15 -7
- package/types/_deployment/deployment.d.ts +10 -8
- package/types/_deployment/factory.d.ts +2 -4
- package/types/_deployment/images/factory.d.ts +6 -8
- package/types/_deployment/images/image.d.ts +16 -1
- package/types/_deployment/images/registry.d.ts +8 -11
- package/types/_deployment/operator.d.ts +10 -9
- package/types/_deployment/registry.d.ts +7 -4
- package/types/_deployment/service.d.ts +4 -3
- package/types/dependency.ts +79 -0
- package/types/index.ts +1 -0
- package/types/dependency.d.ts +0 -39
- package/types/index.d.ts +0 -1
|
@@ -1,77 +1,162 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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(
|
|
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.#
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
|
63
|
-
const module =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
1
|
+
import { join } from 'node:path'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const fs = require('fs-extra')
|
|
5
|
-
const { createHash } = require('node:crypto')
|
|
3
|
+
import { Bundle } from './bundle.js'
|
|
6
4
|
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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'
|