@toa.io/operations 1.0.0-alpha.284 → 1.0.0-alpha.287

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 (50) hide show
  1. package/CHANGELOG.md +1 -224
  2. package/package.json +4 -4
  3. package/readme.md +43 -0
  4. package/src/deployment/.deployment/.describe/compositions.js +7 -5
  5. package/src/deployment/.deployment/.describe/events.js +7 -7
  6. package/src/deployment/.deployment/.describe/fold.js +10 -3
  7. package/src/deployment/.deployment/.describe/fold.test.js +30 -14
  8. package/src/deployment/.deployment/.describe/mounts.js +4 -7
  9. package/src/deployment/.deployment/.describe/resources.js +7 -5
  10. package/src/deployment/.deployment/.describe/resources.test.js +13 -6
  11. package/src/deployment/.deployment/.describe/services.js +10 -9
  12. package/src/deployment/.deployment/.describe/services.test.js +22 -5
  13. package/src/deployment/.deployment/.describe/variables.js +2 -3
  14. package/src/deployment/.deployment/describe.js +11 -7
  15. package/src/deployment/.deployment/merge.js +11 -6
  16. package/src/deployment/.deployment/merge.test.js +41 -10
  17. package/src/deployment/composition.js +1 -1
  18. package/src/deployment/deployment.js +35 -16
  19. package/src/deployment/factory.js +39 -19
  20. package/src/deployment/images/composition.Dockerfile +1 -1
  21. package/src/deployment/images/composition.js +10 -9
  22. package/src/deployment/images/factory.js +17 -6
  23. package/src/deployment/images/format.js +9 -5
  24. package/src/deployment/images/image.fixtures.js +2 -2
  25. package/src/deployment/images/image.js +38 -21
  26. package/src/deployment/images/image.test.js +7 -2
  27. package/src/deployment/images/mono.Dockerfile +1 -1
  28. package/src/deployment/images/mono.js +10 -9
  29. package/src/deployment/images/publish.js +98 -0
  30. package/src/deployment/images/runtime-base.test.js +12 -6
  31. package/src/deployment/images/service.Dockerfile +1 -1
  32. package/src/deployment/images/service.js +4 -4
  33. package/src/deployment/operator.js +8 -8
  34. package/src/deployment/registry.js +89 -61
  35. package/src/deployment/registry.test.js +117 -43
  36. package/src/deployment/service.js +3 -1
  37. package/src/deployment/workspace.js +1 -1
  38. package/src/process.js +1 -1
  39. package/src/process.test.js +8 -4
  40. package/types/_deployment/composition.d.ts +0 -2
  41. package/types/_deployment/dependency.d.ts +10 -6
  42. package/types/_deployment/deployment.d.ts +0 -4
  43. package/types/_deployment/factory.d.ts +0 -2
  44. package/types/_deployment/images/factory.d.ts +4 -6
  45. package/types/_deployment/images/image.d.ts +2 -4
  46. package/types/_deployment/images/registry.d.ts +7 -10
  47. package/types/_deployment/operator.d.ts +5 -7
  48. package/types/_deployment/registry.d.ts +6 -8
  49. package/types/_deployment/service.d.ts +0 -2
  50. package/types/dependency.ts +3 -0
@@ -21,13 +21,13 @@ export class Image {
21
21
  build: {}
22
22
  }
23
23
 
