@gnar-engine/cli 1.0.6 → 1.0.7
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 +1 -1
- package/src/config.js +5 -1
- package/src/dev/commands.js +5 -1
- package/src/dev/dev.service.js +176 -125
- package/src/services/docker.js +173 -0
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
2
3
|
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
3
6
|
export const gnarEngineCliConfig = {
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The path the Gnar Engine service core should be found in the service containers
|
|
7
10
|
*/
|
|
8
|
-
corePath: '/usr/gnar_engine/app/node_modules/@gnar-engine/core'
|
|
11
|
+
corePath: '/usr/gnar_engine/app/node_modules/@gnar-engine/core',
|
|
12
|
+
coreDevPath: path.join(__dirname, '../../core'),
|
|
9
13
|
|
|
10
14
|
}
|
|
11
15
|
|
package/src/dev/commands.js
CHANGED
|
@@ -12,9 +12,11 @@ export function registerDevCommands(program) {
|
|
|
12
12
|
.description('🛠️ Up Development Containers')
|
|
13
13
|
.option('-b, --build', 'Build without cache')
|
|
14
14
|
.option('-d, --detach', 'Run containers in background')
|
|
15
|
+
.option('-a, --attach-all', 'Attach all services including database and message queues for debugging')
|
|
15
16
|
.option('-t --test', 'Run the tests with ephemeral databases')
|
|
16
17
|
.option('--test-service <service>', 'Run the tests for the specified service with ephemeral databases (e.g. --test-service user)')
|
|
17
18
|
.addOption(new Option('--core-dev').hideHelp())
|
|
19
|
+
.addOption(new Option('--bootstrap-dev').hideHelp())
|
|
18
20
|
.action(async (options) => {
|
|
19
21
|
let response = {};
|
|
20
22
|
|
|
@@ -39,8 +41,10 @@ export function registerDevCommands(program) {
|
|
|
39
41
|
build: options.build || false,
|
|
40
42
|
detach: options.detach || false,
|
|
41
43
|
coreDev: options.coreDev || false,
|
|
44
|
+
bootstrapDev: options.bootstrapDev || false,
|
|
42
45
|
test: options.test || false,
|
|
43
|
-
testService: options.testService || ''
|
|
46
|
+
testService: options.testService || '',
|
|
47
|
+
attachAll: options.attachAll || false
|
|
44
48
|
});
|
|
45
49
|
} catch (err) {
|
|
46
50
|
console.error("❌ Error running containers:", err.message);
|
package/src/dev/dev.service.js
CHANGED
|
@@ -3,10 +3,13 @@ import Docker from "dockerode";
|
|
|
3
3
|
import process from "process";
|
|
4
4
|
import fs from "fs/promises";
|
|
5
5
|
import path from "path";
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
6
7
|
import yaml from "js-yaml";
|
|
7
8
|
import { gnarEngineCliConfig } from "../config.js";
|
|
9
|
+
import { buildImage, createContainer, createBridgeNetwork } from "../services/docker.js";
|
|
8
10
|
import { directories } from "../config.js";
|
|
9
11
|
|
|
12
|
+
|
|
10
13
|
const docker = new Docker();
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -19,14 +22,16 @@ const docker = new Docker();
|
|
|
19
22
|
* @param {boolean} [options.build=false] - Whether to re-build images
|
|
20
23
|
* @param {boolean} [options.detach=false] - Whether to run containers in background
|
|
21
24
|
* @param {boolean} [options.coreDev=false] - Whether to run in core development mode (requires access to core source)
|
|
25
|
+
* @param {boolean} [options.bootstrapDev=false] - Whether to set the cli/src/bootstrap directory as the project directory
|
|
22
26
|
* @param {boolean} [options.test=false] - Whether to run tests with ephemeral databases
|
|
23
27
|
* @param {string} [options.testService=''] - The service to run tests for (only applicable if test=true)
|
|
24
28
|
* @param {boolean} [options.removeOrphans=true] - Whether to remove orphaned containers
|
|
29
|
+
* @param {boolean} [options.attachAll=false] - Attach all services including database and message queues for debugging
|
|
25
30
|
*/
|
|
26
|
-
export async function up({ projectDir, build = false, detach = false, coreDev = false, test = false, testService = '', removeOrphans = true }) {
|
|
31
|
+
export async function up({ projectDir, build = false, detach = false, coreDev = false, bootstrapDev = false, test = false, testService = '', removeOrphans = true, attachAll = false}) {
|
|
27
32
|
|
|
28
|
-
//
|
|
29
|
-
if (
|
|
33
|
+
// bootstrap dev
|
|
34
|
+
if (bootstrapDev) {
|
|
30
35
|
const fileDir = path.dirname(new URL(import.meta.url).pathname);
|
|
31
36
|
projectDir = path.resolve(fileDir, "../../bootstrap/");
|
|
32
37
|
}
|
|
@@ -56,50 +61,51 @@ export async function up({ projectDir, build = false, detach = false, coreDev =
|
|
|
56
61
|
|
|
57
62
|
// create docker-compose.yml dynamically from parsed config and secrets
|
|
58
63
|
const dockerComposePath = path.join(gnarHiddenDir, "docker-compose.dev.yml");
|
|
59
|
-
const dockerCompose = await
|
|
64
|
+
const dockerCompose = await buildAndUpContainers({
|
|
60
65
|
config: parsedConfig.config,
|
|
61
66
|
secrets: parsedSecrets,
|
|
62
67
|
gnarHiddenDir: gnarHiddenDir,
|
|
63
68
|
projectDir: projectDir,
|
|
64
69
|
coreDev: coreDev,
|
|
70
|
+
bootstrapDev: bootstrapDev,
|
|
65
71
|
test: test,
|
|
66
|
-
testService: testService
|
|
72
|
+
testService: testService,
|
|
73
|
+
attachAll: attachAll
|
|
67
74
|
});
|
|
68
|
-
await fs.writeFile(dockerComposePath, yaml.dump(dockerCompose));
|
|
69
|
-
|
|
70
|
-
// up docker-compose
|
|
71
|
-
const args = ["-f", dockerComposePath, "up"];
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
76
|
+
// // up docker-compose
|
|
77
|
+
// const args = ["-f", dockerComposePath, "up"];
|
|
78
|
+
//
|
|
79
|
+
// if (build) {
|
|
80
|
+
// args.push("--build");
|
|
81
|
+
// }
|
|
82
|
+
//
|
|
83
|
+
// if (detach) {
|
|
84
|
+
// args.push("-d");
|
|
85
|
+
// }
|
|
86
|
+
//
|
|
87
|
+
// if (removeOrphans) {
|
|
88
|
+
// args.push("--remove-orphans")
|
|
89
|
+
// }
|
|
90
|
+
|
|
91
|
+
// const processRef = spawn(
|
|
92
|
+
// "docker-compose",
|
|
93
|
+
// args,
|
|
94
|
+
// {
|
|
95
|
+
// cwd: projectDir,
|
|
96
|
+
// stdio: "inherit",
|
|
97
|
+
// shell: "/bin/sh"
|
|
98
|
+
// }
|
|
99
|
+
// );
|
|
100
|
+
//
|
|
101
|
+
// // handle exit
|
|
102
|
+
// const exitCode = await new Promise((resolve) => {
|
|
103
|
+
// processRef.on("close", resolve);
|
|
104
|
+
// });
|
|
105
|
+
//
|
|
106
|
+
// if (exitCode !== 0) {
|
|
107
|
+
// throw new Error(`docker-compose up exited with code ${exitCode}`);
|
|
108
|
+
// }
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/**
|
|
@@ -226,17 +232,23 @@ export async function createDynamicNginxConf({ config, serviceConfDir, projectDi
|
|
|
226
232
|
* @param {string} gnarHiddenDir
|
|
227
233
|
* @param {string} projectDir
|
|
228
234
|
* @param {boolean} coreDev - Whether to volume mount the core source code
|
|
235
|
+
* @param {boolean} bootstrapDev - Whether to set the cli/src/bootstrap directory as the project directory
|
|
236
|
+
* @param {boolean} build - Whether to re-build images
|
|
229
237
|
* @param {boolean} test - Whether to run tests with ephemeral databases
|
|
230
238
|
* @param {string} testService - The service to run tests for (only applicable if test=true)
|
|
231
239
|
* @param {boolean} attachAll - Whether to attach to all containers' stdio (otherwise databases and message queue are detached)
|
|
232
240
|
*/
|
|
233
|
-
async function
|
|
241
|
+
async function buildAndUpContainers({ config, secrets, gnarHiddenDir, projectDir, coreDev = false, bootstrapDev = false, build = false, test = false, testService, attachAll = false }) {
|
|
242
|
+
|
|
234
243
|
let mysqlPortsCounter = 3306;
|
|
235
244
|
let mongoPortsCounter = 27017;
|
|
236
245
|
let mysqlHostsRequired = [];
|
|
237
246
|
let mongoHostsRequired = [];
|
|
238
247
|
const services = {};
|
|
239
248
|
|
|
249
|
+
console.log('======== g n a r e n g i n e ========');
|
|
250
|
+
console.log('⛏️ Starting development environment...');
|
|
251
|
+
|
|
240
252
|
// test mode env var adjustments
|
|
241
253
|
for (const svc of config.services) {
|
|
242
254
|
if (test) {
|
|
@@ -247,7 +259,6 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
|
|
|
247
259
|
if (secrets.services?.[svc.name]) {
|
|
248
260
|
secrets.services[svc.name].NODE_ENV = 'test';
|
|
249
261
|
|
|
250
|
-
console.log(testService, svc.name);
|
|
251
262
|
if (testService && svc.name === testService) {
|
|
252
263
|
secrets.services[svc.name].RUN_TESTS = 'true';
|
|
253
264
|
}
|
|
@@ -255,63 +266,92 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
|
|
|
255
266
|
}
|
|
256
267
|
}
|
|
257
268
|
|
|
269
|
+
// create bridge network
|
|
270
|
+
const networkName = `ge-${config.environment}-${config.namespace}`;
|
|
271
|
+
createBridgeNetwork({
|
|
272
|
+
name: networkName
|
|
273
|
+
})
|
|
274
|
+
|
|
258
275
|
// provision the provisioner service
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
276
|
+
const provisionerTag = `ge-${config.environment}-${config.namespace}-provisioner`;
|
|
277
|
+
|
|
278
|
+
if (build) {
|
|
279
|
+
await buildImage({
|
|
263
280
|
context: directories.provisioner,
|
|
264
|
-
dockerfile:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
PROVISIONER_SECRETS: JSON.stringify(secrets)
|
|
268
|
-
},
|
|
269
|
-
volumes: [
|
|
270
|
-
`${directories.provisioner}/src:/usr/gnar_engine/app/src`
|
|
271
|
-
],
|
|
272
|
-
restart: 'no',
|
|
273
|
-
attach: attachAll
|
|
281
|
+
dockerfile: 'Dockerfile',
|
|
282
|
+
imageTag: provisionerTag
|
|
283
|
+
});
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
287
|
+
const __dirname = path.dirname(__filename);
|
|
288
|
+
|
|
289
|
+
const provisionerBinds = [
|
|
290
|
+
`${path.resolve(__dirname, '../provisioner', 'src')}:/usr/gnar_engine/app/src`
|
|
291
|
+
];
|
|
292
|
+
|
|
276
293
|
if (coreDev) {
|
|
277
|
-
|
|
294
|
+
provisionerBinds.push(`${gnarEngineCliConfig.coreDevPath}:${gnarEngineCliConfig.corePath}`);
|
|
278
295
|
}
|
|
279
296
|
|
|
280
|
-
|
|
281
|
-
|
|
297
|
+
const provisioner = await createContainer({
|
|
298
|
+
name: provisionerTag,
|
|
299
|
+
image: provisionerTag,
|
|
300
|
+
env: {
|
|
301
|
+
PROVISIONER_SECRETS: JSON.stringify(secrets)
|
|
302
|
+
},
|
|
303
|
+
ports: {},
|
|
304
|
+
binds: provisionerBinds,
|
|
305
|
+
restart: 'no',
|
|
306
|
+
attach: attachAll,
|
|
307
|
+
network: networkName
|
|
308
|
+
});
|
|
309
|
+
services[provisionerTag] = provisioner;
|
|
310
|
+
|
|
311
|
+
// Nginx
|
|
312
|
+
const nginxName = `ge-${config.environment}-${config.namespace}-nginx`;
|
|
313
|
+
services[nginxName] = await createContainer({
|
|
314
|
+
name: nginxName,
|
|
282
315
|
image: 'nginx:latest',
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
"80:80",
|
|
286
|
-
"443:443"
|
|
287
|
-
],
|
|
288
|
-
volumes: [
|
|
316
|
+
ports: { 80: 80, 443: 443 },
|
|
317
|
+
binds: [
|
|
289
318
|
`${gnarHiddenDir}/nginx/nginx.conf:/etc/nginx/nginx.conf`,
|
|
290
319
|
`${gnarHiddenDir}/nginx/service_conf:/etc/nginx/service_conf`
|
|
291
320
|
],
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
321
|
+
attach: attachAll,
|
|
322
|
+
network: networkName
|
|
323
|
+
});
|
|
295
324
|
|
|
296
|
-
//
|
|
297
|
-
|
|
325
|
+
// Rabbit MQ
|
|
326
|
+
const rabbitMqName = `ge-${config.environment}-${config.namespace}-rabbitmq`;
|
|
327
|
+
services[rabbitMqName] = await createContainer({
|
|
328
|
+
name: rabbitMqName,
|
|
298
329
|
image: 'rabbitmq:management',
|
|
299
|
-
|
|
300
|
-
ports: [
|
|
301
|
-
"5672:5672",
|
|
302
|
-
"15672:15672"
|
|
303
|
-
],
|
|
304
|
-
environment: {
|
|
330
|
+
env: {
|
|
305
331
|
RABBITMQ_DEFAULT_USER: secrets.global.RABBITMQ_USER || '',
|
|
306
332
|
RABBITMQ_DEFAULT_PASS: secrets.global.RABBITMQ_PASS || ''
|
|
307
333
|
},
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
334
|
+
ports: { 5672: 5672, 15672: 15672 },
|
|
335
|
+
binds: [],
|
|
336
|
+
attach: attachAll,
|
|
337
|
+
network: networkName,
|
|
338
|
+
aliases: ['rabbitmq']
|
|
339
|
+
});
|
|
311
340
|
|
|
312
341
|
// services
|
|
313
342
|
for (const svc of config.services) {
|
|
314
343
|
|
|
344
|
+
// build service image
|
|
345
|
+
const svcTag = `ge-${config.environment}-${config.namespace}-${svc.name}`;
|
|
346
|
+
|
|
347
|
+
if (build) {
|
|
348
|
+
await buildImage({
|
|
349
|
+
context: path.resolve(projectDir, 'services', svc.name),
|
|
350
|
+
dockerfile: 'Dockerfile',
|
|
351
|
+
imageTag: svcTag
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
315
355
|
// env variables
|
|
316
356
|
const serviceEnvVars = secrets.services?.[svc.name] || {};
|
|
317
357
|
const localisedServiceEnvVars = {};
|
|
@@ -333,29 +373,35 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
|
|
|
333
373
|
}
|
|
334
374
|
}
|
|
335
375
|
|
|
336
|
-
// service block
|
|
337
|
-
services[`${svc.name}-service`] = {
|
|
338
|
-
container_name: `ge-${config.environment}-${config.namespace}-${svc.name}`,
|
|
339
|
-
image: `ge-${config.environment}-${config.namespace}-${svc.name}`,
|
|
340
|
-
build: {
|
|
341
|
-
context: projectDir,
|
|
342
|
-
dockerfile: `./services/${svc.name}/Dockerfile`
|
|
343
|
-
},
|
|
344
|
-
command: svc.command || [],
|
|
345
|
-
environment: env,
|
|
346
|
-
ports: svc.ports || [],
|
|
347
|
-
depends_on: svc.depends_on || [],
|
|
348
|
-
volumes: [
|
|
349
|
-
`${projectDir}/services/${svc.name}/src:/usr/gnar_engine/app/src`
|
|
350
|
-
],
|
|
351
|
-
restart: 'always'
|
|
352
|
-
};
|
|
353
|
-
|
|
354
376
|
// add the core source code mount if in coreDev mode
|
|
377
|
+
const serviceVolumes = [
|
|
378
|
+
`${path.resolve(projectDir, 'services', svc.name, 'src')}:/usr/gnar_engine/app/src`
|
|
379
|
+
];
|
|
380
|
+
|
|
355
381
|
if (coreDev) {
|
|
356
|
-
|
|
382
|
+
serviceVolumes.push(`${gnarEngineCliConfig.coreDevPath}:${gnarEngineCliConfig.corePath}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// split from "port:port" to { port: port }
|
|
386
|
+
const ports = {};
|
|
387
|
+
for (const portMapping of svc.ports || []) {
|
|
388
|
+
const [hostPort, containerPort] = portMapping.split(':').map(p => parseInt(p, 10));
|
|
389
|
+
ports[containerPort] = hostPort;
|
|
357
390
|
}
|
|
358
391
|
|
|
392
|
+
services[svcTag] = await createContainer({
|
|
393
|
+
name: svcTag,
|
|
394
|
+
image: svcTag,
|
|
395
|
+
command: svc.command || [],
|
|
396
|
+
env: env,
|
|
397
|
+
ports: ports,
|
|
398
|
+
binds: serviceVolumes,
|
|
399
|
+
restart: 'always',
|
|
400
|
+
attach: true,
|
|
401
|
+
network: networkName,
|
|
402
|
+
aliases: [`${svc.name}-service`]
|
|
403
|
+
});
|
|
404
|
+
|
|
359
405
|
// check if mysql service required
|
|
360
406
|
if (
|
|
361
407
|
serviceEnvVars.MYSQL_HOST &&
|
|
@@ -380,29 +426,30 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
|
|
|
380
426
|
continue;
|
|
381
427
|
}
|
|
382
428
|
|
|
383
|
-
|
|
384
|
-
|
|
429
|
+
const mysqlContainerName = `ge-${config.environment}-${config.namespace}-${host}`;
|
|
430
|
+
services[mysqlContainerName] = await createContainer({
|
|
431
|
+
name: `ge-${config.environment}-${config.namespace}-${host}`,
|
|
385
432
|
image: 'mysql',
|
|
386
|
-
|
|
387
|
-
`${mysqlPortsCounter}:${mysqlPortsCounter}`
|
|
388
|
-
],
|
|
389
|
-
restart: 'always',
|
|
390
|
-
environment: {
|
|
433
|
+
env: {
|
|
391
434
|
MYSQL_HOST: host,
|
|
392
435
|
MYSQL_ROOT_PASSWORD: secrets.provision.MYSQL_ROOT_PASSWORD
|
|
393
436
|
},
|
|
394
|
-
|
|
437
|
+
ports: {
|
|
438
|
+
[mysqlPortsCounter]: mysqlPortsCounter
|
|
439
|
+
},
|
|
440
|
+
binds: [
|
|
395
441
|
`${gnarHiddenDir}/data/${host}-data:/var/lib/mysql`
|
|
396
442
|
],
|
|
397
|
-
|
|
398
|
-
|
|
443
|
+
restart: 'always',
|
|
444
|
+
attach: attachAll,
|
|
445
|
+
network: networkName,
|
|
446
|
+
aliases: [host]
|
|
447
|
+
});
|
|
399
448
|
|
|
400
449
|
mysqlPortsCounter++;
|
|
401
450
|
}
|
|
402
|
-
|
|
403
|
-
services['provisioner'].depends_on = [...new Set(mysqlHostsRequired)];
|
|
404
451
|
}
|
|
405
|
-
|
|
452
|
+
|
|
406
453
|
// add mongo hosts if required
|
|
407
454
|
if (mongoHostsRequired.length > 0) {
|
|
408
455
|
for (const host of mongoHostsRequired) {
|
|
@@ -410,33 +457,37 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
|
|
|
410
457
|
continue;
|
|
411
458
|
}
|
|
412
459
|
|
|
413
|
-
|
|
414
|
-
|
|
460
|
+
const mongoContainerName = `ge-${config.environment}-${config.namespace}-${host}`;
|
|
461
|
+
services[mongoContainerName] = await createContainer({
|
|
462
|
+
name: `ge-${config.environment}-${config.namespace}-${host}`,
|
|
415
463
|
image: 'mongo:latest',
|
|
416
|
-
|
|
417
|
-
`${mongoPortsCounter}:27017`
|
|
418
|
-
],
|
|
419
|
-
restart: 'always',
|
|
420
|
-
environment: {
|
|
464
|
+
env: {
|
|
421
465
|
MONGO_INITDB_ROOT_USERNAME: 'root',
|
|
422
466
|
MONGO_INITDB_ROOT_PASSWORD: secrets.provision.MONGO_ROOT_PASSWORD
|
|
423
467
|
},
|
|
424
|
-
|
|
468
|
+
ports: {
|
|
469
|
+
[mongoPortsCounter]: 27017
|
|
470
|
+
},
|
|
471
|
+
binds: [
|
|
425
472
|
`${gnarHiddenDir}/data/${host}-data:/data/db`,
|
|
426
|
-
'./mongo-init-scripts:/docker-entrypoint-initdb.d'
|
|
473
|
+
//'./mongo-init-scripts:/docker-entrypoint-initdb.d'
|
|
427
474
|
],
|
|
428
|
-
|
|
429
|
-
|
|
475
|
+
restart: 'always',
|
|
476
|
+
attach: attachAll,
|
|
477
|
+
network: networkName,
|
|
478
|
+
aliases: [host]
|
|
479
|
+
});
|
|
430
480
|
|
|
431
481
|
// increment mongo port for next service as required
|
|
432
482
|
mongoPortsCounter++;
|
|
433
483
|
}
|
|
434
484
|
}
|
|
435
485
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
services
|
|
439
|
-
|
|
486
|
+
// start the containers
|
|
487
|
+
Object.keys(services).forEach(async (key) => {
|
|
488
|
+
const container = services[key];
|
|
489
|
+
container.start();
|
|
490
|
+
});
|
|
440
491
|
}
|
|
441
492
|
|
|
442
493
|
/**
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import Docker from 'dockerode';
|
|
2
|
+
import { Writable } from 'stream';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const docker = new Docker();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build Docker image
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} options.context - The build context directory
|
|
13
|
+
* @param {string} options.dockerfile - The Dockerfile path relative to the context
|
|
14
|
+
* @param {string} options.imageTag - The tag to assign to the built image
|
|
15
|
+
*/
|
|
16
|
+
export async function buildImage({ context, dockerfile, imageTag }) {
|
|
17
|
+
|
|
18
|
+
console.log('Building image...', imageTag);
|
|
19
|
+
|
|
20
|
+
const tarStream = await docker.buildImage(
|
|
21
|
+
{
|
|
22
|
+
context: context,
|
|
23
|
+
src: fs.readdirSync(context)
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
t: imageTag,
|
|
27
|
+
dockerfile
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
docker.modem.followProgress(tarStream, (err) => (err ? reject(err) : resolve()));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
console.log('Built image:', imageTag);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create docker container
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} options.name - Container name
|
|
43
|
+
* @param {string} options.image - Docker image to use
|
|
44
|
+
* @param {Array} [options.command] - Command to run in the container
|
|
45
|
+
* @param {Object} [options.env] - Environment variables
|
|
46
|
+
* @param {Object} [options.ports] - Port mappings (containerPort: hostPort)
|
|
47
|
+
* @param {Array} [options.binds] - Volume bindings
|
|
48
|
+
* @param {string} [options.restart] - Restart policy
|
|
49
|
+
* @param {boolean} [options.attach] - Whether to attach to container output
|
|
50
|
+
* @param {string} [options.network] - Network name
|
|
51
|
+
* @returns {Promise<Docker.Container>} - The started container
|
|
52
|
+
*/
|
|
53
|
+
export async function createContainer({ name, image, command = [], env = {}, ports = {}, binds = [], restart = 'always', attach = true, network, aliases = [] }) {
|
|
54
|
+
|
|
55
|
+
// remove container first
|
|
56
|
+
try {
|
|
57
|
+
const existingContainer = docker.getContainer(name);
|
|
58
|
+
await existingContainer.inspect();
|
|
59
|
+
await existingContainer.remove({ force: true });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Container does not exist, ignore
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// create container
|
|
65
|
+
const containerConfig = {
|
|
66
|
+
name,
|
|
67
|
+
Image: image,
|
|
68
|
+
Cmd: command,
|
|
69
|
+
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
|
70
|
+
HostConfig: {
|
|
71
|
+
RestartPolicy: { Name: restart },
|
|
72
|
+
Binds: binds,
|
|
73
|
+
PortBindings: Object.fromEntries(
|
|
74
|
+
Object.entries(ports).map(([cPort, hPort]) => [
|
|
75
|
+
`${cPort}/tcp`,
|
|
76
|
+
[{ HostPort: String(hPort) }]
|
|
77
|
+
])
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
ExposedPorts: Object.fromEntries(
|
|
81
|
+
Object.keys(ports).map(p => [`${p}/tcp`, {}])
|
|
82
|
+
)
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const container = await docker.createContainer(containerConfig);
|
|
86
|
+
|
|
87
|
+
// Attach logs before starting the container
|
|
88
|
+
if (attach) {
|
|
89
|
+
const stream = await container.attach({ stream: true, stdout: true, stderr: true, logs: true });
|
|
90
|
+
const stdoutStream = createPrefixStream(name, process.stdout);
|
|
91
|
+
const stderrStream = createPrefixStream(name, process.stderr);
|
|
92
|
+
|
|
93
|
+
container.modem.demuxStream(stream, stdoutStream, stderrStream);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await docker.getNetwork(network).connect({
|
|
97
|
+
Container: container.id,
|
|
98
|
+
EndpointConfig: {
|
|
99
|
+
Aliases: aliases
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return container;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create network
|
|
108
|
+
*
|
|
109
|
+
* @param {String} name - Network name
|
|
110
|
+
*/
|
|
111
|
+
export async function createBridgeNetwork({ name }) {
|
|
112
|
+
try {
|
|
113
|
+
await docker.createNetwork({
|
|
114
|
+
Name: name,
|
|
115
|
+
Driver: 'bridge',
|
|
116
|
+
CheckDuplicate: true
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.statusCode === 409) {
|
|
120
|
+
// network already exists, ignore
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
console.error(err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tansform stream
|
|
129
|
+
*
|
|
130
|
+
* @param {String} name - Container name
|
|
131
|
+
* @returns {Transform} - Transform stream
|
|
132
|
+
*/
|
|
133
|
+
function createPrefixStream(name, targetStream) {
|
|
134
|
+
const color = colorForName(name);
|
|
135
|
+
const labelWidth = 38;
|
|
136
|
+
name = '[' + name + ']';
|
|
137
|
+
const paddedName = name.padEnd(labelWidth, ' ');
|
|
138
|
+
|
|
139
|
+
return new Writable({
|
|
140
|
+
write(chunk, encoding, callback) {
|
|
141
|
+
const lines = chunk.toString().split(/\r?\n/);
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (line.trim()) {
|
|
144
|
+
targetStream.write(`${color}${paddedName}${RESET} ${line}\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
callback();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Colours for container name
|
|
154
|
+
*/
|
|
155
|
+
const COLORS = [
|
|
156
|
+
'\x1b[31m', // red
|
|
157
|
+
'\x1b[32m', // green
|
|
158
|
+
'\x1b[33m', // yellow
|
|
159
|
+
'\x1b[34m', // blue
|
|
160
|
+
'\x1b[35m', // magenta
|
|
161
|
+
'\x1b[36m', // cyan
|
|
162
|
+
'\x1b[37m', // white
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const RESET = '\x1b[0m';
|
|
166
|
+
|
|
167
|
+
function colorForName(name) {
|
|
168
|
+
let hash = 0;
|
|
169
|
+
for (let i = 0; i < name.length; i++) {
|
|
170
|
+
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
171
|
+
}
|
|
172
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
173
|
+
}
|