@toa.io/operations 1.0.0-alpha.31 → 1.0.0-alpha.310

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/image/runtime.Dockerfile +14 -0
  3. package/package.json +10 -10
  4. package/readme.md +201 -0
  5. package/src/deployment/.deployment/.describe/components.js +1 -5
  6. package/src/deployment/.deployment/.describe/compositions.js +26 -7
  7. package/src/deployment/.deployment/.describe/dependencies.js +1 -6
  8. package/src/deployment/.deployment/.describe/events.js +63 -0
  9. package/src/deployment/.deployment/.describe/fold.js +42 -0
  10. package/src/deployment/.deployment/.describe/fold.test.js +108 -0
  11. package/src/deployment/.deployment/.describe/index.js +4 -13
  12. package/src/deployment/.deployment/.describe/mounts.js +17 -0
  13. package/src/deployment/.deployment/.describe/resources.js +129 -0
  14. package/src/deployment/.deployment/.describe/resources.test.js +129 -0
  15. package/src/deployment/.deployment/.describe/services.js +32 -6
  16. package/src/deployment/.deployment/.describe/services.test.js +78 -0
  17. package/src/deployment/.deployment/.describe/variables.js +5 -9
  18. package/src/deployment/.deployment/declare.js +1 -5
  19. package/src/deployment/.deployment/describe.js +153 -10
  20. package/src/deployment/.deployment/index.js +3 -9
  21. package/src/deployment/.deployment/merge.js +66 -6
  22. package/src/deployment/.deployment/merge.test.js +133 -0
  23. package/src/deployment/chart/templates/components.configmap.yaml +14 -0
  24. package/src/deployment/chart/templates/components.yaml +1 -1
  25. package/src/deployment/chart/templates/compositions.yaml +109 -4
  26. package/src/deployment/chart/templates/mono.yaml +195 -0
  27. package/src/deployment/chart/templates/services.yaml +97 -7
  28. package/src/deployment/chart/values.yaml +23 -1
  29. package/src/deployment/composition.js +8 -6
  30. package/src/deployment/deployment.js +147 -34
  31. package/src/deployment/drain.js +70 -0
  32. package/src/deployment/drain.test.js +64 -0
  33. package/src/deployment/factory.js +169 -37
  34. package/src/deployment/images/bundle.js +88 -0
  35. package/src/deployment/images/composition.Dockerfile +9 -17
  36. package/src/deployment/images/composition.js +9 -56
  37. package/src/deployment/images/dependencies.Dockerfile +23 -0
  38. package/src/deployment/images/dependencies.js +171 -0
  39. package/src/deployment/images/dependencies.test.js +268 -0
  40. package/src/deployment/images/factory.js +31 -15
  41. package/src/deployment/images/format.js +66 -0
  42. package/src/deployment/images/format.test.js +149 -0
  43. package/src/deployment/images/image.fixtures.js +17 -19
  44. package/src/deployment/images/image.js +79 -38
  45. package/src/deployment/images/image.test.js +11 -5
  46. package/src/deployment/images/index.js +1 -5
  47. package/src/deployment/images/mono.Dockerfile +11 -0
  48. package/src/deployment/images/mono.js +15 -0
  49. package/src/deployment/images/publish.js +91 -0
  50. package/src/deployment/images/runtime-base.test.js +97 -0
  51. package/src/deployment/images/service.Dockerfile +17 -5
  52. package/src/deployment/images/service.js +63 -14
  53. package/src/deployment/index.js +1 -5
  54. package/src/deployment/operator.js +22 -16
  55. package/src/deployment/operator.test.js +57 -7
  56. package/src/deployment/registry.js +210 -46
  57. package/src/deployment/registry.test.js +539 -0
  58. package/src/deployment/service.js +4 -6
  59. package/src/deployment/workspace.js +13 -8
  60. package/src/index.js +3 -2
  61. package/src/process.js +51 -13
  62. package/src/process.test.js +33 -0
  63. package/types/_deployment/composition.d.ts +2 -3
  64. package/types/_deployment/dependency.d.ts +13 -7
  65. package/types/_deployment/deployment.d.ts +7 -8
  66. package/types/_deployment/factory.d.ts +2 -4
  67. package/types/_deployment/images/factory.d.ts +6 -8
  68. package/types/_deployment/images/image.d.ts +16 -1
  69. package/types/_deployment/images/registry.d.ts +8 -11
  70. package/types/_deployment/operator.d.ts +10 -9
  71. package/types/_deployment/registry.d.ts +7 -4
  72. package/types/_deployment/service.d.ts +4 -3
  73. package/types/dependency.ts +79 -0
  74. package/types/index.ts +1 -0
  75. package/types/dependency.d.ts +0 -39
  76. package/types/index.d.ts +0 -1
