@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 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.280",
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.278",
32
- "@toa.io/norm": "1.0.0-alpha.278",
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": "2cf23f576c46efbe56c21427d877cef3e7ed98d4"
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
- addVariables(composition, dependency.variables)
7
- addMounts(composition, dependency.mounts)
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
- addVariables(service, variables)
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
- for (const service of dependency.services ?? []) {
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
- * In Kubernetes these are separate pods, but `toa mono` and a local run put every
39
- * service in one process — so a port may be claimed once and only once.
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
- const claimed = new Map()
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
- for (const [port, claimant] of ports(service)) {
49
- const conflicting = claimed.get(port)
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
- if (conflicting !== undefined)
52
- throw new Error(`Port ${port} is claimed by both ${conflicting} and ${claimant}`)
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
- claimed.set(port, claimant)
67
+ claimed.set(port, claimant)
68
+ }
55
69
  }
56
70
  }
57
71
 
58
72
  function * ports (service) {
59
- const name = `'${service.group}-${service.name}'`
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
- const service = (name, extra = {}) => ({ group: 'group', name, version: '0', ...extra })
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
- * In Kubernetes these are separate pods, but `toa mono` and a local run put every
19
- * service in one process.
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
- it('should reject two services claiming one port', () => {
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.throws(() => merge(dependencies), (error) => /Port 8000 is claimed by both 'group-one' and 'group-two'/.test(error.message))
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: [service('one', { port: 8000 })] },
43
- { services: [service('two', { port: 8002, probe: { path: '/.ready', port: 8000 } })] }
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', () => {
@@ -6,7 +6,7 @@ metadata:
6
6
  spec:
7
7
  type: ClusterIP
8
8
  selector:
9
- {{ . }}: "1"
9
+ toa/component-{{ . }}: "1"
10
10
  ports:
11
11
  - name: http-binding
12
12
  protocol: TCP
@@ -10,13 +10,16 @@ spec:
10
10
  progressDeadlineSeconds: 900
11
11
  selector:
12
12
  matchLabels:
13
- toa.io/composition: {{ .name }}
13
+ toa/composition: {{ .name }}
14
14
  template:
15
15
  metadata:
16
16
  labels:
17
- toa.io/composition: {{ .name }}
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.io/composition: {{ .name }}
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.io/composition: mono
17
+ toa/composition: mono
18
18
  template:
19
19
  metadata:
20
20
  labels:
21
- toa.io/composition: mono
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.io/composition: mono
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.io/composition: mono
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.io/service: extension-{{ .name }}
21
+ toa/service: extension-{{ .name }}
21
22
  template:
22
23
  metadata:
23
24
  labels:
24
- toa.io/service: extension-{{ .name }}
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.io/service: extension-{{ .name }}
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.io/service: extension-{{ .name }}
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 !== undefined) dependencies.push(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
- this.#mono ? service : this.#service(reference, service))
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
- get name () {}
41
+ /** @returns {string | undefined} */
42
+ get name () { return undefined }
42
43
 
43
- get version () {}
44
+ /** @returns {string | undefined} */
45
+ get version () { return undefined }
44
46
 
45
- get base () {}
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
- get run () {}
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')
@@ -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 {