influx-local-cli 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/LICENSE +21 -0
- package/README.md +265 -0
- package/bin/influx-local.js +5 -0
- package/package.json +42 -0
- package/src/cli.js +1092 -0
- package/src/config.js +349 -0
- package/src/manager.js +1794 -0
- package/src/versions.js +270 -0
- package/src/web.js +387 -0
- package/web/app.js +816 -0
- package/web/index.html +136 -0
- package/web/style.css +208 -0
package/src/manager.js
ADDED
|
@@ -0,0 +1,1794 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs/promises');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const net = require('net');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const configMod = require('./config');
|
|
9
|
+
const {
|
|
10
|
+
loadConfig,
|
|
11
|
+
saveMiseToml,
|
|
12
|
+
readCreds,
|
|
13
|
+
writeCreds,
|
|
14
|
+
randomPassword,
|
|
15
|
+
randomToken,
|
|
16
|
+
miseBin,
|
|
17
|
+
} = configMod;
|
|
18
|
+
const { flavorOf, artifactsFor, assertIdentifier } = require('./versions');
|
|
19
|
+
|
|
20
|
+
const noopLog = () => {};
|
|
21
|
+
|
|
22
|
+
// --- Low-level process helpers ---------------------------------------------
|
|
23
|
+
|
|
24
|
+
// Spawn a command, capturing stdout/stderr. Resolves {code, stdout, stderr}.
|
|
25
|
+
function spawnOut(cmd, args, opts = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let child;
|
|
28
|
+
try {
|
|
29
|
+
child = spawn(cmd, args, {
|
|
30
|
+
env: { ...baseEnv(), ...(opts.env || {}) },
|
|
31
|
+
cwd: opts.cwd,
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
reject(err);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const out = [];
|
|
39
|
+
const errOut = [];
|
|
40
|
+
child.stdout.on('data', (d) => out.push(d));
|
|
41
|
+
child.stderr.on('data', (d) => errOut.push(d));
|
|
42
|
+
child.on('error', (err) => reject(err));
|
|
43
|
+
child.on('close', (code) =>
|
|
44
|
+
resolve({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(errOut).toString('utf8') }),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function errText(msg, res) {
|
|
50
|
+
const detail = (res.stderr || '').trim() || (res.stdout || '').trim();
|
|
51
|
+
return detail ? `${msg}: ${detail}` : msg;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Environment for every child process: the user's own InfluxDB variables are
|
|
55
|
+
// stripped so an exported INFLUXDB_HTTP_BIND_ADDRESS / INFLUX_TOKEN cannot leak
|
|
56
|
+
// into an instance we manage.
|
|
57
|
+
function baseEnv() {
|
|
58
|
+
const env = {};
|
|
59
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
60
|
+
if (/^INFLUX(_LOCAL)?(_|$)|^INFLUXDB|^INFLUXD/.test(key)) continue;
|
|
61
|
+
env[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return env;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pidAlive(pid) {
|
|
67
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return err.code === 'EPERM'; // exists but owned by someone else
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readPidFile(pidPath) {
|
|
77
|
+
try {
|
|
78
|
+
const data = (await fs.readFile(pidPath, 'utf8')).trim();
|
|
79
|
+
const pid = parseInt(data, 10);
|
|
80
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// True when something is already listening on host:port.
|
|
87
|
+
function checkPort(port, host = '127.0.0.1') {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const server = net.createServer();
|
|
90
|
+
server.once('error', () => resolve(true));
|
|
91
|
+
server.listen(port, host, () => {
|
|
92
|
+
server.close(() => resolve(false));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Probe address that matches how the instance binds (0.0.0.0 listens on
|
|
98
|
+
// 127.0.0.1 as well).
|
|
99
|
+
function probeHost(cfg) {
|
|
100
|
+
return cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host || '127.0.0.1';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function httpBase(cfg, { boot = false } = {}) {
|
|
104
|
+
return `http://${boot ? '127.0.0.1' : probeHost(cfg)}:${cfg.port}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function instancePortBound(cfg) {
|
|
108
|
+
return checkPort(cfg.port, probeHost(cfg));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function waitBound(cfg, timeoutMs = 3000) {
|
|
112
|
+
const deadline = Date.now() + timeoutMs;
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
if (await instancePortBound(cfg)) return true;
|
|
115
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// HTTP probe with a hard timeout; never throws.
|
|
121
|
+
async function httpProbe(url, { headers = {}, timeoutMs = 2500 } = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' });
|
|
124
|
+
let body = null;
|
|
125
|
+
try {
|
|
126
|
+
body = await res.text();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
body = null;
|
|
129
|
+
}
|
|
130
|
+
return { status: res.status, body };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { status: 0, body: null, error: err };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function httpJson(url, opts = {}) {
|
|
137
|
+
const res = await httpProbe(url, opts);
|
|
138
|
+
if (!res.body) return { status: res.status, json: null };
|
|
139
|
+
try {
|
|
140
|
+
return { status: res.status, json: JSON.parse(res.body) };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { status: res.status, json: null, body: res.body };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function basicAuth(user, password) {
|
|
147
|
+
return `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Wait until the server answers on /ping (1.x) or /health (2.x/3.x).
|
|
151
|
+
// `accept` lists the statuses that mean "up": InfluxDB 3 answers 401 on
|
|
152
|
+
// /health until a token is presented, which still proves the server is up.
|
|
153
|
+
async function waitReady(cfg, { boot = false, timeoutMs = 30000, pollMs = 400, accept } = {}) {
|
|
154
|
+
const url = `${httpBase(cfg, { boot })}${cfg.flavor === 'v1' ? '/ping' : '/health'}`;
|
|
155
|
+
const ok = accept || (cfg.flavor === 'v1' ? [204, 200] : cfg.flavor === 'v2' ? [200] : [200, 401, 403]);
|
|
156
|
+
const deadline = Date.now() + timeoutMs;
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
const res = await httpProbe(url);
|
|
159
|
+
if (ok.includes(res.status)) return true;
|
|
160
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function waitPortFree(cfg, { timeoutMs = 15000, pollMs = 200 } = {}) {
|
|
166
|
+
const deadline = Date.now() + timeoutMs;
|
|
167
|
+
while (Date.now() < deadline) {
|
|
168
|
+
if (!(await checkPort(cfg.port, probeHost(cfg)))) return true;
|
|
169
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Toolchain (mise) -------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
// One binary path per executable name, resolved through mise's install dir for
|
|
177
|
+
// this instance's pinned version. Cached per (instance dir, version) so a long
|
|
178
|
+
// running web dashboard does not re-shell out for every operation.
|
|
179
|
+
const binCache = new Map();
|
|
180
|
+
|
|
181
|
+
function clearBinCache(cfg) {
|
|
182
|
+
for (const key of [...binCache.keys()]) {
|
|
183
|
+
if (key.startsWith(`${cfg.instanceDir}::`)) binCache.delete(key);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Environment for every mise invocation. Two things matter:
|
|
188
|
+
// MISE_CEILING_PATHS stops the config search at the instance dir, so an
|
|
189
|
+
// unrelated (and untrusted) .mise.toml in $HOME or
|
|
190
|
+
// any parent directory cannot abort the call with
|
|
191
|
+
// "Config files ... are not trusted".
|
|
192
|
+
// MISE_TRUSTED_CONFIG_PATHS trusts the .mise.toml we generate per instance.
|
|
193
|
+
function miseEnv(cfg) {
|
|
194
|
+
return {
|
|
195
|
+
MISE_CEILING_PATHS: cfg.instanceDir,
|
|
196
|
+
MISE_TRUSTED_CONFIG_PATHS: cfg.instanceDir,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function resolveBins(cfg, { require = true } = {}) {
|
|
201
|
+
const cacheKey = `${cfg.instanceDir}::${cfg.version}`;
|
|
202
|
+
const cached = binCache.get(cacheKey);
|
|
203
|
+
if (cached) return cached;
|
|
204
|
+
|
|
205
|
+
const found = {};
|
|
206
|
+
const problems = [];
|
|
207
|
+
for (const artifact of artifactsFor(cfg.version)) {
|
|
208
|
+
const version = artifact.artifactVersion || cfg.version;
|
|
209
|
+
const res = await spawnOut(miseBin(), ['where', '-C', cfg.instanceDir, `http:${artifact.tool}@${version}`], {
|
|
210
|
+
env: miseEnv(cfg),
|
|
211
|
+
});
|
|
212
|
+
const dir = res.stdout.trim();
|
|
213
|
+
if (res.code !== 0 || !dir) {
|
|
214
|
+
const detail = (res.stderr || res.stdout || '')
|
|
215
|
+
.split(/\r?\n/)
|
|
216
|
+
.map((line) => line.trim())
|
|
217
|
+
.filter((line) => line && !/^mise (ERROR )?(Version:|Run with)/.test(line))
|
|
218
|
+
.slice(-3)
|
|
219
|
+
.join(' ');
|
|
220
|
+
problems.push(`mise where http:${artifact.tool}@${version} failed (exit ${res.code})${detail ? `: ${detail}` : ''}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const exe of artifact.exe) {
|
|
224
|
+
if (found[exe]) continue;
|
|
225
|
+
const resolved = await findExecutable(dir, exe);
|
|
226
|
+
if (resolved) found[exe] = resolved;
|
|
227
|
+
else problems.push(`"${exe}" not found under ${dir}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (require && !found[cfg.serverBin]) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`InfluxDB ${cfg.version} is not installed for "${cfg.name}" — run "influx-local install ${cfg.name}" first` +
|
|
233
|
+
(problems.length ? `\n ${problems.join('\n ')}` : ''),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
binCache.set(cacheKey, found);
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function findExecutable(root, name, maxDepth = 5) {
|
|
241
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
242
|
+
while (queue.length) {
|
|
243
|
+
const { dir, depth } = queue.shift();
|
|
244
|
+
let entries;
|
|
245
|
+
try {
|
|
246
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
const full = path.join(dir, entry.name);
|
|
252
|
+
if (entry.isDirectory()) {
|
|
253
|
+
if (depth < maxDepth) queue.push({ dir: full, depth: depth + 1 });
|
|
254
|
+
} else if (entry.name === name) {
|
|
255
|
+
try {
|
|
256
|
+
await fs.access(full, fs.constants.X_OK);
|
|
257
|
+
return full;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
// not executable
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Install (idempotent) the instance's pinned version through mise.
|
|
268
|
+
async function installTools(cfg, log = noopLog) {
|
|
269
|
+
log(`installing InfluxDB ${cfg.version} (${cfg.flavorLabel}) via ${miseBin()}…`);
|
|
270
|
+
await saveMiseToml(cfg.name, cfg.version);
|
|
271
|
+
const res = await spawnOut(miseBin(), ['install', '-C', cfg.instanceDir], { env: miseEnv(cfg) });
|
|
272
|
+
if (res.code !== 0) {
|
|
273
|
+
throw new Error(errText(`failed to install InfluxDB ${cfg.version}`, res));
|
|
274
|
+
}
|
|
275
|
+
clearBinCache(cfg);
|
|
276
|
+
const bins = await resolveBins(cfg);
|
|
277
|
+
const list = Object.entries(bins).map(([exe, p]) => `${exe} → ${p}`).join(', ');
|
|
278
|
+
log(`toolchain ready: ${list}`);
|
|
279
|
+
return bins;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Run an InfluxDB binary (resolved through mise) with our environment.
|
|
283
|
+
async function runBin(cfg, exe, args, opts = {}) {
|
|
284
|
+
const bins = opts.bins || (await resolveBins(cfg));
|
|
285
|
+
const bin = bins[exe];
|
|
286
|
+
if (!bin) {
|
|
287
|
+
throw new Error(`"${exe}" is not installed for "${cfg.name}" — run "influx-local install ${cfg.name}"`);
|
|
288
|
+
}
|
|
289
|
+
return spawnOut(bin, args, { env: { ...cliEnv(cfg), ...(opts.env || {}) }, cwd: opts.cwd });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// InfluxDB 2.x's CLI stores host/token/org shortcuts in a config file; keep it
|
|
293
|
+
// inside the instance dir instead of the user's ~/.influxdbv2/configs.
|
|
294
|
+
function cliEnv(cfg) {
|
|
295
|
+
if (cfg.flavor === 'v2') return { INFLUX_CONFIGS_PATH: cfg.cliConfigsPath };
|
|
296
|
+
return {};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- Daemon lifecycle -------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function bindAddress(cfg) {
|
|
302
|
+
return `${cfg.host}:${cfg.port}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function daemonArgs(cfg, { boot = false } = {}) {
|
|
306
|
+
if (cfg.flavor === 'v1') {
|
|
307
|
+
// 1.x is configured entirely through INFLUXDB_* environment variables.
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
if (cfg.flavor === 'v2') {
|
|
311
|
+
return [
|
|
312
|
+
'--bolt-path', path.join(cfg.dataDir, 'influxd.bolt'),
|
|
313
|
+
'--engine-path', path.join(cfg.dataDir, 'engine'),
|
|
314
|
+
'--http-bind-address', boot ? `127.0.0.1:${cfg.port}` : bindAddress(cfg),
|
|
315
|
+
'--reporting-disabled',
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
// InfluxDB 3 Core: a single binary with a `serve` subcommand.
|
|
319
|
+
return [
|
|
320
|
+
'serve',
|
|
321
|
+
'--node-id', nodeId(cfg),
|
|
322
|
+
'--object-store', 'file',
|
|
323
|
+
'--data-dir', cfg.dataDir,
|
|
324
|
+
'--http-bind', boot ? `127.0.0.1:${cfg.port}` : bindAddress(cfg),
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3 Core uses the node id as a prefix in object-store paths.
|
|
329
|
+
function nodeId(cfg) {
|
|
330
|
+
return cfg.name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function daemonEnv(cfg, { boot = false, withoutAuth = false } = {}) {
|
|
334
|
+
if (cfg.flavor !== 'v1') return {};
|
|
335
|
+
return {
|
|
336
|
+
INFLUXDB_META_DIR: path.join(cfg.dataDir, 'meta'),
|
|
337
|
+
INFLUXDB_DATA_DIR: path.join(cfg.dataDir, 'data'),
|
|
338
|
+
INFLUXDB_DATA_WAL_DIR: path.join(cfg.dataDir, 'wal'),
|
|
339
|
+
INFLUXDB_HTTP_BIND_ADDRESS: boot ? `127.0.0.1:${cfg.port}` : bindAddress(cfg),
|
|
340
|
+
INFLUXDB_HTTP_AUTH_ENABLED: withoutAuth || boot ? 'false' : 'true',
|
|
341
|
+
INFLUXDB_REPORTING_DISABLED: 'true',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function ensureDirs(cfg) {
|
|
346
|
+
await fs.mkdir(cfg.dataDir, { recursive: true });
|
|
347
|
+
if (cfg.flavor === 'v2') await fs.mkdir(path.join(cfg.dataDir, 'engine'), { recursive: true });
|
|
348
|
+
await fs.mkdir(path.dirname(cfg.logPath), { recursive: true });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Fork the server so it outlives this CLI: detached, stdio to the instance log,
|
|
352
|
+
// pid recorded by us (InfluxDB has no --fork/--pidfile).
|
|
353
|
+
async function spawnDaemon(cfg, { boot = false, withoutAuth = false, log = noopLog } = {}) {
|
|
354
|
+
const bins = await resolveBins(cfg);
|
|
355
|
+
await ensureDirs(cfg);
|
|
356
|
+
const logPath = boot ? cfg.bootLogPath : cfg.logPath;
|
|
357
|
+
const pidPath = boot ? cfg.bootPidPath : cfg.pidPath;
|
|
358
|
+
const args = daemonArgs(cfg, { boot });
|
|
359
|
+
if (withoutAuth && cfg.flavor === 'v3') args.push('--without-auth');
|
|
360
|
+
const env = { ...baseEnv(), ...daemonEnv(cfg, { boot, withoutAuth }) };
|
|
361
|
+
|
|
362
|
+
log(args.length ? `${cfg.serverBin} ${args.join(' ')}` : `starting ${cfg.serverBin}`);
|
|
363
|
+
const handle = await fs.open(logPath, 'a');
|
|
364
|
+
let child;
|
|
365
|
+
try {
|
|
366
|
+
child = spawn(bins[cfg.serverBin], args, {
|
|
367
|
+
detached: true,
|
|
368
|
+
stdio: ['ignore', handle.fd, handle.fd],
|
|
369
|
+
env,
|
|
370
|
+
});
|
|
371
|
+
} finally {
|
|
372
|
+
await handle.close();
|
|
373
|
+
}
|
|
374
|
+
child.unref();
|
|
375
|
+
|
|
376
|
+
const failed = await new Promise((resolve) => {
|
|
377
|
+
let settled = false;
|
|
378
|
+
const done = (value) => {
|
|
379
|
+
if (!settled) {
|
|
380
|
+
settled = true;
|
|
381
|
+
resolve(value);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
child.once('error', (err) => done(err));
|
|
385
|
+
child.once('exit', (code, signal) => done({ code, signal }));
|
|
386
|
+
setTimeout(() => done(null), 400);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (failed) {
|
|
390
|
+
const tail = await logTail(logPath, 20);
|
|
391
|
+
const detail = failed instanceof Error ? failed.message : `exited with code ${failed.code}${failed.signal ? ` (${failed.signal})` : ''}`;
|
|
392
|
+
throw new Error(`${cfg.serverBin} failed to start (${detail})${tail ? `\n ${path.basename(logPath)} tail:\n${tail}` : ''}`);
|
|
393
|
+
}
|
|
394
|
+
await fs.writeFile(pidPath, String(child.pid));
|
|
395
|
+
log(`${cfg.serverBin} started (pid ${child.pid})`);
|
|
396
|
+
return child.pid;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Last `lines` of a log file (best effort, capped read).
|
|
400
|
+
async function logTail(logPath, lines = 15) {
|
|
401
|
+
try {
|
|
402
|
+
const stat = await fs.stat(logPath);
|
|
403
|
+
if (!stat.isFile() || stat.size === 0) return '';
|
|
404
|
+
const chunk = Math.min(64 * 1024, stat.size);
|
|
405
|
+
const handle = await fs.open(logPath, 'r');
|
|
406
|
+
const buf = Buffer.alloc(chunk);
|
|
407
|
+
let text = '';
|
|
408
|
+
try {
|
|
409
|
+
const { bytesRead } = await handle.read(buf, 0, chunk, Math.max(0, stat.size - chunk));
|
|
410
|
+
text = buf.slice(0, bytesRead).toString('utf8');
|
|
411
|
+
} finally {
|
|
412
|
+
await handle.close();
|
|
413
|
+
}
|
|
414
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-lines).join('\n');
|
|
415
|
+
} catch (err) {
|
|
416
|
+
return '';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function stopPid(pid, { force = false, log = noopLog, name = 'server' } = {}) {
|
|
421
|
+
if (!pid || !pidAlive(pid)) return;
|
|
422
|
+
log(`stopping ${name} (pid ${pid})…`);
|
|
423
|
+
try {
|
|
424
|
+
process.kill(pid, 'SIGTERM');
|
|
425
|
+
} catch (err) {
|
|
426
|
+
// already gone
|
|
427
|
+
}
|
|
428
|
+
const deadline = Date.now() + 8000;
|
|
429
|
+
while (Date.now() < deadline && pidAlive(pid)) {
|
|
430
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
431
|
+
}
|
|
432
|
+
if (pidAlive(pid)) {
|
|
433
|
+
if (!force) {
|
|
434
|
+
throw new Error(`${name} (pid ${pid}) did not stop in time — re-run with --force to kill it`);
|
|
435
|
+
}
|
|
436
|
+
log('terminating forcefully (SIGKILL)…');
|
|
437
|
+
try {
|
|
438
|
+
process.kill(pid, 'SIGKILL');
|
|
439
|
+
} catch (err) {}
|
|
440
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
441
|
+
if (pidAlive(pid)) throw new Error(`${name} (pid ${pid}) survived SIGKILL`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function stopDaemon(cfg, { force = false, pidPath, log = noopLog } = {}) {
|
|
446
|
+
const target = pidPath || cfg.pidPath;
|
|
447
|
+
const pid = await readPidFile(target);
|
|
448
|
+
if (!pid || !pidAlive(pid)) return false;
|
|
449
|
+
await stopPid(pid, { force, log, name: cfg.serverBin });
|
|
450
|
+
await fs.rm(target, { force: true }).catch(() => {});
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- Credentials ------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
// Fresh credentials in the shape the instance's auth model needs.
|
|
457
|
+
function freshCreds(cfg) {
|
|
458
|
+
if (cfg.flavor === 'v1') {
|
|
459
|
+
return {
|
|
460
|
+
ADMIN_USER: cfg.adminUser,
|
|
461
|
+
ADMIN_PW: randomPassword(),
|
|
462
|
+
APP_USER: cfg.appUser,
|
|
463
|
+
APP_PW: randomPassword(),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (cfg.flavor === 'v2') {
|
|
467
|
+
// The token is minted by the server during onboarding; the placeholder is
|
|
468
|
+
// replaced by whatever value the operator token ends up with.
|
|
469
|
+
return {
|
|
470
|
+
ADMIN_USER: cfg.adminUser,
|
|
471
|
+
ADMIN_PW: randomPassword(),
|
|
472
|
+
ADMIN_TOKEN: randomToken(),
|
|
473
|
+
APP_TOKEN: '',
|
|
474
|
+
ORG: cfg.org,
|
|
475
|
+
BUCKET: cfg.database,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return { ADMIN_TOKEN: '' }; // 3 Core mints its admin token on first setup
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function resolveCreds(cfg, log = noopLog) {
|
|
482
|
+
let creds = await readCreds(cfg.name, cfg.flavor);
|
|
483
|
+
if (!creds) {
|
|
484
|
+
creds = freshCreds(cfg);
|
|
485
|
+
if (cfg.flavor !== 'v3') {
|
|
486
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
487
|
+
log(`generated new credentials -> ${cfg.credsPath} (mode 0600)`);
|
|
488
|
+
}
|
|
489
|
+
return creds;
|
|
490
|
+
}
|
|
491
|
+
// Keep identifiers in sync with config (a renamed user/org/database/bucket).
|
|
492
|
+
let changed = false;
|
|
493
|
+
const sync = (field, value) => {
|
|
494
|
+
if (value && creds[field] !== value) {
|
|
495
|
+
creds[field] = value;
|
|
496
|
+
changed = true;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
if (cfg.flavor === 'v1') {
|
|
500
|
+
sync('ADMIN_USER', cfg.adminUser);
|
|
501
|
+
sync('APP_USER', cfg.appUser);
|
|
502
|
+
} else if (cfg.flavor === 'v2') {
|
|
503
|
+
sync('ADMIN_USER', cfg.adminUser);
|
|
504
|
+
sync('ORG', cfg.org);
|
|
505
|
+
sync('BUCKET', cfg.database);
|
|
506
|
+
}
|
|
507
|
+
if (changed) await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
508
|
+
return creds;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --- v1: InfluxQL users / databases ----------------------------------------
|
|
512
|
+
|
|
513
|
+
function sqlString(value) {
|
|
514
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function ident(name) {
|
|
518
|
+
return `"${String(name).replace(/"/g, '\\"')}"`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function v1BaseArgs(cfg, { boot = false, creds = null, admin = false, database = null, format = 'json' } = {}) {
|
|
522
|
+
const args = ['-host', boot ? '127.0.0.1' : probeHost(cfg), '-port', String(cfg.port)];
|
|
523
|
+
if (creds) {
|
|
524
|
+
args.push('-username', admin ? creds.ADMIN_USER : creds.APP_USER);
|
|
525
|
+
args.push('-password', admin ? creds.ADMIN_PW : creds.APP_PW);
|
|
526
|
+
}
|
|
527
|
+
if (database) args.push('-database', database);
|
|
528
|
+
if (format) args.push('-format', format);
|
|
529
|
+
return args;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function v1Query(cfg, statement, opts = {}) {
|
|
533
|
+
const args = [...v1BaseArgs(cfg, opts), '-execute', statement];
|
|
534
|
+
const res = await runBin(cfg, cfg.cliBin, args, opts);
|
|
535
|
+
if (res.code !== 0) {
|
|
536
|
+
throw new Error(errText(`InfluxQL statement failed: ${statement}`, res));
|
|
537
|
+
}
|
|
538
|
+
const text = res.stdout.trim();
|
|
539
|
+
if (!text) return {};
|
|
540
|
+
try {
|
|
541
|
+
return JSON.parse(text);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return {};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Flatten InfluxQL JSON results into rows of plain values.
|
|
548
|
+
function v1Rows(json) {
|
|
549
|
+
const rows = [];
|
|
550
|
+
for (const result of (json && json.results) || []) {
|
|
551
|
+
for (const series of result.series || []) {
|
|
552
|
+
for (const values of series.values || []) rows.push(values);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return rows;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function v1Show(cfg, statement, opts = {}) {
|
|
559
|
+
const rows = v1Rows(await v1Query(cfg, statement, opts));
|
|
560
|
+
return rows.map((row) => row[0]);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function v1UserExists(cfg, user, opts) {
|
|
564
|
+
const users = await v1Show(cfg, 'SHOW USERS', opts);
|
|
565
|
+
return users.includes(user);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function v1DatabaseExists(cfg, db, opts) {
|
|
569
|
+
const dbs = await v1Show(cfg, 'SHOW DATABASES', opts);
|
|
570
|
+
return dbs.includes(db);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function v1GrantsFor(cfg, user, opts) {
|
|
574
|
+
try {
|
|
575
|
+
const rows = v1Rows(await v1Query(cfg, `SHOW GRANTS FOR ${ident(user)}`, opts));
|
|
576
|
+
return rows.map(([database, privilege]) => ({ database, privilege }));
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Create/repair the two users, the database and the seed measurement.
|
|
583
|
+
// `phase` decides who runs the statements:
|
|
584
|
+
// bootstrap → the temporary unauthenticated server (no credentials)
|
|
585
|
+
// persistent → the running authenticated server (admin credentials)
|
|
586
|
+
async function provisionV1(cfg, creds, { phase, log }) {
|
|
587
|
+
const opts = phase === 'bootstrap'
|
|
588
|
+
? { boot: true, database: cfg.database }
|
|
589
|
+
: { creds, admin: true };
|
|
590
|
+
const label = phase === 'bootstrap' ? ' (temporary unauthenticated server)' : '';
|
|
591
|
+
log(`creating/updating InfluxQL users + database${label}…`);
|
|
592
|
+
|
|
593
|
+
if (await v1UserExists(cfg, creds.ADMIN_USER, opts)) {
|
|
594
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(creds.ADMIN_USER)} = ${sqlString(creds.ADMIN_PW)}`, opts);
|
|
595
|
+
} else {
|
|
596
|
+
await v1Query(
|
|
597
|
+
cfg,
|
|
598
|
+
`CREATE USER ${ident(creds.ADMIN_USER)} WITH PASSWORD ${sqlString(creds.ADMIN_PW)} WITH ALL PRIVILEGES`,
|
|
599
|
+
opts,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (await v1UserExists(cfg, creds.APP_USER, opts)) {
|
|
604
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(creds.APP_USER)} = ${sqlString(creds.APP_PW)}`, opts);
|
|
605
|
+
} else {
|
|
606
|
+
await v1Query(cfg, `CREATE USER ${ident(creds.APP_USER)} WITH PASSWORD ${sqlString(creds.APP_PW)}`, opts);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!(await v1DatabaseExists(cfg, cfg.database, opts))) {
|
|
610
|
+
await v1Query(cfg, `CREATE DATABASE ${ident(cfg.database)}`, opts);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The app user gets ALL on its database only (the InfluxQL equivalent of
|
|
614
|
+
// MongoDB's readWrite on one database).
|
|
615
|
+
const grants = await v1GrantsFor(cfg, creds.APP_USER, opts);
|
|
616
|
+
const hasGrant = (grants || []).some(
|
|
617
|
+
(g) => g.database === cfg.database && /ALL/i.test(g.privilege || ''),
|
|
618
|
+
);
|
|
619
|
+
if (!hasGrant) {
|
|
620
|
+
await v1Query(cfg, `GRANT ALL ON ${ident(cfg.database)} TO ${ident(creds.APP_USER)}`, opts);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Seed the measurement so a fresh instance is immediately usable (the
|
|
624
|
+
// equivalent of mongo-local-cli creating the initial collection).
|
|
625
|
+
if (cfg.measurement) {
|
|
626
|
+
const measurements = [];
|
|
627
|
+
try {
|
|
628
|
+
measurements.push(...(await v1Show(cfg, `SHOW MEASUREMENTS ON ${ident(cfg.database)}`, opts)));
|
|
629
|
+
} catch (err) {
|
|
630
|
+
// empty database returns no series
|
|
631
|
+
}
|
|
632
|
+
if (!measurements.includes(cfg.measurement)) {
|
|
633
|
+
await v1Query(
|
|
634
|
+
cfg,
|
|
635
|
+
`INSERT ${cfg.measurement},host=bootstrap,created_by=influx-local value=1`,
|
|
636
|
+
{ ...opts, database: cfg.database },
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
log('users + database ready');
|
|
641
|
+
return creds;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// --- v2: onboarding, buckets, tokens ---------------------------------------
|
|
645
|
+
|
|
646
|
+
const APP_TOKEN_DESCRIPTION = (cfg) => `influx-local app (rw ${cfg.database})`;
|
|
647
|
+
const ADMIN_TOKEN_DESCRIPTION = 'influx-local admin (all-access)';
|
|
648
|
+
|
|
649
|
+
async function v2TokenWorks(cfg, token) {
|
|
650
|
+
if (!token) return false;
|
|
651
|
+
const res = await httpProbe(`${httpBase(cfg)}/api/v2/buckets`, {
|
|
652
|
+
headers: { Authorization: `Token ${token}` },
|
|
653
|
+
});
|
|
654
|
+
return res.status === 200;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function v2AppTokenWorks(cfg, token, creds) {
|
|
658
|
+
if (!token) return false;
|
|
659
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
660
|
+
'query', '--host', httpBase(cfg), '--token', token, '--org', creds.ORG, '--raw',
|
|
661
|
+
`from(bucket: ${JSON.stringify(cfg.database)}) |> range(start: -1h) |> limit(n: 1)`,
|
|
662
|
+
], {});
|
|
663
|
+
return res.code === 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function v2BucketId(cfg, token, creds) {
|
|
667
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
668
|
+
'bucket', 'list', '--host', httpBase(cfg), '--token', token,
|
|
669
|
+
'--org', creds.ORG, '--name', cfg.database, '--json',
|
|
670
|
+
]);
|
|
671
|
+
if (res.code !== 0) return null;
|
|
672
|
+
try {
|
|
673
|
+
const buckets = JSON.parse(res.stdout.trim() || '[]');
|
|
674
|
+
return Array.isArray(buckets) && buckets.length ? buckets[0].id : null;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function v2AuthList(cfg, token) {
|
|
681
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
682
|
+
'auth', 'list', '--host', httpBase(cfg), '--token', token, '--json',
|
|
683
|
+
]);
|
|
684
|
+
if (res.code !== 0) return null;
|
|
685
|
+
try {
|
|
686
|
+
return JSON.parse(res.stdout.trim() || '[]');
|
|
687
|
+
} catch (err) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function v2CreateScopedToken(cfg, creds, { token, description, log }) {
|
|
693
|
+
const bucketId = await v2BucketId(cfg, token, creds);
|
|
694
|
+
if (!bucketId) throw new Error(`bucket "${cfg.database}" not found on ${httpBase(cfg)}`);
|
|
695
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
696
|
+
'auth', 'create', '--host', httpBase(cfg), '--token', token, '--org', creds.ORG,
|
|
697
|
+
'--description', description, '--read-bucket', bucketId, '--write-bucket', bucketId, '--json',
|
|
698
|
+
]);
|
|
699
|
+
if (res.code !== 0) throw new Error(errText('failed to create the app token', res));
|
|
700
|
+
const parsed = JSON.parse(res.stdout.trim() || '{}');
|
|
701
|
+
if (!parsed.token) throw new Error('the server did not return a token for the app authorization');
|
|
702
|
+
log(`created app token (read/write on bucket "${cfg.database}")`);
|
|
703
|
+
return { token: parsed.token, id: parsed.id, bucketId };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function v2DeleteAuth(cfg, adminToken, id) {
|
|
707
|
+
if (!id) return;
|
|
708
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
709
|
+
'auth', 'delete', '--host', httpBase(cfg), '--token', adminToken, '--id', id,
|
|
710
|
+
]);
|
|
711
|
+
if (res.code !== 0) throw new Error(errText(`failed to delete authorization ${id}`, res));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Onboarding + repair for 2.x. There is no unauthenticated window: `influx
|
|
715
|
+
// setup` is the only way in, and it is refused once the instance is onboarded.
|
|
716
|
+
async function provisionV2(cfg, creds, { phase, log }) {
|
|
717
|
+
const host = httpBase(cfg);
|
|
718
|
+
const setupState = await httpJson(`${host}/api/v2/setup`);
|
|
719
|
+
const allowed = setupState.json ? setupState.json.allowed : null;
|
|
720
|
+
|
|
721
|
+
if (allowed === true) {
|
|
722
|
+
log(`onboarding InfluxDB 2.x (user "${creds.ADMIN_USER}", org "${creds.ORG}", bucket "${cfg.database}")…`);
|
|
723
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
724
|
+
'setup', '--host', host,
|
|
725
|
+
'--username', creds.ADMIN_USER,
|
|
726
|
+
'--password', creds.ADMIN_PW,
|
|
727
|
+
'--org', creds.ORG,
|
|
728
|
+
'--bucket', cfg.database,
|
|
729
|
+
'--token', creds.ADMIN_TOKEN,
|
|
730
|
+
'--retention', '0',
|
|
731
|
+
'--force',
|
|
732
|
+
]);
|
|
733
|
+
if (res.code !== 0 && !/already set up/i.test(`${res.stderr}${res.stdout}`)) {
|
|
734
|
+
throw new Error(errText('influx setup failed', res));
|
|
735
|
+
}
|
|
736
|
+
// The CLI writes its own client config next to creds.env: keep it private.
|
|
737
|
+
await fs.chmod(cfg.cliConfigsPath, 0o600).catch(() => {});
|
|
738
|
+
} else if (allowed === false) {
|
|
739
|
+
log('instance is already onboarded — reconciling tokens/bucket (no re-setup)');
|
|
740
|
+
} else {
|
|
741
|
+
throw new Error(`cannot read the onboarding state from ${host}/api/v2/setup — is the server healthy?`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!(await v2TokenWorks(cfg, creds.ADMIN_TOKEN))) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`the admin token in ${cfg.credsPath} was rejected by the server. ` +
|
|
747
|
+
`InfluxDB 2.x cannot re-issue an operator token for an onboarded instance: ` +
|
|
748
|
+
`restore creds.env or destroy the instance ("influx-local destroy ${cfg.name}") and set it up again.`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Repair: make sure the bucket exists.
|
|
753
|
+
if (!(await v2BucketId(cfg, creds.ADMIN_TOKEN, creds))) {
|
|
754
|
+
log(`creating bucket "${cfg.database}"…`);
|
|
755
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
756
|
+
'bucket', 'create', '--host', host, '--token', creds.ADMIN_TOKEN,
|
|
757
|
+
'--org', creds.ORG, '--name', cfg.database, '--retention', '0',
|
|
758
|
+
]);
|
|
759
|
+
if (res.code !== 0) throw new Error(errText(`failed to create bucket "${cfg.database}"`, res));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Repair: make sure an app token exists and still works.
|
|
763
|
+
if (!(await v2AppTokenWorks(cfg, creds.APP_TOKEN, creds))) {
|
|
764
|
+
const list = await v2AuthList(cfg, creds.ADMIN_TOKEN);
|
|
765
|
+
const stale = (list || []).filter((a) => a.description === APP_TOKEN_DESCRIPTION(cfg));
|
|
766
|
+
for (const entry of stale) await v2DeleteAuth(cfg, creds.ADMIN_TOKEN, entry.id);
|
|
767
|
+
const created = await v2CreateScopedToken(cfg, creds, {
|
|
768
|
+
token: creds.ADMIN_TOKEN,
|
|
769
|
+
description: APP_TOKEN_DESCRIPTION(cfg),
|
|
770
|
+
log,
|
|
771
|
+
});
|
|
772
|
+
creds.APP_TOKEN = created.token;
|
|
773
|
+
} else {
|
|
774
|
+
log('app token is valid — nothing to repair');
|
|
775
|
+
}
|
|
776
|
+
return creds;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- v3: admin token + database --------------------------------------------
|
|
780
|
+
|
|
781
|
+
async function v3TokenWorks(cfg, token) {
|
|
782
|
+
if (!token) return false;
|
|
783
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
784
|
+
'show', 'databases', '--host', httpBase(cfg), '--token', token,
|
|
785
|
+
]);
|
|
786
|
+
return res.code === 0;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function parseTokenOutput(stdout) {
|
|
790
|
+
const match = /apiv3_[A-Za-z0-9_=+/-]+/.exec(stdout);
|
|
791
|
+
if (match) return match[0];
|
|
792
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
793
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function v3CreateAdminToken(cfg, { log }) {
|
|
797
|
+
const res = await runBin(cfg, cfg.serverBin, ['create', 'token', '--admin', '--host', httpBase(cfg)]);
|
|
798
|
+
const token = parseTokenOutput(res.stdout);
|
|
799
|
+
if (res.code !== 0 || !token) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
errText(
|
|
802
|
+
'failed to create an admin token — InfluxDB 3 Core refuses to mint a second "_admin" token ' +
|
|
803
|
+
`for an already-initialised instance; remove ${cfg.dataDir} or restore creds.env`,
|
|
804
|
+
res,
|
|
805
|
+
),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
log('created a new admin token');
|
|
809
|
+
return token;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function v3Databases(cfg, token) {
|
|
813
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
814
|
+
'show', 'databases', '--host', httpBase(cfg), '--token', token, '--format', 'json',
|
|
815
|
+
]);
|
|
816
|
+
if (res.code !== 0) return null;
|
|
817
|
+
const text = res.stdout.trim();
|
|
818
|
+
try {
|
|
819
|
+
const parsed = JSON.parse(text);
|
|
820
|
+
const rows = Array.isArray(parsed) ? parsed : parsed.data || parsed.databases || [];
|
|
821
|
+
if (rows.every((row) => typeof row === 'string')) return rows;
|
|
822
|
+
if (rows.every((row) => row && typeof row === 'object')) {
|
|
823
|
+
return rows
|
|
824
|
+
.map((row) => row['iox::database'] || row.database || row.name || Object.values(row)[0])
|
|
825
|
+
.filter(Boolean);
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
} catch (err) {
|
|
829
|
+
return null; // unknown shape: the caller falls back to a best-effort create
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function provisionV3(cfg, creds, { phase, log }) {
|
|
834
|
+
if (!(await v3TokenWorks(cfg, creds.ADMIN_TOKEN))) {
|
|
835
|
+
creds.ADMIN_TOKEN = await v3CreateAdminToken(cfg, { log });
|
|
836
|
+
// The server shows an admin token exactly once: persist it before doing
|
|
837
|
+
// anything else, or it is lost for good.
|
|
838
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
839
|
+
log(`saved the new admin token -> ${cfg.credsPath} (mode 0600)`);
|
|
840
|
+
} else {
|
|
841
|
+
log('admin token is valid');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const databases = await v3Databases(cfg, creds.ADMIN_TOKEN);
|
|
845
|
+
if (databases === null || !databases.includes(cfg.database)) {
|
|
846
|
+
log(`creating database "${cfg.database}"…`);
|
|
847
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
848
|
+
'create', 'database', cfg.database, '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
849
|
+
]);
|
|
850
|
+
// When the database list could not be read we create unconditionally, so an
|
|
851
|
+
// "already exists" answer is not an error.
|
|
852
|
+
if (res.code !== 0 && !/already exists/i.test(`${res.stdout}${res.stderr}`)) {
|
|
853
|
+
throw new Error(errText(`failed to create database "${cfg.database}"`, res));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (cfg.measurement) {
|
|
858
|
+
const tables = await v3Tables(cfg, creds.ADMIN_TOKEN).catch(() => null);
|
|
859
|
+
if (tables && !tables.includes(cfg.measurement)) {
|
|
860
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
861
|
+
'write', '--database', cfg.database, '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
862
|
+
`${cfg.measurement},host=bootstrap,created_by=influx-local value=1`,
|
|
863
|
+
]);
|
|
864
|
+
if (res.code !== 0) throw new Error(errText(`failed to seed table "${cfg.measurement}"`, res));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
log('admin token + database ready');
|
|
868
|
+
return creds;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function v3Query(cfg, sql, { token, format = 'json' } = {}) {
|
|
872
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
873
|
+
'query', '--database', cfg.database, '--host', httpBase(cfg), '--token', token, '--format', format, sql,
|
|
874
|
+
]);
|
|
875
|
+
if (res.code !== 0) throw new Error(errText(`SQL statement failed: ${sql}`, res));
|
|
876
|
+
return res.stdout;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function v3Tables(cfg, token) {
|
|
880
|
+
const out = await v3Query(cfg, 'SHOW TABLES', { token });
|
|
881
|
+
try {
|
|
882
|
+
const parsed = JSON.parse(out.trim() || '[]');
|
|
883
|
+
const rows = Array.isArray(parsed) ? parsed : parsed.data || [];
|
|
884
|
+
// 3 Core reports tables from the `iox` (user data) and `system` schemas.
|
|
885
|
+
return rows
|
|
886
|
+
.filter((row) => {
|
|
887
|
+
const schema = row.table_schema || row['table_schema'] || 'iox';
|
|
888
|
+
return schema !== 'system' && schema !== 'information_schema';
|
|
889
|
+
})
|
|
890
|
+
.map((row) => row.table_name || row.name)
|
|
891
|
+
.filter(Boolean);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return [];
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// --- Flavor dispatch --------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
const PROVISIONERS = { v1: provisionV1, v2: provisionV2, v3: provisionV3 };
|
|
900
|
+
|
|
901
|
+
function provision(cfg, creds, { phase, log }) {
|
|
902
|
+
return PROVISIONERS[cfg.flavor](cfg, creds, { phase, log });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Do the managed credentials still work against the running server?
|
|
906
|
+
async function verifyAdmin(cfg, creds) {
|
|
907
|
+
if (!creds) return false;
|
|
908
|
+
try {
|
|
909
|
+
if (cfg.flavor === 'v1') {
|
|
910
|
+
const res = await httpProbe(`${httpBase(cfg)}/query?db=admin&q=SHOW+DATABASES`, {
|
|
911
|
+
headers: { Authorization: basicAuth(creds.ADMIN_USER, creds.ADMIN_PW) },
|
|
912
|
+
});
|
|
913
|
+
return res.status === 200;
|
|
914
|
+
}
|
|
915
|
+
if (cfg.flavor === 'v2') return v2TokenWorks(cfg, creds.ADMIN_TOKEN);
|
|
916
|
+
return v3TokenWorks(cfg, creds.ADMIN_TOKEN);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// --- Public operations ------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
async function startInstance(name, { log = noopLog } = {}) {
|
|
925
|
+
const cfg = await loadConfig(name);
|
|
926
|
+
await ensureDirs(cfg);
|
|
927
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
928
|
+
if (pid && pidAlive(pid)) {
|
|
929
|
+
if (await instancePortBound(cfg)) {
|
|
930
|
+
return { alreadyRunning: true, pid, config: cfg };
|
|
931
|
+
}
|
|
932
|
+
log(`pid ${pid} exists but port ${cfg.port} is not bound — cleaning up stale daemon…`);
|
|
933
|
+
await stopPid(pid, { force: true, log, name: cfg.serverBin });
|
|
934
|
+
await fs.rm(cfg.pidPath, { force: true }).catch(() => {});
|
|
935
|
+
}
|
|
936
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
937
|
+
if (!creds) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`no credentials for "${name}" yet — run "influx-local setup ${name}" once to create users and start the server`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
if (await checkPort(cfg.port, probeHost(cfg))) {
|
|
943
|
+
throw new Error(`port ${cfg.port} is occupied by a process this tool does not manage — stop it first, or change the port`);
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
const startedPid = await spawnDaemon(cfg, { log });
|
|
947
|
+
if (!(await waitReady(cfg, { timeoutMs: 30000 }))) {
|
|
948
|
+
const tail = await logTail(cfg.logPath, 15);
|
|
949
|
+
throw new Error(`${cfg.serverBin} did not become ready on ${httpBase(cfg)}${tail ? `\n ${path.basename(cfg.logPath)} tail:\n${tail}` : ''}`);
|
|
950
|
+
}
|
|
951
|
+
if (!(await verifyAdmin(cfg, creds))) {
|
|
952
|
+
throw new Error(
|
|
953
|
+
`credentials in ${cfg.credsPath} were rejected on ${httpBase(cfg)} — the data directory is not provisioned (or is out of sync)`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
return { alreadyRunning: false, pid: startedPid, config: cfg };
|
|
957
|
+
} catch (err) {
|
|
958
|
+
// Heal the common trap: a data dir that has no managed credentials yet.
|
|
959
|
+
if (/were rejected|not provisioned|no matching/i.test(err.message)) {
|
|
960
|
+
log('authentication failed — running automatic setup/repair (keeps data)…');
|
|
961
|
+
const healed = await setupInstance(name, { log });
|
|
962
|
+
return { alreadyRunning: false, pid: healed.pid, config: healed.config || cfg, healed: true };
|
|
963
|
+
}
|
|
964
|
+
throw err;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Full provisioning + start.
|
|
969
|
+
// already running & credentials OK → repair online (no restart)
|
|
970
|
+
// otherwise → 1.x: temporary unauthenticated server
|
|
971
|
+
// 2.x/3.x: start, then provision over the API
|
|
972
|
+
async function setupInstance(name, { log = noopLog } = {}) {
|
|
973
|
+
const cfg = await loadConfig(name);
|
|
974
|
+
await ensureDirs(cfg);
|
|
975
|
+
await installTools(cfg, log);
|
|
976
|
+
|
|
977
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
978
|
+
const running = pid != null && pidAlive(pid);
|
|
979
|
+
let creds = await resolveCreds(cfg, log);
|
|
980
|
+
|
|
981
|
+
if (running) {
|
|
982
|
+
if (await verifyAdmin(cfg, creds)) {
|
|
983
|
+
log('instance is running and credentials are valid — repairing online (no restart)');
|
|
984
|
+
creds = await provision(cfg, creds, { phase: 'online', log });
|
|
985
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
986
|
+
return { online: true, pid, config: cfg, credsPath: cfg.credsPath };
|
|
987
|
+
}
|
|
988
|
+
log('credentials were rejected — falling back to offline repair');
|
|
989
|
+
await stopDaemon(cfg, { log });
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (await checkPort(cfg.port, probeHost(cfg))) {
|
|
993
|
+
throw new Error(
|
|
994
|
+
`port ${cfg.port} is occupied by a process this tool does not manage — stop it first, or change the port`,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// InfluxDB 1.x users can only be created while authentication is off, so the
|
|
999
|
+
// instance is provisioned through a short-lived unauthenticated server bound
|
|
1000
|
+
// to 127.0.0.1 and then restarted with auth enabled.
|
|
1001
|
+
if (cfg.flavor === 'v1') {
|
|
1002
|
+
log(`starting temporary ${cfg.serverBin} (no auth) on 127.0.0.1:${cfg.port}…`);
|
|
1003
|
+
const bootPid = await spawnDaemon(cfg, { boot: true, log });
|
|
1004
|
+
try {
|
|
1005
|
+
if (!(await waitReady(cfg, { boot: true, timeoutMs: 30000 }))) {
|
|
1006
|
+
throw new Error(`temporary ${cfg.serverBin} did not become ready`);
|
|
1007
|
+
}
|
|
1008
|
+
creds = await provision(cfg, creds, { phase: 'bootstrap', log });
|
|
1009
|
+
} finally {
|
|
1010
|
+
if (bootPid && pidAlive(bootPid)) {
|
|
1011
|
+
log(`stopping temporary ${cfg.serverBin} (pid ${bootPid})…`);
|
|
1012
|
+
await stopPid(bootPid, { log, name: cfg.serverBin });
|
|
1013
|
+
}
|
|
1014
|
+
await fs.rm(cfg.bootPidPath, { force: true }).catch(() => {});
|
|
1015
|
+
if (!(await waitPortFree(cfg))) {
|
|
1016
|
+
throw new Error(`port ${cfg.port} is still occupied after stopping the temporary server`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const finalPid = await spawnDaemon(cfg, { log });
|
|
1022
|
+
if (!(await waitReady(cfg, { timeoutMs: 30000 }))) {
|
|
1023
|
+
const tail = await logTail(cfg.logPath, 15);
|
|
1024
|
+
throw new Error(`${cfg.serverBin} did not become ready on ${httpBase(cfg)}${tail ? `\n ${path.basename(cfg.logPath)} tail:\n${tail}` : ''}`);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (cfg.flavor !== 'v1') {
|
|
1028
|
+
creds = await provision(cfg, creds, { phase: 'persistent', log });
|
|
1029
|
+
}
|
|
1030
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
1031
|
+
|
|
1032
|
+
if (!(await verifyAdmin(cfg, creds))) {
|
|
1033
|
+
const tail = await logTail(cfg.logPath, 15);
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
`${cfg.serverBin} did not accept the admin credentials on ${httpBase(cfg)}${tail ? `\n ${path.basename(cfg.logPath)} tail:\n${tail}` : ''}`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
return { online: false, pid: finalPid, config: cfg, credsPath: cfg.credsPath };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function stopInstance(name, { force = false, log = noopLog } = {}) {
|
|
1042
|
+
const cfg = await loadConfig(name);
|
|
1043
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1044
|
+
if (!pid || !pidAlive(pid)) {
|
|
1045
|
+
return { alreadyStopped: true, config: cfg };
|
|
1046
|
+
}
|
|
1047
|
+
await stopPid(pid, { force, log, name: cfg.serverBin });
|
|
1048
|
+
await fs.rm(cfg.pidPath, { force: true }).catch(() => {});
|
|
1049
|
+
return { alreadyStopped: false, forced: force, config: cfg };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function restartInstance(name, { force = false, log = noopLog } = {}) {
|
|
1053
|
+
await stopInstance(name, { force, log });
|
|
1054
|
+
return startInstance(name, { log });
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// First free port at or after `start` (used for clones / create defaults).
|
|
1058
|
+
async function nextFreePort(start, host = '127.0.0.1', maxAttempts = 50) {
|
|
1059
|
+
for (let port = start; port < start + maxAttempts; port++) {
|
|
1060
|
+
if (!(await checkPort(port, host))) return port;
|
|
1061
|
+
}
|
|
1062
|
+
return start;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Rename an instance by moving its directory. The server must be stopped first
|
|
1066
|
+
// (pid/log/data paths live inside the directory).
|
|
1067
|
+
async function renameInstance(name, newName, { log = noopLog } = {}) {
|
|
1068
|
+
if (!(await configMod.configExists(name))) throw new Error(`instance "${name}" not found`);
|
|
1069
|
+
configMod.assertValidInstanceName(newName);
|
|
1070
|
+
if (await configMod.configExists(newName)) throw new Error(`instance "${newName}" already exists`);
|
|
1071
|
+
const cfg = await loadConfig(name);
|
|
1072
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1073
|
+
if (pid && pidAlive(pid)) {
|
|
1074
|
+
throw new Error(`stop "${name}" first, then re-run the rename`);
|
|
1075
|
+
}
|
|
1076
|
+
await fs.rename(cfg.instanceDir, configMod.getInstanceDir(newName));
|
|
1077
|
+
log(`renamed "${name}" -> "${newName}"`);
|
|
1078
|
+
return newName;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Clone: copies config + credentials into a new instance directory (no data).
|
|
1082
|
+
// The clone is stopped; run setup to provision it.
|
|
1083
|
+
async function cloneInstance(name, newName, { port, log = noopLog } = {}) {
|
|
1084
|
+
if (!(await configMod.configExists(name))) throw new Error(`instance "${name}" not found`);
|
|
1085
|
+
configMod.assertValidInstanceName(newName);
|
|
1086
|
+
if (await configMod.configExists(newName)) throw new Error(`instance "${newName}" already exists`);
|
|
1087
|
+
const stored = JSON.parse(await fs.readFile(configMod.getConfigPath(name), 'utf8'));
|
|
1088
|
+
const srcCfg = await loadConfig(name);
|
|
1089
|
+
const flavor = flavorOf(stored.version || srcCfg.version);
|
|
1090
|
+
const finalPort = port != null ? port : await nextFreePort((stored.port || srcCfg.port) + 1, srcCfg.host);
|
|
1091
|
+
await configMod.saveConfig(newName, { ...stored, port: finalPort });
|
|
1092
|
+
await saveMiseToml(newName, stored.version || srcCfg.version);
|
|
1093
|
+
const creds = await readCreds(name, flavor.id);
|
|
1094
|
+
if (creds) await writeCreds(newName, flavor.id, creds, { ...srcCfg, database: stored.database || name });
|
|
1095
|
+
log(`cloned "${name}" -> "${newName}" (port ${finalPort})`);
|
|
1096
|
+
return { name: newName, port: finalPort, config: await loadConfig(newName) };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// --- Users ------------------------------------------------------------------
|
|
1100
|
+
|
|
1101
|
+
const V1_PRIVILEGES = ['read', 'write', 'all'];
|
|
1102
|
+
const USER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
1103
|
+
|
|
1104
|
+
function assertUserName(username) {
|
|
1105
|
+
if (typeof username !== 'string' || !USER_NAME.test(username)) {
|
|
1106
|
+
throw new Error(`invalid username "${username}" — 1-64 chars, letters/digits/dot/dash/underscore, start alnum`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function requireCapability(cfg, capability, what) {
|
|
1111
|
+
if (!cfg.capabilities[capability]) {
|
|
1112
|
+
throw new Error(`${cfg.flavorLabel} does not support ${what}`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function requireRunning(cfg) {
|
|
1117
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1118
|
+
if (!pid || !pidAlive(pid)) {
|
|
1119
|
+
throw new Error(`${cfg.serverBin} is not running — start it with "influx-local start ${cfg.name}", then re-run`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function listUsers(name) {
|
|
1124
|
+
const cfg = await loadConfig(name);
|
|
1125
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1126
|
+
const builtins = [];
|
|
1127
|
+
if (cfg.flavor === 'v1') {
|
|
1128
|
+
builtins.push(
|
|
1129
|
+
{ user: creds ? creds.ADMIN_USER : cfg.adminUser, db: 'admin', roles: ['all privileges'], builtin: true },
|
|
1130
|
+
{ user: creds ? creds.APP_USER : cfg.appUser, db: cfg.database, roles: ['all'], builtin: true },
|
|
1131
|
+
);
|
|
1132
|
+
} else if (cfg.flavor === 'v2') {
|
|
1133
|
+
builtins.push({ user: creds ? creds.ADMIN_USER : cfg.adminUser, db: cfg.org, roles: ['operator'], builtin: true });
|
|
1134
|
+
} else {
|
|
1135
|
+
builtins.push({ user: '_admin (token)', db: cfg.org, roles: ['admin token'], builtin: true });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1139
|
+
if (!pid || !pidAlive(pid) || !creds) return { running: false, users: builtins };
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
if (cfg.flavor === 'v1') {
|
|
1143
|
+
const users = await v1Show(cfg, 'SHOW USERS', { creds, admin: true });
|
|
1144
|
+
const merged = [];
|
|
1145
|
+
for (const user of users) {
|
|
1146
|
+
const grants = await v1GrantsFor(cfg, user, { creds, admin: true });
|
|
1147
|
+
const adminUser = user === creds.ADMIN_USER;
|
|
1148
|
+
const roleText = adminUser
|
|
1149
|
+
? 'all privileges'
|
|
1150
|
+
: (grants || []).map((g) => `${g.privilege} on ${g.database}`).join(', ') || '—';
|
|
1151
|
+
merged.push({
|
|
1152
|
+
user,
|
|
1153
|
+
db: adminUser ? 'admin' : (grants || []).map((g) => g.database).join(','),
|
|
1154
|
+
roles: [roleText],
|
|
1155
|
+
builtin: user === creds.ADMIN_USER || user === creds.APP_USER,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return { running: true, users: merged };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (cfg.flavor === 'v2') {
|
|
1162
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1163
|
+
'user', 'list', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN, '--json',
|
|
1164
|
+
]);
|
|
1165
|
+
if (res.code !== 0) {
|
|
1166
|
+
return { running: true, users: builtins, error: errText('failed to list users on the server', res) };
|
|
1167
|
+
}
|
|
1168
|
+
const users = JSON.parse(res.stdout.trim() || '[]');
|
|
1169
|
+
const merged = [...builtins];
|
|
1170
|
+
for (const u of users) {
|
|
1171
|
+
if (!merged.some((b) => b.user === u.name)) {
|
|
1172
|
+
merged.push({
|
|
1173
|
+
user: u.name,
|
|
1174
|
+
db: cfg.org,
|
|
1175
|
+
roles: [u.name === creds.ADMIN_USER ? 'operator' : 'member'],
|
|
1176
|
+
builtin: u.name === creds.ADMIN_USER,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return { running: true, users: merged };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// 3 Core: tokens, not users.
|
|
1184
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
1185
|
+
'show', 'tokens', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN, '--format', 'json',
|
|
1186
|
+
]);
|
|
1187
|
+
let tokens = [];
|
|
1188
|
+
if (res.code === 0) {
|
|
1189
|
+
try {
|
|
1190
|
+
const parsed = JSON.parse(res.stdout.trim() || '[]');
|
|
1191
|
+
const rows = Array.isArray(parsed) ? parsed : parsed.data || [];
|
|
1192
|
+
tokens = rows.map((row) => row.name).filter(Boolean);
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
tokens = [];
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
running: true,
|
|
1199
|
+
users: [
|
|
1200
|
+
...builtins,
|
|
1201
|
+
...tokens.filter((t) => t !== '_admin').map((t) => ({ user: t, db: '—', roles: ['token'], builtin: false })),
|
|
1202
|
+
],
|
|
1203
|
+
};
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
return { running: true, users: builtins, error: err.message };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async function addUser(name, { username, db = 'database', password, privileges, log = noopLog } = {}) {
|
|
1210
|
+
const cfg = await loadConfig(name);
|
|
1211
|
+
requireCapability(cfg, 'users', 'creating users');
|
|
1212
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1213
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1214
|
+
await requireRunning(cfg);
|
|
1215
|
+
assertUserName(username);
|
|
1216
|
+
if (typeof password !== 'string' || password.length < 6) throw new Error('password must be at least 6 characters');
|
|
1217
|
+
|
|
1218
|
+
if (cfg.flavor === 'v1') {
|
|
1219
|
+
// `db` arrives as the literal 'database' when --db is omitted.
|
|
1220
|
+
const requested = db === 'database' ? cfg.database : db;
|
|
1221
|
+
if (requested !== 'admin' && requested !== cfg.database) {
|
|
1222
|
+
throw new Error(`target must be "admin" or "${cfg.database}" (got "${db}")`);
|
|
1223
|
+
}
|
|
1224
|
+
const scope = requested === 'admin' ? 'admin' : cfg.database;
|
|
1225
|
+
const list = (privileges && privileges.length ? privileges : ['all']).map((p) => p.toLowerCase());
|
|
1226
|
+
for (const p of list) {
|
|
1227
|
+
if (!V1_PRIVILEGES.includes(p)) {
|
|
1228
|
+
throw new Error(`invalid privilege "${p}" — valid values: ${V1_PRIVILEGES.join(', ')}`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const opts = { creds, admin: true };
|
|
1232
|
+
if (await v1UserExists(cfg, username, opts)) {
|
|
1233
|
+
throw new Error(`user "${username}" already exists`);
|
|
1234
|
+
}
|
|
1235
|
+
const withAdmin = scope === 'admin' && list.includes('all');
|
|
1236
|
+
await v1Query(
|
|
1237
|
+
cfg,
|
|
1238
|
+
`CREATE USER ${ident(username)} WITH PASSWORD ${sqlString(password)}${withAdmin ? ' WITH ALL PRIVILEGES' : ''}`,
|
|
1239
|
+
opts,
|
|
1240
|
+
);
|
|
1241
|
+
if (!withAdmin) {
|
|
1242
|
+
for (const p of list) {
|
|
1243
|
+
await v1Query(cfg, `GRANT ${p.toUpperCase()} ON ${ident(scope)} TO ${ident(username)}`, opts);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
log(`created user "${username}" (${withAdmin ? 'all privileges' : `${list.join(', ')} on ${scope}`})`);
|
|
1247
|
+
return { user: username, db: scope, privileges: withAdmin ? ['all privileges'] : list };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// 2.x
|
|
1251
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1252
|
+
'user', 'create', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
1253
|
+
'--name', username, '--password', password, '--org', creds.ORG, '--json',
|
|
1254
|
+
]);
|
|
1255
|
+
if (res.code !== 0) throw new Error(errText(`failed to create user "${username}"`, res));
|
|
1256
|
+
log(`created user "${username}" on org "${creds.ORG}" (InfluxDB 2.x scopes access with tokens, not user roles)`);
|
|
1257
|
+
return { user: username, db: creds.ORG, privileges: ['member'] };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function setAnyPassword(name, { username, password, log = noopLog } = {}) {
|
|
1261
|
+
const cfg = await loadConfig(name);
|
|
1262
|
+
requireCapability(cfg, 'userPasswords', 'setting user passwords');
|
|
1263
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1264
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1265
|
+
await requireRunning(cfg);
|
|
1266
|
+
assertUserName(username);
|
|
1267
|
+
if (typeof password !== 'string' || password.length < 6) throw new Error('password must be at least 6 characters');
|
|
1268
|
+
|
|
1269
|
+
if (cfg.flavor === 'v1') {
|
|
1270
|
+
const opts = { creds, admin: true };
|
|
1271
|
+
if (!(await v1UserExists(cfg, username, opts))) throw new Error(`user "${username}" not found`);
|
|
1272
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(username)} = ${sqlString(password)}`, opts);
|
|
1273
|
+
} else {
|
|
1274
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1275
|
+
'user', 'password', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
1276
|
+
'--name', username, '--password', password,
|
|
1277
|
+
]);
|
|
1278
|
+
if (res.code !== 0) throw new Error(errText(`failed to update the password for "${username}"`, res));
|
|
1279
|
+
}
|
|
1280
|
+
log(`updated password for "${username}"`);
|
|
1281
|
+
return { user: username };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async function removeUser(name, { username, log = noopLog } = {}) {
|
|
1285
|
+
const cfg = await loadConfig(name);
|
|
1286
|
+
requireCapability(cfg, 'users', 'deleting users');
|
|
1287
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1288
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1289
|
+
await requireRunning(cfg);
|
|
1290
|
+
assertUserName(username);
|
|
1291
|
+
if (
|
|
1292
|
+
(cfg.flavor === 'v1' && (username === creds.ADMIN_USER || username === creds.APP_USER)) ||
|
|
1293
|
+
(cfg.flavor === 'v2' && username === creds.ADMIN_USER)
|
|
1294
|
+
) {
|
|
1295
|
+
throw new Error(`refusing to delete the built-in user "${username}" (it is stored in creds.env)`);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (cfg.flavor === 'v1') {
|
|
1299
|
+
const opts = { creds, admin: true };
|
|
1300
|
+
if (!(await v1UserExists(cfg, username, opts))) throw new Error(`user "${username}" not found`);
|
|
1301
|
+
await v1Query(cfg, `DROP USER ${ident(username)}`, opts);
|
|
1302
|
+
} else {
|
|
1303
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1304
|
+
'user', 'delete', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN, '--name', username,
|
|
1305
|
+
]);
|
|
1306
|
+
if (res.code !== 0) throw new Error(errText(`failed to delete user "${username}"`, res));
|
|
1307
|
+
}
|
|
1308
|
+
log(`deleted user "${username}"`);
|
|
1309
|
+
return { user: username };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// --- Status / logs / doctor -------------------------------------------------
|
|
1313
|
+
|
|
1314
|
+
function urisFor(cfg, creds) {
|
|
1315
|
+
if (!creds) return null;
|
|
1316
|
+
const host = cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
|
|
1317
|
+
const publicHost = cfg.publicHost;
|
|
1318
|
+
const uris = { endpoint: `http://${host}:${cfg.port}` };
|
|
1319
|
+
const build = (h) => {
|
|
1320
|
+
if (cfg.flavor === 'v1') {
|
|
1321
|
+
return {
|
|
1322
|
+
appUri: `influxdb://${encodeURIComponent(creds.APP_USER)}:${encodeURIComponent(creds.APP_PW)}@${h}:${cfg.port}/${cfg.database}`,
|
|
1323
|
+
adminUri: `influxdb://${encodeURIComponent(creds.ADMIN_USER)}:${encodeURIComponent(creds.ADMIN_PW)}@${h}:${cfg.port}`,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
if (cfg.flavor === 'v2') {
|
|
1327
|
+
const out = {
|
|
1328
|
+
adminUri: `influxdb2://${encodeURIComponent(creds.ADMIN_TOKEN)}@${h}:${cfg.port}?org=${encodeURIComponent(creds.ORG)}`,
|
|
1329
|
+
};
|
|
1330
|
+
// The app token only exists once setup has provisioned it.
|
|
1331
|
+
if (creds.APP_TOKEN) {
|
|
1332
|
+
out.appUri = `influxdb2://${encodeURIComponent(creds.APP_TOKEN)}@${h}:${cfg.port}?org=${encodeURIComponent(creds.ORG)}&bucket=${encodeURIComponent(cfg.database)}`;
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
appUri: `influxdb3://${encodeURIComponent(creds.ADMIN_TOKEN)}@${h}:${cfg.port}/${cfg.database}`,
|
|
1338
|
+
};
|
|
1339
|
+
};
|
|
1340
|
+
Object.assign(uris, build(host));
|
|
1341
|
+
if (publicHost) {
|
|
1342
|
+
const pub = build(publicHost);
|
|
1343
|
+
for (const [key, value] of Object.entries(pub)) {
|
|
1344
|
+
uris[`public${key[0].toUpperCase()}${key.slice(1)}`] = value;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return uris;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
async function serverVersion(cfg) {
|
|
1351
|
+
if (cfg.flavor === 'v1') {
|
|
1352
|
+
try {
|
|
1353
|
+
const res = await fetch(`${httpBase(cfg)}/ping`, { signal: AbortSignal.timeout(2000) });
|
|
1354
|
+
return res.headers.get('x-influxdb-version') || null;
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const res = await httpJson(`${httpBase(cfg)}/health`);
|
|
1360
|
+
if (res.json && res.json.version) return res.json.version;
|
|
1361
|
+
return cfg.version;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function measurements(cfg, creds) {
|
|
1365
|
+
try {
|
|
1366
|
+
if (cfg.flavor === 'v1') {
|
|
1367
|
+
return await v1Show(cfg, 'SHOW MEASUREMENTS', { creds, database: cfg.database });
|
|
1368
|
+
}
|
|
1369
|
+
if (cfg.flavor === 'v3') return await v3Tables(cfg, creds.ADMIN_TOKEN);
|
|
1370
|
+
|
|
1371
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1372
|
+
'query', '--host', httpBase(cfg), '--token', creds.APP_TOKEN, '--org', creds.ORG, '--raw',
|
|
1373
|
+
`import "influxdata/influxdb/schema"\nschema.measurements(bucket: ${JSON.stringify(cfg.database)})`,
|
|
1374
|
+
]);
|
|
1375
|
+
if (res.code !== 0) return [];
|
|
1376
|
+
return res.stdout
|
|
1377
|
+
.split(/\r?\n/)
|
|
1378
|
+
.map((line) => line.trim())
|
|
1379
|
+
.filter((line) => line && !line.startsWith('#') && !/^(,)?result/.test(line))
|
|
1380
|
+
.map((line) => line.split(',').pop())
|
|
1381
|
+
.filter((value) => value && value !== '_value');
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
async function getStatus(name, { detail = true } = {}) {
|
|
1388
|
+
const cfg = await loadConfig(name);
|
|
1389
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1390
|
+
const pidLive = pid != null && pidAlive(pid);
|
|
1391
|
+
const bound = await instancePortBound(cfg);
|
|
1392
|
+
const running = pidLive && bound;
|
|
1393
|
+
const state = running ? 'running' : bound ? 'port-conflict' : pidLive ? 'stale-pid' : 'stopped';
|
|
1394
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1395
|
+
|
|
1396
|
+
const status = {
|
|
1397
|
+
name: cfg.name,
|
|
1398
|
+
state,
|
|
1399
|
+
running,
|
|
1400
|
+
pid: running ? pid : null,
|
|
1401
|
+
stalePid: !running && pidLive ? pid : null,
|
|
1402
|
+
flavor: cfg.flavor,
|
|
1403
|
+
flavorLabel: cfg.flavorLabel,
|
|
1404
|
+
version: cfg.version,
|
|
1405
|
+
installedVersion: null,
|
|
1406
|
+
host: cfg.host,
|
|
1407
|
+
port: cfg.port,
|
|
1408
|
+
endpoint: `http://${cfg.host === '0.0.0.0' ? '127.0.0.1' : cfg.host}:${cfg.port}`,
|
|
1409
|
+
database: cfg.database,
|
|
1410
|
+
measurement: cfg.capabilities.measurement ? cfg.measurement : null,
|
|
1411
|
+
org: cfg.flavor === 'v2' ? cfg.org : null,
|
|
1412
|
+
adminUser: cfg.flavor === 'v3' ? null : (creds ? creds.ADMIN_USER : cfg.adminUser),
|
|
1413
|
+
appUser: cfg.flavor === 'v1' ? (creds ? creds.APP_USER : cfg.appUser) : null,
|
|
1414
|
+
credsFile: creds ? cfg.credsPath : null,
|
|
1415
|
+
dataDir: cfg.dataDir,
|
|
1416
|
+
logPath: cfg.logPath,
|
|
1417
|
+
runningVersion: null,
|
|
1418
|
+
measurements: null,
|
|
1419
|
+
uris: null,
|
|
1420
|
+
config: cfg,
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
if (running && creds) {
|
|
1424
|
+
status.uris = urisFor(cfg, creds);
|
|
1425
|
+
if (detail) {
|
|
1426
|
+
status.runningVersion = await serverVersion(cfg);
|
|
1427
|
+
status.measurements = await measurements(cfg, creds);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return status;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Rotate the managed secrets on the running server.
|
|
1434
|
+
// v1: SET PASSWORD for the admin/app users
|
|
1435
|
+
// v2: a fresh all-access admin token (+ operator password) and/or app token
|
|
1436
|
+
// v3: delete + re-create the admin token through a short unauthenticated window
|
|
1437
|
+
async function rotateInstance(name, { users = 'all', log = noopLog } = {}) {
|
|
1438
|
+
const cfg = await loadConfig(name);
|
|
1439
|
+
if (!['all', 'admin', 'app'].includes(users)) {
|
|
1440
|
+
throw new Error(`rotate target must be one of: all, admin, app (got "${users}")`);
|
|
1441
|
+
}
|
|
1442
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1443
|
+
if (!pid || !pidAlive(pid)) {
|
|
1444
|
+
throw new Error(`${cfg.serverBin} is not running — start it with "influx-local start ${name}", then re-run`);
|
|
1445
|
+
}
|
|
1446
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1447
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1448
|
+
|
|
1449
|
+
const doAdmin = users === 'all' || users === 'admin';
|
|
1450
|
+
const doApp = users === 'all' || users === 'app';
|
|
1451
|
+
|
|
1452
|
+
if (cfg.flavor === 'v1') {
|
|
1453
|
+
if (doAdmin) {
|
|
1454
|
+
const next = randomPassword();
|
|
1455
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(creds.ADMIN_USER)} = ${sqlString(next)}`, { creds, admin: true });
|
|
1456
|
+
creds.ADMIN_PW = next;
|
|
1457
|
+
}
|
|
1458
|
+
if (doApp) {
|
|
1459
|
+
const next = randomPassword();
|
|
1460
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(creds.APP_USER)} = ${sqlString(next)}`, { creds, admin: true });
|
|
1461
|
+
creds.APP_PW = next;
|
|
1462
|
+
}
|
|
1463
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
1464
|
+
log(`${[doAdmin && 'admin', doApp && 'app'].filter(Boolean).join(' + ')} password(s) rotated`);
|
|
1465
|
+
await verifyRotated(cfg, creds);
|
|
1466
|
+
return { changed: { admin: doAdmin, app: doApp } };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (cfg.flavor === 'v2') {
|
|
1470
|
+
if (doAdmin) {
|
|
1471
|
+
const nextPassword = randomPassword();
|
|
1472
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1473
|
+
'user', 'password', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
1474
|
+
'--name', creds.ADMIN_USER, '--password', nextPassword,
|
|
1475
|
+
]);
|
|
1476
|
+
if (res.code !== 0) throw new Error(errText('failed to rotate the operator password', res));
|
|
1477
|
+
|
|
1478
|
+
const created = await runBin(cfg, cfg.cliBin, [
|
|
1479
|
+
'auth', 'create', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN, '--org', creds.ORG,
|
|
1480
|
+
'--description', ADMIN_TOKEN_DESCRIPTION, '--all-access', '--json',
|
|
1481
|
+
]);
|
|
1482
|
+
if (created.code !== 0) throw new Error(errText('failed to create a replacement admin token', created));
|
|
1483
|
+
const parsed = JSON.parse(created.stdout.trim() || '{}');
|
|
1484
|
+
if (!parsed.token) throw new Error('the server did not return a replacement admin token');
|
|
1485
|
+
|
|
1486
|
+
const staleIds = ((await v2AuthList(cfg, creds.ADMIN_TOKEN)) || [])
|
|
1487
|
+
.filter((a) => a.token === creds.ADMIN_TOKEN)
|
|
1488
|
+
.map((a) => a.id);
|
|
1489
|
+
creds.ADMIN_PW = nextPassword;
|
|
1490
|
+
creds.ADMIN_TOKEN = parsed.token;
|
|
1491
|
+
for (const id of staleIds) await v2DeleteAuth(cfg, creds.ADMIN_TOKEN, id);
|
|
1492
|
+
}
|
|
1493
|
+
if (doApp) {
|
|
1494
|
+
const staleIds = ((await v2AuthList(cfg, creds.ADMIN_TOKEN)) || [])
|
|
1495
|
+
.filter((a) => a.description === APP_TOKEN_DESCRIPTION(cfg))
|
|
1496
|
+
.map((a) => a.id);
|
|
1497
|
+
const created = await v2CreateScopedToken(cfg, creds, {
|
|
1498
|
+
token: creds.ADMIN_TOKEN,
|
|
1499
|
+
description: APP_TOKEN_DESCRIPTION(cfg),
|
|
1500
|
+
log,
|
|
1501
|
+
});
|
|
1502
|
+
creds.APP_TOKEN = created.token;
|
|
1503
|
+
for (const id of staleIds) {
|
|
1504
|
+
if (id !== created.id) await v2DeleteAuth(cfg, creds.ADMIN_TOKEN, id);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
await writeCreds(cfg.name, cfg.flavor, creds, cfg);
|
|
1508
|
+
log(`${[doAdmin && 'admin', doApp && 'app'].filter(Boolean).join(' + ')} secret(s) rotated`);
|
|
1509
|
+
await verifyRotated(cfg, creds);
|
|
1510
|
+
return { changed: { admin: doAdmin, app: doApp } };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 3 Core: the only credential is the admin token. InfluxDB refuses to mint a
|
|
1514
|
+
// second "_admin" token, so the old one is removed through a short
|
|
1515
|
+
// unauthenticated window and a fresh one is created.
|
|
1516
|
+
throw new Error(
|
|
1517
|
+
'InfluxDB 3 Core cannot rotate its admin token in place (the server refuses to create a second "_admin" token).\n' +
|
|
1518
|
+
` Rotate by recreating the instance: "influx-local destroy ${name} && influx-local setup ${name}"`,
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function verifyRotated(cfg, creds) {
|
|
1523
|
+
if (!(await verifyAdmin(cfg, creds))) {
|
|
1524
|
+
throw new Error('the new credentials were not accepted — creds.env and the server are out of sync; re-run setup to repair');
|
|
1525
|
+
}
|
|
1526
|
+
if (cfg.flavor === 'v2' && creds.APP_TOKEN) {
|
|
1527
|
+
if (!(await v2AppTokenWorks(cfg, creds.APP_TOKEN, creds))) {
|
|
1528
|
+
throw new Error('the new app token was not accepted — creds.env and the server are out of sync; re-run setup to repair');
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Set an explicit password (v1/v2). v3 has no password at all.
|
|
1534
|
+
async function setPassword(name, { user = 'admin', password, log = noopLog } = {}) {
|
|
1535
|
+
const cfg = await loadConfig(name);
|
|
1536
|
+
if (cfg.flavor === 'v3') {
|
|
1537
|
+
throw new Error(`${cfg.flavorLabel} authenticates with tokens only — there is no password to set`);
|
|
1538
|
+
}
|
|
1539
|
+
if (!['admin', 'app'].includes(user)) throw new Error(`password target must be admin or app (got "${user}")`);
|
|
1540
|
+
if (cfg.flavor === 'v1' && user === 'app') {
|
|
1541
|
+
// handled below (app user exists in 1.x)
|
|
1542
|
+
} else if (cfg.flavor === 'v2' && user === 'app') {
|
|
1543
|
+
throw new Error('InfluxDB 2.x scopes access with tokens, not an app user — use "influx-local rotate --app" to replace the app token');
|
|
1544
|
+
}
|
|
1545
|
+
if (typeof password !== 'string' || password.length < 6) {
|
|
1546
|
+
throw new Error('password must be a string of at least 6 characters');
|
|
1547
|
+
}
|
|
1548
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1549
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1550
|
+
|
|
1551
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1552
|
+
const running = pid != null && pidAlive(pid);
|
|
1553
|
+
const field = user === 'admin' ? 'ADMIN_PW' : 'APP_PW';
|
|
1554
|
+
const next = { ...creds, [field]: password };
|
|
1555
|
+
|
|
1556
|
+
if (!running) {
|
|
1557
|
+
await writeCreds(cfg.name, cfg.flavor, next, cfg);
|
|
1558
|
+
log(`updated creds.env for "${user}" — server is stopped; run setup to apply it on the server`);
|
|
1559
|
+
return { user, applied: false };
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (cfg.flavor === 'v1') {
|
|
1563
|
+
const username = user === 'admin' ? creds.ADMIN_USER : creds.APP_USER;
|
|
1564
|
+
await v1Query(cfg, `SET PASSWORD FOR ${ident(username)} = ${sqlString(password)}`, { creds, admin: true });
|
|
1565
|
+
} else {
|
|
1566
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1567
|
+
'user', 'password', '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
1568
|
+
'--name', creds.ADMIN_USER, '--password', password,
|
|
1569
|
+
]);
|
|
1570
|
+
if (res.code !== 0) throw new Error(errText('failed to change the operator password', res));
|
|
1571
|
+
}
|
|
1572
|
+
await writeCreds(cfg.name, cfg.flavor, next, cfg);
|
|
1573
|
+
const refreshed = await readCreds(cfg.name, cfg.flavor);
|
|
1574
|
+
await verifyRotated(cfg, refreshed);
|
|
1575
|
+
log(`${user} password updated on the running server`);
|
|
1576
|
+
return { user, applied: true };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
async function destroyInstance(name, { log = noopLog } = {}) {
|
|
1580
|
+
const cfg = await loadConfig(name);
|
|
1581
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1582
|
+
if (pid && pidAlive(pid)) {
|
|
1583
|
+
await stopPid(pid, { log, name: cfg.serverBin });
|
|
1584
|
+
await fs.rm(cfg.pidPath, { force: true }).catch(() => {});
|
|
1585
|
+
}
|
|
1586
|
+
const bootPid = await readPidFile(cfg.bootPidPath);
|
|
1587
|
+
if (bootPid && pidAlive(bootPid)) {
|
|
1588
|
+
await stopPid(bootPid, { force: true, log, name: cfg.serverBin });
|
|
1589
|
+
}
|
|
1590
|
+
if (await checkPort(cfg.port, probeHost(cfg))) {
|
|
1591
|
+
throw new Error(`port ${cfg.port} is occupied by a process this tool does not manage — stop it first, then re-run`);
|
|
1592
|
+
}
|
|
1593
|
+
await configMod.deleteInstanceDir(name);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async function readLogTail(name, maxLines = 200) {
|
|
1597
|
+
const cfg = await loadConfig(name);
|
|
1598
|
+
const logPath = cfg.logPath;
|
|
1599
|
+
let stat;
|
|
1600
|
+
try {
|
|
1601
|
+
stat = await fs.stat(logPath);
|
|
1602
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
return { logPath, text: '', missing: true };
|
|
1605
|
+
}
|
|
1606
|
+
const chunkSize = Math.min(256 * 1024, stat.size);
|
|
1607
|
+
const handle = await fs.open(logPath, 'r');
|
|
1608
|
+
let text = '';
|
|
1609
|
+
try {
|
|
1610
|
+
const buf = Buffer.alloc(chunkSize);
|
|
1611
|
+
const { bytesRead } = await handle.read(buf, 0, chunkSize, Math.max(0, stat.size - chunkSize));
|
|
1612
|
+
text = buf.slice(0, bytesRead).toString('utf8');
|
|
1613
|
+
} finally {
|
|
1614
|
+
await handle.close();
|
|
1615
|
+
}
|
|
1616
|
+
const lines = text.split(/\r?\n/);
|
|
1617
|
+
return { logPath, text: lines.slice(-maxLines).join('\n'), missing: false };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
async function doctorInstance(name) {
|
|
1621
|
+
const cfg = await loadConfig(name);
|
|
1622
|
+
const checks = [];
|
|
1623
|
+
|
|
1624
|
+
const mise = await spawnOut(miseBin(), ['--version']).catch((err) => ({ code: 1, stderr: err.message }));
|
|
1625
|
+
checks.push({
|
|
1626
|
+
ok: mise.code === 0,
|
|
1627
|
+
label: `mise is available (${miseBin()})`,
|
|
1628
|
+
detail: mise.code === 0 ? String(mise.stdout || '').trim() : 'install mise first: https://mise.jdx.dev',
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
const artifacts = artifactsFor(cfg.version);
|
|
1633
|
+
checks.push({
|
|
1634
|
+
ok: true,
|
|
1635
|
+
label: `flavor / version`,
|
|
1636
|
+
detail: `${cfg.flavorLabel} ${cfg.version} — auth: ${cfg.auth}, artifacts: ${artifacts.map((a) => a.tool).join(', ')}`,
|
|
1637
|
+
});
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
checks.push({ ok: false, label: 'flavor / version', detail: err.message });
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
checks.push({ ok: true, label: 'instance configuration', detail: cfg.dataDir });
|
|
1643
|
+
|
|
1644
|
+
let installed = null;
|
|
1645
|
+
try {
|
|
1646
|
+
installed = await resolveBins(cfg, { require: false });
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
installed = {};
|
|
1649
|
+
}
|
|
1650
|
+
const serverBin = installed[cfg.serverBin];
|
|
1651
|
+
checks.push({
|
|
1652
|
+
ok: !!serverBin,
|
|
1653
|
+
label: `InfluxDB ${cfg.version} installed`,
|
|
1654
|
+
detail: serverBin || `missing — run "influx-local install ${name}"`,
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
const pid = await readPidFile(cfg.pidPath);
|
|
1658
|
+
checks.push({
|
|
1659
|
+
ok: !pid || pidAlive(pid),
|
|
1660
|
+
label: 'pid file state',
|
|
1661
|
+
detail: pid ? `pid ${pid} ${pidAlive(pid) ? 'alive' : 'STALE'}` : 'no pid file (stopped)',
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
const portInUse = await checkPort(cfg.port, probeHost(cfg));
|
|
1665
|
+
checks.push({
|
|
1666
|
+
ok: !portInUse || (pid != null && pidAlive(pid)),
|
|
1667
|
+
label: `port ${cfg.port}`,
|
|
1668
|
+
detail: portInUse ? 'in use' : 'free',
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1672
|
+
checks.push({
|
|
1673
|
+
ok: !!creds,
|
|
1674
|
+
label: 'credentials file',
|
|
1675
|
+
detail: creds
|
|
1676
|
+
? `found (${Object.keys(creds).filter((k) => !k.endsWith('_PW')).join(', ')})`
|
|
1677
|
+
: 'missing — run "influx-local setup" to create',
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
try {
|
|
1681
|
+
await fs.access(cfg.dataDir, fs.constants.W_OK);
|
|
1682
|
+
checks.push({ ok: true, label: 'data directory writable', detail: cfg.dataDir });
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
checks.push({ ok: false, label: 'data directory writable', detail: `${cfg.dataDir} (${err.code || err.message})` });
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
return checks;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Interactive shell: 1.x has a REPL, 2.x/3.x do not.
|
|
1691
|
+
async function shellInstance(name, { admin = false, args = [] } = {}) {
|
|
1692
|
+
const cfg = await loadConfig(name);
|
|
1693
|
+
requireCapability(cfg, 'shell', 'an interactive shell');
|
|
1694
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1695
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1696
|
+
const bins = await resolveBins(cfg);
|
|
1697
|
+
const argv = [...v1BaseArgs(cfg, { creds, admin, format: null, database: cfg.database }), ...args];
|
|
1698
|
+
const res = await new Promise((resolve, reject) => {
|
|
1699
|
+
const child = spawn(bins[cfg.cliBin], argv, { stdio: 'inherit', env: baseEnv() });
|
|
1700
|
+
child.on('error', reject);
|
|
1701
|
+
child.on('close', (code) => resolve(code));
|
|
1702
|
+
});
|
|
1703
|
+
return res;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// One-shot query in the instance's native language:
|
|
1707
|
+
// v1 → InfluxQL (influx -execute) v2 → Flux (influx query) v3 → SQL
|
|
1708
|
+
async function queryInstance(name, statement, { admin = false, format = 'pretty' } = {}) {
|
|
1709
|
+
const cfg = await loadConfig(name);
|
|
1710
|
+
const creds = await readCreds(cfg.name, cfg.flavor);
|
|
1711
|
+
if (!creds) throw new Error(`no credentials for "${name}" — run "influx-local setup ${name}" first`);
|
|
1712
|
+
await requireRunning(cfg);
|
|
1713
|
+
|
|
1714
|
+
if (cfg.flavor === 'v1') {
|
|
1715
|
+
const args = [...v1BaseArgs(cfg, { creds, admin, database: cfg.database }), '-execute', statement];
|
|
1716
|
+
const res = await runBin(cfg, cfg.cliBin, args);
|
|
1717
|
+
return { code: res.code, stdout: res.stdout, stderr: res.stderr };
|
|
1718
|
+
}
|
|
1719
|
+
if (cfg.flavor === 'v2') {
|
|
1720
|
+
const res = await runBin(cfg, cfg.cliBin, [
|
|
1721
|
+
'query', '--host', httpBase(cfg), '--token', admin ? creds.ADMIN_TOKEN : creds.APP_TOKEN,
|
|
1722
|
+
'--org', creds.ORG, '--raw', statement,
|
|
1723
|
+
]);
|
|
1724
|
+
return { code: res.code, stdout: res.stdout, stderr: res.stderr };
|
|
1725
|
+
}
|
|
1726
|
+
const res = await runBin(cfg, cfg.serverBin, [
|
|
1727
|
+
'query', '--database', cfg.database, '--host', httpBase(cfg), '--token', creds.ADMIN_TOKEN,
|
|
1728
|
+
'--format', format, statement,
|
|
1729
|
+
]);
|
|
1730
|
+
return { code: res.code, stdout: res.stdout, stderr: res.stderr };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// True when the instance's data dir already holds a server's files (used to
|
|
1734
|
+
// refuse switching an instance between incompatible InfluxDB lines).
|
|
1735
|
+
async function instanceHasData(cfg) {
|
|
1736
|
+
try {
|
|
1737
|
+
const entries = await fs.readdir(cfg.dataDir);
|
|
1738
|
+
return entries.length > 0;
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async function listStatuses() {
|
|
1745
|
+
const names = await configMod.listInstances();
|
|
1746
|
+
const out = [];
|
|
1747
|
+
for (const name of names) {
|
|
1748
|
+
const status = await getStatus(name, { detail: false });
|
|
1749
|
+
out.push({
|
|
1750
|
+
name,
|
|
1751
|
+
state: status.state,
|
|
1752
|
+
running: status.running,
|
|
1753
|
+
pid: status.pid,
|
|
1754
|
+
port: status.port,
|
|
1755
|
+
host: status.host,
|
|
1756
|
+
database: status.database,
|
|
1757
|
+
version: status.version,
|
|
1758
|
+
flavor: status.flavor,
|
|
1759
|
+
flavorLabel: status.flavorLabel,
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
return out;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
module.exports = {
|
|
1766
|
+
installTools,
|
|
1767
|
+
checkPort,
|
|
1768
|
+
nextFreePort,
|
|
1769
|
+
startInstance,
|
|
1770
|
+
setupInstance,
|
|
1771
|
+
stopInstance,
|
|
1772
|
+
restartInstance,
|
|
1773
|
+
getStatus,
|
|
1774
|
+
listStatuses,
|
|
1775
|
+
rotateInstance,
|
|
1776
|
+
setPassword,
|
|
1777
|
+
destroyInstance,
|
|
1778
|
+
renameInstance,
|
|
1779
|
+
cloneInstance,
|
|
1780
|
+
listUsers,
|
|
1781
|
+
addUser,
|
|
1782
|
+
setAnyPassword,
|
|
1783
|
+
removeUser,
|
|
1784
|
+
readLogTail,
|
|
1785
|
+
doctorInstance,
|
|
1786
|
+
shellInstance,
|
|
1787
|
+
queryInstance,
|
|
1788
|
+
resolveBins,
|
|
1789
|
+
verifyAdmin,
|
|
1790
|
+
instanceHasData,
|
|
1791
|
+
httpBase,
|
|
1792
|
+
probeHost,
|
|
1793
|
+
baseEnv,
|
|
1794
|
+
};
|