@@ -0,0 +1,149 @@
1
+ import { it, describe, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { Locator } from '@toa.io/core'
8
+ import { revive } from '@toa.io/norm'
9
+
10
+ import { declare, format, normalized } from './format.js'
11
+
12
+ let root
13
+
14
+ before(async () => {
15
+ root = await mkdtemp(join(tmpdir(), 'format-'))
16
+ })
17
+
18
+ after(async () => {
19
+ await rm(root, { recursive: true, force: true })
20
+ })
21
+
22
+ describe('format', () => {
23
+ it('should read the nearest manifest above', async () => {
24
+ const workspace = join(root, 'modules')
25
+ const component = join(workspace, 'components', 'one')
26
+
27
+ await mkdir(component, { recursive: true })
28
+ await writeFile(join(workspace, 'package.json'), JSON.stringify({ type: 'module' }))
29
+
30
+ assert.strictEqual(format(component), 'module')
31
+ })
32
+
33
+ it('should default to commonjs', async () => {
34
+ const workspace = join(root, 'scripts')
35
+ const component = join(workspace, 'components', 'one')
36
+
37
+ await mkdir(component, { recursive: true })
38
+ await writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'scripts' }))
39
+
40
+ assert.strictEqual(format(component), 'commonjs')
41
+ })
42
+ })
43
+
44
+ describe('declare', () => {
45
+ it('should state the format the component was written under', async () => {
46
+ const workspace = join(root, 'stated')
47
+ const source = join(workspace, 'components', 'one')
48
+ const target = join(root, 'image', 'one')
49
+
50
+ await mkdir(source, { recursive: true })
51
+ await mkdir(target, { recursive: true })
52
+ await writeFile(join(workspace, 'package.json'), JSON.stringify({ type: 'module' }))
53
+
54
+ await declare(source, target, 'default-one')
55
+
56
+ const manifest = JSON.parse(await readFile(join(target, 'package.json'), 'utf8'))
57
+
58
+ assert.strictEqual(manifest.type, 'module')
59
+ assert.strictEqual(manifest.name, 'default-one')
60
+ assert.strictEqual(manifest.private, true)
61
+ })
62
+
63
+ it('should leave a manifest the component ships alone', async () => {
64
+ const source = join(root, 'own', 'components', 'one')
65
+ const target = join(root, 'image', 'own')
66
+
67
+ await mkdir(source, { recursive: true })
68
+ await mkdir(target, { recursive: true })
69
+
70
+ const own = JSON.stringify({ name: 'own', dependencies: { matchacho: '0.6.0' } })
71
+
72
+ await writeFile(join(target, 'package.json'), own)
73
+ await declare(source, target, 'default-own')
74
+
75
+ assert.strictEqual(await readFile(join(target, 'package.json'), 'utf8'), own)
76
+ })
77
+ })
78
+
79
+ describe('normalized', () => {
80
+ it('should write what a process reads instead of normalizing', async () => {
81
+ const target = join(root, 'image', 'normalized')
82
+
83
+ await mkdir(target, { recursive: true })
84
+
85
+ const component = {
86
+ name: 'tasks',
87
+ namespace: 'todos',
88
+ version: 'abcdef12',
89
+ operations: { compute: { type: 'computation' } },
90
+ path: '/workspace/components/todos.tasks',
91
+ locator: new Locator('tasks', 'todos'),
92
+ packages: { cloudinary: '2.11.0' }
93
+ }
94
+
95
+ await normalized(component, target)
96
+
97
+ const carried = JSON.parse(
98
+ await readFile(join(target, 'manifest.toa.json'), 'utf8')
99
+ )
100
+
101
+ // where the manifest is, and what only a deploy reads, are not what it declares
102
+ assert.ok(!('path' in carried))
103
+ assert.ok(!('locator' in carried))
104
+ assert.ok(!('packages' in carried))
105
+
106
+ const read = revive(carried, '/composition/todos-tasks')
107
+
108
+ assert.deepStrictEqual(read.operations, component.operations)
109
+ assert.strictEqual(read.version, component.version)
110
+ assert.strictEqual(read.path, '/composition/todos-tasks')
111
+ assert.strictEqual(read.locator.id, 'todos.tasks')
112
+ })
113
+ })
114
+
115
+ describe('a service image', () => {
116
+ it('installs what its components declare beside Toa as well', async () => {
117
+ const { Service } = await import('./service.js')
118
+
119
+ const path = join(root, 'extension')
120
+ const component = join(path, 'components', 'octets')
121
+
122
+ await mkdir(component, { recursive: true })
123
+ await writeFile(join(path, 'package.json'), JSON.stringify({ name: 'ext', version: '1' }))
124
+ await writeFile(
125
+ join(component, 'package.json'),
126
+ JSON.stringify({ dependencies: { cloudinary: '2.11.0', jose: '6.2.10' } })
127
+ )
128
+
129
+ const service = new Service('', { version: '1' }, {}, path, {
130
+ group: 'g',
131
+ name: 'n',
132
+ version: '1'
133
+ })
134
+
135
+ service.reference = 'probe'
136
+
137
+ const context = await service.prepare(join(root, 'service'))
138
+
139
+ // what an extension imports on a component's behalf is imported from where Toa is
140
+ assert.strictEqual(
141
+ await readFile(join(context, '.packages'), 'utf8'),
142
+ 'cloudinary@2.11.0\njose@6.2.10'
143
+ )
144
+
145
+ const dockerfile = await readFile(join(context, 'Dockerfile'), 'utf8')
146
+
147
+ assert.ok(dockerfile.includes('npm i --prefix /toa --omit=dev $(cat .packages)'))
148
+ })
149
+ })
@@ -1,38 +1,36 @@
1
- 'use strict'
1
+ import { Image } from './image.js'
2
+ import { generate } from 'randomstring'
2
3
 
