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