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

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/package.json +4 -4
  2. package/readme.md +43 -0
  3. package/src/deployment/.deployment/.describe/compositions.js +7 -5
  4. package/src/deployment/.deployment/.describe/events.js +7 -7
  5. package/src/deployment/.deployment/.describe/fold.js +10 -3
  6. package/src/deployment/.deployment/.describe/fold.test.js +30 -14
  7. package/src/deployment/.deployment/.describe/mounts.js +4 -7
  8. package/src/deployment/.deployment/.describe/resources.js +7 -5
  9. package/src/deployment/.deployment/.describe/resources.test.js +13 -6
  10. package/src/deployment/.deployment/.describe/services.js +10 -9
  11. package/src/deployment/.deployment/.describe/services.test.js +22 -5
  12. package/src/deployment/.deployment/.describe/variables.js +2 -3
  13. package/src/deployment/.deployment/describe.js +11 -7
  14. package/src/deployment/.deployment/merge.js +11 -6
  15. package/src/deployment/.deployment/merge.test.js +41 -10
  16. package/src/deployment/composition.js +1 -1
  17. package/src/deployment/deployment.js +35 -16
  18. package/src/deployment/factory.js +39 -19
  19. package/src/deployment/images/composition.Dockerfile +1 -1
  20. package/src/deployment/images/composition.js +10 -9
  21. package/src/deployment/images/factory.js +17 -6
  22. package/src/deployment/images/format.js +9 -5
  23. package/src/deployment/images/image.fixtures.js +2 -2
  24. package/src/deployment/images/image.js +38 -21
  25. package/src/deployment/images/image.test.js +7 -2
  26. package/src/deployment/images/mono.Dockerfile +1 -1
  27. package/src/deployment/images/mono.js +10 -9
  28. package/src/deployment/images/publish.js +98 -0
  29. package/src/deployment/images/runtime-base.test.js +12 -6
  30. package/src/deployment/images/service.Dockerfile +1 -1
  31. package/src/deployment/images/service.js +4 -4
  32. package/src/deployment/operator.js +8 -8
  33. package/src/deployment/registry.js +89 -61
  34. package/src/deployment/registry.test.js +117 -43
  35. package/src/deployment/service.js +3 -1
  36. package/src/deployment/workspace.js +1 -1
  37. package/src/process.js +1 -1
  38. package/src/process.test.js +8 -4
  39. package/types/_deployment/composition.d.ts +0 -2
  40. package/types/_deployment/dependency.d.ts +10 -6
  41. package/types/_deployment/deployment.d.ts +0 -4
  42. package/types/_deployment/factory.d.ts +0 -2
  43. package/types/_deployment/images/factory.d.ts +4 -6
  44. package/types/_deployment/images/image.d.ts +2 -4
  45. package/types/_deployment/images/registry.d.ts +7 -10
  46. package/types/_deployment/operator.d.ts +5 -7
  47. package/types/_deployment/registry.d.ts +6 -8
  48. package/types/_deployment/service.d.ts +0 -2
  49. package/types/dependency.ts +3 -0
  50. package/CHANGELOG.md +0 -231
@@ -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
  }
@@ -1,12 +1,9 @@
1
- import { posix } from 'node:path'
2
1
  import * as workspace from './workspace.js'
3
2
 
4
3
  /**
5
4
  * @implements {toa.deployment.Registry}
6
5
  */
