@toa.io/operations 1.0.0-alpha.280 → 1.0.0-alpha.283
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 +19 -0
- package/package.json +4 -4
- package/readme.md +18 -0
- package/src/deployment/.deployment/.describe/compositions.js +18 -2
- package/src/deployment/.deployment/.describe/fold.js +35 -0
- package/src/deployment/.deployment/.describe/fold.test.js +92 -0
- package/src/deployment/.deployment/.describe/resources.js +6 -1
- package/src/deployment/.deployment/.describe/resources.test.js +63 -0
- package/src/deployment/.deployment/.describe/services.js +3 -1
- package/src/deployment/.deployment/describe.js +5 -18
- package/src/deployment/.deployment/merge.js +27 -12
- package/src/deployment/.deployment/merge.test.js +39 -9
- package/src/deployment/chart/templates/components.yaml +1 -1
- package/src/deployment/chart/templates/compositions.yaml +20 -6
- package/src/deployment/chart/templates/mono.yaml +5 -5
- package/src/deployment/chart/templates/services.yaml +7 -4
- package/src/deployment/chart/values.yaml +12 -0
- package/src/deployment/composition.js +3 -0
- package/src/deployment/factory.js +49 -2
- package/src/deployment/images/image.js +10 -4
- package/types/dependency.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **lint:** lint the operations package ([aab0db5](https://github.com/toa-io/toa/commit/aab0db5ebcec05f5f49db716b86ea71f4377cfe3))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **operations:** run extension services inside a composition ([154aced](https://github.com/toa-io/toa/commit/154aced047228a250ac3bab353e09432fcc5a34d))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# [1.0.0-alpha.282](https://github.com/toa-io/toa/compare/v1.0.0-alpha.281...v1.0.0-alpha.282) (2026-09-03)
|
|
18
|
+
|
|
19
|
+
**Note:** Version bump only for package @toa.io/operations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
# [1.0.0-alpha.280](https://github.com/toa-io/toa/compare/v1.0.0-alpha.279...v1.0.0-alpha.280) (2026-09-03)
|
|
7
26
|
|
|
8
27
|
### Bug Fixes
|
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.283",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Deployment",
|
|
6
6
|
"homepage": "https://toa.io",
|
|
@@ -28,11 +28,11 @@
|
|
|
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.
|
|
31
|
+
"@toa.io/generic": "1.0.0-alpha.283",
|
|
32
|
+
"@toa.io/norm": "1.0.0-alpha.283",
|
|
33
33
|
"execa": "10.0.1",
|
|
34
34
|
"fs-extra": "11.4.0",
|
|
35
35
|
"js-yaml": "5.4.1"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "2f52b023bed0ccb2ecdb2b82fe59fbe38bf282e1"
|
|
38
38
|
}
|
package/readme.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Toa Operations
|
|
2
2
|
|
|
3
|
+
## Compositions
|
|
4
|
+
|
|
5
|
+
A composition is deployed as one pod. Beside its components it may run extension services,
|
|
6
|
+
which are otherwise each deployed on their own:
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
# context.toa.yaml
|
|
10
|
+
|
|
11
|
+
compositions:
|
|
12
|
+
- name: edge
|
|
13
|
+
components:
|
|
14
|
+
- todos.tasks
|
|
15
|
+
services:
|
|
16
|
+
- exposition
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
See [compositions](../documentation/compositions.md).
|
|
20
|
+
|
|
3
21
|
## Context
|
|
4
22
|
|
|
5
23
|
### Container Registry
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { addVariables } from './variables.js'
|
|
2
2
|
import { addMounts } from './mounts.js'
|
|
3
|
+
import { fold } from './fold.js'
|
|
3
4
|
|
|
4
5
|
export function compositions (compositions, dependency) {
|
|
5
6
|
for (const composition of compositions) {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const claimed = (dependency.services ?? [])
|
|
8
|
+
.filter((service) => service.workload?.includes(composition.name) === true)
|
|
9
|
+
|
|
10
|
+
// a service brings components of its own — the identity components inside the gateway —
|
|
11
|
+
// and their variables are keyed by their own labels, not by the composition's
|
|
12
|
+
const keys = composition.components
|
|
13
|
+
.concat(...claimed.map((service) => service.components ?? []))
|
|
14
|
+
|
|
15
|
+
addVariables(composition, dependency.variables, keys)
|
|
16
|
+
addMounts(composition, dependency.mounts, keys)
|
|
17
|
+
|
|
18
|
+
if (claimed.length > 0) {
|
|
19
|
+
// the label each hosted service's own Service selects on
|
|
20
|
+
composition.hosted = claimed.map((service) => service.name)
|
|
21
|
+
|
|
22
|
+
fold(composition, claimed, dependency)
|
|
23
|
+
}
|
|
8
24
|
|
|
9
25
|
if (dependency.probe !== undefined && dependency.probe !== false)
|
|
10
26
|
composition.probe ??= dependency.probe
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a workload takes on by running services in its own pod: their variables, the ports
|
|
3
|
+
* they bind, and a readiness probe answered by one of them rather than by the workload.
|
|
4
|
+
*
|
|
5
|
+
* What it does *not* take on is how they are reached. A composition-hosted service keeps
|
|
6
|
+
* its own Service and Ingress — only the pods behind them change — so the ingress union
|
|
7
|
+
* belongs to `mono`, the one workload that fronts every service under a single name.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} workload
|
|
10
|
+
* @param {toa.deployment.dependency.Service[]} services
|
|
11
|
+
* @param {toa.deployment.Dependency} dependency
|
|
12
|
+
*/
|
|
13
|
+
export function fold (workload, services, dependency) {
|
|
14
|
+
workload.variables ??= []
|
|
15
|
+
|
|
16
|
+
for (const service of services) {
|
|
17
|
+
if (service.variables !== undefined)
|
|
18
|
+
for (const variable of service.variables)
|
|
19
|
+
if (!workload.variables.some((item) => item.name === variable.name))
|
|
20
|
+
workload.variables.push(variable)
|
|
21
|
+
|
|
22
|
+
// every declared port is bound by the single process, none is primary
|
|
23
|
+
if (service.port !== undefined)
|
|
24
|
+
(workload.backends ??= []).push({ port: service.port, path: service.ingress?.path ?? '/' })
|
|
25
|
+
|
|
26
|
+
if (service.probe !== undefined && service.probe !== false)
|
|
27
|
+
workload.probe = service.probe
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (workload.probe === undefined && dependency.probe !== undefined && dependency.probe !== false)
|
|
31
|
+
workload.probe = dependency.probe
|
|
32
|
+
|
|
33
|
+
// the more specific prefix must come first, whatever the controller's tie-break
|
|
34
|
+
workload.backends?.sort((a, b) => b.path.length - a.path.length)
|
|
35
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { fold } from './fold.js'
|
|
5
|
+
|
|
6
|
+
const service = (name, extra = {}) => ({ group: 'group', name, version: '0', ...extra })
|
|
7
|
+
|
|
8
|
+
it('should collect the ports of every service', () => {
|
|
9
|
+
const workload = {}
|
|
10
|
+
|
|
11
|
+
fold(workload, [service('one', { port: 8000 }), service('two', { port: 8002 })], {})
|
|
12
|
+
|
|
13
|
+
assert.deepStrictEqual(workload.backends,
|
|
14
|
+
[{ port: 8000, path: '/' }, { port: 8002, path: '/' }])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should take the path a service declares', () => {
|
|
18
|
+
const workload = {}
|
|
19
|
+
|
|
20
|
+
fold(workload, [service('one', { port: 8002, ingress: { path: '/explorer' } })], {})
|
|
21
|
+
|
|
22
|
+
assert.deepStrictEqual(workload.backends, [{ port: 8002, path: '/explorer' }])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should put the more specific prefix first', () => {
|
|
26
|
+
const workload = {}
|
|
27
|
+
|
|
28
|
+
fold(workload, [
|
|
29
|
+
service('one', { port: 8000, ingress: { path: '/' } }),
|
|
30
|
+
service('two', { port: 8002, ingress: { path: '/explorer' } })
|
|
31
|
+
], {})
|
|
32
|
+
|
|
33
|
+
assert.deepStrictEqual(workload.backends.map((backend) => backend.path), ['/explorer', '/'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should leave a service without a port out of the backends', () => {
|
|
37
|
+
const workload = {}
|
|
38
|
+
|
|
39
|
+
fold(workload, [service('one')], {})
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(workload.backends, undefined)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('variables', () => {
|
|
45
|
+
it('should take those of every service', () => {
|
|
46
|
+
const workload = {}
|
|
47
|
+
|
|
48
|
+
fold(workload, [
|
|
49
|
+
service('one', { variables: [{ name: 'A', value: '1' }] }),
|
|
50
|
+
service('two', { variables: [{ name: 'B', value: '2' }] })
|
|
51
|
+
], {})
|
|
52
|
+
|
|
53
|
+
assert.deepStrictEqual(workload.variables, [{ name: 'A', value: '1' }, { name: 'B', value: '2' }])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should keep the first of a name', () => {
|
|
57
|
+
const workload = { variables: [{ name: 'A', value: 'own' }] }
|
|
58
|
+
|
|
59
|
+
fold(workload, [service('one', { variables: [{ name: 'A', value: 'theirs' }] })], {})
|
|
60
|
+
|
|
61
|
+
assert.deepStrictEqual(workload.variables, [{ name: 'A', value: 'own' }])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('probe', () => {
|
|
66
|
+
const probe = { path: '/.ready', port: 8004 }
|
|
67
|
+
const fallback = { path: '/.ready', port: 8001 }
|
|
68
|
+
|
|
69
|
+
it('should take the probe of a service over the default', () => {
|
|
70
|
+
const workload = {}
|
|
71
|
+
|
|
72
|
+
fold(workload, [service('one', { port: 8000, probe })], { probe: fallback })
|
|
73
|
+
|
|
74
|
+
assert.deepStrictEqual(workload.probe, probe)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should fall back to the default', () => {
|
|
78
|
+
const workload = {}
|
|
79
|
+
|
|
80
|
+
fold(workload, [service('one', { port: 8000 })], { probe: fallback })
|
|
81
|
+
|
|
82
|
+
assert.deepStrictEqual(workload.probe, fallback)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should ignore a disabled probe', () => {
|
|
86
|
+
const workload = {}
|
|
87
|
+
|
|
88
|
+
fold(workload, [service('one', { port: 8000, probe: false })], { probe: false })
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(workload.probe, undefined)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -28,6 +28,11 @@ function * units (values) {
|
|
|
28
28
|
for (const composition of values.compositions ?? [])
|
|
29
29
|
yield { deployment: composition, subject: `Composition '${composition.name}'` }
|
|
30
30
|
|
|
31
|
-
for (const service of values.services ?? [])
|
|
31
|
+
for (const service of values.services ?? []) {
|
|
32
|
+
// a service a composition runs has no deployment of its own to size; the composition
|
|
33
|
+
// it runs in states what the pod may take
|
|
34
|
+
if (service.workload !== undefined) continue
|
|
35
|
+
|
|
32
36
|
yield { deployment: service, subject: `Service '${service.name}'` }
|
|
37
|
+
}
|
|
33
38
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { resources } from './resources.js'
|
|
5
|
+
|
|
6
|
+
const declared = { cpu: ['100m', '1'], memory: ['100Mi', '1Gi'] }
|
|
7
|
+
|
|
8
|
+
it('should take the declaration of a deployment', () => {
|
|
9
|
+
const values = { compositions: [{ name: 'edge', resources: declared }] }
|
|
10
|
+
|
|
11
|
+
resources({}, values)
|
|
12
|
+
|
|
13
|
+
assert.deepStrictEqual(values.compositions[0].resources, declared)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should fall back to the context', () => {
|
|
17
|
+
const values = { compositions: [{ name: 'edge' }] }
|
|
18
|
+
|
|
19
|
+
resources({ resources: declared }, values)
|
|
20
|
+
|
|
21
|
+
assert.deepStrictEqual(values.compositions[0].resources, declared)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should take `null` as an answer', () => {
|
|
25
|
+
const values = { compositions: [{ name: 'edge', resources: null }] }
|
|
26
|
+
|
|
27
|
+
assert.doesNotThrow(() => resources({ resources: declared }, values))
|
|
28
|
+
assert.strictEqual(values.compositions[0].resources, null)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('refusal', () => {
|
|
32
|
+
it('should refuse a composition that declares none', () => {
|
|
33
|
+
const values = { compositions: [{ name: 'edge' }] }
|
|
34
|
+
|
|
35
|
+
assert.throws(() => resources({}, values),
|
|
36
|
+
(error) => /^Composition 'edge' declares no resources\./.test(error.message))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should refuse a service that declares none', () => {
|
|
40
|
+
const values = { services: [{ name: 'exposition-gateway' }] }
|
|
41
|
+
|
|
42
|
+
assert.throws(() => resources({}, values),
|
|
43
|
+
(error) => /^Service 'exposition-gateway' declares no resources\./.test(error.message))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should refuse the mono deployment when it declares none', () => {
|
|
47
|
+
const values = { mono: {} }
|
|
48
|
+
|
|
49
|
+
assert.throws(() => resources({}, values),
|
|
50
|
+
(error) => /^The mono deployment declares no resources\./.test(error.message))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should not ask a service a composition runs', () => {
|
|
54
|
+
// it has no deployment of its own to size; the composition running it states the pod's
|
|
55
|
+
const values = {
|
|
56
|
+
compositions: [{ name: 'edge', resources: declared }],
|
|
57
|
+
services: [{ name: 'exposition-gateway', workload: ['edge'] }]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
assert.doesNotThrow(() => resources({}, values))
|
|
61
|
+
assert.strictEqual(values.services[0].resources, undefined)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -2,7 +2,9 @@ import { addVariables } from './variables.js'
|
|
|
2
2
|
|
|
3
3
|
export function services (services, variables, probe, ingress) {
|
|
4
4
|
for (const service of services) {
|
|
5
|
-
|
|
5
|
+
// one a composition runs has no deployment of its own to carry them; the composition
|
|
6
|
+
// it runs in states them, and repeating them here is dead weight in the values
|
|
7
|
+
if (service.workload === undefined) addVariables(service, variables)
|
|
6
8
|
|
|
7
9
|
if (service.probe === false)
|
|
8
10
|
delete service.probe
|
|
@@ -3,6 +3,7 @@ import { addVariables } from './.describe/variables.js'
|
|
|
3
3
|
import { addMounts } from './.describe/mounts.js'
|
|
4
4
|
import { resources } from './.describe/resources.js'
|
|
5
5
|
import { events } from './.describe/events.js'
|
|
6
|
+
import { fold } from './.describe/fold.js'
|
|
6
7
|
|
|
7
8
|
export const describe = (context, compositions, dependency, image) => {
|
|
8
9
|
const { services } = dependency
|
|
@@ -120,16 +121,11 @@ function unit (context, dependency) {
|
|
|
120
121
|
addVariables(mono, variables, Object.keys(variables))
|
|
121
122
|
addMounts(mono, dependency.mounts, Object.keys(mounts))
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
if (service.variables !== undefined)
|
|
125
|
-
for (const variable of service.variables)
|
|
126
|
-
if (!mono.variables.some((item) => item.name === variable.name))
|
|
127
|
-
mono.variables.push(variable)
|
|
128
|
-
|
|
129
|
-
// every declared port is bound by the single mono process, none is primary
|
|
130
|
-
if (service.port !== undefined)
|
|
131
|
-
(mono.backends ??= []).push({ port: service.port, path: service.ingress?.path ?? '/' })
|
|
124
|
+
fold(mono, dependency.services ?? [], dependency)
|
|
132
125
|
|
|
126
|
+
// mono is the one workload that fronts every service it runs under a single name,
|
|
127
|
+
// so it inherits how they are reached as well as what they run
|
|
128
|
+
for (const service of dependency.services ?? []) {
|
|
133
129
|
// one Service fronts every backend, so its annotations are the union
|
|
134
130
|
if (service.annotations !== undefined)
|
|
135
131
|
mono.annotations = Object.assign(mono.annotations ?? {}, service.annotations)
|
|
@@ -143,16 +139,7 @@ function unit (context, dependency) {
|
|
|
143
139
|
if (hosts !== undefined)
|
|
144
140
|
mono.ingress.hosts = [...new Set([...(mono.ingress.hosts ?? []), ...hosts])]
|
|
145
141
|
}
|
|
146
|
-
|
|
147
|
-
if (service.probe !== undefined && service.probe !== false)
|
|
148
|
-
mono.probe = service.probe
|
|
149
142
|
}
|
|
150
143
|
|
|
151
|
-
if (mono.probe === undefined && dependency.probe !== undefined && dependency.probe !== false)
|
|
152
|
-
mono.probe = dependency.probe
|
|
153
|
-
|
|
154
|
-
// the more specific prefix must come first, whatever the controller's tie-break
|
|
155
|
-
mono.backends?.sort((a, b) => b.path.length - a.path.length)
|
|
156
|
-
|
|
157
144
|
return mono
|
|
158
145
|
}
|
|
@@ -35,28 +35,43 @@ export const merge = (dependencies) => {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* A workload that hosts services puts them in one process — `mono`, a composition that
|
|
39
|
+
* declares them, a local run — so within one a port may be claimed once and only once.
|
|
40
|
+
* A service deploying on its own is a pod of its own, and shares a port with nobody.
|
|
40
41
|
*/
|
|
41
42
|
const reserve = (services, probe) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (probe !== undefined && probe !== false)
|
|
45
|
-
claimed.set(probe.port, 'the readiness probe')
|
|
43
|
+
/** @type {Map<string, Map<number, string>>} */
|
|
44
|
+
const workloads = new Map()
|
|
46
45
|
|
|
47
46
|
for (const service of services)
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// a service several compositions run binds its ports in each of their pods
|
|
48
|
+
for (const workload of service.workload ?? [service.name]) {
|
|
49
|
+
let claimed = workloads.get(workload)
|
|
50
|
+
|
|
51
|
+
if (claimed === undefined) {
|
|
52
|
+
claimed = new Map()
|
|
53
|
+
|
|
54
|
+
if (probe !== undefined && probe !== false)
|
|
55
|
+
claimed.set(probe.port, 'the readiness probe')
|
|
56
|
+
|
|
57
|
+
workloads.set(workload, claimed)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [port, claimant] of ports(service)) {
|
|
61
|
+
const conflicting = claimed.get(port)
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
if (conflicting !== undefined)
|
|
64
|
+
throw new Error(`Port ${port} is claimed by both ${conflicting} and ${claimant}` +
|
|
65
|
+
(service.workload === undefined ? '' : ` in '${workload}'`))
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
claimed.set(port, claimant)
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
function * ports (service) {
|
|
59
|
-
|
|
73
|
+
// every service reaching here is named the way it is deployed, `<group>-<name>`
|
|
74
|
+
const name = `'${service.name}'`
|
|
60
75
|
|
|
61
76
|
if (service.port !== undefined)
|
|
62
77
|
yield [service.port, name]
|
|
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import { merge } from './merge.js'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// a service is named the way it is deployed by the time it reaches `merge`
|
|
7
|
+
const service = (name, extra = {}) => ({ group: 'group', name: `group-${name}`, version: '0', ...extra })
|
|
7
8
|
|
|
8
9
|
it('should merge services of all dependencies', () => {
|
|
9
10
|
const merged = merge([
|
|
@@ -15,17 +16,46 @@ it('should merge services of all dependencies', () => {
|
|
|
15
16
|
})
|
|
16
17
|
|
|
17
18
|
/*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* A workload that hosts services puts them in one process, so a port is claimed once
|
|
20
|
+
* within one. Services in separate pods share nothing.
|
|
20
21
|
*/
|
|
21
22
|
describe('port reservation', () => {
|
|
22
|
-
|
|
23
|
+
const hosted = (name, extra = {}) => service(name, { workload: ['mono'], ...extra })
|
|
24
|
+
|
|
25
|
+
it('should reject two services of one workload claiming one port', () => {
|
|
26
|
+
const dependencies = [
|
|
27
|
+
{ services: [hosted('one', { port: 8000 })] },
|
|
28
|
+
{ services: [hosted('two', { port: 8000 })] }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
assert.throws(() => merge(dependencies), (error) => /Port 8000 is claimed by both 'group-one' and 'group-two' in 'mono'/.test(error.message))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should allow two workloads to claim one port', () => {
|
|
35
|
+
const dependencies = [
|
|
36
|
+
{ services: [service('one', { port: 8000, workload: ['edge'] })] },
|
|
37
|
+
{ services: [service('two', { port: 8000, workload: ['inner'] })] }
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
assert.doesNotThrow(() => merge(dependencies))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should reserve the ports of a service in every workload running it', () => {
|
|
44
|
+
const dependencies = [
|
|
45
|
+
{ services: [service('one', { port: 8000, workload: ['edge', 'inner'] })] },
|
|
46
|
+
{ services: [service('two', { port: 8000, workload: ['inner'] })] }
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
assert.throws(() => merge(dependencies), (error) => /Port 8000 is claimed by both 'group-one' and 'group-two' in 'inner'/.test(error.message))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should allow two services deployed on their own to claim one port', () => {
|
|
23
53
|
const dependencies = [
|
|
24
54
|
{ services: [service('one', { port: 8000 })] },
|
|
25
55
|
{ services: [service('two', { port: 8000 })] }
|
|
26
56
|
]
|
|
27
57
|
|
|
28
|
-
assert.
|
|
58
|
+
assert.doesNotThrow(() => merge(dependencies))
|
|
29
59
|
})
|
|
30
60
|
|
|
31
61
|
it('should reject a service claiming the port of the readiness probe', () => {
|
|
@@ -37,13 +67,13 @@ describe('port reservation', () => {
|
|
|
37
67
|
assert.throws(() => merge(dependencies), (error) => /Port 8001 is claimed by both the readiness probe and 'group-one'/.test(error.message))
|
|
38
68
|
})
|
|
39
69
|
|
|
40
|
-
it('should reject a probe claiming the port of another service', () => {
|
|
70
|
+
it('should reject a probe claiming the port of another service of the workload', () => {
|
|
41
71
|
const dependencies = [
|
|
42
|
-
{ services: [
|
|
43
|
-
{ services: [
|
|
72
|
+
{ services: [hosted('one', { port: 8000 })] },
|
|
73
|
+
{ services: [hosted('two', { port: 8002, probe: { path: '/.ready', port: 8000 } })] }
|
|
44
74
|
]
|
|
45
75
|
|
|
46
|
-
assert.throws(() => merge(dependencies), (error) => /Port 8000 is claimed by both 'group-one' and the readiness probe of 'group-two'/.test(error.message))
|
|
76
|
+
assert.throws(() => merge(dependencies), (error) => /Port 8000 is claimed by both 'group-one' and the readiness probe of 'group-two' in 'mono'/.test(error.message))
|
|
47
77
|
})
|
|
48
78
|
|
|
49
79
|
it('should allow a service to probe its own port', () => {
|
|
@@ -10,13 +10,16 @@ spec:
|
|
|
10
10
|
progressDeadlineSeconds: 900
|
|
11
11
|
selector:
|
|
12
12
|
matchLabels:
|
|
13
|
-
toa
|
|
13
|
+
toa/composition: {{ .name }}
|
|
14
14
|
template:
|
|
15
15
|
metadata:
|
|
16
16
|
labels:
|
|
17
|
-
toa
|
|
17
|
+
toa/composition: {{ .name }}
|
|
18
18
|
{{- range .components }}
|
|
19
|
-
{{ . }}: "1"
|
|
19
|
+
toa/component-{{ . }}: "1"
|
|
20
|
+
{{- end }}
|
|
21
|
+
{{- range .hosted }}
|
|
22
|
+
toa/service-{{ . }}: "1"
|
|
20
23
|
{{- end }}
|
|
21
24
|
spec:
|
|
22
25
|
containers:
|
|
@@ -41,15 +44,26 @@ spec:
|
|
|
41
44
|
{{- end }}
|
|
42
45
|
{{- end }}
|
|
43
46
|
{{- end }}
|
|
44
|
-
{{- if .variables }}
|
|
47
|
+
{{- if or .variables .services }}
|
|
45
48
|
env:
|
|
49
|
+
{{- if .services }}
|
|
50
|
+
- name: TOA_SERVICES
|
|
51
|
+
value: {{ join " " .services | quote }}
|
|
52
|
+
{{- end }}
|
|
46
53
|
{{- range .variables }}
|
|
47
54
|
{{- include "env.var" . | indent 12 }}
|
|
48
55
|
{{- end }}
|
|
49
56
|
{{- end }}
|
|
50
|
-
{{- if .probe }}
|
|
57
|
+
{{- if or .backends .probe }}
|
|
51
58
|
ports:
|
|
59
|
+
{{- range .backends }}
|
|
60
|
+
- containerPort: {{ .port }}
|
|
61
|
+
{{- end }}
|
|
62
|
+
{{- if .probe }}
|
|
52
63
|
- containerPort: {{ .probe.port }}
|
|
64
|
+
{{- end }}
|
|
65
|
+
{{- end }}
|
|
66
|
+
{{- if .probe }}
|
|
53
67
|
startupProbe:
|
|
54
68
|
httpGet:
|
|
55
69
|
path: {{ .probe.path }}
|
|
@@ -98,6 +112,6 @@ spec:
|
|
|
98
112
|
whenUnsatisfiable: ScheduleAnyway
|
|
99
113
|
labelSelector:
|
|
100
114
|
matchLabels:
|
|
101
|
-
toa
|
|
115
|
+
toa/composition: {{ .name }}
|
|
102
116
|
---
|
|
103
117
|
{{- end }}
|
|
@@ -14,13 +14,13 @@ spec:
|
|
|
14
14
|
maxSurge: 50%
|
|
15
15
|
selector:
|
|
16
16
|
matchLabels:
|
|
17
|
-
toa
|
|
17
|
+
toa/composition: mono
|
|
18
18
|
template:
|
|
19
19
|
metadata:
|
|
20
20
|
labels:
|
|
21
|
-
toa
|
|
21
|
+
toa/composition: mono
|
|
22
22
|
{{- range .components }}
|
|
23
|
-
{{ . }}: "1"
|
|
23
|
+
toa/component-{{ . }}: "1"
|
|
24
24
|
{{- end }}
|
|
25
25
|
spec:
|
|
26
26
|
containers:
|
|
@@ -109,7 +109,7 @@ spec:
|
|
|
109
109
|
whenUnsatisfiable: ScheduleAnyway
|
|
110
110
|
labelSelector:
|
|
111
111
|
matchLabels:
|
|
112
|
-
toa
|
|
112
|
+
toa/composition: mono
|
|
113
113
|
{{- if .backends }}
|
|
114
114
|
---
|
|
115
115
|
apiVersion: v1
|
|
@@ -123,7 +123,7 @@ metadata:
|
|
|
123
123
|
spec:
|
|
124
124
|
type: ClusterIP
|
|
125
125
|
selector:
|
|
126
|
-
toa
|
|
126
|
+
toa/composition: mono
|
|
127
127
|
ports:
|
|
128
128
|
{{- range .backends }}
|
|
129
129
|
- name: port-{{ .port }}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{{- range .Values.services }}
|
|
2
|
+
{{- if not .workload }}
|
|
2
3
|
apiVersion: apps/v1
|
|
3
4
|
kind: Deployment
|
|
4
5
|
metadata:
|
|
@@ -17,11 +18,12 @@ spec:
|
|
|
17
18
|
maxSurge: 50%
|
|
18
19
|
selector:
|
|
19
20
|
matchLabels:
|
|
20
|
-
toa
|
|
21
|
+
toa/service: extension-{{ .name }}
|
|
21
22
|
template:
|
|
22
23
|
metadata:
|
|
23
24
|
labels:
|
|
24
|
-
toa
|
|
25
|
+
toa/service: extension-{{ .name }}
|
|
26
|
+
toa/service-{{ .name }}: "1"
|
|
25
27
|
spec:
|
|
26
28
|
containers:
|
|
27
29
|
- name: extension-{{ .name }}
|
|
@@ -81,7 +83,8 @@ spec:
|
|
|
81
83
|
whenUnsatisfiable: ScheduleAnyway
|
|
82
84
|
labelSelector:
|
|
83
85
|
matchLabels:
|
|
84
|
-
toa
|
|
86
|
+
toa/service: extension-{{ .name }}
|
|
87
|
+
{{- end }}
|
|
85
88
|
{{- if .port }}
|
|
86
89
|
---
|
|
87
90
|
apiVersion: v1
|
|
@@ -95,7 +98,7 @@ metadata:
|
|
|
95
98
|
spec:
|
|
96
99
|
type: ClusterIP
|
|
97
100
|
selector:
|
|
98
|
-
toa
|
|
101
|
+
toa/service-{{ .name }}: "1"
|
|
99
102
|
ports:
|
|
100
103
|
- name: port-{{ .port }}
|
|
101
104
|
protocol: TCP
|
|
@@ -25,11 +25,23 @@ compositions:
|
|
|
25
25
|
replicas: 3
|
|
26
26
|
components:
|
|
27
27
|
- users-users
|
|
28
|
+
# the extensions whose services this composition runs in its own pod
|
|
29
|
+
services:
|
|
30
|
+
- '@toa.io/extensions.exposition'
|
|
31
|
+
# each of those, as its own Service selects it
|
|
32
|
+
hosted:
|
|
33
|
+
- exposition-gateway
|
|
34
|
+
# the ports they bind
|
|
35
|
+
backends:
|
|
36
|
+
- port: 8000
|
|
37
|
+
path: /
|
|
28
38
|
components:
|
|
29
39
|
- todos-tasks
|
|
30
40
|
- todos-stats
|
|
31
41
|
- users-users
|
|
32
42
|
services:
|
|
43
|
+
# a service a composition runs carries `workload`, and gets no deployment of its own —
|
|
44
|
+
# its Service and Ingress stay, and select that composition's pods
|
|
33
45
|
- name: resources-gateway
|
|
34
46
|
image: localhost:5000/resources-gateway:0.0.0
|
|
35
47
|
port: 8000
|
|
@@ -9,6 +9,9 @@ export class Composition {
|
|
|
9
9
|
this.image = image.reference
|
|
10
10
|
this.components = composition.components.map(component)
|
|
11
11
|
this.resources = composition.resources
|
|
12
|
+
|
|
13
|
+
// the extensions whose services this composition runs, as `TOA_SERVICES` for the process
|
|
14
|
+
if (composition.services !== undefined) this.services = composition.services
|
|
12
15
|
}
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -23,6 +23,12 @@ export class Factory {
|
|
|
23
23
|
#process
|
|
24
24
|
#image
|
|
25
25
|
|
|
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
|
+
|
|
26
32
|
constructor (context, options = {}) {
|
|
27
33
|
this.#context = context
|
|
28
34
|
this.#mono = options.mono === true
|
|
@@ -31,6 +37,7 @@ export class Factory {
|
|
|
31
37
|
const imagesFactory = new ImagesFactory(context.name, context.runtime, context.registry)
|
|
32
38
|
|
|
33
39
|
this.#registry = new Registry(context.name, context.registry, imagesFactory, this.#process)
|
|
40
|
+
this.#claims = claims(context)
|
|
34
41
|
this.#dependencies = this.#getDependencies()
|
|
35
42
|
this.#compositions = []
|
|
36
43
|
|
|
@@ -71,12 +78,26 @@ export class Factory {
|
|
|
71
78
|
|
|
72
79
|
if (this.#context.dependencies === undefined) return dependencies
|
|
73
80
|
|
|
81
|
+
/** the claimed references that turned out to contribute a service */
|
|
82
|
+
const contributing = new Set()
|
|
83
|
+
|
|
74
84
|
for (const [reference, instances] of Object.entries(this.#context.dependencies)) {
|
|
75
85
|
const dependency = await this.#getDependency(reference, instances)
|
|
76
86
|
|
|
77
|
-
if (dependency
|
|
87
|
+
if (dependency === undefined) continue
|
|
88
|
+
|
|
89
|
+
if (dependency.services?.length > 0) contributing.add(reference)
|
|
90
|
+
|
|
91
|
+
dependencies.push(dependency)
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
// this is the first point where every `deployment()` has run, so it is the first
|
|
95
|
+
// point where a reference that yields nothing can be told from one that yields a service
|
|
96
|
+
for (const [reference, compositions] of this.#claims)
|
|
97
|
+
if (!contributing.has(reference))
|
|
98
|
+
throw new Error(`Composition '${compositions[0]}' lists '${reference}', ` +
|
|
99
|
+
'which contributes no service.')
|
|
100
|
+
|
|
80
101
|
return dependencies
|
|
81
102
|
}
|
|
82
103
|
|
|
@@ -93,9 +114,16 @@ export class Factory {
|
|
|
93
114
|
/** @type {toa.deployment.dependency.Declaration} */
|
|
94
115
|
const dependency = module.deployment(instances, annotation)
|
|
95
116
|
|
|
117
|
+
// mono claims every service, including one an extension added since this context was
|
|
118
|
+
// written, so its claim is the wildcard rather than a list
|
|
119
|
+
const workload = this.#mono ? [MONO] : this.#claims.get(reference)
|
|
120
|
+
|
|
96
121
|
/** @type {toa.deployment.Service[]} */
|
|
97
122
|
const services = dependency.services?.map((service) =>
|
|
98
|
-
|
|
123
|
+
workload === undefined
|
|
124
|
+
? this.#service(reference, service) // its own deployment, its own image
|
|
125
|
+
// named the way `Service` would name it, since it skips that wrapper
|
|
126
|
+
: { ...service, name: `${service.group}-${service.name}`, workload })
|
|
99
127
|
|
|
100
128
|
return { ...dependency, services }
|
|
101
129
|
}
|
|
@@ -117,3 +145,22 @@ export class Factory {
|
|
|
117
145
|
return new Factory(context, options)
|
|
118
146
|
}
|
|
119
147
|
}
|
|
148
|
+
|
|
149
|
+
const MONO = 'mono'
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {toa.norm.Context} context
|
|
153
|
+
* @returns {Map<string, string[]>}
|
|
154
|
+
*/
|
|
155
|
+
function claims (context) {
|
|
156
|
+
const map = new Map()
|
|
157
|
+
|
|
158
|
+
for (const composition of context.compositions ?? [])
|
|
159
|
+
for (const reference of composition.services ?? []) {
|
|
160
|
+
if (!map.has(reference)) map.set(reference, [])
|
|
161
|
+
|
|
162
|
+
map.get(reference).push(composition.name)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return map
|
|
166
|
+
}
|
|
@@ -38,13 +38,19 @@ export class Image {
|
|
|
38
38
|
this.reference = posix.join(this.#registry.base ?? '', this.#scope, `${this.name}:${tag}`)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** @returns {string | undefined} */
|
|
42
|
+
get name () { return undefined }
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/** @returns {string | undefined} */
|
|
45
|
+
get version () { return undefined }
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
/** The image to build `FROM`. Undefined takes the runtime's, which is what a service does.
|
|
48
|
+
* @returns {string | undefined} */
|
|
49
|
+
get base () { return undefined }
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
/** Build commands to add. Undefined adds none, which is what a service does.
|
|
52
|
+
* @returns {string | undefined} */
|
|
53
|
+
get run () { return undefined }
|
|
48
54
|
|
|
49
55
|
async prepare (root) {
|
|
50
56
|
if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
|
package/types/dependency.ts
CHANGED
|
@@ -13,6 +13,9 @@ export interface Service {
|
|
|
13
13
|
variables?: Variable[]
|
|
14
14
|
components?: string[]
|
|
15
15
|
probe?: Probe | false
|
|
16
|
+
/** The workloads running this service, when compositions declared it instead of letting
|
|
17
|
+
* it deploy on its own. Set by the deployment, never by an extension. */
|
|
18
|
+
workload?: string[]
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface Variable {
|