@primafuture/telemetry-stack 0.1.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/README.md +235 -0
- package/bin/telemetry-stack.mjs +654 -0
- package/package.json +26 -0
- package/stack/configs/config.alloy +74 -0
- package/stack/configs/grafana-datasources.yaml +105 -0
- package/stack/configs/prometheus.yaml +23 -0
- package/stack/configs/redpanda-console.yaml +5 -0
- package/stack/docker-compose.yaml +146 -0
- package/stack/templates/loki.yaml +63 -0
- package/stack/templates/mimir.yaml +52 -0
- package/stack/templates/tempo.yaml +99 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// This CLI is the thin wrapper around the packaged Docker Compose stack.
|
|
4
|
+
// It keeps project-specific state outside the npm package by requiring a data directory.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
// Resolve paths from the installed package, not from the user's current directory.
|
|
13
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const composeFile = path.join(packageRoot, 'stack', 'docker-compose.yaml');
|
|
15
|
+
const instanceConfigFileName = 'telemetry-stack.env';
|
|
16
|
+
const generatedConfigDirectoryName = 'generated-configs';
|
|
17
|
+
|
|
18
|
+
const generatedConfigTemplates = [
|
|
19
|
+
['loki.yaml', path.join(packageRoot, 'stack', 'templates', 'loki.yaml')],
|
|
20
|
+
['mimir.yaml', path.join(packageRoot, 'stack', 'templates', 'mimir.yaml')],
|
|
21
|
+
['tempo.yaml', path.join(packageRoot, 'stack', 'templates', 'tempo.yaml')],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const instanceConfigKeys = [
|
|
25
|
+
'TELEMETRY_MODE',
|
|
26
|
+
'TELEMETRY_BIND_ADDRESS',
|
|
27
|
+
'TELEMETRY_MINIO_ACCESS_KEY',
|
|
28
|
+
'TELEMETRY_MINIO_SECRET_KEY',
|
|
29
|
+
'GRAFANA_ANONYMOUS_ENABLED',
|
|
30
|
+
'GRAFANA_ANONYMOUS_ORG_ROLE',
|
|
31
|
+
'GRAFANA_DISABLE_LOGIN_FORM',
|
|
32
|
+
'GRAFANA_ADMIN_USER',
|
|
33
|
+
'GRAFANA_ADMIN_PASSWORD',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const validModes = new Set(['dev', 'prod']);
|
|
37
|
+
|
|
38
|
+
// These host directories back Docker bind mounts used by the stack services.
|
|
39
|
+
const dataSubdirectories = [
|
|
40
|
+
'tempo',
|
|
41
|
+
'minio',
|
|
42
|
+
'redpanda',
|
|
43
|
+
'loki',
|
|
44
|
+
'mimir',
|
|
45
|
+
'prometheus',
|
|
46
|
+
'grafana',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// Each CLI port option maps to the environment variable consumed by docker-compose.yaml.
|
|
50
|
+
const portOptions = {
|
|
51
|
+
'--grafana-port': ['GRAFANA_PORT', '3000'],
|
|
52
|
+
'--otlp-grpc-port': ['OTLP_GRPC_PORT', '4317'],
|
|
53
|
+
'--otlp-http-port': ['OTLP_HTTP_PORT', '4318'],
|
|
54
|
+
'--tempo-port': ['TEMPO_PORT', '3200'],
|
|
55
|
+
'--loki-port': ['LOKI_PORT', '3100'],
|
|
56
|
+
'--prometheus-port': ['PROMETHEUS_PORT', '9090'],
|
|
57
|
+
'--mimir-port': ['MIMIR_PORT', '9009'],
|
|
58
|
+
'--minio-console-port': ['MINIO_CONSOLE_PORT', '9001'],
|
|
59
|
+
'--redpanda-port': ['REDPANDA_PORT', '9092'],
|
|
60
|
+
'--redpanda-console-port': ['REDPANDA_CONSOLE_PORT', '8080'],
|
|
61
|
+
'--alloy-port': ['ALLOY_PORT', '12345'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The open command uses these service targets to build local browser URLs.
|
|
65
|
+
const urlTargets = {
|
|
66
|
+
grafana: ['GRAFANA_PORT', '3000', '/'],
|
|
67
|
+
minio: ['MINIO_CONSOLE_PORT', '9001', '/'],
|
|
68
|
+
prometheus: ['PROMETHEUS_PORT', '9090', '/'],
|
|
69
|
+
tempo: ['TEMPO_PORT', '3200', '/'],
|
|
70
|
+
loki: ['LOKI_PORT', '3100', '/ready'],
|
|
71
|
+
mimir: ['MIMIR_PORT', '9009', '/'],
|
|
72
|
+
redpanda: ['REDPANDA_CONSOLE_PORT', '8080', '/'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function printUsage() {
|
|
76
|
+
console.log(`Usage:
|
|
77
|
+
telemetry-stack init <data-dir> [options]
|
|
78
|
+
telemetry-stack up <data-dir> [options]
|
|
79
|
+
telemetry-stack down <data-dir> [options]
|
|
80
|
+
telemetry-stack restart <data-dir> [options]
|
|
81
|
+
telemetry-stack ps <data-dir> [options]
|
|
82
|
+
telemetry-stack logs <data-dir> [service] [options]
|
|
83
|
+
telemetry-stack open <data-dir> [target] [options]
|
|
84
|
+
telemetry-stack config <data-dir> [options]
|
|
85
|
+
telemetry-stack doctor
|
|
86
|
+
|
|
87
|
+
Options:
|
|
88
|
+
--name <name> Override Docker Compose project name.
|
|
89
|
+
--mode <dev|prod> Instance mode for newly created config. Default: dev
|
|
90
|
+
|
|
91
|
+
Port options:
|
|
92
|
+
--grafana-port <port> Default: 3000
|
|
93
|
+
--otlp-grpc-port <port> Default: 4317
|
|
94
|
+
--otlp-http-port <port> Default: 4318
|
|
95
|
+
--tempo-port <port> Default: 3200
|
|
96
|
+
--loki-port <port> Default: 3100
|
|
97
|
+
--prometheus-port <port> Default: 9090
|
|
98
|
+
--mimir-port <port> Default: 9009
|
|
99
|
+
--minio-console-port <port> Default: 9001
|
|
100
|
+
--redpanda-port <port> Default: 9092
|
|
101
|
+
--redpanda-console-port <port> Default: 8080
|
|
102
|
+
--alloy-port <port> Default: 12345
|
|
103
|
+
|
|
104
|
+
Logs options:
|
|
105
|
+
--follow, -f Follow log output. Only for logs.
|
|
106
|
+
--tail <lines> Number of log lines to show. Only for logs.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
telemetry-stack init ./data/telemetry --mode prod
|
|
110
|
+
telemetry-stack up ./data/telemetry
|
|
111
|
+
telemetry-stack up ./data/telemetry --grafana-port 3300 --otlp-http-port 14318
|
|
112
|
+
telemetry-stack logs ./data/telemetry alloy --follow --tail 100
|
|
113
|
+
telemetry-stack open ./data/telemetry grafana`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fail(message, exitCode = 1) {
|
|
117
|
+
console.error(`[!] ${message}`);
|
|
118
|
+
process.exit(exitCode);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isIntegerPort(value) {
|
|
122
|
+
const port = Number(value);
|
|
123
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validatePort(flag, value) {
|
|
127
|
+
if (!isIntegerPort(value)) {
|
|
128
|
+
fail(`${flag} must be a TCP port number in the range 1-65535.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateProjectName(name) {
|
|
133
|
+
// Docker Compose project names have strict rules, so validate early and
|
|
134
|
+
// return a clearer message than the raw docker compose error.
|
|
135
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
|
136
|
+
fail('--name must start with a lowercase letter or number and may contain only lowercase letters, numbers, _ and -.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateMode(mode) {
|
|
141
|
+
if (!validModes.has(mode)) {
|
|
142
|
+
fail('--mode must be either dev or prod.');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseArguments(argv) {
|
|
147
|
+
// Keep parsing dependency-free so the package can run directly through npx.
|
|
148
|
+
const command = argv[2];
|
|
149
|
+
|
|
150
|
+
if (!command || command === '-h' || command === '--help') {
|
|
151
|
+
printUsage();
|
|
152
|
+
process.exit(command ? 0 : 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const options = {
|
|
156
|
+
name: undefined,
|
|
157
|
+
mode: undefined,
|
|
158
|
+
ports: {},
|
|
159
|
+
follow: false,
|
|
160
|
+
tail: undefined,
|
|
161
|
+
};
|
|
162
|
+
const positionals = [];
|
|
163
|
+
const args = argv.slice(3);
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
166
|
+
const arg = args[i];
|
|
167
|
+
|
|
168
|
+
if (arg === '-h' || arg === '--help') {
|
|
169
|
+
printUsage();
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (arg === '--name') {
|
|
174
|
+
const value = args[++i];
|
|
175
|
+
if (!value) {
|
|
176
|
+
fail('--name requires a value.');
|
|
177
|
+
}
|
|
178
|
+
validateProjectName(value);
|
|
179
|
+
options.name = value;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (arg === '--mode') {
|
|
184
|
+
const value = args[++i];
|
|
185
|
+
if (!value) {
|
|
186
|
+
fail('--mode requires a value.');
|
|
187
|
+
}
|
|
188
|
+
validateMode(value);
|
|
189
|
+
options.mode = value;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (arg in portOptions) {
|
|
194
|
+
const value = args[++i];
|
|
195
|
+
if (!value) {
|
|
196
|
+
fail(`${arg} requires a value.`);
|
|
197
|
+
}
|
|
198
|
+
validatePort(arg, value);
|
|
199
|
+
options.ports[portOptions[arg][0]] = value;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (arg === '--follow' || arg === '-f') {
|
|
204
|
+
options.follow = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (arg === '--tail') {
|
|
209
|
+
const value = args[++i];
|
|
210
|
+
if (!value || !/^[0-9]+$/.test(value)) {
|
|
211
|
+
fail('--tail requires a non-negative integer.');
|
|
212
|
+
}
|
|
213
|
+
options.tail = value;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (arg.startsWith('-')) {
|
|
218
|
+
fail(`Unknown option: ${arg}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
positionals.push(arg);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
command,
|
|
226
|
+
positionals,
|
|
227
|
+
options,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateCommandOptions(command, options) {
|
|
232
|
+
if (command !== 'logs' && (options.follow || options.tail !== undefined)) {
|
|
233
|
+
fail('--follow, -f and --tail can be used only with the logs command.');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveDataDir(rawDataDir) {
|
|
238
|
+
if (!rawDataDir) {
|
|
239
|
+
fail('Missing <data-dir>. This directory is where telemetry stack data will be stored.');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return path.resolve(process.cwd(), rawDataDir);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function deriveProjectName(dataDir, explicitName) {
|
|
246
|
+
if (explicitName) {
|
|
247
|
+
return explicitName;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A stable hash of the absolute data directory makes the same data directory
|
|
251
|
+
// control the same Docker Compose project without requiring --name.
|
|
252
|
+
const hash = crypto.createHash('sha1').update(dataDir).digest('hex').slice(0, 8);
|
|
253
|
+
return `telemetry-stack-${hash}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function instanceConfigPath(dataDir) {
|
|
257
|
+
return path.join(dataDir, instanceConfigFileName);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function generatedConfigDirectory(dataDir) {
|
|
261
|
+
return path.join(dataDir, generatedConfigDirectoryName);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function randomToken(bytes = 32) {
|
|
265
|
+
return crypto.randomBytes(bytes)
|
|
266
|
+
.toString('base64')
|
|
267
|
+
.replace(/\+/g, '-')
|
|
268
|
+
.replace(/\//g, '_')
|
|
269
|
+
.replace(/=+$/g, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function makeDefaultInstanceConfig(mode) {
|
|
273
|
+
validateMode(mode);
|
|
274
|
+
|
|
275
|
+
if (mode === 'prod') {
|
|
276
|
+
return {
|
|
277
|
+
TELEMETRY_MODE: 'prod',
|
|
278
|
+
TELEMETRY_BIND_ADDRESS: '127.0.0.1',
|
|
279
|
+
TELEMETRY_MINIO_ACCESS_KEY: `telemetry_${randomToken(12)}`,
|
|
280
|
+
TELEMETRY_MINIO_SECRET_KEY: randomToken(32),
|
|
281
|
+
GRAFANA_ANONYMOUS_ENABLED: 'false',
|
|
282
|
+
GRAFANA_ANONYMOUS_ORG_ROLE: 'Viewer',
|
|
283
|
+
GRAFANA_DISABLE_LOGIN_FORM: 'false',
|
|
284
|
+
GRAFANA_ADMIN_USER: 'admin',
|
|
285
|
+
GRAFANA_ADMIN_PASSWORD: randomToken(32),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
TELEMETRY_MODE: 'dev',
|
|
291
|
+
TELEMETRY_BIND_ADDRESS: '0.0.0.0',
|
|
292
|
+
TELEMETRY_MINIO_ACCESS_KEY: 'telemetry',
|
|
293
|
+
TELEMETRY_MINIO_SECRET_KEY: 'supersecret',
|
|
294
|
+
GRAFANA_ANONYMOUS_ENABLED: 'true',
|
|
295
|
+
GRAFANA_ANONYMOUS_ORG_ROLE: 'Admin',
|
|
296
|
+
GRAFANA_DISABLE_LOGIN_FORM: 'true',
|
|
297
|
+
GRAFANA_ADMIN_USER: 'admin',
|
|
298
|
+
GRAFANA_ADMIN_PASSWORD: 'admin',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function formatInstanceConfig(values) {
|
|
303
|
+
const lines = [
|
|
304
|
+
'# Generated by @primafuture/telemetry-stack.',
|
|
305
|
+
'# This file belongs to one telemetry stack data directory.',
|
|
306
|
+
'# Do not commit production secrets.',
|
|
307
|
+
'',
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
for (const key of instanceConfigKeys) {
|
|
311
|
+
lines.push(`${key}=${values[key]}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return `${lines.join('\n')}\n`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseInstanceConfig(content, configPath) {
|
|
318
|
+
const values = {};
|
|
319
|
+
|
|
320
|
+
for (const [index, rawLine] of content.split(/\r?\n/).entries()) {
|
|
321
|
+
const line = rawLine.trim();
|
|
322
|
+
if (!line || line.startsWith('#')) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const separatorIndex = line.indexOf('=');
|
|
327
|
+
if (separatorIndex === -1) {
|
|
328
|
+
fail(`Invalid line ${index + 1} in ${configPath}: expected KEY=value.`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
332
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
333
|
+
values[key] = value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return values;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function validateInstanceConfig(values, configPath) {
|
|
340
|
+
for (const key of instanceConfigKeys) {
|
|
341
|
+
if (!values[key]) {
|
|
342
|
+
fail(`Missing ${key} in ${configPath}.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
validateMode(values.TELEMETRY_MODE);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function readInstanceConfig(configPath) {
|
|
350
|
+
const values = parseInstanceConfig(fs.readFileSync(configPath, 'utf8'), configPath);
|
|
351
|
+
validateInstanceConfig(values, configPath);
|
|
352
|
+
return values;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeInstanceConfig(configPath, values) {
|
|
356
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
357
|
+
fs.writeFileSync(configPath, formatInstanceConfig(values), {
|
|
358
|
+
flag: 'wx',
|
|
359
|
+
mode: 0o600,
|
|
360
|
+
});
|
|
361
|
+
fs.chmodSync(configPath, 0o600);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function resolveInstanceConfig(dataDir, requestedMode, createIfMissing) {
|
|
365
|
+
const configPath = instanceConfigPath(dataDir);
|
|
366
|
+
|
|
367
|
+
if (fs.existsSync(configPath)) {
|
|
368
|
+
const values = readInstanceConfig(configPath);
|
|
369
|
+
if (requestedMode && requestedMode !== values.TELEMETRY_MODE) {
|
|
370
|
+
fail(`Instance config already uses mode ${values.TELEMETRY_MODE}. Remove ${configPath} or use --mode ${values.TELEMETRY_MODE}.`);
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
configPath,
|
|
374
|
+
values,
|
|
375
|
+
created: false,
|
|
376
|
+
persisted: true,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const values = makeDefaultInstanceConfig(requestedMode || 'dev');
|
|
381
|
+
|
|
382
|
+
if (!createIfMissing) {
|
|
383
|
+
return {
|
|
384
|
+
configPath,
|
|
385
|
+
values,
|
|
386
|
+
created: false,
|
|
387
|
+
persisted: false,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
writeInstanceConfig(configPath, values);
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
configPath,
|
|
395
|
+
values,
|
|
396
|
+
created: true,
|
|
397
|
+
persisted: true,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function yamlDoubleQuoted(value) {
|
|
402
|
+
return String(value)
|
|
403
|
+
.replace(/\\/g, '\\\\')
|
|
404
|
+
.replace(/"/g, '\\"');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderTemplate(template, values, templatePath) {
|
|
408
|
+
return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
|
|
409
|
+
if (!(key in values)) {
|
|
410
|
+
fail(`Template ${templatePath} references missing value ${key}.`);
|
|
411
|
+
}
|
|
412
|
+
return yamlDoubleQuoted(values[key]);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function renderGeneratedConfigs(dataDir, instanceConfig) {
|
|
417
|
+
const targetDirectory = generatedConfigDirectory(dataDir);
|
|
418
|
+
fs.mkdirSync(targetDirectory, {
|
|
419
|
+
recursive: true,
|
|
420
|
+
mode: 0o700,
|
|
421
|
+
});
|
|
422
|
+
fs.chmodSync(targetDirectory, 0o700);
|
|
423
|
+
|
|
424
|
+
for (const [targetFileName, templatePath] of generatedConfigTemplates) {
|
|
425
|
+
const rendered = renderTemplate(fs.readFileSync(templatePath, 'utf8'), instanceConfig, templatePath);
|
|
426
|
+
const targetPath = path.join(targetDirectory, targetFileName);
|
|
427
|
+
fs.writeFileSync(targetPath, rendered, { mode: 0o644 });
|
|
428
|
+
fs.chmodSync(targetPath, 0o644);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return targetDirectory;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function makeEnvironment(dataDir, options, instanceConfig) {
|
|
435
|
+
// Docker Compose reads this environment to choose bind mounts and host ports.
|
|
436
|
+
const env = {
|
|
437
|
+
...process.env,
|
|
438
|
+
...instanceConfig,
|
|
439
|
+
TELEMETRY_DATA_DIR: dataDir,
|
|
440
|
+
TELEMETRY_GENERATED_CONFIG_DIR: generatedConfigDirectory(dataDir),
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
for (const [envName, value] of Object.entries(options.ports)) {
|
|
444
|
+
env[envName] = value;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return env;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function makeContext(rawDataDir, options, createConfig) {
|
|
451
|
+
// The context bundles all values every Docker Compose-backed command needs.
|
|
452
|
+
const dataDir = resolveDataDir(rawDataDir);
|
|
453
|
+
const projectName = deriveProjectName(dataDir, options.name);
|
|
454
|
+
const instance = resolveInstanceConfig(dataDir, options.mode, createConfig);
|
|
455
|
+
const generatedConfigDir = instance.persisted
|
|
456
|
+
? renderGeneratedConfigs(dataDir, instance.values)
|
|
457
|
+
: generatedConfigDirectory(dataDir);
|
|
458
|
+
const env = makeEnvironment(dataDir, options, instance.values);
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
dataDir,
|
|
462
|
+
projectName,
|
|
463
|
+
env,
|
|
464
|
+
options,
|
|
465
|
+
instance,
|
|
466
|
+
generatedConfigDir,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function ensureDataDirectories(dataDir) {
|
|
471
|
+
// Docker could create missing bind-mount directories itself, often as root.
|
|
472
|
+
// Creating them first keeps ownership predictable for the current user.
|
|
473
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
474
|
+
|
|
475
|
+
for (const directory of dataSubdirectories) {
|
|
476
|
+
fs.mkdirSync(path.join(dataDir, directory), { recursive: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function dockerComposeArgs(context, args) {
|
|
481
|
+
// Always call the compose file from inside the package and isolate instances by project name.
|
|
482
|
+
return [
|
|
483
|
+
'compose',
|
|
484
|
+
'-f',
|
|
485
|
+
composeFile,
|
|
486
|
+
'-p',
|
|
487
|
+
context.projectName,
|
|
488
|
+
...args,
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function runDockerCompose(context, args, stdio = 'inherit') {
|
|
493
|
+
// The CLI delegates lifecycle operations to Docker Compose and only prepares arguments/env.
|
|
494
|
+
const result = spawnSync('docker', dockerComposeArgs(context, args), {
|
|
495
|
+
env: context.env,
|
|
496
|
+
stdio,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (result.error) {
|
|
500
|
+
fail(`Unable to start docker: ${result.error.message}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return result.status ?? 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function getPort(env, envName, fallback) {
|
|
507
|
+
return env[envName] || fallback;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function openUrl(url) {
|
|
511
|
+
console.log(url);
|
|
512
|
+
|
|
513
|
+
if (process.platform !== 'linux') {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// xdg-open is a convenience for Linux desktops. If it is unavailable, the
|
|
518
|
+
// command still succeeds because the URL was already printed above.
|
|
519
|
+
const result = spawnSync('xdg-open', [url], {
|
|
520
|
+
stdio: 'ignore',
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (result.error) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function runDoctor() {
|
|
529
|
+
// Doctor checks local prerequisites without starting or changing the stack.
|
|
530
|
+
let failed = false;
|
|
531
|
+
|
|
532
|
+
const checks = [
|
|
533
|
+
['node', ['--version']],
|
|
534
|
+
['docker', ['--version']],
|
|
535
|
+
['docker', ['compose', 'version']],
|
|
536
|
+
['docker', ['info']],
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
for (const [command, args] of checks) {
|
|
540
|
+
const result = spawnSync(command, args, {
|
|
541
|
+
encoding: 'utf8',
|
|
542
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
if (result.error || result.status !== 0) {
|
|
546
|
+
failed = true;
|
|
547
|
+
console.log(`[FAIL] ${command} ${args.join(' ')}`);
|
|
548
|
+
if (result.error) {
|
|
549
|
+
console.log(` ${result.error.message}`);
|
|
550
|
+
} else if (result.stderr.trim()) {
|
|
551
|
+
console.log(` ${result.stderr.trim().split('\n')[0]}`);
|
|
552
|
+
}
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const output = `${result.stdout}${result.stderr}`.trim().split('\n')[0];
|
|
557
|
+
console.log(`[OK] ${command} ${args.join(' ')}${output ? ` -> ${output}` : ''}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (!fs.existsSync(composeFile)) {
|
|
561
|
+
failed = true;
|
|
562
|
+
console.log(`[FAIL] compose file missing: ${composeFile}`);
|
|
563
|
+
} else {
|
|
564
|
+
console.log(`[OK] compose file -> ${composeFile}`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
process.exit(failed ? 1 : 0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function printInitResult(context) {
|
|
571
|
+
const action = context.instance.created ? 'Created' : 'Using existing';
|
|
572
|
+
console.log(`${action} config: ${context.instance.configPath}`);
|
|
573
|
+
console.log(`Generated service configs: ${context.generatedConfigDir}`);
|
|
574
|
+
console.log(`Mode: ${context.instance.values.TELEMETRY_MODE}`);
|
|
575
|
+
console.log(`Grafana user: ${context.instance.values.GRAFANA_ADMIN_USER}`);
|
|
576
|
+
console.log(`Grafana password: stored in ${context.instance.configPath}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function run() {
|
|
580
|
+
// Command dispatch stays explicit so the public CLI surface is easy to audit.
|
|
581
|
+
const { command, positionals, options } = parseArguments(process.argv);
|
|
582
|
+
validateCommandOptions(command, options);
|
|
583
|
+
|
|
584
|
+
if (command === 'doctor') {
|
|
585
|
+
runDoctor();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const createConfig = command === 'init' || command === 'up' || command === 'restart' || command === 'config';
|
|
590
|
+
const context = makeContext(positionals[0], options, createConfig);
|
|
591
|
+
|
|
592
|
+
if (command === 'init') {
|
|
593
|
+
ensureDataDirectories(context.dataDir);
|
|
594
|
+
printInitResult(context);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (command === 'up') {
|
|
599
|
+
ensureDataDirectories(context.dataDir);
|
|
600
|
+
process.exit(runDockerCompose(context, ['up', '-d']));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (command === 'down') {
|
|
604
|
+
process.exit(runDockerCompose(context, ['down']));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (command === 'restart') {
|
|
608
|
+
ensureDataDirectories(context.dataDir);
|
|
609
|
+
const downStatus = runDockerCompose(context, ['down']);
|
|
610
|
+
if (downStatus !== 0) {
|
|
611
|
+
process.exit(downStatus);
|
|
612
|
+
}
|
|
613
|
+
process.exit(runDockerCompose(context, ['up', '-d']));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (command === 'ps') {
|
|
617
|
+
process.exit(runDockerCompose(context, ['ps']));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (command === 'logs') {
|
|
621
|
+
const service = positionals[1];
|
|
622
|
+
const args = ['logs'];
|
|
623
|
+
if (options.follow) {
|
|
624
|
+
args.push('--follow');
|
|
625
|
+
}
|
|
626
|
+
if (options.tail !== undefined) {
|
|
627
|
+
args.push('--tail', options.tail);
|
|
628
|
+
}
|
|
629
|
+
if (service) {
|
|
630
|
+
args.push(service);
|
|
631
|
+
}
|
|
632
|
+
process.exit(runDockerCompose(context, args));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (command === 'open') {
|
|
636
|
+
const target = positionals[1] || 'grafana';
|
|
637
|
+
const targetConfig = urlTargets[target];
|
|
638
|
+
if (!targetConfig) {
|
|
639
|
+
fail(`Unknown open target: ${target}`);
|
|
640
|
+
}
|
|
641
|
+
const [envName, fallbackPort, urlPath] = targetConfig;
|
|
642
|
+
const port = getPort(context.env, envName, fallbackPort);
|
|
643
|
+
openUrl(`http://localhost:${port}${urlPath}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (command === 'config') {
|
|
648
|
+
process.exit(runDockerCompose(context, ['config']));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
fail(`Unknown command: ${command}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
run();
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@primafuture/telemetry-stack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Reusable PrimaFuture telemetry stack launcher powered by Docker Compose.",
|
|
6
|
+
"author": "PrimaFuture.cz s.r.o. <dev@primafuture.cz>",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"telemetry-stack": "bin/telemetry-stack.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"stack",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"check": "node --check ./bin/telemetry-stack.mjs",
|
|
24
|
+
"test:package": "npm pack --dry-run"
|
|
25
|
+
}
|
|
26
|
+
}
|