@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.
- package/CHANGELOG.md +213 -0
- package/image/runtime.Dockerfile +14 -0
- package/package.json +10 -10
- package/readme.md +201 -0
- package/src/deployment/.deployment/.describe/components.js +1 -5
- package/src/deployment/.deployment/.describe/compositions.js +26 -7
- package/src/deployment/.deployment/.describe/dependencies.js +1 -6
- package/src/deployment/.deployment/.describe/events.js +63 -0
- package/src/deployment/.deployment/.describe/fold.js +42 -0
- package/src/deployment/.deployment/.describe/fold.test.js +108 -0
- package/src/deployment/.deployment/.describe/index.js +4 -13
- package/src/deployment/.deployment/.describe/mounts.js +17 -0
- package/src/deployment/.deployment/.describe/resources.js +129 -0
- package/src/deployment/.deployment/.describe/resources.test.js +129 -0
- package/src/deployment/.deployment/.describe/services.js +32 -6
- package/src/deployment/.deployment/.describe/services.test.js +78 -0
- package/src/deployment/.deployment/.describe/variables.js +5 -9
- package/src/deployment/.deployment/declare.js +1 -5
- package/src/deployment/.deployment/describe.js +153 -10
- package/src/deployment/.deployment/index.js +3 -9
- package/src/deployment/.deployment/merge.js +66 -6
- package/src/deployment/.deployment/merge.test.js +133 -0
- package/src/deployment/chart/templates/components.configmap.yaml +14 -0
- package/src/deployment/chart/templates/components.yaml +1 -1
- package/src/deployment/chart/templates/compositions.yaml +109 -4
- package/src/deployment/chart/templates/mono.yaml +195 -0
- package/src/deployment/chart/templates/services.yaml +97 -7
- package/src/deployment/chart/values.yaml +23 -1
- package/src/deployment/composition.js +8 -6
- package/src/deployment/deployment.js +147 -34
- package/src/deployment/drain.js +70 -0
- package/src/deployment/drain.test.js +64 -0
- package/src/deployment/factory.js +169 -37
- package/src/deployment/images/bundle.js +88 -0
- package/src/deployment/images/composition.Dockerfile +9 -17
- package/src/deployment/images/composition.js +9 -56
- package/src/deployment/images/dependencies.Dockerfile +23 -0
- package/src/deployment/images/dependencies.js +171 -0
- package/src/deployment/images/dependencies.test.js +268 -0
- package/src/deployment/images/factory.js +31 -15
- package/src/deployment/images/format.js +66 -0
- package/src/deployment/images/format.test.js +149 -0
- package/src/deployment/images/image.fixtures.js +17 -19
- package/src/deployment/images/image.js +79 -38
- package/src/deployment/images/image.test.js +11 -5
- package/src/deployment/images/index.js +1 -5
- package/src/deployment/images/mono.Dockerfile +11 -0
- package/src/deployment/images/mono.js +15 -0
- package/src/deployment/images/publish.js +91 -0
- package/src/deployment/images/runtime-base.test.js +97 -0
- package/src/deployment/images/service.Dockerfile +17 -5
- package/src/deployment/images/service.js +63 -14
- package/src/deployment/index.js +1 -5
- package/src/deployment/operator.js +22 -16
- package/src/deployment/operator.test.js +57 -7
- package/src/deployment/registry.js +210 -46
- package/src/deployment/registry.test.js +539 -0
- package/src/deployment/service.js +4 -6
- package/src/deployment/workspace.js +13 -8
- package/src/index.js +3 -2
- package/src/process.js +51 -13
- package/src/process.test.js +33 -0
- package/types/_deployment/composition.d.ts +2 -3
- package/types/_deployment/dependency.d.ts +13 -7
- package/types/_deployment/deployment.d.ts +7 -8
- package/types/_deployment/factory.d.ts +2 -4
- package/types/_deployment/images/factory.d.ts +6 -8
- package/types/_deployment/images/image.d.ts +16 -1
- package/types/_deployment/images/registry.d.ts +8 -11
- package/types/_deployment/operator.d.ts +10 -9
- package/types/_deployment/registry.d.ts +7 -4
- package/types/_deployment/service.d.ts +4 -3
- package/types/dependency.ts +79 -0
- package/types/index.ts +1 -0
- package/types/dependency.d.ts +0 -39
- 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
|
-
|
|
1
|
+
import { Image } from './image.js'
|
|
2
|
+
import { generate } from 'randomstring'
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
61
|
+
return hash.digest('hex').slice(0, 8)
|
|
62
|
+
}
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
/** @returns {string | undefined} */
|
|
65
|
+
get name() {
|
|
66
|
+
return undefined
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
|
|
69
|
+
/** @returns {string | undefined} */
|
|
70
|
+
get version() {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
52
73
|
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
if (this.dockerfile === undefined) throw new Error(
|
|
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(
|
|
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
|
-
|
|
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)
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
165
|
+
export const RUNTIME_IMAGE = 'ghcr.io/toa-io/runtime'
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
25
|
+
await assert.rejects(instance.prepare(generate()), (error) =>
|
|
26
|
+
/Dockerfile isn't specified/.test(error.message)
|
|
27
|
+
)
|
|
22
28
|
})
|
|
23
29
|
})
|
|
@@ -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
|
|
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
|
|
10
|
+
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
|
11
|
+
npm i --omit=dev
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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}}
|