spindb 0.32.2 → 0.33.1

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.
@@ -0,0 +1,1227 @@
1
+ import { spawn, type SpawnOptions } from 'child_process'
2
+ import { existsSync } from 'fs'
3
+ import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
4
+ import { join } from 'path'
5
+ import { BaseEngine } from '../base-engine'
6
+ import { paths } from '../../config/paths'
7
+ import { getEngineDefaults } from '../../config/defaults'
8
+ import { platformService, isWindows } from '../../core/platform-service'
9
+ import { configManager } from '../../core/config-manager'
10
+ import { logDebug, logWarning } from '../../core/error-handler'
11
+ import { processManager } from '../../core/process-manager'
12
+ import { portManager } from '../../core/port-manager'
13
+ import { influxdbBinaryManager } from './binary-manager'
14
+ import { getBinaryUrl } from './binary-urls'
15
+ import { normalizeVersion, SUPPORTED_MAJOR_VERSIONS } from './version-maps'
16
+ import { fetchAvailableVersions as fetchHostdbVersions } from './hostdb-releases'
17
+ import {
18
+ detectBackupFormat as detectBackupFormatImpl,
19
+ restoreBackup,
20
+ } from './restore'
21
+ import { createBackup } from './backup'
22
+ import { influxdbApiRequest } from './api-client'
23
+ import {
24
+ type Platform,
25
+ type Arch,
26
+ type ContainerConfig,
27
+ type ProgressCallback,
28
+ type BackupFormat,
29
+ type BackupOptions,
30
+ type BackupResult,
31
+ type RestoreResult,
32
+ type DumpResult,
33
+ type StatusResult,
34
+ type QueryResult,
35
+ type QueryOptions,
36
+ } from '../../types'
37
+ import { parseRESTAPIResult } from '../../core/query-parser'
38
+
39
+ const ENGINE = 'influxdb'
40
+ const engineDef = getEngineDefaults(ENGINE)
41
+
42
+ /**
43
+ * Initial delay before checking if InfluxDB is ready after spawning.
44
+ * Windows requires a longer delay as process startup is slower.
45
+ */
46
+ const START_CHECK_DELAY_MS = isWindows() ? 2000 : 500
47
+
48
+ /**
49
+ * Parse an InfluxDB connection string
50
+ * Supported formats:
51
+ * - http://host:port
52
+ * - https://host:port
53
+ * - influxdb://host:port (converted to http)
54
+ */
55
+ function parseInfluxDBConnectionString(connectionString: string): {
56
+ baseUrl: string
57
+ headers: Record<string, string>
58
+ database?: string
59
+ } {
60
+ let url: URL
61
+ let scheme = 'http'
62
+
63
+ // Handle influxdb:// scheme by converting to http://
64
+ let normalized = connectionString.trim()
65
+ if (normalized.startsWith('influxdb://')) {
66
+ normalized = normalized.replace('influxdb://', 'http://')
67
+ }
68
+
69
+ // Ensure scheme is present
70
+ if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
71
+ normalized = `http://${normalized}`
72
+ }
73
+
74
+ try {
75
+ url = new URL(normalized)
76
+ scheme = url.protocol.replace(':', '')
77
+ } catch {
78
+ throw new Error(
79
+ `Invalid InfluxDB connection string: ${connectionString}\n` +
80
+ 'Expected format: http://host:port or influxdb://host:port',
81
+ )
82
+ }
83
+
84
+ // Extract token if provided
85
+ const token = url.searchParams.get('token')
86
+ const database = url.searchParams.get('db') || undefined
87
+
88
+ const headers: Record<string, string> = {
89
+ 'Content-Type': 'application/json',
90
+ }
91
+
92
+ if (token) {
93
+ headers['Authorization'] = `Bearer ${token}`
94
+ }
95
+
96
+ // Construct base URL without query params
97
+ const port = url.port || '8086'
98
+ const baseUrl = `${scheme}://${url.hostname}:${port}`
99
+
100
+ return { baseUrl, headers, database }
101
+ }
102
+
103
+ /**
104
+ * Make an HTTP request to a remote InfluxDB server
105
+ */
106
+ async function remoteInfluxDBRequest(
107
+ baseUrl: string,
108
+ method: string,
109
+ path: string,
110
+ headers: Record<string, string>,
111
+ body?: Record<string, unknown>,
112
+ timeoutMs = 30000,
113
+ ): Promise<{ status: number; data: unknown }> {
114
+ const url = `${baseUrl}${path}`
115
+
116
+ const controller = new AbortController()
117
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
118
+
119
+ const options: RequestInit = {
120
+ method,
121
+ headers,
122
+ signal: controller.signal,
123
+ }
124
+
125
+ if (body) {
126
+ options.body = JSON.stringify(body)
127
+ }
128
+
129
+ try {
130
+ const response = await fetch(url, options)
131
+
132
+ let data: unknown
133
+ const contentType = response.headers.get('content-type') || ''
134
+ if (contentType.includes('application/json')) {
135
+ data = await response.json()
136
+ } else {
137
+ data = await response.text()
138
+ }
139
+
140
+ return { status: response.status, data }
141
+ } catch (error) {
142
+ if (error instanceof Error && error.name === 'AbortError') {
143
+ throw new Error(
144
+ `Remote InfluxDB request timed out after ${timeoutMs / 1000}s: ${method} ${path}`,
145
+ )
146
+ }
147
+ throw error
148
+ } finally {
149
+ clearTimeout(timeoutId)
150
+ }
151
+ }
152
+
153
+ export class InfluxDBEngine extends BaseEngine {
154
+ name = ENGINE
155
+ displayName = 'InfluxDB'
156
+ defaultPort = engineDef.defaultPort
157
+ supportedVersions = SUPPORTED_MAJOR_VERSIONS
158
+
159
+ // Get platform info for binary operations
160
+ getPlatformInfo(): { platform: Platform; arch: Arch } {
161
+ return platformService.getPlatformInfo()
162
+ }
163
+
164
+ // Fetch available versions from hostdb
165
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
166
+ return fetchHostdbVersions()
167
+ }
168
+
169
+ // Get binary download URL from hostdb
170
+ getBinaryUrl(version: string, platform: Platform, arch: Arch): string {
171
+ return getBinaryUrl(version, platform, arch)
172
+ }
173
+
174
+ // Resolves version string to full version (e.g., '3' -> '3.8.0')
175
+ resolveFullVersion(version: string): string {
176
+ return normalizeVersion(version)
177
+ }
178
+
179
+ // Get the path where binaries for a version would be installed
180
+ getBinaryPath(version: string): string {
181
+ const fullVersion = this.resolveFullVersion(version)
182
+ const { platform: p, arch: a } = this.getPlatformInfo()
183
+ return paths.getBinaryPath({
184
+ engine: 'influxdb',
185
+ version: fullVersion,
186
+ platform: p,
187
+ arch: a,
188
+ })
189
+ }
190
+
191
+ // Verify that InfluxDB binaries are available
192
+ async verifyBinary(binPath: string): Promise<boolean> {
193
+ const ext = platformService.getExecutableExtension()
194
+ const serverPath = join(binPath, 'bin', `influxdb3${ext}`)
195
+ return existsSync(serverPath)
196
+ }
197
+
198
+ // Check if a specific InfluxDB version is installed
199
+ async isBinaryInstalled(version: string): Promise<boolean> {
200
+ const { platform, arch } = this.getPlatformInfo()
201
+ return influxdbBinaryManager.isInstalled(version, platform, arch)
202
+ }
203
+
204
+ /**
205
+ * Ensure InfluxDB binaries are available for a specific version
206
+ * Downloads from hostdb if not already installed
207
+ * Returns the path to the bin directory
208
+ */
209
+ async ensureBinaries(
210
+ version: string,
211
+ onProgress?: ProgressCallback,
212
+ ): Promise<string> {
213
+ const { platform, arch } = this.getPlatformInfo()
214
+
215
+ const binPath = await influxdbBinaryManager.ensureInstalled(
216
+ version,
217
+ platform,
218
+ arch,
219
+ onProgress,
220
+ )
221
+
222
+ // Register binaries in config
223
+ const ext = platformService.getExecutableExtension()
224
+ const tools = ['influxdb3'] as const
225
+
226
+ for (const tool of tools) {
227
+ const toolPath = join(binPath, 'bin', `${tool}${ext}`)
228
+ if (existsSync(toolPath)) {
229
+ await configManager.setBinaryPath(tool, toolPath, 'bundled')
230
+ }
231
+ }
232
+
233
+ return binPath
234
+ }
235
+
236
+ /**
237
+ * Initialize a new InfluxDB data directory
238
+ */
239
+ async initDataDir(
240
+ containerName: string,
241
+ _version: string,
242
+ _options: Record<string, unknown> = {},
243
+ ): Promise<string> {
244
+ const dataDir = paths.getContainerDataPath(containerName, {
245
+ engine: ENGINE,
246
+ })
247
+ const containerDir = paths.getContainerPath(containerName, {
248
+ engine: ENGINE,
249
+ })
250
+
251
+ // Create container directory if it doesn't exist
252
+ if (!existsSync(containerDir)) {
253
+ await mkdir(containerDir, { recursive: true })
254
+ }
255
+
256
+ // Create data directory if it doesn't exist
257
+ if (!existsSync(dataDir)) {
258
+ await mkdir(dataDir, { recursive: true })
259
+ logDebug(`Created InfluxDB data directory: ${dataDir}`)
260
+ }
261
+
262
+ return dataDir
263
+ }
264
+
265
+ // Get the path to influxdb3 server for a version
266
+ async getInfluxDBServerPath(version: string): Promise<string> {
267
+ const { platform, arch } = this.getPlatformInfo()
268
+ const fullVersion = normalizeVersion(version)
269
+ const binPath = paths.getBinaryPath({
270
+ engine: 'influxdb',
271
+ version: fullVersion,
272
+ platform,
273
+ arch,
274
+ })
275
+ const ext = platformService.getExecutableExtension()
276
+ const serverPath = join(binPath, 'bin', `influxdb3${ext}`)
277
+ if (existsSync(serverPath)) {
278
+ return serverPath
279
+ }
280
+ throw new Error(
281
+ `InfluxDB ${version} is not installed. Run: spindb engines download influxdb ${version}`,
282
+ )
283
+ }
284
+
285
+ // Get the path to influxdb3 binary
286
+ async getInfluxDBPath(version?: string): Promise<string> {
287
+ // Check config cache first
288
+ const cached = await configManager.getBinaryPath('influxdb3')
289
+ if (cached && existsSync(cached)) {
290
+ return cached
291
+ }
292
+
293
+ // If version provided, use downloaded binary
294
+ if (version) {
295
+ const { platform, arch } = this.getPlatformInfo()
296
+ const fullVersion = normalizeVersion(version)
297
+ const binPath = paths.getBinaryPath({
298
+ engine: 'influxdb',
299
+ version: fullVersion,
300
+ platform,
301
+ arch,
302
+ })
303
+ const ext = platformService.getExecutableExtension()
304
+ const influxdbPath = join(binPath, 'bin', `influxdb3${ext}`)
305
+ if (existsSync(influxdbPath)) {
306
+ return influxdbPath
307
+ }
308
+ }
309
+
310
+ throw new Error(
311
+ 'influxdb3 not found. Run: spindb engines download influxdb <version>',
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Start InfluxDB server
317
+ * CLI: influxdb3 serve --data-dir /path/to/data --http-bind 127.0.0.1:PORT
318
+ */
319
+ async start(
320
+ container: ContainerConfig,
321
+ onProgress?: ProgressCallback,
322
+ ): Promise<{ port: number; connectionString: string }> {
323
+ const { name, port, version, binaryPath } = container
324
+
325
+ // Check if already running
326
+ const alreadyRunning = await processManager.isRunning(name, {
327
+ engine: ENGINE,
328
+ })
329
+ if (alreadyRunning) {
330
+ return {
331
+ port,
332
+ connectionString: this.getConnectionString(container),
333
+ }
334
+ }
335
+
336
+ // Use stored binary path if available
337
+ let influxdbServer: string | null = null
338
+
339
+ if (binaryPath && existsSync(binaryPath)) {
340
+ const ext = platformService.getExecutableExtension()
341
+ const serverPath = join(binaryPath, 'bin', `influxdb3${ext}`)
342
+ if (existsSync(serverPath)) {
343
+ influxdbServer = serverPath
344
+ logDebug(`Using stored binary path: ${influxdbServer}`)
345
+ }
346
+ }
347
+
348
+ // Fall back to normal path
349
+ if (!influxdbServer) {
350
+ try {
351
+ influxdbServer = await this.getInfluxDBServerPath(version)
352
+ } catch (error) {
353
+ const originalMessage =
354
+ error instanceof Error ? error.message : String(error)
355
+ throw new Error(
356
+ `InfluxDB ${version} is not installed. Run: spindb engines download influxdb ${version}\n` +
357
+ ` Original error: ${originalMessage}`,
358
+ )
359
+ }
360
+ }
361
+
362
+ logDebug(`Using influxdb3 for version ${version}: ${influxdbServer}`)
363
+
364
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
365
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
366
+ const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
367
+ const pidFile = join(containerDir, 'influxdb.pid')
368
+
369
+ // On Windows, wait longer for ports to be released
370
+ const portWaitTimeout = isWindows() ? 60000 : 0
371
+ const portCheckStart = Date.now()
372
+ const portCheckInterval = 1000
373
+
374
+ // Check if HTTP port is available
375
+ while (!(await portManager.isPortAvailable(port))) {
376
+ if (Date.now() - portCheckStart >= portWaitTimeout) {
377
+ throw new Error(`HTTP port ${port} is already in use.`)
378
+ }
379
+ logDebug(`Waiting for HTTP port ${port} to become available...`)
380
+ await new Promise((resolve) => setTimeout(resolve, portCheckInterval))
381
+ }
382
+
383
+ onProgress?.({ stage: 'starting', message: 'Starting InfluxDB...' })
384
+
385
+ // Build command arguments
386
+ // InfluxDB 3.x uses 'serve' subcommand with required --node-id
387
+ // Use fixed node-id so data persists across container renames
388
+ const args = [
389
+ 'serve',
390
+ '--node-id',
391
+ 'spindb',
392
+ '--object-store',
393
+ 'file',
394
+ '--data-dir',
395
+ dataDir,
396
+ '--http-bind',
397
+ `127.0.0.1:${port}`,
398
+ '--without-auth',
399
+ ]
400
+
401
+ logDebug(`Starting influxdb3 with args: ${args.join(' ')}`)
402
+
403
+ /**
404
+ * Check log file for startup errors
405
+ */
406
+ const checkLogForError = async (): Promise<string | null> => {
407
+ try {
408
+ const logContent = await readFile(logFile, 'utf-8')
409
+ const recentLog = logContent.slice(-2000) // Last 2KB
410
+
411
+ if (
412
+ recentLog.includes('Address already in use') ||
413
+ recentLog.includes('bind: Address already in use')
414
+ ) {
415
+ return `Port ${port} is already in use`
416
+ }
417
+ if (recentLog.includes('Failed to bind')) {
418
+ return `Port ${port} is already in use`
419
+ }
420
+ } catch {
421
+ // Log file might not exist yet
422
+ }
423
+ return null
424
+ }
425
+
426
+ // InfluxDB runs in foreground, so we need to spawn detached
427
+ if (isWindows()) {
428
+ return new Promise((resolve, reject) => {
429
+ const spawnOpts: SpawnOptions = {
430
+ cwd: containerDir,
431
+ stdio: ['ignore', 'pipe', 'pipe'],
432
+ detached: true,
433
+ windowsHide: true,
434
+ }
435
+
436
+ const proc = spawn(influxdbServer, args, spawnOpts)
437
+ let settled = false
438
+ let stderrOutput = ''
439
+ let stdoutOutput = ''
440
+
441
+ proc.on('error', (err) => {
442
+ if (settled) return
443
+ settled = true
444
+ reject(new Error(`Failed to spawn InfluxDB server: ${err.message}`))
445
+ })
446
+
447
+ proc.on('exit', (code, signal) => {
448
+ if (settled) return
449
+ settled = true
450
+ const reason = signal ? `signal ${signal}` : `code ${code}`
451
+ reject(
452
+ new Error(
453
+ `InfluxDB process exited unexpectedly (${reason}).\n` +
454
+ `Stderr: ${stderrOutput || '(none)'}\n` +
455
+ `Stdout: ${stdoutOutput || '(none)'}`,
456
+ ),
457
+ )
458
+ })
459
+
460
+ proc.stdout?.on('data', (data: Buffer) => {
461
+ const str = data.toString()
462
+ stdoutOutput += str
463
+ logDebug(`influxdb3 stdout: ${str}`)
464
+ })
465
+ proc.stderr?.on('data', (data: Buffer) => {
466
+ const str = data.toString()
467
+ stderrOutput += str
468
+ logDebug(`influxdb3 stderr: ${str}`)
469
+ })
470
+
471
+ proc.unref()
472
+
473
+ setTimeout(async () => {
474
+ if (settled) return
475
+
476
+ if (!proc.pid) {
477
+ settled = true
478
+ reject(
479
+ new Error('InfluxDB server process failed to start (no PID)'),
480
+ )
481
+ return
482
+ }
483
+
484
+ try {
485
+ await writeFile(pidFile, String(proc.pid))
486
+ } catch {
487
+ // Non-fatal
488
+ }
489
+
490
+ const ready = await this.waitForReady(port)
491
+ if (settled) return
492
+
493
+ if (ready) {
494
+ settled = true
495
+ resolve({
496
+ port,
497
+ connectionString: this.getConnectionString(container),
498
+ })
499
+ } else {
500
+ settled = true
501
+
502
+ // Clean up the orphaned detached process before rejecting
503
+ if (proc.pid && platformService.isProcessRunning(proc.pid)) {
504
+ try {
505
+ await platformService.terminateProcess(proc.pid, true)
506
+ } catch {
507
+ // Ignore cleanup errors
508
+ }
509
+ }
510
+
511
+ const portError = await checkLogForError()
512
+
513
+ const errorDetails = [
514
+ portError || 'InfluxDB failed to start within timeout.',
515
+ `Binary: ${influxdbServer}`,
516
+ `Log file: ${logFile}`,
517
+ stderrOutput ? `Stderr:\n${stderrOutput}` : '',
518
+ stdoutOutput ? `Stdout:\n${stdoutOutput}` : '',
519
+ ]
520
+ .filter(Boolean)
521
+ .join('\n')
522
+
523
+ reject(new Error(errorDetails))
524
+ }
525
+ }, START_CHECK_DELAY_MS)
526
+ })
527
+ }
528
+
529
+ // macOS/Linux: spawn with ignored stdio so Node.js can exit cleanly
530
+ const proc = spawn(influxdbServer, args, {
531
+ cwd: containerDir,
532
+ stdio: ['ignore', 'ignore', 'ignore'],
533
+ detached: true,
534
+ })
535
+ proc.unref()
536
+
537
+ if (!proc.pid) {
538
+ throw new Error('InfluxDB server process failed to start (no PID)')
539
+ }
540
+
541
+ try {
542
+ await writeFile(pidFile, String(proc.pid))
543
+ } catch {
544
+ // Non-fatal
545
+ }
546
+
547
+ // Wait for InfluxDB to be ready
548
+ const ready = await this.waitForReady(port)
549
+
550
+ if (ready) {
551
+ return {
552
+ port,
553
+ connectionString: this.getConnectionString(container),
554
+ }
555
+ }
556
+
557
+ // Clean up the orphaned detached process before throwing
558
+ if (proc.pid) {
559
+ try {
560
+ process.kill(-proc.pid, 'SIGTERM')
561
+ logDebug(`Killed process group ${proc.pid}`)
562
+ } catch {
563
+ try {
564
+ process.kill(proc.pid, 'SIGTERM')
565
+ logDebug(`Killed process ${proc.pid}`)
566
+ } catch {
567
+ // Ignore - process may have already exited
568
+ }
569
+ }
570
+ }
571
+
572
+ // Clean up PID file
573
+ if (existsSync(pidFile)) {
574
+ try {
575
+ await unlink(pidFile)
576
+ } catch {
577
+ // Non-fatal
578
+ }
579
+ }
580
+
581
+ const portError = await checkLogForError()
582
+
583
+ const errorDetails = [
584
+ portError || 'InfluxDB failed to start within timeout.',
585
+ `Binary: ${influxdbServer}`,
586
+ `Log file: ${logFile}`,
587
+ ]
588
+ .filter(Boolean)
589
+ .join('\n')
590
+
591
+ throw new Error(errorDetails)
592
+ }
593
+
594
+ // Wait for InfluxDB to be ready to accept connections
595
+ private async waitForReady(
596
+ port: number,
597
+ timeoutMs = 30000,
598
+ ): Promise<boolean> {
599
+ const startTime = Date.now()
600
+ const checkInterval = 500
601
+
602
+ while (Date.now() - startTime < timeoutMs) {
603
+ try {
604
+ // InfluxDB 3.x health check endpoint
605
+ const response = await influxdbApiRequest(port, 'GET', '/health')
606
+ if (response.status === 200) {
607
+ logDebug(`InfluxDB ready on port ${port}`)
608
+ return true
609
+ }
610
+ } catch {
611
+ // Connection failed, wait and retry
612
+ }
613
+ await new Promise((resolve) => setTimeout(resolve, checkInterval))
614
+ }
615
+
616
+ logDebug(`InfluxDB did not become ready within ${timeoutMs}ms`)
617
+ return false
618
+ }
619
+
620
+ /**
621
+ * Stop InfluxDB server
622
+ */
623
+ async stop(container: ContainerConfig): Promise<void> {
624
+ const { name, port } = container
625
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
626
+ const pidFile = join(containerDir, 'influxdb.pid')
627
+
628
+ logDebug(`Stopping InfluxDB container "${name}" on port ${port}`)
629
+
630
+ // Get PID and terminate
631
+ let pid: number | null = null
632
+
633
+ if (existsSync(pidFile)) {
634
+ try {
635
+ const content = await readFile(pidFile, 'utf8')
636
+ pid = parseInt(content.trim(), 10)
637
+ } catch {
638
+ // Ignore
639
+ }
640
+ }
641
+
642
+ // Kill process if running
643
+ if (pid && platformService.isProcessRunning(pid)) {
644
+ logDebug(`Killing InfluxDB process ${pid}`)
645
+ try {
646
+ if (isWindows()) {
647
+ await platformService.terminateProcess(pid, true)
648
+ } else {
649
+ await platformService.terminateProcess(pid, false)
650
+ await new Promise((resolve) => setTimeout(resolve, 2000))
651
+
652
+ if (platformService.isProcessRunning(pid)) {
653
+ logWarning(`Graceful termination failed, force killing ${pid}`)
654
+ await platformService.terminateProcess(pid, true)
655
+ }
656
+ }
657
+ } catch (error) {
658
+ logDebug(`Process termination error: ${error}`)
659
+ }
660
+ }
661
+
662
+ // Wait for process to fully terminate
663
+ if (isWindows()) {
664
+ await new Promise((resolve) => setTimeout(resolve, 3000))
665
+ }
666
+
667
+ // Kill any processes still listening on the port
668
+ const portPids = await platformService.findProcessByPort(port)
669
+ for (const portPid of portPids) {
670
+ if (platformService.isProcessRunning(portPid)) {
671
+ logDebug(`Killing process ${portPid} still on port ${port}`)
672
+ try {
673
+ await platformService.terminateProcess(portPid, true)
674
+ } catch {
675
+ // Ignore
676
+ }
677
+ }
678
+ }
679
+
680
+ // On Windows, wait again after killing port processes
681
+ if (isWindows() && portPids.length > 0) {
682
+ await new Promise((resolve) => setTimeout(resolve, 2000))
683
+ }
684
+
685
+ // Cleanup PID file
686
+ if (existsSync(pidFile)) {
687
+ try {
688
+ await unlink(pidFile)
689
+ } catch {
690
+ // Ignore
691
+ }
692
+ }
693
+
694
+ // On Windows, wait for ports to be released
695
+ if (isWindows()) {
696
+ logDebug(`Waiting for port ${port} to be released...`)
697
+ const portWaitStart = Date.now()
698
+ const portWaitTimeout = 30000
699
+ const checkInterval = 500
700
+
701
+ while (Date.now() - portWaitStart < portWaitTimeout) {
702
+ const httpAvailable = await portManager.isPortAvailable(port)
703
+
704
+ if (httpAvailable) {
705
+ logDebug('Port released successfully')
706
+ break
707
+ }
708
+ await new Promise((resolve) => setTimeout(resolve, checkInterval))
709
+ }
710
+ }
711
+
712
+ logDebug('InfluxDB stopped')
713
+ }
714
+
715
+ // Get InfluxDB server status
716
+ async status(container: ContainerConfig): Promise<StatusResult> {
717
+ const { name, port } = container
718
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
719
+ const pidFile = join(containerDir, 'influxdb.pid')
720
+
721
+ // Try health check via REST API
722
+ try {
723
+ const response = await influxdbApiRequest(port, 'GET', '/health')
724
+ if (response.status === 200) {
725
+ return { running: true, message: 'InfluxDB is running' }
726
+ }
727
+ } catch {
728
+ // Not responding, check PID
729
+ }
730
+
731
+ // Check PID file
732
+ if (existsSync(pidFile)) {
733
+ try {
734
+ const content = await readFile(pidFile, 'utf8')
735
+ const pid = parseInt(content.trim(), 10)
736
+ if (!isNaN(pid) && pid > 0 && platformService.isProcessRunning(pid)) {
737
+ return {
738
+ running: true,
739
+ message: `InfluxDB is running (PID: ${pid})`,
740
+ }
741
+ }
742
+ } catch {
743
+ // Ignore
744
+ }
745
+ }
746
+
747
+ return { running: false, message: 'InfluxDB is not running' }
748
+ }
749
+
750
+ // Detect backup format
751
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
752
+ return detectBackupFormatImpl(filePath)
753
+ }
754
+
755
+ /**
756
+ * Restore a backup
757
+ * InfluxDB can be running during SQL restore (via REST API)
758
+ */
759
+ async restore(
760
+ container: ContainerConfig,
761
+ backupPath: string,
762
+ _options: { database?: string; flush?: boolean } = {},
763
+ ): Promise<RestoreResult> {
764
+ const { name, port } = container
765
+ const database = _options.database || container.database
766
+
767
+ return restoreBackup(backupPath, {
768
+ containerName: name,
769
+ port,
770
+ database,
771
+ })
772
+ }
773
+
774
+ /**
775
+ * Get connection string
776
+ * Format: http://127.0.0.1:PORT
777
+ */
778
+ getConnectionString(container: ContainerConfig, _database?: string): string {
779
+ const { port } = container
780
+ return `http://127.0.0.1:${port}`
781
+ }
782
+
783
+ // Open HTTP API (InfluxDB uses REST API)
784
+ async connect(container: ContainerConfig, _database?: string): Promise<void> {
785
+ const { port } = container
786
+ const url = `http://127.0.0.1:${port}`
787
+
788
+ console.log(`InfluxDB REST API available at: ${url}`)
789
+ console.log('')
790
+ console.log('Example commands:')
791
+ console.log(` curl ${url}/health`)
792
+ console.log(
793
+ ` curl -X POST ${url}/api/v3/query_sql -H "Content-Type: application/json" -d '{"db":"mydb","q":"SELECT 1"}'`,
794
+ )
795
+ }
796
+
797
+ /**
798
+ * Create a new database
799
+ * InfluxDB 3.x creates databases implicitly on first write,
800
+ * but we can verify the server is running
801
+ */
802
+ async createDatabase(
803
+ container: ContainerConfig,
804
+ database: string,
805
+ ): Promise<void> {
806
+ const { port } = container
807
+
808
+ // InfluxDB 3.x creates databases implicitly when data is written
809
+ // Verify server is accessible and write a test record to create the database
810
+ const response = await influxdbApiRequest(
811
+ port,
812
+ 'POST',
813
+ `/api/v3/write_lp?db=${encodeURIComponent(database)}`,
814
+ undefined,
815
+ )
816
+
817
+ // A 204 or 200 means success, but we might also get a 2xx with empty body
818
+ // which is fine - database will be created on first write
819
+ if (response.status >= 400) {
820
+ logDebug(
821
+ `Database creation note: ${JSON.stringify(response.data)}. Database will be created on first write.`,
822
+ )
823
+ }
824
+
825
+ logDebug(`InfluxDB database "${database}" ready (created on first write)`)
826
+ }
827
+
828
+ /**
829
+ * Drop a database
830
+ * InfluxDB 3.x doesn't have a direct DROP DATABASE command via REST
831
+ */
832
+ async dropDatabase(
833
+ container: ContainerConfig,
834
+ database: string,
835
+ ): Promise<void> {
836
+ const { port } = container
837
+
838
+ // Try to delete tables in the database
839
+ const tablesResponse = await influxdbApiRequest(
840
+ port,
841
+ 'POST',
842
+ '/api/v3/query_sql',
843
+ {
844
+ db: database,
845
+ q: 'SHOW TABLES',
846
+ format: 'json',
847
+ },
848
+ )
849
+
850
+ if (tablesResponse.status === 200) {
851
+ const tables = tablesResponse.data as Array<Record<string, unknown>>
852
+ if (Array.isArray(tables)) {
853
+ for (const row of tables) {
854
+ // Only drop user tables (iox schema), skip system/information_schema
855
+ const schema = row.table_schema as string | undefined
856
+ if (schema && schema !== 'iox') continue
857
+ const tableName =
858
+ (row.table_name as string) ||
859
+ (row.name as string) ||
860
+ (Object.values(row)[0] as string)
861
+ if (tableName) {
862
+ await influxdbApiRequest(port, 'POST', '/api/v3/query_sql', {
863
+ db: database,
864
+ q: `DROP TABLE "${tableName}"`,
865
+ format: 'json',
866
+ })
867
+ }
868
+ }
869
+ }
870
+ }
871
+
872
+ logDebug(`Dropped tables in InfluxDB database: ${database}`)
873
+ }
874
+
875
+ /**
876
+ * Get the storage size of the InfluxDB instance
877
+ */
878
+ async getDatabaseSize(_container: ContainerConfig): Promise<number | null> {
879
+ // InfluxDB 3.x doesn't have a direct size endpoint
880
+ // Return null to use filesystem-based calculation
881
+ return null
882
+ }
883
+
884
+ /**
885
+ * Dump from a remote InfluxDB connection
886
+ * Uses InfluxDB's REST API to query tables and export data as SQL
887
+ */
888
+ async dumpFromConnectionString(
889
+ connectionString: string,
890
+ outputPath: string,
891
+ ): Promise<DumpResult> {
892
+ const { baseUrl, headers, database } =
893
+ parseInfluxDBConnectionString(connectionString)
894
+
895
+ logDebug(`Connecting to remote InfluxDB at ${baseUrl}`)
896
+
897
+ // Check connectivity
898
+ const healthResponse = await remoteInfluxDBRequest(
899
+ baseUrl,
900
+ 'GET',
901
+ '/health',
902
+ headers,
903
+ )
904
+ if (healthResponse.status !== 200) {
905
+ throw new Error(
906
+ `Failed to connect to InfluxDB at ${baseUrl}: ${JSON.stringify(healthResponse.data)}`,
907
+ )
908
+ }
909
+
910
+ const db = database || 'mydb'
911
+ const warnings: string[] = []
912
+
913
+ // Get list of tables
914
+ const tablesResponse = await remoteInfluxDBRequest(
915
+ baseUrl,
916
+ 'POST',
917
+ '/api/v3/query_sql',
918
+ headers,
919
+ { db, q: 'SHOW TABLES', format: 'json' },
920
+ )
921
+
922
+ const tablesData = tablesResponse.data as Array<Record<string, unknown>>
923
+ const tables: string[] = []
924
+ if (Array.isArray(tablesData)) {
925
+ for (const row of tablesData) {
926
+ const schema = row.table_schema as string | undefined
927
+ if (schema && schema !== 'iox') continue
928
+ const tableName =
929
+ (row.table_name as string) ||
930
+ (row.name as string) ||
931
+ (Object.values(row)[0] as string)
932
+ if (tableName) tables.push(tableName)
933
+ }
934
+ }
935
+
936
+ logDebug(`Found ${tables.length} tables on remote server`)
937
+
938
+ if (tables.length === 0) {
939
+ warnings.push(
940
+ `Remote InfluxDB instance has no tables in database "${db}"`,
941
+ )
942
+ }
943
+
944
+ // Build SQL dump (same format as local backup)
945
+ let sqlContent = `-- InfluxDB SQL Backup\n`
946
+ sqlContent += `-- Database: ${db}\n`
947
+ sqlContent += `-- Source: ${baseUrl}\n`
948
+ sqlContent += `-- Created: ${new Date().toISOString()}\n\n`
949
+
950
+ for (const table of tables) {
951
+ // Query column metadata for tag identification
952
+ const tagColumns: string[] = []
953
+ try {
954
+ const colResponse = await remoteInfluxDBRequest(
955
+ baseUrl,
956
+ 'POST',
957
+ '/api/v3/query_sql',
958
+ headers,
959
+ {
960
+ db,
961
+ q: `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${table.replace(/'/g, "''")}'`,
962
+ format: 'json',
963
+ },
964
+ )
965
+ if (colResponse.status === 200 && Array.isArray(colResponse.data)) {
966
+ for (const col of colResponse.data as Array<
967
+ Record<string, unknown>
968
+ >) {
969
+ if (String(col.data_type || '').includes('Dictionary')) {
970
+ tagColumns.push(String(col.column_name))
971
+ }
972
+ }
973
+ }
974
+ } catch {
975
+ logDebug(`Warning: Could not query column metadata for ${table}`)
976
+ }
977
+
978
+ // Query all data from the table
979
+ const dataResponse = await remoteInfluxDBRequest(
980
+ baseUrl,
981
+ 'POST',
982
+ '/api/v3/query_sql',
983
+ headers,
984
+ {
985
+ db,
986
+ q: `SELECT * FROM "${table.replace(/"/g, '""')}"`,
987
+ format: 'json',
988
+ },
989
+ )
990
+
991
+ if (dataResponse.status !== 200) {
992
+ const msg = `Failed to export table ${table}: ${JSON.stringify(dataResponse.data)}`
993
+ logDebug(`Warning: ${msg}`)
994
+ warnings.push(msg)
995
+ continue
996
+ }
997
+
998
+ const rows = dataResponse.data as Array<Record<string, unknown>>
999
+ if (Array.isArray(rows) && rows.length > 0) {
1000
+ sqlContent += `-- Table: ${table}\n`
1001
+ if (tagColumns.length > 0) {
1002
+ sqlContent += `-- Tags: ${tagColumns.join(', ')}\n`
1003
+ }
1004
+
1005
+ for (const row of rows) {
1006
+ const columns = Object.keys(row)
1007
+ const values = columns.map((col) => {
1008
+ const val = row[col]
1009
+ if (val === null || val === undefined) return 'NULL'
1010
+ if (typeof val === 'number') return String(val)
1011
+ if (typeof val === 'boolean') return val ? 'true' : 'false'
1012
+ return `'${String(val).replace(/'/g, "''")}'`
1013
+ })
1014
+ sqlContent += `INSERT INTO "${table.replace(/"/g, '""')}" (${columns.map((c) => `"${c.replace(/"/g, '""')}"`).join(', ')}) VALUES (${values.join(', ')});\n`
1015
+ }
1016
+ sqlContent += '\n'
1017
+ }
1018
+ }
1019
+
1020
+ // Write SQL content to file
1021
+ await writeFile(outputPath, sqlContent, 'utf-8')
1022
+
1023
+ return {
1024
+ filePath: outputPath,
1025
+ warnings,
1026
+ }
1027
+ }
1028
+
1029
+ // Create a backup
1030
+ async backup(
1031
+ container: ContainerConfig,
1032
+ outputPath: string,
1033
+ options: BackupOptions,
1034
+ ): Promise<BackupResult> {
1035
+ return createBackup(container, outputPath, options)
1036
+ }
1037
+
1038
+ // Run a command - InfluxDB uses REST API with SQL
1039
+ async runScript(
1040
+ container: ContainerConfig,
1041
+ options: { file?: string; sql?: string; database?: string },
1042
+ ): Promise<void> {
1043
+ const { port } = container
1044
+ const database = options.database || container.database
1045
+
1046
+ if (options.file) {
1047
+ // Read file content and execute as SQL
1048
+ const content = await readFile(options.file, 'utf-8')
1049
+ const statements = content
1050
+ .split('\n')
1051
+ .filter((line) => !line.startsWith('--') && line.trim().length > 0)
1052
+ .join('\n')
1053
+ .split(';')
1054
+ .map((s) => s.trim())
1055
+ .filter((s) => s.length > 0)
1056
+
1057
+ for (const sql of statements) {
1058
+ const response = await influxdbApiRequest(
1059
+ port,
1060
+ 'POST',
1061
+ '/api/v3/query_sql',
1062
+ {
1063
+ db: database,
1064
+ q: sql,
1065
+ format: 'json',
1066
+ },
1067
+ )
1068
+
1069
+ if (response.status >= 400) {
1070
+ throw new Error(
1071
+ `SQL error: ${JSON.stringify(response.data)}\nStatement: ${sql}`,
1072
+ )
1073
+ }
1074
+ }
1075
+ return
1076
+ }
1077
+
1078
+ if (options.sql) {
1079
+ const response = await influxdbApiRequest(
1080
+ port,
1081
+ 'POST',
1082
+ '/api/v3/query_sql',
1083
+ {
1084
+ db: database,
1085
+ q: options.sql,
1086
+ format: 'json',
1087
+ },
1088
+ )
1089
+
1090
+ if (response.status >= 400) {
1091
+ throw new Error(`SQL error: ${JSON.stringify(response.data)}`)
1092
+ }
1093
+
1094
+ if (response.data) {
1095
+ console.log(JSON.stringify(response.data, null, 2))
1096
+ }
1097
+ return
1098
+ }
1099
+
1100
+ throw new Error('Either file or sql option must be provided')
1101
+ }
1102
+
1103
+ /**
1104
+ * Execute a query via REST API
1105
+ *
1106
+ * Query format: SQL statement or METHOD /path [JSON body]
1107
+ * Examples:
1108
+ * SELECT * FROM cpu
1109
+ * GET /health
1110
+ * POST /api/v3/query_sql {"db": "mydb", "q": "SELECT 1"}
1111
+ */
1112
+ async executeQuery(
1113
+ container: ContainerConfig,
1114
+ query: string,
1115
+ options?: QueryOptions,
1116
+ ): Promise<QueryResult> {
1117
+ const { port } = container
1118
+ const database = options?.database || container.database
1119
+ const trimmed = query.trim()
1120
+
1121
+ // Check if this is a REST API-style query (starts with HTTP method)
1122
+ const httpMethods = ['GET', 'POST', 'PUT', 'DELETE']
1123
+ const firstWord = trimmed.split(/\s+/)[0].toUpperCase()
1124
+
1125
+ if (httpMethods.includes(firstWord)) {
1126
+ // Parse as REST API query: METHOD /path [body]
1127
+ const spaceIdx = trimmed.indexOf(' ')
1128
+ const method = (options?.method || firstWord) as
1129
+ | 'GET'
1130
+ | 'POST'
1131
+ | 'PUT'
1132
+ | 'DELETE'
1133
+ const rest = trimmed.substring(spaceIdx + 1).trim()
1134
+
1135
+ let path: string
1136
+ let body: Record<string, unknown> | undefined = options?.body
1137
+
1138
+ const bodyStart = rest.indexOf('{')
1139
+ if (bodyStart !== -1) {
1140
+ path = rest.substring(0, bodyStart).trim()
1141
+ if (!body) {
1142
+ try {
1143
+ body = JSON.parse(rest.substring(bodyStart)) as Record<
1144
+ string,
1145
+ unknown
1146
+ >
1147
+ } catch {
1148
+ throw new Error('Invalid JSON body in query')
1149
+ }
1150
+ }
1151
+ } else {
1152
+ path = rest
1153
+ }
1154
+
1155
+ if (!path.startsWith('/')) {
1156
+ path = '/' + path
1157
+ }
1158
+
1159
+ const response = await influxdbApiRequest(port, method, path, body)
1160
+
1161
+ if (response.status >= 400) {
1162
+ throw new Error(
1163
+ `InfluxDB API error (${response.status}): ${JSON.stringify(response.data)}`,
1164
+ )
1165
+ }
1166
+
1167
+ return parseRESTAPIResult(JSON.stringify(response.data))
1168
+ }
1169
+
1170
+ // Default: treat as SQL query
1171
+ const response = await influxdbApiRequest(
1172
+ port,
1173
+ 'POST',
1174
+ '/api/v3/query_sql',
1175
+ {
1176
+ db: database,
1177
+ q: trimmed,
1178
+ format: 'json',
1179
+ },
1180
+ )
1181
+
1182
+ if (response.status >= 400) {
1183
+ throw new Error(
1184
+ `InfluxDB SQL error (${response.status}): ${JSON.stringify(response.data)}`,
1185
+ )
1186
+ }
1187
+
1188
+ return parseRESTAPIResult(JSON.stringify(response.data))
1189
+ }
1190
+
1191
+ /**
1192
+ * List databases for InfluxDB.
1193
+ * InfluxDB 3.x uses GET /api/v3/configure/database?format=json
1194
+ */
1195
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1196
+ const { port } = container
1197
+
1198
+ try {
1199
+ const response = await influxdbApiRequest(
1200
+ port,
1201
+ 'GET',
1202
+ '/api/v3/configure/database?format=json',
1203
+ )
1204
+
1205
+ if (response.status === 200 && response.data) {
1206
+ const data = response.data as Array<Record<string, unknown>>
1207
+ if (Array.isArray(data)) {
1208
+ const databases = data
1209
+ .map((row) => {
1210
+ return (
1211
+ (row['iox::database'] as string) ||
1212
+ (row.name as string) ||
1213
+ (Object.values(row)[0] as string)
1214
+ )
1215
+ })
1216
+ .filter(Boolean)
1217
+ return databases.length > 0 ? databases : [container.database]
1218
+ }
1219
+ }
1220
+ return [container.database]
1221
+ } catch {
1222
+ return [container.database]
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ export const influxdbEngine = new InfluxDBEngine()