@toa.io/operations 1.0.0-alpha.24 → 1.0.0-alpha.243

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.
@@ -0,0 +1,4 @@
1
+ FROM node:24.14.0-alpine3.22
2
+
3
+ ARG VERSION
4
+ RUN npm i -g @toa.io/runtime@${VERSION} --omit=dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.24",
3
+ "version": "1.0.0-alpha.243",
4
4
  "description": "Toa Deployment",
5
5
  "homepage": "https://toa.io",
6
6
  "author": {
@@ -16,23 +16,23 @@
16
16
  "url": "https://github.com/toa-io/toa/issues"
17
17
  },
18
18
  "engines": {
19
- "node": ">= 18.0.0"
19
+ "node": ">= 20.0.0"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
24
  "main": "src/index.js",
25
- "types": "types/index.d.ts",
25
+ "types": "types/index.ts",
26
26
  "scripts": {
27
27
  "test": "echo \"Error: run tests from root\" && exit 1"
28
28
  },
29
29
  "dependencies": {
30
- "@toa.io/filesystem": "1.0.0-alpha.24",
31
- "@toa.io/generic": "1.0.0-alpha.24",
32
- "@toa.io/norm": "1.0.0-alpha.24",
33
- "@toa.io/yaml": "1.0.0-alpha.24",
30
+ "@toa.io/filesystem": "1.0.0-alpha.225",
31
+ "@toa.io/generic": "1.0.0-alpha.225",
32
+ "@toa.io/norm": "1.0.0-alpha.243",
33
+ "@toa.io/yaml": "1.0.0-alpha.225",
34
34
  "execa": "5.1.1",
35
35
  "fs-extra": "11.1.1"
36
36
  },
37
- "gitHead": "308058561dfc97337a8b9755f357644e7dba8859"
37
+ "gitHead": "743e8060d35ca750f70487c04aef9698591c5edd"
38
38
  }
package/readme.md CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ### Container Registry
6
6
 
7
+ Deploy images default to `FROM ghcr.io/toa-io/runtime:<runtime.version>`
8
+ (published with each Toa release). The base image already has `@toa.io/runtime`
9
+ installed; composition/service Dockerfiles only install component dependencies.
10
+
11
+ To use a custom base image, set `registry.build.image` (or `composition.image`).
12
+ That image must provide the `toa` CLI, or install it via `registry.build.run`:
13
+
14
+ ```yaml
15
+ # context.toa.yaml
16
+
17
+ registry:
18
+ build:
19
+ image: node:24.14.0-alpine3.22
20
+ run: npm i -g @toa.io/runtime --omit=dev
21
+ ```
22
+
7
23
  #### Build Options
8
24
 
