@toa.io/operations 1.0.0-alpha.261 → 1.0.0-alpha.263

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,42 @@
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.263](https://github.com/toa-io/toa/compare/v1.0.0-alpha.262...v1.0.0-alpha.263) (2026-08-29)
7
+
8
+ **Note:** Version bump only for package @toa.io/operations
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.262](https://github.com/toa-io/toa/compare/v1.0.0-alpha.261...v1.0.0-alpha.262) (2026-08-28)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **cli:** boot toa mono from components and env, not context ([1518f72](https://github.com/toa-io/toa/commit/1518f729d7611ae88e0538f286231d283a7098ef))
20
+
21
+
22
+ * feat(operations)!: require service ports to be unique ([b22cf87](https://github.com/toa-io/toa/commit/b22cf8770d24c555b5f355a5f117a9043a57799b))
23
+
24
+
25
+ ### Features
26
+
27
+ * **operations:** let a service claim a path prefix on the host ([02677d1](https://github.com/toa-io/toa/commit/02677d17fcd4222b07c3be458d93bcc496a016a8))
28
+
29
+
30
+ ### BREAKING CHANGES
31
+
32
+ * an application whose own extension runs a service on a port
33
+ already taken by another — 8000 by the exposition gateway, 8001 by the telemetry
34
+ readiness probe — must move it.
35
+
36
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
37
+
38
+
39
+
40
+
41
+
6
42
  # [1.0.0-alpha.261](https://github.com/toa-io/toa/compare/v1.0.0-alpha.260...v1.0.0-alpha.261) (2026-08-26)
7
43
 
8
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.261",
3
+ "version": "1.0.0-alpha.263",
4
4
  "description": "Toa Deployment",
5
5
  "homepage": "https://toa.io",
6
6
  "author": {
@@ -29,10 +29,10 @@
29
29
  "dependencies": {
30
30
  "@toa.io/filesystem": "1.0.0-alpha.254",
31
31
  "@toa.io/generic": "1.0.0-alpha.254",
32
- "@toa.io/norm": "1.0.0-alpha.259",
32
+ "@toa.io/norm": "1.0.0-alpha.263",
33
33
  "@toa.io/yaml": "1.0.0-alpha.254",
34
34
  "execa": "5.1.1",
35
35
  "fs-extra": "11.1.1"
36
36
  },
37
- "gitHead": "0351da80ae8f1757c950a6787521941931d400c4"
37
+ "gitHead": "165daa284bb7a75907a593fde359e3553b083417"
38
38
  }
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Every deployment states what it may take. One that states nothing is `BestEffort`: the
5
+ * first evicted under memory pressure and the last given CPU under contention — which on
6
+ * a busy node is slow enough for its own startup probe to kill it, in a loop, while the
7
+ * chart reports nothing wrong. That is not a thing to find out in production.
8
+ *
9
+ * A deployment takes its own declaration, or the context's `resources` where it has none.
10
+ * Deploying without any is a decision rather than an omission, and it is spelled
11
+ * `resources: null` — at either place.
12
+ */
13
+ function resources (context, values) {
14
+ for (const unit of units(values)) {
15
+ // `null` is an answer and `undefined` is not one, so the fallback reads only the latter
16
+ if (unit.deployment.resources === undefined)
17
+ unit.deployment.resources = context.resources
18
+
19
+ if (unit.deployment.resources === undefined)
20
+ throw new Error(`${unit.subject} declares no resources. ` +
21
+ 'Declare them on it or as the context\'s \'resources\', ' +
22
+ 'or \'resources: null\' to deploy it without any.')
23
+ }
24
+ }
25
+
26
+ function * units (values) {
27
+ if (values.mono !== undefined)
28
+ yield { deployment: values.mono, subject: 'The mono deployment' }
29
+
30
+ for (const composition of values.compositions ?? [])
31
+ yield { deployment: composition, subject: `Composition '${composition.name}'` }
32
+
33
+ for (const service of values.services ?? [])
34
+ yield { deployment: service, subject: `Service '${service.name}'` }
35
+ }
36
+
37
+ exports.resources = resources
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { addVariables } = require('./variables')
4
4
 
5
- function services (services, variables, probe) {
5
+ function services (services, variables, probe, ingress) {
6
6
  for (const service of services) {
7
7
  addVariables(service, variables)
8
8
 
@@ -10,7 +10,29 @@ function services (services, variables, probe) {
10
10
  delete service.probe
11
11
  else if (service.probe === undefined && probe !== undefined && probe !== false)
12
12
  service.probe = probe
13
+
14
+ if (service.ingress !== undefined)
15
+ expose(service, ingress)
13
16
  }
14
17
  }
15
18
 
19
+ /**
20
+ * A service declares only its own intent — a path, a port. Where that lands is
21
+ * the context's business, so everything else comes from its `ingress` section.
22
+ * What the service did declare wins.
23
+ */
24
+ function expose (service, ingress = {}) {
25
+ const declared = Object.fromEntries(
26
+ Object.entries(service.ingress).filter(([, value]) => value !== undefined))
27
+
28
+ service.ingress = Object.assign({}, ingress, declared)
29
+
30
+ if (service.ingress.hosts === undefined)
31
+ throw new Error(`Service '${service.name}' declares an ingress, but no hosts are defined. ` +
32
+ "Declare them in the context's 'ingress' section.")
33
+
34
+ if (service.port === undefined)
35
+ throw new Error(`Service '${service.name}' declares an ingress, but no port.`)
36
+ }
37
+
16
38
  exports.services = services
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const { services } = require('./services')
4
+
5
+ const service = (extra = {}) => ({ group: 'group', name: 'group-one', version: '0', ...extra })
6
+
7
+ it('should leave a service without an ingress alone', () => {
8
+ const list = [service()]
9
+
10
+ services(list, {}, undefined, { hosts: ['api.dev'] })
11
+
12
+ expect(list[0].ingress).toBeUndefined()
13
+ })
14
+
15
+ /*
16
+ * A service declares only where it wants to land; the cluster-level plumbing
17
+ * belongs to the context.
18
+ */
19
+ it('should supply hosts, class and annotations from the context', () => {
20
+ const list = [service({ port: 8002, ingress: { path: '/.introspection' } })]
21
+
22
+ services(list, {}, undefined, { hosts: ['api.dev'], class: 'alb', annotations: { a: 'b' } })
23
+
24
+ expect(list[0].ingress).toStrictEqual({
25
+ path: '/.introspection',
26
+ hosts: ['api.dev'],
27
+ class: 'alb',
28
+ annotations: { a: 'b' }
29
+ })
30
+ })
31
+
32
+ it('should keep what the service declared itself', () => {
33
+ const list = [service({ port: 8000, ingress: { path: '/', hosts: ['own.dev'] } })]
34
+
35
+ services(list, {}, undefined, { hosts: ['api.dev'], class: 'alb' })
36
+
37
+ expect(list[0].ingress.hosts).toStrictEqual(['own.dev'])
38
+ expect(list[0].ingress.class).toStrictEqual('alb')
39
+ })
40
+
41
+ it('should ignore properties the service left undefined', () => {
42
+ const list = [service({ port: 8000, ingress: { path: '/', class: undefined } })]
43
+
44
+ services(list, {}, undefined, { hosts: ['api.dev'], class: 'alb' })
45
+
46
+ expect(list[0].ingress.class).toStrictEqual('alb')
47
+ })
48
+
49
+ it('should reject an ingress with nowhere to land', () => {
50
+ const list = [service({ port: 8002, ingress: { path: '/.introspection' } })]
51
+
52
+ expect(() => services(list, {}, undefined, undefined))
53
+ .toThrow("Service 'group-one' declares an ingress, but no hosts are defined")
54
+ })
55
+
56
+ it('should reject an ingress without a port', () => {
57
+ const list = [service({ ingress: { path: '/.introspection' } })]
58
+
59
+ expect(() => services(list, {}, undefined, { hosts: ['api.dev'] }))
60
+ .toThrow("Service 'group-one' declares an ingress, but no port")
61
+ })
@@ -3,6 +3,7 @@
3
3
  const desc = require('./.describe')
4
4
  const { addVariables } = require('./.describe/variables')
5
5
  const { addMounts } = require('./.describe/mounts')
6
+ const { resources } = require('./.describe/resources')
6
7
 
7
8
  const describe = (context, compositions, dependency, image) => {
8
9
  const { services } = dependency
@@ -26,26 +27,34 @@ const describe = (context, compositions, dependency, image) => {
26
27
 
27
28
  mono.image = image.reference
28
29
 
29
- return {
30
+ const values = {
30
31
  compositions: [],
31
32
  components: mono.components,
32
33
  services: [],
33
34
  credentials,
34
35
  mono
35
36
  }
37
+
38
+ resources(context, values)
39
+
40
+ return values
36
41
  }
37
42
 
38
43
  const components = desc.components(compositions)
39
44
 
40
45
  desc.compositions(compositions, dependency)
41
- desc.services(services, dependency.variables, dependency.probe)
46
+ desc.services(services, dependency.variables, dependency.probe, context.ingress)
42
47
 
43
- return {
48
+ const values = {
44
49
  compositions,
45
50
  components,
46
51
  services,
47
52
  credentials
48
53
  }
54
+
55
+ resources(context, values)
56
+
57
+ return values
49
58
  }
50
59
 
51
60
  function unit (context, dependency) {
@@ -61,6 +70,9 @@ function unit (context, dependency) {
61
70
  variables: []
62
71
  }
63
72
 
73
+ if (context.ingress !== undefined)
74
+ mono.ingress = Object.assign({}, context.ingress)
75
+
64
76
  addVariables(mono, variables, Object.keys(variables))
65
77
  addMounts(mono, dependency.mounts, Object.keys(mounts))
66
78
 
@@ -70,11 +82,19 @@ function unit (context, dependency) {
70
82
  if (!mono.variables.some((item) => item.name === variable.name))
71
83
  mono.variables.push(variable)
72
84
 
85
+ // every declared port is bound by the single mono process, none is primary
73
86
  if (service.port !== undefined)
74
- mono.port = service.port
87
+ (mono.backends ??= []).push({ port: service.port, path: service.ingress?.path ?? '/' })
75
88
 
76
- if (service.ingress !== undefined)
77
- mono.ingress = service.ingress
89
+ if (service.ingress !== undefined) {
90
+ const { path, hosts, ...ingress } = service.ingress
91
+
92
+ mono.ingress = Object.assign(mono.ingress ?? {}, ingress)
93
+
94
+ // one Ingress serves every path, so its hosts are the union, not the last word
95
+ if (hosts !== undefined)
96
+ mono.ingress.hosts = [...new Set([...(mono.ingress.hosts ?? []), ...hosts])]
97
+ }
78
98
 
79
99
  if (service.probe !== undefined && service.probe !== false)
80
100
  mono.probe = service.probe
@@ -83,6 +103,9 @@ function unit (context, dependency) {
83
103
  if (mono.probe === undefined && dependency.probe !== undefined && dependency.probe !== false)
84
104
  mono.probe = dependency.probe
85
105
 
106
+ // the more specific prefix must come first, whatever the controller's tie-break
107
+ mono.backends?.sort((a, b) => b.path.length - a.path.length)
108
+
86
109
  return mono
87
110
  }
88
111
 
@@ -27,9 +27,42 @@ const merge = (dependencies) => {
27
27
  if (dependency.probe !== undefined) probe = dependency.probe
28
28
  }
29
29
 
30
+ reserve(services, probe)
31
+
30
32
  return { references, services, proxies, variables, mounts, probe }
31
33
  }
32
34
 
35
+ /**
36
+ * In Kubernetes these are separate pods, but `toa mono` and a local run put every
37
+ * service in one process — so a port may be claimed once and only once.
38
+ */
39
+ const reserve = (services, probe) => {
40
+ const claimed = new Map()
41
+
42
+ if (probe !== undefined && probe !== false)
43
+ claimed.set(probe.port, 'the readiness probe')
44
+
45
+ for (const service of services)
46
+ for (const [port, claimant] of ports(service)) {
47
+ const conflicting = claimed.get(port)
48
+
49
+ if (conflicting !== undefined)
50
+ throw new Error(`Port ${port} is claimed by both ${conflicting} and ${claimant}`)
51
+
52
+ claimed.set(port, claimant)
53
+ }
54
+ }
55
+
56
+ function * ports (service) {
57
+ const name = `'${service.group}-${service.name}'`
58
+
59
+ if (service.port !== undefined)
60
+ yield [service.port, name]
61
+
62
+ if (service.probe !== undefined && service.probe !== false && service.probe.port !== service.port)
63
+ yield [service.probe.port, `the readiness probe of ${name}`]
64
+ }
65
+
33
66
  const append = (merged, variables) => {
34
67
  for (const [component, vars] of Object.entries(variables)) {
35
68
  if (merged[component] === undefined) merged[component] = []
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+
3
+ const { merge } = require('./merge')
4
+
5
+ const service = (name, extra = {}) => ({ group: 'group', name, version: '0', ...extra })
6
+
7
+ it('should merge services of all dependencies', () => {
8
+ const merged = merge([
9
+ { services: [service('one', { port: 8000 })] },
10
+ { services: [service('two', { port: 8001 })] }
11
+ ])
12
+
13
+ expect(merged.services).toHaveLength(2)
14
+ })
15
+
16
+ /*
17
+ * In Kubernetes these are separate pods, but `toa mono` and a local run put every
18
+ * service in one process.
19
+ */
20
+ describe('port reservation', () => {
21
+ it('should reject two services claiming one port', () => {
22
+ const dependencies = [
23
+ { services: [service('one', { port: 8000 })] },
24
+ { services: [service('two', { port: 8000 })] }
25
+ ]
26
+
27
+ expect(() => merge(dependencies))
28
+ .toThrow("Port 8000 is claimed by both 'group-one' and 'group-two'")
29
+ })
30
+
31
+ it('should reject a service claiming the port of the readiness probe', () => {
32
+ const dependencies = [
33
+ { probe: { path: '/.ready', port: 8001 } },
34
+ { services: [service('one', { port: 8001 })] }
35
+ ]
36
+
37
+ expect(() => merge(dependencies))
38
+ .toThrow("Port 8001 is claimed by both the readiness probe and 'group-one'")
39
+ })
40
+
41
+ it('should reject a probe claiming the port of another service', () => {
42
+ const dependencies = [
43
+ { services: [service('one', { port: 8000 })] },
44
+ { services: [service('two', { port: 8002, probe: { path: '/.ready', port: 8000 } })] }
45
+ ]
46
+
47
+ expect(() => merge(dependencies))
48
+ .toThrow("Port 8000 is claimed by both 'group-one' and the readiness probe of 'group-two'")
49
+ })
50
+
51
+ it('should allow a service to probe its own port', () => {
52
+ const dependencies = [
53
+ { services: [service('one', { port: 8000, probe: { path: '/.ready', port: 8000 } })] }
54
+ ]
55
+
56
+ expect(() => merge(dependencies)).not.toThrow()
57
+ })
58
+
59
+ it('should ignore services without a port', () => {
60
+ const dependencies = [
61
+ { services: [service('one'), service('two')] }
62
+ ]
63
+
64
+ expect(() => merge(dependencies)).not.toThrow()
65
+ })
66
+
67
+ it('should ignore a disabled probe', () => {
68
+ const dependencies = [
69
+ { probe: false, services: [service('one', { port: 8000, probe: false })] }
70
+ ]
71
+
72
+ expect(() => merge(dependencies)).not.toThrow()
73
+ })
74
+ })
@@ -26,12 +26,14 @@ spec:
26
26
  containers:
27
27
  - name: mono
28
28
  image: {{ .image }}
29
- {{- if .port }}
29
+ {{- if or .backends .probe }}
30
30
  ports:
31
+ {{- range .backends }}
31
32
  - containerPort: {{ .port }}
32
- {{- else if .probe }}
33
- ports:
33
+ {{- end }}
34
+ {{- if .probe }}
34
35
  - containerPort: {{ .probe.port }}
36
+ {{- end }}
35
37
  {{- end }}
36
38
  {{- if .resources }}
37
39
  resources:
@@ -108,7 +110,7 @@ spec:
108
110
  labelSelector:
109
111
  matchLabels:
110
112
  toa.io/composition: mono
111
- {{- if .port }}
113
+ {{- if .backends }}
112
114
  ---
113
115
  apiVersion: v1
114
116
  kind: Service
@@ -119,10 +121,12 @@ spec:
119
121
  selector:
120
122
  toa.io/composition: mono
121
123
  ports:
124
+ {{- range .backends }}
122
125
  - name: port-{{ .port }}
123
126
  protocol: TCP
124
127
  port: {{ .port }}
125
128
  targetPort: {{ .port }}
129
+ {{- end }}
126
130
  {{- end }}
127
131
  {{- if .ingress }}
128
132
  ---
@@ -138,29 +142,34 @@ spec:
138
142
  {{- if .ingress.class }}
139
143
  ingressClassName: {{ .ingress.class }}
140
144
  {{- end }}
145
+ {{- $backends := .backends }}
141
146
  rules:
142
147
  {{- range .ingress.hosts }}
143
148
  - host: {{ . }}
144
149
  http:
145
150
  paths:
146
- - path: /
151
+ {{- range $backends }}
152
+ - path: {{ .path }}
147
153
  pathType: Prefix
148
154
  backend:
149
155
  service:
150
156
  name: mono
151
157
  port:
152
- number: {{ $.Values.mono.port | default 8000 }}
158
+ number: {{ .port }}
159
+ {{- end }}
153
160
  {{- end }}
154
161
  {{- if .ingress.default }}
155
162
  - http:
156
163
  paths:
157
- - path: /
164
+ {{- range $backends }}
165
+ - path: {{ .path }}
158
166
  pathType: Prefix
159
167
  backend:
160
168
  service:
161
169
  name: mono
162
170
  port:
163
- number: {{ $.Values.mono.port | default 8000 }}
171
+ number: {{ .port }}
172
+ {{- end }}
164
173
  {{- end }}
165
174
  {{- end }}
166
175
  ---
@@ -100,6 +100,8 @@ spec:
100
100
  {{- end }}
101
101
  {{- if .ingress }}
102
102
  {{- $service := .name }}
103
+ {{- $port := .port }}
104
+ {{- $path := .ingress.path | default "/" }}
103
105
  ---
104
106
  apiVersion: networking.k8s.io/v1
105
107
  kind: Ingress
@@ -118,24 +120,24 @@ spec:
118
120
  - host: {{ . }}
119
121
  http:
120
122
  paths:
121
- - path: /
123
+ - path: {{ $path }}
122
124
  pathType: Prefix
123
125
  backend:
124
126
  service:
125
127
  name: extension-{{ $service }}
126
128
  port:
127
- number: 8000
129
+ number: {{ $port }}
128
130
  {{- end }}
129
131
  {{- if .ingress.default }}
130
132
  - http:
131
133
  paths:
132
- - path: /
134
+ - path: {{ $path }}
133
135
  pathType: Prefix
134
136
  backend:
135
137
  service:
136
138
  name: extension-{{ $service }}
137
139
  port:
138
- number: 8000
140
+ number: {{ $port }}
139
141
  {{- end }}
140
142
  {{- end }}
141
143
  ---
@@ -11,7 +11,6 @@ const { Service } = require('./service')
11
11
 
12
12
  class Factory {
13
13
  #context
14
- #root
15
14
  #mono
16
15
  #compositions
17
16
  #dependencies
@@ -21,7 +20,6 @@ class Factory {
21
20
 
22
21
  constructor (context, options = {}) {
23
22
  this.#context = context
24
- this.#root = options.root
25
23
  this.#mono = options.mono === true
26
24
  this.#process = new Process()
27
25
 
@@ -34,7 +32,7 @@ class Factory {
34
32
  if (this.#mono)
35
33
  this.#image = this.#registry.mono({
36
34
  components: context.components
37
- }, this.#root)
35
+ })
38
36
  else
39
37
  this.#compositions = context.compositions.map((composition) => this.#composition(composition))
40
38
  }
@@ -108,7 +106,7 @@ class Factory {
108
106
  static async create (path, environment, options = {}) {
109
107
  const context = await load(path, environment)
110
108
 
111
- return new Factory(context, { ...options, root: path })
109
+ return new Factory(context, options)
112
110
  }
113
111
  }
114
112
 
@@ -50,8 +50,8 @@ class Factory {
50
50
  /**
51
51
  * @returns {Mono}
52
52
  */
53
- mono (composition, root) {
54
- const instance = new Mono(this.#scope, this.#runtime, this.#registry, composition, root)
53
+ mono (composition) {
54
+ const instance = new Mono(this.#scope, this.#runtime, this.#registry, composition)
55
55
 
56
56
  instance.tag()
57
57
 
@@ -12,7 +12,7 @@ COPY --chown=node:node . /composition
12
12
  {{build.run}}
13
13
 
14
14
  RUN --mount=type=cache,target=/root/.npm \
15
- for entry in components/*; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
15
+ for entry in *; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
16
16
 
17
17
  USER node
18
- CMD toa mono .
18
+ CMD toa mono *
@@ -9,14 +9,12 @@ const { Image } = require('./image')
9
9
  class Mono extends Image {
10
10
  dockerfile = join(__dirname, 'mono.Dockerfile')
11
11
 
12
- #root
13
12
  #image
14
13
  #components
15
14
 
16
- constructor (scope, runtime, registry, composition, root) {
15
+ constructor (scope, runtime, registry, composition) {
17
16
  super(scope, runtime, registry)
18
17
 
19
- this.#root = root
20
18
  this.#image = composition.image
21
19
  this.#components = composition.components
22
20
  }
@@ -67,16 +65,11 @@ class Mono extends Image {
67
65
  async prepare (root) {
68
66
  const context = await super.prepare(root)
69
67
 
70
- await fs.copy(join(this.#root, CONTEXT), join(context, CONTEXT))
71
- await fs.ensureDir(join(context, 'components'))
72
-
73
68
  for (const component of this.#components)
74
- await fs.copy(component.path, join(context, 'components', component.locator.label))
69
+ await fs.copy(component.path, join(context, component.locator.label))
75
70
 
76
71
  return context
77
72
  }
78
73
  }
79
74
 
80
- const CONTEXT = 'context.toa.yaml'
81
-
82
75
  exports.Mono = Mono
@@ -35,8 +35,8 @@ class Registry {
35
35
  return this.#create('service', path, service)
36
36
  }
37
37
 
38
- mono (composition, root) {
39
- return this.#create('mono', composition, root)
38
+ mono (composition) {
39
+ return this.#create('mono', composition)
40
40
  }
41
41
 
42
42
  async prepare (root) {
@@ -43,6 +43,8 @@ export interface Dependency {
43
43
  }
44
44
 
45
45
  interface Ingress {
46
+ /** Path prefix this service claims on the host. Defaults to `/`. */
47
+ path?: string
46
48
  default?: boolean
47
49
  hosts?: string[]
48
50
  class?: string