go-dev 0.4.2 → 0.6.0
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/LICENSE +20 -20
- package/README.md +254 -230
- package/bin/go-dev +18 -14
- package/package.json +1 -1
- package/src/cli-args.js +83 -0
- package/src/config.js +143 -122
- package/src/dependency-resolver.js +103 -101
- package/src/index.js +9 -4
- package/src/logger.js +47 -0
- package/src/orchestrator.js +180 -171
- package/src/prefix-lines.js +19 -19
- package/src/process-manager.js +358 -306
- package/src/service-colors.js +58 -0
- package/src/services/base.js +63 -51
- package/src/services/cmd.js +250 -148
- package/src/services/docker.js +194 -197
- package/src/services/ready-check.js +134 -0
- package/src/terminal-formatting/apply-formatting-codes.js +223 -223
- package/src/terminal-formatting/constants.js +150 -150
- package/src/terminal-formatting/detect-last-formatting.js +10 -10
- package/src/terminal-formatting/extract-formatting-codes.js +26 -26
- package/test/go-dev.yml +84 -0
package/src/services/docker.js
CHANGED
|
@@ -1,198 +1,195 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
1
|
+
const log = require('../logger');
|
|
2
|
+
const { BaseService } = require('./base');
|
|
3
|
+
|
|
4
|
+
class DockerService extends BaseService {
|
|
5
|
+
/** @type {Map<string, string[]>} */
|
|
6
|
+
static _servicesToStop = new Map();
|
|
7
|
+
|
|
8
|
+
static async cleanup() {
|
|
9
|
+
if (!DockerService._processManager) {
|
|
10
|
+
log.warn('[DockerService] ProcessManager not initialized, skipping static cleanup.');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (DockerService._servicesToStop.size > 0) {
|
|
15
|
+
log.info('');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const [composeFile, services] of this._servicesToStop.entries()) {
|
|
19
|
+
try {
|
|
20
|
+
DockerService._processManager.runSync(
|
|
21
|
+
'docker',
|
|
22
|
+
['compose', '-f', composeFile, 'stop', ...services],
|
|
23
|
+
{ stdio: 'inherit' },
|
|
24
|
+
);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
log.error(`Failed to stop docker services of '${composeFile}': ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
DockerService._servicesToStop.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(name, mode, config) {
|
|
33
|
+
super(name, mode, config);
|
|
34
|
+
this.dockerServiceName = config.service;
|
|
35
|
+
this.dockerComposeFile = config.composeFile;
|
|
36
|
+
this.containerName = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async start() {
|
|
40
|
+
log.info(`[${this.coloredId}] Starting docker service '${this.dockerServiceName}' (using ${this.dockerComposeFile})...`);
|
|
41
|
+
|
|
42
|
+
let status = this._getContainerStatus();
|
|
43
|
+
if (status === 'running') {
|
|
44
|
+
log.info(
|
|
45
|
+
`[${this.coloredId}] Docker container for '${this.dockerServiceName}' is already running.`,
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
log.info(`[${this.coloredId}] Bringing up docker service '${this.dockerServiceName}'...`);
|
|
49
|
+
try {
|
|
50
|
+
const servicesBeforeStart = this._getCurrentlyRunningServices();
|
|
51
|
+
await DockerService._processManager.runInherited(
|
|
52
|
+
'docker',
|
|
53
|
+
['compose', '-f', this.dockerComposeFile, 'up', this.dockerServiceName, '-d'],
|
|
54
|
+
);
|
|
55
|
+
const servicesAfterStart = this._getCurrentlyRunningServices();
|
|
56
|
+
let servicesOfComposeFile = DockerService._servicesToStop.get(this.dockerComposeFile);
|
|
57
|
+
if (servicesOfComposeFile == null) {
|
|
58
|
+
servicesOfComposeFile = [];
|
|
59
|
+
}
|
|
60
|
+
const newServices = servicesAfterStart.filter(service => {
|
|
61
|
+
return !(
|
|
62
|
+
servicesBeforeStart.includes(service) ||
|
|
63
|
+
servicesOfComposeFile.includes(service)
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
DockerService._servicesToStop.set(
|
|
67
|
+
this.dockerComposeFile,
|
|
68
|
+
servicesOfComposeFile.concat(newServices)
|
|
69
|
+
);
|
|
70
|
+
log.info(`[${this.coloredId}] Docker service '${this.dockerServiceName}' brought up.`);
|
|
71
|
+
if (newServices.length > 1) {
|
|
72
|
+
log.info(`[${this.coloredId}] Dependency service${newServices.length > 2 ? 's' : ''} for '${this.dockerServiceName}': ${newServices.filter(service => service.name !== this.dockerServiceName).join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[${this.coloredId}] Failed to bring up docker service '${this.dockerServiceName}': ${error.message}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.config.healthCheck) {
|
|
82
|
+
log.info(`[${this.coloredId}] Checking healthiness for '${this.dockerServiceName}'...`);
|
|
83
|
+
try {
|
|
84
|
+
await this._checkServiceHealthiness();
|
|
85
|
+
log.info(`[${this.coloredId}] Docker service '${this.dockerServiceName}' is healthy.`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`[${this.coloredId}] Health check failed for '${this.dockerServiceName}': ${error.message}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
log.info(`[${this.coloredId}] Skipping health check for '${this.dockerServiceName}'.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async stop() {
|
|
97
|
+
log.debug(`[${this.coloredId}] Relying on orchestrator's static docker compose stop for '${this.dockerServiceName}'.`);
|
|
98
|
+
this.containerName = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async checkHealth() {
|
|
102
|
+
return await this._checkServiceHealthiness();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_getCurrentlyRunningServices() {
|
|
106
|
+
try {
|
|
107
|
+
const services = DockerService._processManager.runSync(
|
|
108
|
+
'docker',
|
|
109
|
+
['compose', '-f', this.dockerComposeFile, 'ps', '--services'],
|
|
110
|
+
);
|
|
111
|
+
return (services
|
|
112
|
+
.split('\n')
|
|
113
|
+
.map(serviceName => serviceName.trim())
|
|
114
|
+
.filter(serviceName => serviceName !== '')
|
|
115
|
+
);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// console.warn(...);
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_getContainerName() {
|
|
123
|
+
if (this.containerName) {
|
|
124
|
+
return this.containerName;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const name = DockerService._processManager.runSync(
|
|
128
|
+
'docker',
|
|
129
|
+
['compose', '-f', this.dockerComposeFile, 'ps', '-a', '-q', this.dockerServiceName],
|
|
130
|
+
);
|
|
131
|
+
if (name) {
|
|
132
|
+
this.containerName = name;
|
|
133
|
+
return name;
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// console.warn(...);
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_getContainerStatus() {
|
|
142
|
+
const containerName = this._getContainerName();
|
|
143
|
+
if (!containerName) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return DockerService._processManager.runSync(
|
|
148
|
+
'docker',
|
|
149
|
+
['container', 'inspect', '-f', '{{.State.Status}}', containerName],
|
|
150
|
+
);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// console.warn(...);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _checkServiceHealthiness(maxAttempts = 30, delayMs = 1000) {
|
|
158
|
+
const containerName = this._getContainerName();
|
|
159
|
+
if (!containerName) {
|
|
160
|
+
throw new Error(`[${this.coloredId}] Cannot check health: Container for '${this.dockerServiceName}' not found.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
log.info(`[${this.coloredId}] Checking healthiness for '${containerName}'`);
|
|
164
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
165
|
+
if (attempt > 1) {
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const healthStatus = DockerService._processManager.runSync(
|
|
171
|
+
'docker',
|
|
172
|
+
['container', 'inspect', '-f', '{{.State.Health.Status}}', containerName],
|
|
173
|
+
{ stdio: 'pipe' }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (healthStatus === 'healthy' || healthStatus === 'none') {
|
|
177
|
+
log.info(`[${this.coloredId}] Container '${containerName}' healthy!`);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (healthStatus === 'starting' || healthStatus === 'unhealthy') {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error(`[${this.coloredId}] Container '${containerName}' is in unexpected health state: ${healthStatus}`);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new Error(`[${this.coloredId}] Failed to check health for '${containerName}': ${error.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw new Error(`[${this.coloredId}] Service '${this.dockerServiceName}' wasn't healthy in time after ${maxAttempts} attempts.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
198
195
|
module.exports = { DockerService };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const log = require('../logger');
|
|
5
|
+
|
|
6
|
+
const MAX_BUFFER = 16 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves when one of the managed processes prints a line matching `pattern`.
|
|
10
|
+
* Attaches an extra listener on top of the existing prefixing one, so it does
|
|
11
|
+
* not interfere with normal output.
|
|
12
|
+
*/
|
|
13
|
+
function logMatchCheck(processes, pattern, timeoutMs) {
|
|
14
|
+
const regex = new RegExp(pattern);
|
|
15
|
+
let cleanup = () => {};
|
|
16
|
+
const promise = new Promise((resolve, reject) => {
|
|
17
|
+
let buffer = '';
|
|
18
|
+
const listeners = [];
|
|
19
|
+
const onData = (data) => {
|
|
20
|
+
buffer = (buffer + data.toString()).slice(-MAX_BUFFER);
|
|
21
|
+
if (regex.test(buffer)) {
|
|
22
|
+
resolve();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
for (const proc of processes) {
|
|
26
|
+
for (const stream of [proc.stdout, proc.stderr]) {
|
|
27
|
+
if (!stream) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
stream.on('data', onData);
|
|
31
|
+
listeners.push(stream);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for log /${pattern}/`));
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
cleanup = () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
for (const stream of listeners) {
|
|
40
|
+
stream.off('data', onData);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
return { promise: promise.finally(() => cleanup()), cancel: () => cleanup() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves once `filePath` exists on disk, polling until the timeout. */
|
|
48
|
+
function fileCheck(filePath, timeoutMs, pollIntervalMs) {
|
|
49
|
+
const resolved = path.resolve(filePath);
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
const promise = (async () => {
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
while (!cancelled) {
|
|
54
|
+
if (fs.existsSync(resolved)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (Date.now() >= deadline) {
|
|
58
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for file '${resolved}'`);
|
|
59
|
+
}
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
return { promise, cancel: () => { cancelled = true; } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tryConnect(host, port) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const socket = net.connect({ host, port });
|
|
69
|
+
const done = (ok) => {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
resolve(ok);
|
|
72
|
+
};
|
|
73
|
+
socket.once('connect', () => done(true));
|
|
74
|
+
socket.once('error', () => done(false));
|
|
75
|
+
socket.setTimeout(1000, () => done(false));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolves once a TCP connection to `host:port` succeeds, polling until the timeout. */
|
|
80
|
+
function portCheck(host, port, timeoutMs, pollIntervalMs) {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
const promise = (async () => {
|
|
83
|
+
const deadline = Date.now() + timeoutMs;
|
|
84
|
+
while (!cancelled) {
|
|
85
|
+
if (await tryConnect(host, port)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() >= deadline) {
|
|
89
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${host}:${port}`);
|
|
90
|
+
}
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return { promise, cancel: () => { cancelled = true; } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Blocks until the given `readyWhen` conditions are all satisfied for the
|
|
99
|
+
* provided managed processes. Multiple conditions are combined with AND.
|
|
100
|
+
* @param {import('child_process').ChildProcess[]} processes
|
|
101
|
+
* @param {object} readyWhen - Validated `readyWhen` config (with defaults applied).
|
|
102
|
+
* @param {string} coloredId - The service's colored tag, for logging.
|
|
103
|
+
*/
|
|
104
|
+
async function waitForReady(processes, readyWhen, coloredId) {
|
|
105
|
+
const { logMatch, file, port, host, timeoutMs, pollIntervalMs } = readyWhen;
|
|
106
|
+
|
|
107
|
+
const checks = [];
|
|
108
|
+
const labels = [];
|
|
109
|
+
if (logMatch != null) {
|
|
110
|
+
checks.push(logMatchCheck(processes, logMatch, timeoutMs));
|
|
111
|
+
labels.push(`log /${logMatch}/`);
|
|
112
|
+
}
|
|
113
|
+
if (file != null) {
|
|
114
|
+
checks.push(fileCheck(file, timeoutMs, pollIntervalMs));
|
|
115
|
+
labels.push(`file '${file}'`);
|
|
116
|
+
}
|
|
117
|
+
if (port != null) {
|
|
118
|
+
checks.push(portCheck(host, port, timeoutMs, pollIntervalMs));
|
|
119
|
+
labels.push(`${host}:${port}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
log.info(`[${coloredId}] Waiting until ready (${labels.join(' AND ')})...`);
|
|
123
|
+
try {
|
|
124
|
+
await Promise.all(checks.map(check => check.promise));
|
|
125
|
+
log.info(`[${coloredId}] Service is ready.`);
|
|
126
|
+
} finally {
|
|
127
|
+
// Stop any still-pending checks so they don't reject after we're done.
|
|
128
|
+
for (const check of checks) {
|
|
129
|
+
check.cancel();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { waitForReady };
|