@toa.io/operations 1.0.0-alpha.288 → 1.0.0-alpha.289
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 +11 -0
- package/package.json +4 -6
- package/readme.md +16 -0
- package/src/deployment/.deployment/.describe/resources.js +63 -0
- package/src/deployment/.deployment/.describe/resources.test.js +60 -1
- package/src/deployment/chart/templates/compositions.yaml +10 -2
- package/src/deployment/chart/templates/mono.yaml +4 -3
- package/src/deployment/chart/templates/services.yaml +6 -5
- package/src/deployment/deployment.js +6 -2
- package/src/deployment/drain.js +70 -0
- package/src/deployment/drain.test.js +64 -0
- package/src/deployment/images/bundle.js +82 -0
- package/src/deployment/images/composition.Dockerfile +5 -14
- package/src/deployment/images/composition.js +5 -57
- package/src/deployment/images/dependencies.Dockerfile +16 -0
- package/src/deployment/images/dependencies.js +141 -0
- package/src/deployment/images/dependencies.test.js +212 -0
- package/src/deployment/images/image.js +25 -7
- package/src/deployment/images/mono.Dockerfile +5 -13
- package/src/deployment/images/mono.js +4 -62
- package/src/deployment/images/publish.js +4 -9
- package/src/deployment/images/runtime-base.test.js +4 -4
- package/src/deployment/images/service.Dockerfile +2 -1
- package/src/deployment/images/service.js +2 -2
- package/src/deployment/registry.js +59 -13
- package/src/deployment/registry.test.js +169 -13
- package/src/process.js +50 -6
- package/src/process.test.js +13 -4
- package/types/_deployment/images/image.d.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.0.0-alpha.289](https://github.com/toa-io/toa/compare/v1.0.0-alpha.288...v1.0.0-alpha.289) (2026-09-07)
|
|
7
|
+
|
|
8
|
+
* A deploy moves only what changed, and a component sees none of the runtime's environment (#1073) ([f38e3db](https://github.com/toa-io/toa/commit/f38e3db533f24db866c57bfa1ef294eff1eaf499)), closes [#1073](https://github.com/toa-io/toa/issues/1073) [#1064](https://github.com/toa-io/toa/issues/1064) [#1066](https://github.com/toa-io/toa/issues/1066) [#1067](https://github.com/toa-io/toa/issues/1067) [#1068](https://github.com/toa-io/toa/issues/1068) [#1069](https://github.com/toa-io/toa/issues/1069) [#1071](https://github.com/toa-io/toa/issues/1071) [#1070](https://github.com/toa-io/toa/issues/1070) [#1072](https://github.com/toa-io/toa/issues/1072)
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* a component that read `process.env.TOA_*` reads `context` instead;
|
|
13
|
+
`echo(input)` no longer substitutes from the environment; a bash operation sees no
|
|
14
|
+
`TOA_*`; images no longer set `USER node` — see migrations/289.md.
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# [1.0.0-alpha.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @toa.io/operations
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/operations",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.289",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Deployment",
|
|
6
6
|
"homepage": "https://toa.io",
|
|
@@ -28,11 +28,9 @@
|
|
|
28
28
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
32
|
-
"@toa.io/norm": "1.0.0-alpha.
|
|
33
|
-
"execa": "10.0.1",
|
|
34
|
-
"fs-extra": "11.4.0",
|
|
31
|
+
"@toa.io/generic": "1.0.0-alpha.289",
|
|
32
|
+
"@toa.io/norm": "1.0.0-alpha.289",
|
|
35
33
|
"js-yaml": "5.4.1"
|
|
36
34
|
},
|
|
37
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "4fcb482beac5896305d8084cdbc1d5fcf372d108"
|
|
38
36
|
}
|
package/readme.md
CHANGED
|
@@ -38,6 +38,22 @@ registry:
|
|
|
38
38
|
run: npm i -g @toa.io/runtime --omit=dev
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
#### Composition Images
|
|
42
|
+
|
|
43
|
+
A composition is two images in one repository. `composition-<name>:deps-<hash>` is what its
|
|
44
|
+
components depend on, installed on the base image: it is tagged by everything the install
|
|
45
|
+
reads — the runtime version, the base image, the build options and each component's
|
|
46
|
+
`package.json` (and `package-lock.json`, where there is one) — and is built only when one of
|
|
47
|
+
those changes. `composition-<name>:<hash>` is the sources laid over it in a single linked
|
|
48
|
+
layer, so a deploy that changes code alone builds and pushes that layer, and neither
|
|
49
|
+
downloads nor uploads the dependencies again. The same holds for `mono`.
|
|
50
|
+
|
|
51
|
+
A pushed image builds on the `toa` container builder, whose registry exporter is what lays
|
|
52
|
+
an image over a base it never pulled; `toa build` loads on the daemon's own.
|
|
53
|
+
|
|
54
|
+
A dependency named by a moving git ref is installed when the dependencies image is built and
|
|
55
|
+
not again until its manifest changes, so pin such a dependency to a commit and bump it there.
|
|
56
|
+
|
|
41
57
|
#### Extension Service Images
|
|
42
58
|
|
|
43
59
|
`registry.services` says where an extension service's image comes from.
|
|
@@ -20,9 +20,72 @@ export function resources(context, values) {
|
|
|
20
20
|
"Declare them on it or as the context's 'resources', " +
|
|
21
21
|
"or 'resources: null' to deploy it without any."
|
|
22
22
|
)
|
|
23
|
+
|
|
24
|
+
heap(unit.deployment)
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The memory limit sizes the heap. Node reads the machine's memory rather than the
|
|
30
|
+
* container's, so without this the heap grows past the limit and the pod is killed instead
|
|
31
|
+
* of collected. What is left of the limit is the process itself: its code, its buffers and
|
|
32
|
+
* its threads. A deployment that states `NODE_OPTIONS` of its own keeps them.
|
|
33
|
+
*/
|
|
34
|
+
function heap(deployment) {
|
|
35
|
+
const limit = deployment.resources?.memory?.[1]
|
|
36
|
+
|
|
37
|
+
if (limit === undefined) return
|
|
38
|
+
|
|
39
|
+
deployment.variables ??= []
|
|
40
|
+
|
|
41
|
+
if (deployment.variables.some((variable) => variable.name === NODE_OPTIONS)) return
|
|
42
|
+
|
|
43
|
+
const megabytes = Math.floor((quantity(limit) * HEAP_SHARE) / 2 ** 20)
|
|
44
|
+
|
|
45
|
+
deployment.variables.push({
|
|
46
|
+
name: NODE_OPTIONS,
|
|
47
|
+
value: `--max-old-space-size=${megabytes}`
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A Kubernetes quantity, in bytes.
|
|
53
|
+
*
|
|
54
|
+
* @param {string | number} value
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
export function quantity(value) {
|
|
58
|
+
const match = String(value).match(/^(\d+(?:\.\d+)?)([KMGTPE]i?|[kmun])?$/)
|
|
59
|
+
|
|
60
|
+
if (match === null) throw new Error(`'${value}' is not a quantity`)
|
|
61
|
+
|
|
62
|
+
const [, number, suffix = ''] = match
|
|
63
|
+
const scale = SUFFIXES[suffix]
|
|
64
|
+
|
|
65
|
+
if (scale === undefined) throw new Error(`'${value}' is not a memory quantity`)
|
|
66
|
+
|
|
67
|
+
return Number(number) * scale
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NODE_OPTIONS = 'NODE_OPTIONS'
|
|
71
|
+
const HEAP_SHARE = 0.75
|
|
72
|
+
|
|
73
|
+
const SUFFIXES = {
|
|
74
|
+
'': 1,
|
|
75
|
+
K: 1e3,
|
|
76
|
+
M: 1e6,
|
|
77
|
+
G: 1e9,
|
|
78
|
+
T: 1e12,
|
|
79
|
+
P: 1e15,
|
|
80
|
+
E: 1e18,
|
|
81
|
+
Ki: 2 ** 10,
|
|
82
|
+
Mi: 2 ** 20,
|
|
83
|
+
Gi: 2 ** 30,
|
|
84
|
+
Ti: 2 ** 40,
|
|
85
|
+
Pi: 2 ** 50,
|
|
86
|
+
Ei: 2 ** 60
|
|
87
|
+
}
|
|
88
|
+
|
|
26
89
|
function* units(values) {
|
|
27
90
|
if (values.mono !== undefined)
|
|
28
91
|
yield { deployment: values.mono, subject: 'The mono deployment' }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import { resources } from './resources.js'
|
|
4
|
+
import { resources, quantity } from './resources.js'
|
|
5
5
|
|
|
6
6
|
const declared = { cpu: ['100m', '1'], memory: ['100Mi', '1Gi'] }
|
|
7
7
|
|
|
@@ -68,3 +68,62 @@ describe('refusal', () => {
|
|
|
68
68
|
assert.strictEqual(values.services[0].resources, undefined)
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
describe('heap', () => {
|
|
73
|
+
it('should size the heap by the memory limit', () => {
|
|
74
|
+
const values = { compositions: [{ name: 'edge', resources: declared }] }
|
|
75
|
+
|
|
76
|
+
resources({}, values)
|
|
77
|
+
|
|
78
|
+
assert.deepStrictEqual(values.compositions[0].variables, [
|
|
79
|
+
{ name: 'NODE_OPTIONS', value: '--max-old-space-size=768' }
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should size the heap of a service and of the mono deployment', () => {
|
|
84
|
+
const values = {
|
|
85
|
+
services: [
|
|
86
|
+
{ name: 'exposition-gateway', resources: { memory: ['200Mi', '500Mi'] } }
|
|
87
|
+
],
|
|
88
|
+
mono: { variables: [{ name: 'TOA_ENV', value: 'test' }] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resources({ resources: declared }, values)
|
|
92
|
+
|
|
93
|
+
assert.deepStrictEqual(values.services[0].variables, [
|
|
94
|
+
{ name: 'NODE_OPTIONS', value: '--max-old-space-size=375' }
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
assert.deepStrictEqual(values.mono.variables, [
|
|
98
|
+
{ name: 'TOA_ENV', value: 'test' },
|
|
99
|
+
{ name: 'NODE_OPTIONS', value: '--max-old-space-size=768' }
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should keep the options a deployment states', () => {
|
|
104
|
+
const variables = [{ name: 'NODE_OPTIONS', value: '--max-old-space-size=100' }]
|
|
105
|
+
const values = { compositions: [{ name: 'edge', resources: declared, variables }] }
|
|
106
|
+
|
|
107
|
+
resources({}, values)
|
|
108
|
+
|
|
109
|
+
assert.deepStrictEqual(values.compositions[0].variables, variables)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should not size the heap without a memory limit', () => {
|
|
113
|
+
const values = { compositions: [{ name: 'edge', resources: { cpu: ['100m', '1'] } }] }
|
|
114
|
+
|
|
115
|
+
resources({}, values)
|
|
116
|
+
|
|
117
|
+
assert.strictEqual(values.compositions[0].variables, undefined)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should read a quantity', () => {
|
|
121
|
+
assert.strictEqual(quantity('512Mi'), 512 * 2 ** 20)
|
|
122
|
+
assert.strictEqual(quantity('1Gi'), 2 ** 30)
|
|
123
|
+
assert.strictEqual(quantity('500M'), 500e6)
|
|
124
|
+
assert.strictEqual(quantity('1.5Gi'), 1.5 * 2 ** 30)
|
|
125
|
+
assert.strictEqual(quantity(1024), 1024)
|
|
126
|
+
assert.throws(() => quantity('500m'), /not a memory quantity/)
|
|
127
|
+
assert.throws(() => quantity('big'), /not a quantity/)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -8,6 +8,13 @@ spec:
|
|
|
8
8
|
# A backstop for stuck rollouts only. Must stay above the `helm upgrade --timeout`
|
|
9
9
|
# the deploy runs with, so a slow but progressing rollout is not marked failed.
|
|
10
10
|
progressDeadlineSeconds: 900
|
|
11
|
+
strategy:
|
|
12
|
+
type: RollingUpdate
|
|
13
|
+
rollingUpdate:
|
|
14
|
+
# a replacement must pass readiness before a running replica is taken out, and every
|
|
15
|
+
# replacement starts at once: the rollout takes one start-up, not one per replica
|
|
16
|
+
maxUnavailable: 0
|
|
17
|
+
maxSurge: 100%
|
|
11
18
|
selector:
|
|
12
19
|
matchLabels:
|
|
13
20
|
toa/composition: {{ .name }}
|
|
@@ -78,9 +85,10 @@ spec:
|
|
|
78
85
|
httpGet:
|
|
79
86
|
path: {{ .probe.path }}
|
|
80
87
|
port: {{ .probe.port }}
|
|
81
|
-
|
|
88
|
+
# a replica is ready when it says so, not at the next tick of a slow clock
|
|
89
|
+
periodSeconds: 2
|
|
82
90
|
timeoutSeconds: 3
|
|
83
|
-
failureThreshold:
|
|
91
|
+
failureThreshold: 5
|
|
84
92
|
{{- end }}
|
|
85
93
|
{{- if .mounts }}
|
|
86
94
|
volumeMounts:
|
|
@@ -11,7 +11,7 @@ spec:
|
|
|
11
11
|
type: RollingUpdate
|
|
12
12
|
rollingUpdate:
|
|
13
13
|
maxUnavailable: 0
|
|
14
|
-
maxSurge:
|
|
14
|
+
maxSurge: 100%
|
|
15
15
|
selector:
|
|
16
16
|
matchLabels:
|
|
17
17
|
toa/composition: mono
|
|
@@ -75,9 +75,10 @@ spec:
|
|
|
75
75
|
httpGet:
|
|
76
76
|
path: {{ .probe.path }}
|
|
77
77
|
port: {{ .probe.port }}
|
|
78
|
-
|
|
78
|
+
# a replica is ready when it says so, not at the next tick of a slow clock
|
|
79
|
+
periodSeconds: 2
|
|
79
80
|
timeoutSeconds: 3
|
|
80
|
-
failureThreshold:
|
|
81
|
+
failureThreshold: 5
|
|
81
82
|
{{- end }}
|
|
82
83
|
{{- if .mounts }}
|
|
83
84
|
volumeMounts:
|
|
@@ -12,10 +12,10 @@ spec:
|
|
|
12
12
|
strategy:
|
|
13
13
|
type: RollingUpdate
|
|
14
14
|
rollingUpdate:
|
|
15
|
-
# a replacement must pass readiness before a running replica is taken out,
|
|
16
|
-
#
|
|
15
|
+
# a replacement must pass readiness before a running replica is taken out, and every
|
|
16
|
+
# replacement starts at once: the rollout takes one start-up, not one per replica
|
|
17
17
|
maxUnavailable: 0
|
|
18
|
-
maxSurge:
|
|
18
|
+
maxSurge: 100%
|
|
19
19
|
selector:
|
|
20
20
|
matchLabels:
|
|
21
21
|
toa/service: extension-{{ .name }}
|
|
@@ -68,9 +68,10 @@ spec:
|
|
|
68
68
|
httpGet:
|
|
69
69
|
path: {{ .probe.path }}
|
|
70
70
|
port: {{ .probe.port }}
|
|
71
|
-
|
|
71
|
+
# a replica is ready when it says so, not at the next tick of a slow clock
|
|
72
|
+
periodSeconds: 2
|
|
72
73
|
timeoutSeconds: 3
|
|
73
|
-
failureThreshold:
|
|
74
|
+
failureThreshold: 5
|
|
74
75
|
{{- end }}
|
|
75
76
|
lifecycle:
|
|
76
77
|
preStop:
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { writeFile as write } from 'node:fs/promises'
|
|
3
3
|
import { yaml as jsyaml } from '@toa.io/generic'
|
|
4
|
-
import
|
|
4
|
+
import { cp } from 'node:fs/promises'
|
|
5
5
|
|
|
6
6
|
import { merge, declare, describe } from './.deployment/index.js'
|
|
7
|
+
import { drain } from './drain.js'
|
|
7
8
|
|
|
8
9
|
export class Deployment {
|
|
9
10
|
#chart
|
|
@@ -26,7 +27,7 @@ export class Deployment {
|
|
|
26
27
|
await Promise.all([
|
|
27
28
|
write(join(target, 'Chart.yaml'), chart),
|
|
28
29
|
write(join(target, 'values.yaml'), values),
|
|
29
|
-
|
|
30
|
+
cp(TEMPLATES, join(target, 'templates'), { recursive: true })
|
|
30
31
|
])
|
|
31
32
|
|
|
32
33
|
this.#target = target
|
|
@@ -50,6 +51,9 @@ export class Deployment {
|
|
|
50
51
|
...args,
|
|
51
52
|
this.#target
|
|
52
53
|
])
|
|
54
|
+
|
|
55
|
+
// ready is not done: the replicas replaced are still draining when helm answers
|
|
56
|
+
if (options.wait === true) await drain(this.#process, options)
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
async template(options) {
|
|
@@ -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
|
+
}
|
|
@@ -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,10 @@
|
|
|
1
1
|
FROM {{build.image}}
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
WORKDIR /composition
|
|
10
|
-
COPY --chown=node:node . /composition
|
|
11
|
-
|
|
12
|
-
{{build.run}}
|
|
13
|
-
|
|
14
|
-
# run 'npm i' in each component
|
|
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
|
|
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
|
|
17
8
|
|
|
18
9
|
USER node
|
|
19
10
|
CMD toa compose *
|
|
@@ -1,75 +1,23 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
-
import fs from 'fs-extra'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
2
|
|
|
5
|
-
import {
|
|
6
|
-
import { declare } from './format.js'
|
|
3
|
+
import { Bundle } from './bundle.js'
|
|
7
4
|
|
|
8
|
-
export class Composition extends
|
|
5
|
+
export class Composition extends Bundle {
|
|
9
6
|
dockerfile = join(import.meta.dirname, 'composition.Dockerfile')
|
|
10
7
|
|
|
11
8
|
#name
|
|
12
|
-
#image
|
|
13
|
-
#components
|
|
14
9
|
|
|
15
10
|
constructor(scope, runtime, registry, composition) {
|
|
16
|
-
super(scope, runtime, registry)
|
|
11
|
+
super(scope, runtime, registry, composition)
|
|
17
12
|
|
|
18
13
|
this.#name = composition.name
|
|
19
|
-
this.#image = composition.image
|
|
20
|
-
this.#components = composition.components
|
|
21
14
|
}
|
|
22
15
|
|
|
23
16
|
get name() {
|
|
24
17
|
return 'composition-' + this.#name
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (const component of this.#components) {
|
|
31
|
-
hash.update(component.locator.id)
|
|
32
|
-
hash.update(component.version)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return hash.digest('hex').slice(0, 8)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
get base() {
|
|
39
|
-
if (this.#image !== undefined) return this.#image
|
|
40
|
-
|
|
41
|
-
const images = new Set(this.#components.map((component) => component.build?.image))
|
|
42
|
-
|
|
43
|
-
if (images.size > 1)
|
|
44
|
-
throw new Error(
|
|
45
|
-
`Composition '${this.#name}' requires different base images for its components. Specify base image for the composition in the context.`
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
return images.values().next().value
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
get run() {
|
|
52
|
-
const commands = []
|
|
53
|
-
|
|
54
|
-
for (const component of this.#components) {
|
|
55
|
-
const run = component.build?.run
|
|
56
|
-
|
|
57
|
-
if (run !== undefined) commands.push(run)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return commands.join('\n')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async prepare(root) {
|
|
64
|
-
const context = await super.prepare(root)
|
|
65
|
-
|
|
66
|
-
for (const component of this.#components) {
|
|
67
|
-
const target = join(context, component.locator.label)
|
|
68
|
-
|
|
69
|
-
await fs.copy(component.path, target)
|
|
70
|
-
await declare(component.path, target, component.locator.label)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
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.`
|
|
74
22
|
}
|
|
75
23
|
}
|
|
@@ -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
|