3
- const { Image } = require('./image')
4
- const { generate } = require('randomstring')
5
-
6
- const version = '168b04ff'
7
- const name = generate()
4
+ const reported = '168b04ff'
5
+ export const name = generate()
8
6
 
9
7
  /**
10
8
  * @implements {toa.deployment.images.Image}
11
9
  */
12
- class Class extends Image {
13
- get name () {
10
+ export class Class extends Image {
11
+ get name() {
14
12
  return name
15
13
  }
16
14
 
17
- get version () {
18
- return version
15
+ get version() {
16
+ return reported
19
17
  }
20
18
  }
21
19
 
22
20
  /** @type {toa.norm.context.Runtime} */
23
- const runtime = {
21
+ export const runtime = {
24
22
  version: '0.0.0'
25
23
  }
26
24
 
27
25
  /** @type {toa.norm.context.Registry} */
28
- const registry = {
26
+ export const registry = {
29
27
  base: 'node:alpine'
30
28
  }
31
29
 
32
- exports.scope = generate()
33
- exports.name = name
34
- exports.version = 'ba2409fc'
35
- exports.Class = Class
36
- exports.runtime = runtime
37
- exports.registry = registry
38
- exports.process = process
30
+ export const scope = generate()
31
+
32
+ // distinct from the version `Class` reports
33
+ export const version = 'ba2409fc'
34
+
35
+ // the fixture stands in for the global process
36
+ export const process = globalThis.process
@@ -1,61 +1,90 @@
1
- 'use strict'
1
+ import { join, posix } from 'node:path'
2
+ import { readFile as read, writeFile as write } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
2
4
 
3
- const {
4
- join,
5
- posix
6
- } = require('node:path')
7
- const {
8
- readFile: read,
9
- writeFile: write
10
- } = require('node:fs/promises')
11
- const { createHash } = require('node:crypto')
12
-
13
- const { overwrite } = require('@toa.io/generic')
14
- const { mkdir } = require('node:fs/promises')
5
+ import { overwrite } from '@toa.io/generic'
6
+ import { MAP } from '@toa.io/definitions'
7
+ import { mkdir } from 'node:fs/promises'
15
8
 
16
9
  /**
17
10
  * @implements {toa.deployment.images.Image}
18
11
  * @abstract
19
12
  */
20
- class Image {
13
+ export class Image {
21
14
  context
22
15
  reference
23
16
  dockerfile
24
17
 
18
+ /**
19
+ * The image this one is laid over, built and pushed as an image of its own.
20
+ * Undefined stands on the base alone, which is what a service does.
21
+ *
22
+ * @type {toa.deployment.images.Image | undefined}
23
+ */
24
+ dependencies
25
+
26
+ /** Whether the build reads `registry.build.arguments`. */
27
+ arguments = false
28
+
25
29
  #scope
26
30
  #registry
27
31
  #runtime
28
32
  #values = {
29
- build: {
30
- image: 'node:20.9.0-alpine3.18'
31
- }
33
+ build: {}
32
34
  }
33
35
 
34
- constructor (scope, runtime, registry) {
36
+ constructor(scope, runtime, registry) {
35
37
  this.#scope = scope
36
38
  this.#registry = registry
37
39
  this.#runtime = runtime
38
40
  }
39
41
 
40
- tag () {
42
+ tag() {
43
+ this.reference = posix.join(
44
+ this.#registry.base ?? '',
45
+ this.#scope,
46
+ `${this.name}:${this.digest()}`
47
+ )
48
+ }
49
+
50
+ /**
51
+ * What follows the colon: the runtime and the image's own version, digested.
52
+ *
53
+ * @returns {string}
54
+ */
55
+ digest() {
41
56
  const hash = createHash('sha256')
42
57
 
43
58
  hash.update(this.#runtime.version)
44
59
  hash.update(this.version)
45
60
 
46
- const tag = hash.digest('hex').slice(0, 8)
61
+ return hash.digest('hex').slice(0, 8)
62
+ }
47
63
 
48
- this.reference = posix.join(this.#registry.base ?? '', this.#scope, `${this.name}:${tag}`)
64
+ /** @returns {string | undefined} */
65
+ get name() {
66
+ return undefined
49
67
  }
50
68
 
51
- get name () {}
69
+ /** @returns {string | undefined} */
70
+ get version() {
71
+ return undefined
72
+ }
52
73
 
53
- get version () {}
74
+ /** The image to build `FROM`. Undefined takes the runtime's, which is what a service does.
75
+ * @returns {string | undefined} */
76
+ get base() {
77
+ return undefined
78
+ }
54
79
 
55
- get base () {}
80
+ /** Build commands to add. Undefined adds none, which is what a service does.
81
+ * @returns {string | undefined} */
82
+ get run() {
83
+ return undefined
84
+ }
56
85
 
57
- async prepare (root) {
58
- if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
86
+ async prepare(root) {
87
+ if (this.dockerfile === undefined) throw new Error("Dockerfile isn't specified")
59
88
 
60
89
  this.#setValues()
61
90
 
@@ -65,7 +94,7 @@ class Image {
65
94
 
66
95
  const template = await read(this.dockerfile, 'utf-8')
67
96
  const contents = template.replace(/{{(\S{1,32})}}/g, (_, key) => this.#value(key))
68
- const ignore = 'Dockerfile'
97
+ const ignore = ['Dockerfile', '**/node_modules'].join('\r\n')
69
98
 
70
99
  await write(join(path, 'Dockerfile'), contents)
71
100
  await write(join(path, '.dockerignore'), ignore)
@@ -75,32 +104,44 @@ class Image {
75
104
  return path
76
105
  }
77
106
 
78
- #setValues () {
107
+ #setValues() {
108
+ this.#values.map = { file: MAP }
79
109
  this.#values.runtime = this.#runtime
80
- this.#values.build = overwrite(this.#values.build, this.#registry.build)
110
+ this.#values.build = overwrite(
111
+ {
112
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
113
+ },
114
+ this.#registry.build
115
+ )
81
116
 
82
117
  const image = this.base
83
118
 
84
- if (image !== undefined) {
85
- this.#values.build.image = image
86
- }
119
+ if (image !== undefined) this.#values.build.image = image
120
+
121
+ const run = this.run
122
+
123
+ if (run !== undefined)
124
+ this.#values.build.run =
125
+ (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
87
126
 
88
- if (this.#values.build.arguments !== undefined) this.#values.build.arguments = createArguments(this.#values.build.arguments)
89
- if (this.#values.build.run !== undefined) this.#values.build.run = createRunCommands(this.#values.build.run)
127
+ if (this.#values.build.arguments !== undefined)
128
+ this.#values.build.arguments = createArguments(this.#values.build.arguments)
129
+ if (this.#values.build.run !== undefined)
130
+ this.#values.build.run = createRunCommands(this.#values.build.run)
90
131
  }
91
132
 
92
133
  /**
93
134
  * @param key {string}
94
135
  * @returns {string}
95
136
  */
96
- #value (key) {
137
+ #value(key) {
97
138
  const [source, property] = key.split('.')
98
139
 
99
140
  return this.#values[source]?.[property] ?? ''
100
141
  }
101
142
  }
102
143
 
103
- function createRunCommands (input) {
144
+ function createRunCommands(input) {
104
145
  const lines = input.split('\n')
105
146
 
106
147
  return lines.reduce((commands, command) => {
@@ -110,7 +151,7 @@ function createRunCommands (input) {
110
151
  }, '')
111
152
  }
112
153
 
113
- function createArguments (variables) {
154
+ function createArguments(variables) {
114
155
  const args = []
115
156
 
116
157
  for (const variable of variables) {
@@ -121,4 +162,4 @@ function createArguments (variables) {
121
162
  return args.join('\n')
122
163
  }
123
164
 
124
- exports.Image = Image
165
+ export const RUNTIME_IMAGE = 'ghcr.io/toa-io/runtime'
@@ -1,7 +1,8 @@
1
- 'use strict'
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
2
3
 
3
- const fixtures = require('./image.fixtures')
4
- const { generate } = require('randomstring')
4
+ import * as fixtures from './image.fixtures.js'
5
+ import { generate } from 'randomstring'
5
6
 
6
7
  /** @type {toa.deployment.images.Image} */
7
8
  let instance
@@ -13,11 +14,16 @@ beforeEach(() => {
13
14
  it('should assign url', () => {
14
15
  instance.tag()
15
16
 
16
- expect(instance.reference).toEqual(`${fixtures.registry.base}/${fixtures.scope}/${fixtures.name}:${fixtures.version}`)
17
+ assert.deepStrictEqual(
18
+ instance.reference,
19
+ `${fixtures.registry.base}/${fixtures.scope}/${fixtures.name}:${fixtures.version}`
20
+ )
17
21
  })
18
22
 
19
23
  describe('prepare', () => {
20
24
  it('should throw error if no dockerfile specified', async () => {
21
- await expect(instance.prepare(generate())).rejects.toThrow(/Dockerfile isn't specified/)
25
+ await assert.rejects(instance.prepare(generate()), (error) =>
26
+ /Dockerfile isn't specified/.test(error.message)
27
+ )
22
28
  })
23
29
  })
@@ -1,5 +1 @@
1
- 'use strict'
2
-
3
- const { Factory } = require('./factory')
4
-
5
- exports.Factory = Factory
1
+ export { Factory } from './factory.js'
@@ -0,0 +1,11 @@
1
+ FROM {{build.image}}
2
+
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
8
+
9
+ # no USER: the runtime drops to `node` itself, and only a process that started as root can
10
+ # close its environment under /proc — see runtime/runtime/bin/toa
11
+ CMD toa mono * --map {{map.file}}
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { Bundle } from './bundle.js'
4
+
5
+ export class Mono extends Bundle {
6
+ dockerfile = join(import.meta.dirname, 'mono.Dockerfile')
7
+
8
+ get name() {
9
+ return 'mono'
10
+ }
11
+
12
+ conflict() {
13
+ return 'Mono deployment requires different base images for its components. Specify base image for the composition in the context.'
14
+ }
15
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises'
2
+ import { readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { definition } from '@toa.io/norm'
6
+
7
+ import { run } from '../../process.js'
8
+ import { Service } from './service.js'
9
+
10
+ /**
11
+ * Builds and pushes the image an extension ships for its service, so that an application
12
+ * with `registry.services: published` takes it instead of building one.
13
+ *
14
+ * The image is built from the package as npm publishes it, not from the workspace: what
15
+ * `files` leaves out is not in the tarball, and an application builds from the tarball.
16
+ *
17
+ * @param {string} workspace path to the extension's directory in this repository
18
+ * @param {string} runtime the runtime version, which is the tag and the base image
19
+ * @param {string[]} platforms
20
+ * @returns {Promise<string>} the reference pushed
21
+ */
22
+ export async function publish(workspace, runtime, platforms) {
23
+ const directory = resolve(workspace)
24
+ const manifest = read(join(directory, 'package.json'))
25
+
26
+ const { image } = (await definition(manifest.name)).module
27
+
28
+ if (image === undefined)
29
+ throw new Error(
30
+ `'${manifest.name}' publishes no service image: it exports no 'image'`
31
+ )
32
+
33
+ const root = await mkdtemp(join(tmpdir(), 'toa-publish'))
34
+
35
+ await writeFile(join(root, 'package.json'), '{"private":true}')
36
+ await run('npm', [
37
+ 'i',
38
+ '--prefix',
39
+ root,
40
+ '--omit=dev',
41
+ `${manifest.name}@${manifest.version}`
42
+ ])
43
+
44
+ const path = join(root, 'node_modules', manifest.name)
45
+
46
+ const service = new Service(SCOPE, { version: runtime }, {}, path, {
47
+ ...name(image),
48
+ version: manifest.version
49
+ })
50
+
51
+ service.reference = `${image}:${runtime}`
52
+
53
+ await service.prepare(await mkdtemp(join(tmpdir(), 'toa-images')))
54
+
55
+ await run('docker', [
56
+ 'buildx',
57
+ 'build',
58
+ '--platform',
59
+ platforms.join(','),
60
+ '--tag',
61
+ service.reference,
62
+ '--provenance=false',
63
+ '--push',
64
+ service.context
65
+ ])
66
+
67
+ return service.reference
68
+ }
69
+
70
+ /** The repository's last segment is what `Service` names an image, so it reads back. */
71
+ function name(image) {
72
+ const match = /\/extension-([^-/]+)-([^/]+)$/.exec(image)
73
+
74
+ if (match === null)
75
+ throw new Error(`'${image}' is not named 'extension-<group>-<name>'`)
76
+
77
+ return { group: match[1], name: match[2] }
78
+ }
79
+
80
+ const read = (path) => JSON.parse(readFileSync(path, 'utf8'))
81
+
82
+ // the reference is assigned rather than derived from a scope
83
+ const SCOPE = ''
84
+
85
+ const PLATFORMS = 'linux/amd64,linux/arm64'
86
+
87
+ if (import.meta.main) {
88
+ const [workspace, runtime, platforms = PLATFORMS] = process.argv.slice(2)
89
+
90
+ console.log(await publish(workspace, runtime, platforms.split(',')))
91
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { join } from 'node:path'
5
+ import { mkdtemp, readFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ import { Image, RUNTIME_IMAGE } from './image.js'
9
+
10
+ const dependenciesDockerfile = join(import.meta.dirname, 'dependencies.Dockerfile')
11
+ const serviceDockerfile = join(import.meta.dirname, 'service.Dockerfile')
12
+
13
+ class TestImage extends Image {
14
+ dockerfile
15
+
16
+ #name
17
+ #base
18
+
19
+ constructor(runtime, registry, dockerfile, name = 'test', base) {
20
+ super('acme', runtime, registry)
21
+
22
+ this.dockerfile = dockerfile
23
+ this.#name = name
24
+ this.#base = base
25
+ }
26
+
27
+ get name() {
28
+ return this.#name
29
+ }
30
+
31
+ get version() {
32
+ return 'abcdef12'
33
+ }
34
+
35
+ get base() {
36
+ return this.#base
37
+ }
38
+ }
39
+
40
+ describe('runtime base image', () => {
41
+ /** @type {string} */
42
+ let root
43
+
44
+ beforeEach(async () => {
45
+ root = await mkdtemp(join(tmpdir(), 'toa-runtime-base-test'))
46
+ })
47
+
48
+ it('should default build.image to version-pinned GHCR runtime image', async () => {
49
+ const runtime = { version: '1.0.0-alpha.232' }
50
+ const image = new TestImage(runtime, {}, dependenciesDockerfile)
51
+
52
+ const path = await image.prepare(root)
53
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
54
+
55
+ assert.ok(dockerfile.includes(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.232`))
56
+ assert.doesNotMatch(dockerfile, /npm i -g @toa\.io\/runtime/)
57
+ })
58
+
59
+ it('should not install runtime in service Dockerfile template', async () => {
60
+ const runtime = { version: '1.0.0-alpha.99' }
61
+ const image = new TestImage(runtime, {}, serviceDockerfile, 'service-test')
62
+
63
+ const path = await image.prepare(root)
64
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
65
+
66
+ assert.ok(dockerfile.includes(`FROM ${RUNTIME_IMAGE}:1.0.0-alpha.99`))
67
+ assert.doesNotMatch(dockerfile, /npm i -g @toa\.io\/runtime/)
68
+ })
69
+
70
+ it('should allow registry.build.image override', async () => {
71
+ const runtime = { version: '1.0.0-alpha.232' }
72
+ const registry = { build: { image: 'node:24.14.0-alpine3.22' } }
73
+ const image = new TestImage(runtime, registry, dependenciesDockerfile)
74
+
75
+ const path = await image.prepare(root)
76
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
77
+
78
+ assert.ok(dockerfile.includes('FROM node:24.14.0-alpine3.22'))
79
+ assert.ok(!dockerfile.includes(RUNTIME_IMAGE))
80
+ })
81
+
82
+ it('should allow composition.image override via base', async () => {
83
+ const runtime = { version: '1.0.0-alpha.232' }
84
+ const image = new TestImage(
85
+ runtime,
86
+ {},
87
+ dependenciesDockerfile,
88
+ 'mono',
89
+ 'custom.example/base:1'
90
+ )
91
+
92
+ const path = await image.prepare(root)
93
+ const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
94
+
95
+ assert.ok(dockerfile.includes('FROM custom.example/base:1'))
96
+ })
97
+ })
@@ -1,14 +1,26 @@
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,sharing=locked \
11
+ npm i --omit=dev
12
12
 
13
- USER node
14
- CMD toa serve .
13
+ # a component of the extension's own declares what it imports, and it is installed beside the
14
+ # component rather than beside the extension: an application that runs none of them, and the
15
+ # runtime image every application is built on, carry nothing for them
16
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
17
+ for entry in components/*; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done
18
+
19
+ # and beside Toa as well, because what an extension imports on a component's behalf — a storage
20
+ # provider's SDK — is imported from where the extension is, which is `/toa` and not here
21
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
22
+ if [ -s .packages ]; then npm i --prefix /toa --omit=dev $(cat .packages); fi
23
+
24
+ # no USER: the runtime drops to `node` itself, and only a process that started as root can
25
+ # close its environment under /proc — see runtime/runtime/bin/toa
26
+ CMD toa serve . --map {{map.file}}