9
25
  ```yaml
@@ -1,10 +1,15 @@
1
1
  'use strict'
2
2
 
3
3
  const { addVariables } = require('./variables')
4
+ const { addMounts } = require('./mounts')
4
5
 
5
- function compositions (compositions, variables) {
6
+ function compositions (compositions, dependency) {
6
7
  for (const composition of compositions) {
7
- addVariables(composition, variables)
8
+ addVariables(composition, dependency.variables)
9
+ addMounts(composition, dependency.mounts)
10
+
11
+ if (dependency.probe !== undefined && dependency.probe !== false)
12
+ composition.probe ??= dependency.probe
8
13
  }
9
14
  }
10
15
 
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ function addMounts (composition, mounts) {
4
+ if (mounts === undefined)
5
+ return
6
+
7
+ const used = new Set()
8
+
9
+ for (const [key, mount] of Object.entries(mounts)) {
10
+ if (key !== 'global' && !composition.components?.includes(key))
11
+ continue
12
+
13
+ for (const { name, path, claim } of mount) {
14
+ if (used.has(name))
15
+ continue
16
+
17
+ composition.mounts ??= []
18
+ composition.mounts.push({ name, path, claim })
19
+ used.add(name)
20
+ }
21
+ }
22
+ }
23
+
24
+ exports.addMounts = addMounts
@@ -2,9 +2,14 @@
2
2
 
3
3
  const { addVariables } = require('./variables')
4
4
 
5
- function services (services, variables) {
5
+ function services (services, variables, probe) {
6
6
  for (const service of services) {
7
7
  addVariables(service, variables)
8
+
9
+ if (service.probe === false)
10
+ delete service.probe
11
+ else if (service.probe === undefined && probe !== undefined && probe !== false)
12
+ service.probe = probe
8
13
  }
9
14
  }
10
15
 
@@ -1,17 +1,18 @@
1
1
  'use strict'
2
2
 
3
- function addVariables (deployment, variables) {
4
- const used = new Set()
3
+ function addVariables (composition, variables) {
4
+ composition.variables ??= []
5
5
 
6
- deployment.variables ??= []
6
+ const used = new Set(composition.variables.map((variable) => variable.name))
7
7
 
8
8
  for (const [key, set] of Object.entries(variables)) {
9
- if (key !== 'global' && !deployment.components?.includes(key)) continue
9
+ if (key !== 'global' && !composition.components?.includes(key))
10
+ continue
10
11
 
11
12
  for (const variable of set) {
12
13
  if (used.has(variable.name)) continue
13
14
 
14
- deployment.variables.push(variable)
15
+ composition.variables.push(variable)
15
16
  used.add(variable.name)
16
17
  }
17
18
  }
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const get = require('./.describe')
3
+ const desc = require('./.describe')
4
4
 
5
5
  const describe = (context, compositions, dependency) => {
6
6
  const { services } = dependency
@@ -17,11 +17,11 @@ const describe = (context, compositions, dependency) => {
17
17
  }
18
18
  )
19
19
 
20
- const components = get.components(compositions)
20
+ const components = desc.components(compositions)
21
21
  const credentials = context.registry?.credentials
22
22
 
23
- get.compositions(compositions, dependency.variables, context.environment)
24
- get.services(services, dependency.variables)
23
+ desc.compositions(compositions, dependency)
24
+ desc.services(services, dependency.variables, dependency.probe)
25
25
 
26
26
  return {
27
27
  compositions,
@@ -13,14 +13,21 @@ const merge = (dependencies) => {
13
13
  /** @type {toa.deployment.dependency.Variables} */
14
14
  const variables = {}
15
15
 
16
+ const mounts = {}
17
+
18
+ /** @type {toa.deployment.dependency.Probe | false | undefined} */
19
+ let probe
20
+
16
21
  for (const dependency of dependencies) {
17
22
  if (dependency.references !== undefined) references.push(...dependency.references)
18
23
  if (dependency.services !== undefined) services.push(...dependency.services)
19
24
  if (dependency.proxies !== undefined) proxies.push(...dependency.proxies)
20
25
  if (dependency.variables !== undefined) append(variables, dependency.variables)
26
+ if (dependency.mounts !== undefined) append(mounts, dependency.mounts)
27
+ if (dependency.probe !== undefined) probe = dependency.probe
21
28
  }
22
29
 
23
- return { references, services, proxies, variables }
30
+ return { references, services, proxies, variables, mounts, probe }
24
31
  }
25
32
 
26
33
  const append = (merged, variables) => {
@@ -5,6 +5,9 @@ metadata:
5
5
  name: composition-{{ required "composition name is required" .name }}
6
6
  spec:
7
7
  replicas: {{ .replicas | default 2 }}
8
+ # A backstop for stuck rollouts only. Must stay above the `helm upgrade --timeout`
9
+ # the deploy runs with, so a slow but progressing rollout is not marked failed.
10
+ progressDeadlineSeconds: 900
8
11
  selector:
9
12
  matchLabels:
10
13
  toa.io/composition: {{ .name }}
@@ -19,15 +22,82 @@ spec:
19
22
  containers:
20
23
  - name: {{ .name }}
21
24
  image: {{ .image }}
25
+ {{- if .resources }}
26
+ resources:
27
+ {{- if or .resources.cpu .resources.memory }}
28
+ requests:
29
+ {{- if .resources.cpu }}
30
+ cpu: {{ index .resources.cpu 0 }}
31
+ {{- end }}
32
+ {{- if .resources.memory }}
33
+ memory: {{ index .resources.memory 0 }}
34
+ {{- end }}
35
+ limits:
36
+ {{- if .resources.cpu }}
37
+ cpu: {{ index .resources.cpu 1 }}
38
+ {{- end }}
39
+ {{- if .resources.memory }}
40
+ memory: {{ index .resources.memory 1 }}
41
+ {{- end }}
42
+ {{- end }}
43
+ {{- end }}
22
44
  {{- if .variables }}
23
45
  env:
24
46
  {{- range .variables }}
25
47
  {{- include "env.var" . | indent 12 }}
26
48
  {{- end }}
27
49
  {{- end }}
50
+ {{- if .probe }}
51
+ ports:
52
+ - containerPort: {{ .probe.port }}
53
+ startupProbe:
54
+ httpGet:
55
+ path: {{ .probe.path }}
56
+ port: {{ .probe.port }}
57
+ {{- if .probe.delay }}
58
+ initialDelaySeconds: {{ .probe.delay }}
59
+ {{- end }}
60
+ periodSeconds: 2
61
+ timeoutSeconds: 3
62
+ failureThreshold: 150
63
+ readinessProbe:
64
+ httpGet:
65
+ path: {{ .probe.path }}
66
+ port: {{ .probe.port }}
67
+ periodSeconds: 10
68
+ timeoutSeconds: 3
69
+ failureThreshold: 3
70
+ {{- end }}
71
+ {{- if .mounts }}
72
+ volumeMounts:
73
+ {{- range .mounts }}
74
+ - name: {{ .name }}
75
+ mountPath: {{ .path }}
76
+ {{- end }}
77
+ {{- end }}
78
+ lifecycle:
79
+ preStop:
80
+ exec:
81
+ command: [sleep, "5"]
82
+ terminationGracePeriodSeconds: 45
28
83
  {{- if $.Values.credentials }}
29
84
  imagePullSecrets:
30
85
  - name: {{ $.Values.credentials }}
31
86
  {{- end }}
87
+ {{- if .mounts }}
88
+ volumes:
89
+ {{- range .mounts }}
90
+ - name: {{ .name }}
91
+ persistentVolumeClaim:
92
+ claimName: {{ .claim }}
93
+ {{- end }}
94
+ {{- end }}
95
+ topologySpreadConstraints:
96
+ - maxSkew: 1
97
+ topologyKey: kubernetes.io/hostname
98
+ whenUnsatisfiable: ScheduleAnyway
99
+ labelSelector:
100
+ matchLabels:
101
+ toa.io/composition: {{ .name }}
32
102
  ---
33
103
  {{- end }}
@@ -5,6 +5,16 @@ metadata:
5
5
  name: extension-{{ required "deployment name is required" .name }}
6
6
  spec:
7
7
  replicas: {{ .replicas | default 2 }}
8
+ # A backstop for stuck rollouts only. Must stay above the `helm upgrade --timeout`
9
+ # the deploy runs with, so a slow but progressing rollout is not marked failed.
10
+ progressDeadlineSeconds: 900
11
+ strategy:
12
+ type: RollingUpdate
13
+ rollingUpdate:
14
+ # a replacement must pass readiness before a running replica is taken out,
15
+ # otherwise a single pod serves all traffic while the others start up
16
+ maxUnavailable: 0
17
+ maxSurge: 50%
8
18
  selector:
9
19
  matchLabels:
10
20
  toa.io/service: extension-{{ .name }}
@@ -16,12 +26,63 @@ spec:
16
26
  containers:
17
27
  - name: extension-{{ .name }}
18
28
  image: {{ .image }}
29
+ {{- if .resources }}
30
+ resources:
31
+ {{- if or .resources.cpu .resources.memory }}
32
+ requests:
33
+ {{- if .resources.cpu }}
34
+ cpu: {{ index .resources.cpu 0 }}
35
+ {{- end }}
36
+ {{- if .resources.memory }}
37
+ memory: {{ index .resources.memory 0 }}
38
+ {{- end }}
39
+ limits:
40
+ {{- if .resources.cpu }}
41
+ cpu: {{ index .resources.cpu 1 }}
42
+ {{- end }}
43
+ {{- if .resources.memory }}
44
+ memory: {{ index .resources.memory 1 }}
45
+ {{- end }}
46
+ {{- end }}
47
+ {{- end }}
19
48
  {{- if .variables }}
20
49
  env:
21
50
  {{- range .variables }}
22
51
  {{- include "env.var" . | indent 12 }}
23
52
  {{- end }}
24
53
  {{- end }}
54
+ {{- if .probe }}
55
+ startupProbe:
56
+ httpGet:
57
+ path: {{ .probe.path }}
58
+ port: {{ .probe.port }}
59
+ {{- if .probe.delay }}
60
+ initialDelaySeconds: {{ .probe.delay }}
61
+ {{- end }}
62
+ periodSeconds: 2
63
+ timeoutSeconds: 3
64
+ failureThreshold: 150
65
+ readinessProbe:
66
+ httpGet:
67
+ path: {{ .probe.path }}
68
+ port: {{ .probe.port }}
69
+ periodSeconds: 10
70
+ timeoutSeconds: 3
71
+ failureThreshold: 3
72
+ {{- end }}
73
+ lifecycle:
74
+ preStop:
75
+ exec:
76
+ command: [sleep, "5"]
77
+ terminationGracePeriodSeconds: 45
78
+ topologySpreadConstraints:
79
+ - maxSkew: 1
80
+ topologyKey: kubernetes.io/hostname
81
+ whenUnsatisfiable: ScheduleAnyway
82
+ labelSelector:
83
+ matchLabels:
84
+ toa.io/service: extension-{{ .name }}
85
+ {{- if .port }}
25
86
  ---
26
87
  apiVersion: v1
27
88
  kind: Service
@@ -36,9 +97,10 @@ spec:
36
97
  protocol: TCP
37
98
  port: {{ .port }}
38
99
  targetPort: {{ .port }}
39
- ---
100
+ {{- end }}
40
101
  {{- if .ingress }}
41
102
  {{- $service := .name }}
103
+ ---
42
104
  apiVersion: networking.k8s.io/v1
43
105
  kind: Ingress
44
106
  metadata:
@@ -64,5 +126,17 @@ spec:
64
126
  port:
65
127
  number: 8000
66
128
  {{- end }}
129
+ {{- if .ingress.default }}
130
+ - http:
131
+ paths:
132
+ - path: /
133
+ pathType: Prefix
134
+ backend:
135
+ service:
136
+ name: extension-{{ $service }}
137
+ port:
138
+ number: 8000
139
+ {{- end }}
67
140
  {{- end }}
141
+ ---
68
142
  {{- end }}
@@ -3,9 +3,16 @@ compositions:
3
3
  - name: todos
4
4
  image: localhost:5000/composition-todos:0.0.0
5
5
  replicas: 2
6
+ mounts:
7
+ - name: mount-name
8
+ path: /storage
9
+ claim: storage-pvc
6
10
  components:
7
11
  - todos-tasks
8
12
  - todos-stats
13
+ resources:
14
+ cpu: [100m, 1]
15
+ memory: [100Mi, 1Gi]
9
16
  variables:
10
17
  - name: TOA_CONFIGURATION_TODOS_TASKS
11
18
  value: foo
@@ -18,7 +25,6 @@ compositions:
18
25
  replicas: 3
19
26
  components:
20
27
  - users-users
21
- # TODO: create component services only if sync binding is being used
22
28
  components:
23
29
  - todos-tasks
24
30
  - todos-stats
@@ -28,7 +34,11 @@ services:
28
34
  image: localhost:5000/resources-gateway:0.0.0
29
35
  port: 8000
30
36
  replicas: 2
37
+ resources:
38
+ cpu: [100m, 1]
39
+ memory: [100Mi, 1Gi]
31
40
  ingress:
41
+ default: true
32
42
  hosts: [dummies.toa.io]
33
43
  class: alb
34
44
  annotations:
@@ -41,6 +51,11 @@ services:
41
51
  secret:
42
52
  name: secret-name
43
53
  key: secret-key
54
+ probe:
55
+ port: 8000
56
+ path: /.ready
57
+ delay: 1
58
+
44
59
  proxies:
45
60
  - name: storage-proxy
46
61
  target: host.docker.internal
@@ -10,6 +10,7 @@ class Composition {
10
10
  this.name = composition.name
11
11
  this.image = image.reference
12
12
  this.components = composition.components.map(component)
13
+ this.resources = composition.resources
13
14
  }
14
15
  }
15
16
 
@@ -42,6 +42,7 @@ class Deployment {
42
42
 
43
43
  if (options.namespace !== undefined) args.push('-n', options.namespace)
44
44
  if (options.wait === true) args.push('--wait')
45
+ if (options.timeout !== undefined) args.push('--timeout', options.timeout)
45
46
 
46
47
  await this.#process.execute('helm', ['dependency', 'update', this.#target])
47
48
  await this.#process.execute('helm', ['upgrade', this.#chart.name, '-i', ...args, this.#target])
@@ -15,7 +15,6 @@ class Factory {
15
15
  #dependencies
16
16
  #registry
17
17
  #process
18
- #extensionComponents = []
19
18
 
20
19
  constructor (context) {
21
20
  this.#context = context
@@ -23,7 +22,7 @@ class Factory {
23
22
 
24
23
  const imagesFactory = new ImagesFactory(context.name, context.runtime, context.registry)
25
24
 
26
- this.#registry = new Registry(context.registry, imagesFactory, this.#process)
25
+ this.#registry = new Registry(context.name, context.registry, imagesFactory, this.#process)
27
26
  this.#compositions = context.compositions.map((composition) => this.#composition(composition))
28
27
  this.#dependencies = this.#getDependencies()
29
28
  }
@@ -5,7 +5,6 @@ FROM {{build.image}}
5
5
  ENV NODE_ENV=production
6
6
  RUN if [ "{{runtime.registry}}" != "" ]; then npm set registry {{runtime.registry}}; fi
7
7
  RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
8
- RUN npm i -g @toa.io/runtime@{{runtime.version}} --omit=dev
9
8
 
10
9
  WORKDIR /composition
11
10
  COPY --chown=node:node . /composition
@@ -13,7 +12,8 @@ COPY --chown=node:node . /composition
13
12
  {{build.run}}
14
13
 
15
14
  # run 'npm i' in each component
16
- RUN for entry in *; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
15
+ RUN --mount=type=cache,target=/root/.npm \
16
+ for entry in *; do if [ -f "$entry/package.json" ]; then (cd $entry && npm i --omit=dev); fi; done
17
17
 
18
18
  USER node
19
19
  CMD toa compose *
@@ -5,6 +5,7 @@ const fs = require('fs-extra')
5
5
  const { createHash } = require('node:crypto')
6
6
 
7
7
  const { Image } = require('./image')
8
+ const { undef } = require('@toa.io/concise/source/expressions/undefined')
8
9
 
9
10
  class Composition extends Image {
10
11
  dockerfile = join(__dirname, 'composition.Dockerfile')
@@ -37,11 +38,7 @@ class Composition extends Image {
37
38
  }
38
39
 
39
40
  get base () {
40
- if (this.#image !== undefined) {
41
- return this.#image
42
- }
43
-
44
- let image = null
41
+ let image = this.#image
45
42
 
46
43
  for (const component of this.#components) {
47
44
  const value = component.build?.image
@@ -53,7 +50,20 @@ class Composition extends Image {
53
50
  image = value
54
51
  }
55
52
 
56
- return image ?? undefined
53
+ return image
54
+ }
55
+
56
+ get run () {
57
+ const commands = []
58
+
59
+ for (const component of this.#components) {
60
+ const run = component.build?.run
61
+
62
+ if (run !== undefined)
63
+ commands.push(run)
64
+ }
65
+
66
+ return commands.join('\n')
57
67
  }
58
68
 
59
69
  async prepare (root) {
@@ -3,9 +3,6 @@
3
3
  const { Composition } = require('./composition')
4
4
  const { Service } = require('./service')
5
5
 
6
- /**
7
- * @implements {toa.deployment.images.Factory}
8
- */
9
6
  class Factory {
10
7
  /** @type {string} */
11
8
  #scope
@@ -26,9 +26,7 @@ class Image {
26
26
  #registry
27
27
  #runtime
28
28
  #values = {
29
- build: {
30
- image: 'node:20.9.0-alpine3.18'
31
- }
29
+ build: {}
32
30
  }
33
31
 
34
32
  constructor (scope, runtime, registry) {
@@ -54,6 +52,8 @@ class Image {
54
52
 
55
53
  get base () {}
56
54
 
55
+ get run () {}
56
+
57
57
  async prepare (root) {
58
58
  if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
59
59
 
@@ -65,7 +65,7 @@ class Image {
65
65
 
66
66
  const template = await read(this.dockerfile, 'utf-8')
67
67
  const contents = template.replace(/{{(\S{1,32})}}/g, (_, key) => this.#value(key))
68
- const ignore = 'Dockerfile'
68
+ const ignore = ['Dockerfile', '**/node_modules'].join('\r\n')
69
69
 
70
70
  await write(join(path, 'Dockerfile'), contents)
71
71
  await write(join(path, '.dockerignore'), ignore)
@@ -77,13 +77,19 @@ class Image {
77
77
 
78
78
  #setValues () {
79
79
  this.#values.runtime = this.#runtime
80
- this.#values.build = overwrite(this.#values.build, this.#registry.build)
80
+ this.#values.build = overwrite({
81
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
82
+ }, this.#registry.build)
81
83
 
82
84
  const image = this.base
83
85
 
84
- if (image !== undefined) {
86
+ if (image !== undefined)
85
87
  this.#values.build.image = image
86
- }
88
+
89
+ const run = this.run
90
+
91
+ if (run !== undefined)
92
+ this.#values.build.run = (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
87
93
 
88
94
  if (this.#values.build.arguments !== undefined) this.#values.build.arguments = createArguments(this.#values.build.arguments)
89
95
  if (this.#values.build.run !== undefined) this.#values.build.run = createRunCommands(this.#values.build.run)
@@ -121,4 +127,7 @@ function createArguments (variables) {
121
127
  return args.join('\n')
122
128
  }
123
129
 
130
+ const RUNTIME_IMAGE = 'ghcr.io/toa-io/runtime'
131
+
124
132
  exports.Image = Image
133
+ exports.RUNTIME_IMAGE = RUNTIME_IMAGE
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+
3
+ const { join } = require('node:path')
4
+ const { readFile } = require('node:fs/promises')
5
+ const { directory } = require('@toa.io/filesystem')
6
+
7
+ const { Image, RUNTIME_IMAGE } = require('./image')
8
+
9
+ const compositionDockerfile = join(__dirname, 'composition.Dockerfile')
10
+ const serviceDockerfile = join(__dirname, 'service.Dockerfile')
11
+
12
+ class TestImage extends Image {
13
+ dockerfile
14
+
15
+ #name
16
+ #base
17
+
18
+ constructor (runtime, registry, dockerfile, name = 'test', base) {
19
+ super('acme', runtime, registry)
20
+
21
+ this.dockerfile = dockerfile
22
+ this.#name = name
23
+ this.#base = base
24
+ }
25
+
26
+ get name () {
27
+ return this.#name
28
+ }
29
+
30
+ get version () {
31
+ return 'abcdef12'
32
+ }
33
+
34
+ get base () {
35
+ return this.#base
36
+ }
37
+ }
38
+
39
+ describe('runtime base image', () => {
40
+ /** @type {string} */
41
+ let root
42
+
43
+ beforeEach(async () => {
44
+ root = await directory.temp('toa-runtime-base-test')
45
+ })
46
+
47
+ it('should default build.image to version-pinned GHCR runtime image', async () => {
48
+ const runtime = { version: '1.0.0-alpha.232' }
49
+ const image = new TestImage(runtime, {}, compositionDockerfile)
50
+
51
+ const path = await image.prepare(root)
52
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
53
+
54
+ expect(dockerfile).toContain(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.232`)
55
+ expect(dockerfile).not.toMatch(/npm i -g @toa\.io\/runtime/)
56
+ })
57
+
58
+ it('should not install runtime in service Dockerfile template', async () => {
59
+ const runtime = { version: '1.0.0-alpha.99' }
60
+ const image = new TestImage(runtime, {}, serviceDockerfile, 'service-test')
61
+
62
+ const path = await image.prepare(root)
63
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
64
+
65
+ expect(dockerfile).toContain(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.99`)
66
+ expect(dockerfile).not.toMatch(/npm i -g @toa\.io\/runtime/)
67
+ })
68
+
69
+ it('should allow registry.build.image override', async () => {
70
+ const runtime = { version: '1.0.0-alpha.232' }
71
+ const registry = { build: { image: 'node:24.14.0-alpine3.22' } }
72
+ const image = new TestImage(runtime, registry, compositionDockerfile)
73
+
74
+ const path = await image.prepare(root)
75
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
76
+
77
+ expect(dockerfile).toContain('FROM node:24.14.0-alpine3.22')
78
+ expect(dockerfile).not.toContain(RUNTIME_IMAGE)
79
+ })
80
+
81
+ it('should allow composition.image override via base', async () => {
82
+ const runtime = { version: '1.0.0-alpha.232' }
83
+ const image = new TestImage(runtime, {}, compositionDockerfile, 'mono', 'custom.example/base:1')
84
+
85
+ const path = await image.prepare(root)
86
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
87
+
88
+ expect(dockerfile).toContain('FROM custom.example/base:1')
89
+ })
90
+ })
@@ -1,14 +1,14 @@
1
- FROM node:20.9.0-alpine3.18
1
+ FROM {{build.image}}
2
2
 
3
3
  ENV NODE_ENV=production
4
4
  RUN if [ "{{runtime.registry}}" != "" ]; then npm set registry {{runtime.registry}}; fi
5
5
  RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
6
- RUN npm i -g @toa.io/runtime@{{runtime.version}} --omit=dev
7
6
 
8
7
  WORKDIR /service
9
8
  COPY --chown=node:node . /service
10
9
 
11
- RUN npm i --omit=dev
10
+ RUN --mount=type=cache,target=/root/.npm \
11
+ npm i --omit=dev
12
12
 
13
13
  USER node
14
14
  CMD toa serve .
@@ -50,6 +50,10 @@ class Operator {
50
50
  variables () {
51
51
  return this.#deployment.variables()
52
52
  }
53
+
54
+ tags () {
55
+ return this.#registry.tags()
56
+ }
53
57
  }
54
58
 
55
59
  /** @type {toa.deployment.installation.Options} */
@@ -1,12 +1,14 @@
1
1
  'use strict'
2
2
 
3
+ const { posix } = require('node:path')
3
4
  const workspace = require('./workspace')
4
- const { newid } = require('@toa.io/generic')
5
5
 
6
6
  /**
7
7
  * @implements {toa.deployment.Registry}
8
8
  */
9
9
  class Registry {
10
+ #scope
11
+
10
12
  #registry
11
13
 
12
14
  #factory
@@ -15,7 +17,11 @@ class Registry {
15
17
 
16
18
  #images = []
17
19
 
18
- constructor (registry, factory, process) {
20
+ /** @type {string | undefined} */
21
+ #builder
22
+
23
+ constructor (scope, registry, factory, process) {
24
+ this.#scope = scope
19
25
  this.#registry = registry
20
26
  this.#factory = factory
21
27
  this.#process = process
@@ -40,9 +46,8 @@ class Registry {
40
46
  async build () {
41
47
  await this.prepare()
42
48
 
43
- for (const image of this.#images) {
49
+ for (const image of this.#images)
44
50
  await this.#build(image)
45
- }
46
51
  }
47
52
 
48
53
  async push () {
@@ -51,6 +56,10 @@ class Registry {
51
56
  for (const image of this.#images) await this.#push(image)
52
57
  }
53
58
 
59
+ tags () {
60
+ return this.#images.map((image) => image.reference)
61
+ }
62
+
54
63
  /**
55
64
  * @param {'composition' | 'service'} type
56
65
  * @param {...any} args
@@ -70,13 +79,17 @@ class Registry {
70
79
  * @returns {Promise<void>}
71
80
  */
72
81
  async #build (image, push = false) {
82
+ if (await this.exists(image.reference)) {
83
+ console.log('Image already exists, skipping:', image.reference)
84
+ return
85
+ }
86
+
73
87
  const args = ['--context=default', 'buildx', 'build']
74
88
 
75
- if (push) {
89
+ if (push)
76
90
  args.push('--push')
77
- } else {
91
+ else
78
92
  args.push('--load')
79
- }
80
93
 
81
94
  args.push('--tag', image.reference, image.context)
82
95
 
@@ -88,14 +101,15 @@ class Registry {
88
101
 
89
102
  if (multiarch) {
90
103
  const platform = this.#registry.platforms.join(',')
91
- const builder = await this.#createBuilder()
104
+ const builder = await this.#ensureBuilder()
92
105
 
93
106
  args.push('--platform', platform)
94
107
  args.push('--builder', builder)
95
108
 
96
- } else {
109
+ if (this.#registry.base !== undefined)
110
+ this.#appendCache(args)
111
+ } else
97
112
  args.push('--builder', 'default')
98
- }
99
113
 
100
114
  args.push('--progress', 'plain')
101
115
 
@@ -106,13 +120,43 @@ class Registry {
106
120
  await this.#build(image, true)
107
121
  }
108
122
 
109
- async #createBuilder () {
110
- const name = `toa-${newid()}`
111
- const create = `buildx create --name ${name} --bootstrap --use`.split(' ')
123
+ async exists (tag) {
124
+ const args = ['manifest', 'inspect', tag]
125
+
126
+ try {
127
+ await this.#process.execute('docker', args, { silently: true })
128
+ } catch (error) {
129
+ console.log(error.message)
130
+
131
+ return false
132
+ }
133
+
134
+ return true
135
+ }
136
+
137
+ async #ensureBuilder () {
138
+ if (this.#builder !== undefined)
139
+ return this.#builder
140
+
141
+ try {
142
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], { silently: true })
143
+ } catch {
144
+ await this.#process.execute('docker', ['buildx', 'create', '--name', BUILDER, '--bootstrap'])
145
+ }
112
146
 
113
- await this.#process.execute('docker', create)
147
+ this.#builder = BUILDER
148
+
149
+ return BUILDER
150
+ }
151
+
152
+ /**
153
+ * @param {string[]} args
154
+ */
155
+ #appendCache (args) {
156
+ const ref = posix.join(this.#registry.base, this.#scope, 'buildcache')
114
157
 
115
- return name
158
+ args.push('--cache-from', `type=registry,ref=${ref}`)
159
+ args.push('--cache-to', `type=registry,ref=${ref},mode=max,image-manifest=true`)
116
160
  }
117
161
  }
118
162
 
@@ -0,0 +1,175 @@
1
+ 'use strict'
2
+
3
+ const { Registry } = require('./registry')
4
+
5
+ /** @type {toa.operations.Process} */
6
+ let process
7
+
8
+ /** @type {toa.deployment.images.Factory} */
9
+ let factory
10
+
11
+ /** @type {toa.deployment.images.Image[]} */
12
+ let images
13
+
14
+ beforeEach(() => {
15
+ images = []
16
+ process = /** @type {toa.operations.Process} */ {
17
+ execute: jest.fn(async (cmd, args) => {
18
+ if (args[0] === 'manifest')
19
+ throw new Error('manifest unknown')
20
+
21
+ if (args[0] === 'buildx' && args[1] === 'inspect')
22
+ throw new Error('builder not found')
23
+
24
+ return ''
25
+ })
26
+ }
27
+
28
+ factory = /** @type {toa.deployment.images.Factory} */ {
29
+ composition: () => createImage('composition-mono'),
30
+ service: () => createImage('extension-realtime')
31
+ }
32
+ })
33
+
34
+ it('should reuse named builder across images', async () => {
35
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
36
+
37
+ registry.composition(/** @type {any} */ ({}))
38
+ registry.service('.', /** @type {any} */ ({}))
39
+
40
+ await registry.build()
41
+
42
+ const creates = process.execute.mock.calls.filter(([, args]) =>
43
+ args[0] === 'buildx' && args[1] === 'create')
44
+
45
+ expect(creates).toHaveLength(1)
46
+ expect(creates[0][1]).toEqual(['buildx', 'create', '--name', 'toa', '--bootstrap'])
47
+
48
+ const builds = process.execute.mock.calls.filter(([, args]) =>
49
+ args[0] === '--context=default' && args[1] === 'buildx' && args[2] === 'build')
50
+
51
+ expect(builds).toHaveLength(2)
52
+
53
+ for (const [, args] of builds) {
54
+ expect(args).toContain('--builder')
55
+ expect(args[args.indexOf('--builder') + 1]).toBe('toa')
56
+ }
57
+ })
58
+
59
+ it('should not create builder when it already exists', async () => {
60
+ process.execute = jest.fn(async (cmd, args) => {
61
+ if (args[0] === 'manifest')
62
+ throw new Error('manifest unknown')
63
+
64
+ return ''
65
+ })
66
+
67
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
68
+
69
+ registry.composition(/** @type {any} */ ({}))
70
+
71
+ await registry.build()
72
+
73
+ const creates = process.execute.mock.calls.filter(([, args]) =>
74
+ args[0] === 'buildx' && args[1] === 'create')
75
+
76
+ expect(creates).toHaveLength(0)
77
+ })
78
+
79
+ it('should add shared registry cache flags when base is set', async () => {
80
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
81
+
82
+ registry.composition(/** @type {any} */ ({}))
83
+ registry.service('.', /** @type {any} */ ({}))
84
+
85
+ await registry.build()
86
+
87
+ const builds = process.execute.mock.calls.filter(([, args]) =>
88
+ args[0] === '--context=default' && args[2] === 'build')
89
+
90
+ expect(builds).toHaveLength(2)
91
+
92
+ for (const [, args] of builds) {
93
+ expect(args).toContain('--cache-from')
94
+ expect(args).toContain('type=registry,ref=example.com/reg/acme/buildcache')
95
+ expect(args).toContain('--cache-to')
96
+ expect(args).toContain('type=registry,ref=example.com/reg/acme/buildcache,mode=max,image-manifest=true')
97
+ }
98
+ })
99
+
100
+ it('should omit cache flags when base is not set', async () => {
101
+ const registry = createRegistry({ platforms: ['linux/amd64'] })
102
+
103
+ registry.composition(/** @type {any} */ ({}))
104
+
105
+ await registry.build()
106
+
107
+ const builds = process.execute.mock.calls.filter(([, args]) =>
108
+ args[0] === '--context=default' && args[2] === 'build')
109
+
110
+ expect(builds).toHaveLength(1)
111
+
112
+ const [, args] = builds[0]
113
+
114
+ expect(args).not.toContain('--cache-from')
115
+ expect(args).not.toContain('--cache-to')
116
+ })
117
+
118
+ it('should use default builder when platforms is null', async () => {
119
+ const registry = createRegistry({ base: 'example.com/reg', platforms: null })
120
+
121
+ registry.composition(/** @type {any} */ ({}))
122
+
123
+ await registry.build()
124
+
125
+ const builds = process.execute.mock.calls.filter(([, args]) =>
126
+ args[0] === '--context=default' && args[2] === 'build')
127
+
128
+ expect(builds).toHaveLength(1)
129
+
130
+ const [, args] = builds[0]
131
+
132
+ expect(args[args.indexOf('--builder') + 1]).toBe('default')
133
+ expect(args).not.toContain('--cache-from')
134
+ expect(args).not.toContain('--platform')
135
+ })
136
+
137
+ it('should skip build when image already exists', async () => {
138
+ process.execute = jest.fn(async () => '')
139
+
140
+ const registry = createRegistry({ base: 'example.com/reg', platforms: ['linux/amd64'] })
141
+
142
+ registry.composition(/** @type {any} */ ({}))
143
+
144
+ await registry.build()
145
+
146
+ const builds = process.execute.mock.calls.filter(([, args]) =>
147
+ args[0] === '--context=default' && args[2] === 'build')
148
+
149
+ expect(builds).toHaveLength(0)
150
+ expect(process.execute).toHaveBeenCalledWith('docker', ['manifest', 'inspect', images[0].reference], { silently: true })
151
+ })
152
+
153
+ /**
154
+ * @param {object} [registry]
155
+ * @returns {Registry}
156
+ */
157
+ function createRegistry (registry = {}) {
158
+ return new Registry('acme', registry, factory, process)
159
+ }
160
+
161
+ /**
162
+ * @param {string} name
163
+ * @returns {toa.deployment.images.Image}
164
+ */
165
+ function createImage (name) {
166
+ const image = /** @type {toa.deployment.images.Image} */ {
167
+ reference: `example.com/reg/acme/${name}:abcdef12`,
168
+ context: `/tmp/${name}`,
169
+ prepare: jest.fn(async () => `/tmp/${name}`)
170
+ }
171
+
172
+ images.push(image)
173
+
174
+ return image
175
+ }
@@ -4,9 +4,11 @@ declare namespace toa.deployment.images {
4
4
  readonly reference: string
5
5
  readonly context: string
6
6
 
7
- tag(): void
7
+ name: string
8
8
 
9
- prepare(root: string): Promise<string>
9
+ tag (): void
10
+
11
+ prepare (root: string): Promise<string>
10
12
  }
11
13
 
12
14
  }
@@ -1,19 +1,21 @@
1
1
  import type * as _norm from '@toa.io/norm/types'
2
2
  import type * as _dependency from './dependency'
3
- import type * as _image from "./images/image"
3
+ import type * as _image from './images/image'
4
4
 
5
5
  declare namespace toa.deployment {
6
-
6
+
7
7
  interface Registry {
8
- composition(composition: _norm.Composition): _image.Image
8
+ composition (composition: _norm.Composition): _image.Image
9
+
10
+ service (path: string, service: _dependency.Service): _image.Image
9
11
 
10
- service(path: string, service: _dependency.Service): _image.Image
12
+ prepare (path: string): Promise<string>
11
13
 
12
- prepare(path: string): Promise<string>
14
+ build (): Promise<void>
13
15
 
14
- build(): Promise<void>
16
+ push (): Promise<void>
15
17
 
16
- push(): Promise<void>
18
+ tags (): string[]
17
19
  }
18
20
 
19
21
  }
@@ -0,0 +1,67 @@
1
+ import type { Manifest } from '@toa.io/norm'
2
+ import type { Locator } from '@toa.io/core'
3
+
4
+ export interface Service {
5
+ group: string
6
+ name: string
7
+ version: string
8
+ port?: number
9
+ ingress?: Ingress
10
+ resources?: Resources
11
+ variables?: Variable[]
12
+ components?: string[]
13
+ probe?: Probe | false
14
+ }
15
+
16
+ export interface Variable {
17
+ name: string
18
+ value?: string
19
+ secret?: {
20
+ name: string
21
+ key: string
22
+ optional?: boolean
23
+ }
24
+ }
25
+
26
+ export interface Instance<T> {
27
+ locator: Locator
28
+ manifest: T
29
+ component: Manifest
30
+ }
31
+
32
+ export type Instances<T> = Array<Instance<T>>
33
+
34
+ export type Variables = Record<'global' | string, Variable[]>
35
+ export type Mounts = Record<'global' | string, Mount[]>
36
+
37
+ export interface Dependency {
38
+ services?: Service[]
39
+ variables?: Variables
40
+ mounts?: Mounts
41
+ /** Default probe for compositions and services without their own probe. `false` disables. */
42
+ probe?: Probe | false
43
+ }
44
+
45
+ interface Ingress {
46
+ default?: boolean
47
+ hosts?: string[]
48
+ class?: string
49
+ annotations?: object
50
+ }
51
+
52
+ export interface Probe {
53
+ port: number
54
+ path: string
55
+ delay?: number
56
+ }
57
+
58
+ interface Mount {
59
+ name: string
60
+ path: string
61
+ claim: string
62
+ }
63
+
64
+ export interface Resources {
65
+ cpu: string[]
66
+ memory: string[]
67
+ }
@@ -1,32 +0,0 @@
1
- export type Service = {
2
- group: string
3
- name: string
4
- version: string
5
- port: number
6
- ingress: Ingress
7
- variables: Variable[]
8
- components?: string[]
9
- }
10
-
11
- export type Variable = {
12
- name: string
13
- value?: string
14
- secret?: {
15
- name: string,
16
- key: string
17
- optional?: boolean
18
- }
19
- }
20
-
21
- export type Variables = Record<'global' | string, Variable[]>
22
-
23
- export type Dependency = {
24
- services?: Service[]
25
- variables?: Variables
26
- }
27
-
28
- type Ingress = {
29
- hosts: string[]
30
- class?: string
31
- annotations?: object
32
- }
File without changes