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.
- package/LICENSE +8 -0
- package/README.md +105 -857
- package/cli/commands/create.ts +5 -1
- package/cli/commands/engines.ts +78 -1
- package/cli/commands/menu/backup-handlers.ts +9 -0
- package/cli/commands/menu/container-handlers.ts +2 -0
- package/cli/commands/menu/engine-handlers.ts +4 -0
- package/cli/commands/menu/settings-handlers.ts +3 -0
- package/cli/commands/menu/shell-handlers.ts +43 -1
- package/cli/constants.ts +4 -0
- package/cli/helpers.ts +73 -0
- package/config/backup-formats.ts +14 -0
- package/config/engine-defaults.ts +13 -0
- package/config/engines.json +17 -0
- package/core/config-manager.ts +10 -0
- package/core/dependency-manager.ts +2 -0
- package/core/docker-exporter.ts +13 -0
- package/engines/index.ts +4 -0
- package/engines/influxdb/README.md +180 -0
- package/engines/influxdb/api-client.ts +64 -0
- package/engines/influxdb/backup.ts +160 -0
- package/engines/influxdb/binary-manager.ts +110 -0
- package/engines/influxdb/binary-urls.ts +69 -0
- package/engines/influxdb/hostdb-releases.ts +23 -0
- package/engines/influxdb/index.ts +1227 -0
- package/engines/influxdb/restore.ts +417 -0
- package/engines/influxdb/version-maps.ts +75 -0
- package/engines/influxdb/version-validator.ts +128 -0
- package/package.json +2 -1
- package/types/index.ts +9 -0
|
@@ -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()
|