24
- constructor (scope, runtime, registry) {
24
+ constructor(scope, runtime, registry) {
25
25
  this.#scope = scope
26
26
  this.#registry = registry
27
27
  this.#runtime = runtime
28
28
  }
29
29
 
30
- tag () {
30
+ tag() {
31
31
  const hash = createHash('sha256')
32
32
 
33
33
  hash.update(this.#runtime.version)
@@ -35,25 +35,37 @@ export class Image {
35
35
 
36
36
  const tag = hash.digest('hex').slice(0, 8)
37
37
 
38
- this.reference = posix.join(this.#registry.base ?? '', this.#scope, `${this.name}:${tag}`)
38
+ this.reference = posix.join(
39
+ this.#registry.base ?? '',
40
+ this.#scope,
41
+ `${this.name}:${tag}`
42
+ )
39
43
  }
40
44
 
41
45
  /** @returns {string | undefined} */
42
- get name () { return undefined }
46
+ get name() {
47
+ return undefined
48
+ }
43
49
 
44
50
  /** @returns {string | undefined} */
45
- get version () { return undefined }
51
+ get version() {
52
+ return undefined
53
+ }
46
54
 
47
55
  /** The image to build `FROM`. Undefined takes the runtime's, which is what a service does.
48
56
  * @returns {string | undefined} */
49
- get base () { return undefined }
57
+ get base() {
58
+ return undefined
59
+ }
50
60
 
51
61
  /** Build commands to add. Undefined adds none, which is what a service does.
52
62
  * @returns {string | undefined} */
53
- get run () { return undefined }
63
+ get run() {
64
+ return undefined
65
+ }
54
66
 
55
- async prepare (root) {
56
- if (this.dockerfile === undefined) throw new Error('Dockerfile isn\'t specified')
67
+ async prepare(root) {
68
+ if (this.dockerfile === undefined) throw new Error("Dockerfile isn't specified")
57
69
 
58
70
  this.#setValues()
59
71
 
@@ -73,38 +85,43 @@ export class Image {
73
85
  return path
74
86
  }
75
87
 
76
- #setValues () {
88
+ #setValues() {
77
89
  this.#values.runtime = this.#runtime
78
- this.#values.build = overwrite({
79
- image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
80
- }, this.#registry.build)
90
+ this.#values.build = overwrite(
91
+ {
92
+ image: `${RUNTIME_IMAGE}:${this.#runtime.version}`
93
+ },
94
+ this.#registry.build
95
+ )
81
96
 
82
97
  const image = this.base
83
98
 
84
- if (image !== undefined)
85
- this.#values.build.image = image
99
+ if (image !== undefined) this.#values.build.image = image
86
100
 
87
101
  const run = this.run
88
102
 
89
103
  if (run !== undefined)
90
- this.#values.build.run = (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
104
+ this.#values.build.run =
105
+ (this.#values.build.run === undefined ? '' : this.#values.build.run + '\n') + run
91
106
 
92
- if (this.#values.build.arguments !== undefined) this.#values.build.arguments = createArguments(this.#values.build.arguments)
93
- if (this.#values.build.run !== undefined) this.#values.build.run = createRunCommands(this.#values.build.run)
107
+ if (this.#values.build.arguments !== undefined)
108
+ this.#values.build.arguments = createArguments(this.#values.build.arguments)
109
+ if (this.#values.build.run !== undefined)
110
+ this.#values.build.run = createRunCommands(this.#values.build.run)
94
111
  }
95
112
 
96
113
  /**
97
114
  * @param key {string}
98
115
  * @returns {string}
99
116
  */
100
- #value (key) {
117
+ #value(key) {
101
118
  const [source, property] = key.split('.')
102
119
 
103
120
  return this.#values[source]?.[property] ?? ''
104
121
  }
105
122
  }
106
123
 
107
- function createRunCommands (input) {
124
+ function createRunCommands(input) {
108
125
  const lines = input.split('\n')
109
126
 
110
127
  return lines.reduce((commands, command) => {
@@ -114,7 +131,7 @@ function createRunCommands (input) {
114
131
  }, '')
115
132
  }
116
133
 
117
- function createArguments (variables) {
134
+ function createArguments(variables) {
118
135
  const args = []
119
136
 
120
137
  for (const variable of variables) {
@@ -14,11 +14,16 @@ beforeEach(() => {
14
14
  it('should assign url', () => {
15
15
  instance.tag()
16
16
 
17
- assert.deepStrictEqual(instance.reference, `${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
+ )
18
21
  })
19
22
 
20
23
  describe('prepare', () => {
21
24
  it('should throw error if no dockerfile specified', async () => {
22
- await assert.rejects(instance.prepare(generate()), (error) => /Dockerfile isn't specified/.test(error.message))
25
+ await assert.rejects(instance.prepare(generate()), (error) =>
26
+ /Dockerfile isn't specified/.test(error.message)
27
+ )
23
28
  })
24
29
  })
@@ -11,7 +11,7 @@ COPY --chown=node:node . /composition
11
11
 
12
12
  {{build.run}}
13
13
 
14
- RUN --mount=type=cache,target=/root/.npm \
14
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
15
15
  for entry in *; do if grep -qs '"dependencies"' "$entry/package.json"; then (cd $entry && npm i --omit=dev); fi; done
16
16
 
17
17
  USER node
@@ -11,18 +11,18 @@ export class Mono extends Image {
11
11
  #image
12
12
  #components
13
13
 
14
- constructor (scope, runtime, registry, composition) {
14
+ constructor(scope, runtime, registry, composition) {
15
15
  super(scope, runtime, registry)
16
16
 
17
17
  this.#image = composition.image
18
18
  this.#components = composition.components
19
19
  }
20
20
 
21
- get name () {
21
+ get name() {
22
22
  return 'mono'
23
23
  }
24
24
 
25
- get version () {
25
+ get version() {
26
26
  const hash = createHash('sha256')
27
27
 
28
28
  for (const component of this.#components) {
@@ -33,31 +33,32 @@ export class Mono extends Image {
33
33
  return hash.digest('hex').slice(0, 8)
34
34
  }
35
35
 
36
- get base () {
36
+ get base() {
37
37
  if (this.#image !== undefined) return this.#image
38
38
 
39
39
  const images = new Set(this.#components.map((component) => component.build?.image))
40
40
 
41
41
  if (images.size > 1)
42
- throw new Error('Mono deployment requires different base images for its components. Specify base image for the composition in the context.')
42
+ throw new Error(
43
+ 'Mono deployment requires different base images for its components. Specify base image for the composition in the context.'
44
+ )
43
45
 
44
46
  return images.values().next().value
45
47
  }
46
48
 
47
- get run () {
49
+ get run() {
48
50
  const commands = []
49
51
 
50
52
  for (const component of this.#components) {
51
53
  const run = component.build?.run
52
54
 
53
- if (run !== undefined)
54
- commands.push(run)
55
+ if (run !== undefined) commands.push(run)
55
56
  }
56
57
 
57
58
  return commands.join('\n')
58
59
  }
59
60
 
60
- async prepare (root) {
61
+ async prepare(root) {
61
62
  const context = await super.prepare(root)
62
63
 
63
64
  for (const component of this.#components) {
@@ -0,0 +1,98 @@
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 { pathToFileURL } from 'node:url'
6
+
7
+ import { execa } from 'execa'
8
+
9
+ import { Service } from './service.js'
10
+
11
+ /**
12
+ * Builds and pushes the image an extension ships for its service, so that an application
13
+ * with `registry.services: published` takes it instead of building one.
14
+ *
15
+ * The image is built from the package as npm publishes it, not from the workspace: what
16
+ * `.npmignore` leaves out is not in the tarball, and an application builds from the tarball.
17
+ *
18
+ * @param {string} workspace path to the extension's directory in this repository
19
+ * @param {string} runtime the runtime version, which is the tag and the base image
20
+ * @param {string[]} platforms
21
+ * @returns {Promise<string>} the reference pushed
22
+ */
23
+ export async function publish(workspace, runtime, platforms) {
24
+ const directory = resolve(workspace)
25
+ const manifest = read(join(directory, 'package.json'))
26
+
27
+ // read from the workspace, where the extension's own dependencies resolve; what is
28
+ // installed below carries the same constant but not everything it takes to load it
29
+ const { image } = await import(pathToFileURL(join(directory, manifest.main)).href)
30
+
31
+ if (image === undefined)
32
+ throw new Error(`'${manifest.name}' publishes no service image: it exports no 'image'`)
33
+
34
+ const root = await mkdtemp(join(tmpdir(), 'toa-publish'))
35
+
36
+ await writeFile(join(root, 'package.json'), '{"private":true}')
37
+ await run('npm', [
38
+ 'i',
39
+ '--prefix',
40
+ root,
41
+ '--omit=dev',
42
+ `${manifest.name}@${manifest.version}`
43
+ ])
44
+
45
+ const path = join(root, 'node_modules', manifest.name)
46
+
47
+ const service = new Service(SCOPE, { version: runtime }, {}, path, {
48
+ ...name(image),
49
+ version: manifest.version
50
+ })
51
+
52
+ service.reference = `${image}:${runtime}`
53
+
54
+ await service.prepare(await mkdtemp(join(tmpdir(), 'toa-images')))
55
+
56
+ await run('docker', [
57
+ 'buildx',
58
+ 'build',
59
+ '--platform',
60
+ platforms.join(','),
61
+ '--tag',
62
+ service.reference,
63
+ '--provenance=false',
64
+ '--push',
65
+ service.context
66
+ ])
67
+
68
+ return service.reference
69
+ }
70
+
71
+ /** The repository's last segment is what `Service` names an image, so it reads back. */
72
+ function name(image) {
73
+ const match = /\/extension-([^-/]+)-([^/]+)$/.exec(image)
74
+
75
+ if (match === null)
76
+ throw new Error(`'${image}' is not named 'extension-<group>-<name>'`)
77
+
78
+ return { group: match[1], name: match[2] }
79
+ }
80
+
81
+ const read = (path) => JSON.parse(readFileSync(path, 'utf8'))
82
+
83
+ const run = async (cmd, args) => {
84
+ console.log('toa>', cmd, args.join(' '))
85
+
86
+ await execa(cmd, args, { stdio: 'inherit' })
87
+ }
88
+
89
+ // the reference is assigned rather than derived from a scope
90
+ const SCOPE = ''
91
+
92
+ const PLATFORMS = 'linux/amd64,linux/arm64'
93
+
94
+ if (import.meta.main) {
95
+ const [workspace, runtime, platforms = PLATFORMS] = process.argv.slice(2)
96
+
97
+ console.log(await publish(workspace, runtime, platforms.split(',')))
98
+ }
@@ -16,7 +16,7 @@ class TestImage extends Image {
16
16
  #name
17
17
  #base
18
18
 
19
- constructor (runtime, registry, dockerfile, name = 'test', base) {
19
+ constructor(runtime, registry, dockerfile, name = 'test', base) {
20
20
  super('acme', runtime, registry)
21
21
 
22
22
  this.dockerfile = dockerfile
@@ -24,15 +24,15 @@ class TestImage extends Image {
24
24
  this.#base = base
25
25
  }
26
26
 
27
- get name () {
27
+ get name() {
28
28
  return this.#name
29
29
  }
30
30
 
31
- get version () {
31
+ get version() {
32
32
  return 'abcdef12'
33
33
  }
34
34
 
35
- get base () {
35
+ get base() {
36
36
  return this.#base
37
37
  }
38
38
  }
@@ -76,12 +76,18 @@ describe('runtime base image', () => {
76
76
  const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
77
77
 
78
78
  assert.ok(dockerfile.includes('FROM node:24.14.0-alpine3.22'))
79
- assert.ok(!(dockerfile.includes(RUNTIME_IMAGE)))
79
+ assert.ok(!dockerfile.includes(RUNTIME_IMAGE))
80
80
  })
81
81
 
82
82
  it('should allow composition.image override via base', async () => {
83
83
  const runtime = { version: '1.0.0-alpha.232' }
84
- const image = new TestImage(runtime, {}, compositionDockerfile, 'mono', 'custom.example/base:1')
84
+ const image = new TestImage(
85
+ runtime,
86
+ {},
87
+ compositionDockerfile,
88
+ 'mono',
89
+ 'custom.example/base:1'
90
+ )
85
91
 
86
92
  const path = await image.prepare(root)
87
93
  const dockerfile = await readFile(join(path, 'Dockerfile'), 'utf8')
@@ -7,7 +7,7 @@ RUN if [ "{{runtime.proxy}}" != "" ]; then npm set proxy {{runtime.proxy}}; fi
7
7
  WORKDIR /service
8
8
  COPY --chown=node:node . /service
9
9
 
10
- RUN --mount=type=cache,target=/root/.npm \
10
+ RUN --mount=type=cache,target=/root/.npm,sharing=locked \
11
11
  npm i --omit=dev
12
12
 
13
13
  USER node
@@ -37,7 +37,7 @@ export class Service extends Image {
37
37
  * @param {string} reference
38
38
  * @param {toa.deployment.dependency.Service} service
39
39
  */
40
- constructor (scope, runtime, registry, reference, service) {
40
+ constructor(scope, runtime, registry, reference, service) {
41
41
  super(scope, runtime, registry)
42
42
 
43
43
  this.service = service.name
@@ -48,15 +48,15 @@ export class Service extends Image {
48
48
  this.#version = service.version
49
49
  }
50
50
 
51
- get name () {
51
+ get name() {
52
52
  return 'extension-' + this.#group + '-' + this.#name
53
53
  }
54
54
 
55
- get version () {
55
+ get version() {
56
56
  return this.#version
57
57
  }
58
58
 
59
- async prepare (root) {
59
+ async prepare(root) {
60
60
  const context = await super.prepare(root)
61
61
 
62
62
  await fs.copy(this.#path, context)
@@ -11,12 +11,12 @@ export class Operator {
11
11
  * @param deployment {toa.deployment.Deployment}
12
12
  * @param registry {toa.deployment.Registry}
13
13
  */
14
- constructor (deployment, registry) {
14
+ constructor(deployment, registry) {
15
15
  this.#deployment = deployment
16
16
  this.#registry = registry
17
17
  }
18
18
 
19
- async export (path) {
19
+ async export(path) {
20
20
  const target = await workspace.create('deployment', path)
21
21
 
22
22
  await this.#deployment.export(target)
@@ -24,32 +24,32 @@ export class Operator {
24
24
  return target
25
25
  }
26
26
 
27
- async prepare (path) {
27
+ async prepare(path) {
28
28
  return await this.#registry.prepare(path)
29
29
  }
30
30
 
31
- async push () {
31
+ async push() {
32
32
  await this.#registry.push()
33
33
  }
34
34
 
35
- async install (options = {}) {
35
+ async install(options = {}) {
36
36
  options = Object.assign({}, OPTIONS, options)
37
37
 
38
38
  await Promise.all([this.export(), this.push()])
39
39
  await this.#deployment.install(options)
40
40
  }
41
41
 
42
- async template (options = {}) {
42
+ async template(options = {}) {
43
43
  await this.export()
44
44
 
45
45
  return await this.#deployment.template(options)
46
46
  }
47
47
 
48
- variables () {
48
+ variables() {
49
49
  return this.#deployment.variables()
50
50
  }
51
51
 
52
- tags () {
52
+ tags() {
53
53
  return this.#registry.tags()
54
54
  }
55
55
  }