@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.
- package/package.json +4 -4
- package/readme.md +43 -0
- package/src/deployment/.deployment/.describe/compositions.js +7 -5
- package/src/deployment/.deployment/.describe/events.js +7 -7
- package/src/deployment/.deployment/.describe/fold.js +10 -3
- package/src/deployment/.deployment/.describe/fold.test.js +30 -14
- package/src/deployment/.deployment/.describe/mounts.js +4 -7
- package/src/deployment/.deployment/.describe/resources.js +7 -5
- package/src/deployment/.deployment/.describe/resources.test.js +13 -6
- package/src/deployment/.deployment/.describe/services.js +10 -9
- package/src/deployment/.deployment/.describe/services.test.js +22 -5
- package/src/deployment/.deployment/.describe/variables.js +2 -3
- package/src/deployment/.deployment/describe.js +11 -7
- package/src/deployment/.deployment/merge.js +11 -6
- package/src/deployment/.deployment/merge.test.js +41 -10
- package/src/deployment/composition.js +1 -1
- package/src/deployment/deployment.js +35 -16
- package/src/deployment/factory.js +39 -19
- package/src/deployment/images/composition.Dockerfile +1 -1
- package/src/deployment/images/composition.js +10 -9
- package/src/deployment/images/factory.js +17 -6
- package/src/deployment/images/format.js +9 -5
- package/src/deployment/images/image.fixtures.js +2 -2
- package/src/deployment/images/image.js +38 -21
- package/src/deployment/images/image.test.js +7 -2
- package/src/deployment/images/mono.Dockerfile +1 -1
- package/src/deployment/images/mono.js +10 -9
- package/src/deployment/images/publish.js +98 -0
- package/src/deployment/images/runtime-base.test.js +12 -6
- package/src/deployment/images/service.Dockerfile +1 -1
- package/src/deployment/images/service.js +4 -4
- package/src/deployment/operator.js +8 -8
- package/src/deployment/registry.js +89 -61
- package/src/deployment/registry.test.js +117 -43
- package/src/deployment/service.js +3 -1
- package/src/deployment/workspace.js +1 -1
- package/src/process.js +1 -1
- package/src/process.test.js +8 -4
- package/types/_deployment/composition.d.ts +0 -2
- package/types/_deployment/dependency.d.ts +10 -6
- package/types/_deployment/deployment.d.ts +0 -4
- package/types/_deployment/factory.d.ts +0 -2
- package/types/_deployment/images/factory.d.ts +4 -6
- package/types/_deployment/images/image.d.ts +2 -4
- package/types/_deployment/images/registry.d.ts +7 -10
- package/types/_deployment/operator.d.ts +5 -7
- package/types/_deployment/registry.d.ts +6 -8
- package/types/_deployment/service.d.ts +0 -2
- package/types/dependency.ts +3 -0
- 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
|
|
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(!
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
14
|
+
constructor(deployment, registry) {
|
|
15
15
|
this.#deployment = deployment
|
|
16
16
|
this.#registry = registry
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async export
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
24
|
+
composition(composition) {
|
|
29
25
|
return this.#create('composition', composition)
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
service
|
|
28
|
+
service(path, service) {
|
|
33
29
|
return this.#create('service', path, service)
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
mono
|
|
32
|
+
mono(composition) {
|
|
37
33
|
return this.#create('mono', composition)
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
async prepare
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
106
|
+
for (const arg of this.#registry.build.arguments)
|
|
107
|
+
args.push('--build-arg', `${arg}=${process.env[arg]}`)
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
const platforms = this.#registry.platforms
|
|
111
|
+
|
|
112
|
+
if (platforms !== null) args.push('--platform', platforms.join(','))
|
|
107
113
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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], {
|
|
154
|
+
await this.#process.execute('docker', ['buildx', 'inspect', BUILDER], {
|
|
155
|
+
silently: true
|
|
156
|
+
})
|
|
145
157
|
} catch {
|
|
146
|
-
await this.#process.execute('docker', [
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|