7
6
  export class Registry {
8
- #scope
9
-
10
7
  #registry
11
8
 
12
9
  #factory
@@ -15,29 +12,28 @@ export class Registry {
15
12
 
16
13
  #images = []
17
14
 
18
- /** @type {string | undefined} */
15
+ /** @type {Promise<string> | undefined} */
19
16
  #builder
20
17
 
21
- constructor (scope, registry, factory, process) {
22
- this.#scope = scope
18
+ constructor(registry, factory, process) {
23
19
  this.#registry = registry
24
20
  this.#factory = factory
25
21
  this.#process = process
26
22
  }
27
23
 
28
- composition (composition) {
24
+ composition(composition) {
29
25
  return this.#create('composition', composition)
30
26
  }
31
27
 
32
- service (path, service) {
28
+ service(path, service) {
33
29
  return this.#create('service', path, service)
34
30
  }
35
31
 
36
- mono (composition) {
32
+ mono(composition) {
37
33
  return this.#create('mono', composition)
38
34
  }
39
35
 
40
- async prepare (root) {
36
+ async prepare(root) {
41
37
  const path = await workspace.create('images', root)
42
38
 
43
39
  await Promise.all(this.#images.map((image) => image.prepare(path)))
@@ -45,20 +41,17 @@ export class Registry {
45
41
  return path
46
42
  }
47
43
 
48
- async build () {
44
+ async build() {
49
45
  await this.prepare()
50
-
51
- for (const image of this.#images)
52
- await this.#build(image)
46
+ await this.#run(false)
53
47
  }
54
48
 
55
- async push () {
49
+ async push() {
56
50
  await this.prepare()
57
-
58
- for (const image of this.#images) await this.#push(image)
51
+ await this.#run(true)
59
52
  }
60
53
 
61
- tags () {
54
+ tags() {
62
55
  return this.#images.map((image) => image.reference)
63
56
  }
64
57
 
@@ -67,7 +60,7 @@ export class Registry {
67
60
  * @param {...any} args
68
61
  * @returns {toa.deployment.images.Image}
69
62
  */
70
- #create (type, ...args) {
63
+ #create(type, ...args) {
71
64
  const image = this.#factory[type](...args)
72
65
 
73
66
  this.#images.push(image)
@@ -75,54 +68,65 @@ export class Registry {
75
68
  return image
76
69
  }
77
70
 
71
+ /**
72
+ * Every image is probed at once, and what is missing is built concurrently: a build is
73
+ * bound by the registry it uploads to, not by the runner, so one at a time leaves both idle.
74
+ *
75
+ * @param {boolean} push
76
+ * @returns {Promise<void>}
77
+ */
78
+ async #run(push) {
79
+ const existing = await Promise.all(
80
+ this.#images.map((image) => this.exists(image.reference))
81
+ )
82
+
83
+ const missing = this.#images.filter((image, index) => {
84
+ if (existing[index]) console.log('Image already exists, skipping:', image.reference)
85
+
86
+ return !existing[index]
87
+ })
88
+
89
+ await pool(missing, (image) => this.#build(image, push))
90
+ }
91
+
78
92
  /**
79
93
  * @param {toa.deployment.images.Image} image
80
94
  * @param {boolean} [push]
81
95
  * @returns {Promise<void>}
82
96
  */
83
- async #build (image, push = false) {
84
- if (await this.exists(image.reference)) {
85
- console.log('Image already exists, skipping:', image.reference)
86
- return
87
- }
88
-
97
+ async #build(image, push = false) {
89
98
  const args = ['--context=default', 'buildx', 'build']
90
99
 
91
- if (push)
92
- args.push('--push')
93
- else
94
- args.push('--load')
100
+ if (push) args.push('--push')
101
+ else args.push('--load')
95
102
 
96
103
  args.push('--tag', image.reference, image.context)
97
104
 
98
- const multiarch = this.#registry.platforms !== null
99
-
100
105
  if (this.#registry.build?.arguments !== undefined) {
101
- for (const arg of this.#registry.build.arguments) args.push('--build-arg', `${arg}=${process.env[arg]}`)
106
+ for (const arg of this.#registry.build.arguments)
107
+ args.push('--build-arg', `${arg}=${process.env[arg]}`)
102
108
  }
103
109
 
104
- if (multiarch) {
105
- const platform = this.#registry.platforms.join(',')
106
- const builder = await this.#ensureBuilder()
110
+ const platforms = this.#registry.platforms
111
+
112
+ if (platforms !== null) args.push('--platform', platforms.join(','))
107
113
 
108
- args.push('--platform', platform)
109
- args.push('--builder', builder)
114
+ // the container driver is what builds for a platform the runner is not; for a single
115
+ // platform it costs a buildkit image to pull and buys nothing
116
+ if (platforms !== null && platforms.length > 1) {
117
+ args.push('--builder', await this.#ensureBuilder())
110
118
 
111
- if (this.#registry.base !== undefined)
112
- this.#appendCache(args)
113
- } else
114
- args.push('--builder', 'default')
119
+ // the driver that produces attestations is this one, and nothing reads them;
120
+ // each is a manifest list to export and push per image
121
+ args.push('--provenance=false')
122
+ } else args.push('--builder', 'default')
115
123
 
116
124
  args.push('--progress', 'plain')
117
125
 
118
126
  await this.#process.execute('docker', args)
119
127
  }
120
128
 
121
- async #push (image) {
122
- await this.#build(image, true)
123
- }
124
-
125
- async exists (tag) {
129
+ async exists(tag) {
126
130
  const args = ['manifest', 'inspect', tag]
127
131
 
128
132
  try {
@@ -136,30 +140,54 @@ export class Registry {
136
140
  return true
137
141
  }
138
142
 
139
- async #ensureBuilder () {
140
- if (this.#builder !== undefined)
141
- return this.#builder
143
+ /** @returns {Promise<string>} */
144
+ #ensureBuilder() {
145
+ // the promise is what is memoized, not its value: concurrent builds ask before any answers
146
+ this.#builder ??= this.#createBuilder()
147
+
148
+ return this.#builder
149
+ }
142
150
 
151
+ /** @returns {Promise<string>} */
152
+ async #createBuilder() {
143
153
  try {
144
- await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], { silently: true })
154
+ await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], {
155
+ silently: true
156
+ })
145
157
  } catch {
146
- await this.#process.execute('docker', ['buildx', 'create', '--name', BUILDER, '--bootstrap'])
158
+ await this.#process.execute('docker', [
159
+ 'buildx',
160
+ 'create',
161
+ '--name',
162
+ BUILDER,
163
+ '--bootstrap'
164
+ ])
147
165
  }
148
166
 
149
- this.#builder = BUILDER
150
-
151
167
  return BUILDER
152
168
  }
169
+ }
153
170
 
154
- /**
155
- * @param {string[]} args
156
- */
157
- #appendCache (args) {
158
- const ref = posix.join(this.#registry.base, this.#scope, 'buildcache')
171
+ /**
172
+ * @template T
173
+ * @param {T[]} items
174
+ * @param {(item: T) => Promise<void>} work
175
+ * @returns {Promise<void>}
176
+ */
177
+ async function pool(items, work) {
178
+ const queue = items.slice()
159
179
 
160
- args.push('--cache-from', `type=registry,ref=${ref}`)
161
- args.push('--cache-to', `type=registry,ref=${ref},mode=max,image-manifest=true`)
162
- }
180
+ const workers = Array.from(
181
+ { length: Math.min(CONCURRENCY, queue.length) },
182
+ async () => {
183
+ while (queue.length > 0) await work(queue.shift())
184
+ }
185
+ )
186
+
187
+ await Promise.all(workers)
163
188
  }
164
189
 
165
190
  const BUILDER = 'toa'
191
+
192
+ /** How many builds run at once. Past this the uploads contend for the same uplink. */
193
+ const CONCURRENCY = 3