@toa.io/operations 1.0.0-alpha.287 → 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 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.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
+
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)
18
+
19
+ **Note:** Version bump only for package @toa.io/operations
20
+
21
+
22
+
23
+
24
+
6
25
  # [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
7
26
 
8
27
  **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.287",
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.286",
32
- "@toa.io/norm": "1.0.0-alpha.287",
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": "5c7944a70c16065ab6372072385cc1f02f5db77c"
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
- periodSeconds: 10
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: 3
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: 50%
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
- periodSeconds: 10
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: 3
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
- # otherwise a single pod serves all traffic while the others start up
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: 50%
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
- periodSeconds: 10
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: 3
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 fs from 'fs-extra'
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
- fs.copy(TEMPLATES, join(target, 'templates'))
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
- {{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
- # 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 { Image } from './image.js'
6
- import { declare } from './format.js'
3
+ import { Bundle } from './bundle.js'
7
4
 
8
- export class Composition extends Image {
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
- get version() {
28
- const hash = createHash('sha256')
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