spindb 0.4.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -101
- package/cli/commands/clone.ts +3 -1
- package/cli/commands/connect.ts +54 -24
- package/cli/commands/create.ts +309 -189
- package/cli/commands/delete.ts +3 -1
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/list.ts +14 -3
- package/cli/commands/menu.ts +510 -198
- package/cli/commands/restore.ts +66 -43
- package/cli/commands/start.ts +50 -19
- package/cli/commands/stop.ts +3 -1
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +99 -34
- package/config/defaults.ts +40 -15
- package/config/engine-defaults.ts +107 -0
- package/config/os-dependencies.ts +119 -124
- package/config/paths.ts +82 -56
- package/core/binary-manager.ts +44 -6
- package/core/config-manager.ts +17 -5
- package/core/container-manager.ts +124 -60
- package/core/dependency-manager.ts +9 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +51 -32
- package/core/process-manager.ts +26 -8
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/index.ts +7 -2
- package/engines/mysql/binary-detection.ts +325 -0
- package/engines/mysql/index.ts +808 -0
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +17 -9
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +9 -3
- package/types/index.ts +29 -5
- package/cli/commands/postgres-tools.ts +0 -216
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL Engine implementation
|
|
3
|
+
* Manages MySQL database containers using system-installed MySQL binaries
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, exec } from 'child_process'
|
|
7
|
+
import { promisify } from 'util'
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { BaseEngine } from '../base-engine'
|
|
12
|
+
import { paths } from '../../config/paths'
|
|
13
|
+
import { getEngineDefaults } from '../../config/defaults'
|
|
14
|
+
import {
|
|
15
|
+
logDebug,
|
|
16
|
+
logWarning,
|
|
17
|
+
ErrorCodes,
|
|
18
|
+
SpinDBError,
|
|
19
|
+
} from '../../core/error-handler'
|
|
20
|
+
import {
|
|
21
|
+
getMysqldPath,
|
|
22
|
+
getMysqlClientPath,
|
|
23
|
+
getMysqladminPath,
|
|
24
|
+
getMysqldumpPath,
|
|
25
|
+
getMysqlInstallDbPath,
|
|
26
|
+
getMariadbInstallDbPath,
|
|
27
|
+
isMariaDB,
|
|
28
|
+
detectInstalledVersions,
|
|
29
|
+
getInstallInstructions,
|
|
30
|
+
} from './binary-detection'
|
|
31
|
+
import {
|
|
32
|
+
detectBackupFormat as detectBackupFormatImpl,
|
|
33
|
+
restoreBackup,
|
|
34
|
+
parseConnectionString,
|
|
35
|
+
} from './restore'
|
|
36
|
+
import type {
|
|
37
|
+
ContainerConfig,
|
|
38
|
+
ProgressCallback,
|
|
39
|
+
BackupFormat,
|
|
40
|
+
RestoreResult,
|
|
41
|
+
DumpResult,
|
|
42
|
+
StatusResult,
|
|
43
|
+
} from '../../types'
|
|
44
|
+
|
|
45
|
+
// Re-export modules for external access
|
|
46
|
+
export * from './version-validator'
|
|
47
|
+
export * from './restore'
|
|
48
|
+
|
|
49
|
+
const execAsync = promisify(exec)
|
|
50
|
+
|
|
51
|
+
const ENGINE = 'mysql'
|
|
52
|
+
const engineDef = getEngineDefaults(ENGINE)
|
|
53
|
+
|
|
54
|
+
export class MySQLEngine extends BaseEngine {
|
|
55
|
+
name = ENGINE
|
|
56
|
+
displayName = 'MySQL'
|
|
57
|
+
defaultPort = engineDef.defaultPort
|
|
58
|
+
supportedVersions = engineDef.supportedVersions
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetch available versions from system
|
|
62
|
+
* Unlike PostgreSQL which downloads binaries, MySQL uses system-installed versions
|
|
63
|
+
*/
|
|
64
|
+
async fetchAvailableVersions(): Promise<Record<string, string[]>> {
|
|
65
|
+
const installed = await detectInstalledVersions()
|
|
66
|
+
const versions: Record<string, string[]> = {}
|
|
67
|
+
|
|
68
|
+
for (const [major, full] of Object.entries(installed)) {
|
|
69
|
+
versions[major] = [full]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If no versions found, return supported versions as placeholders
|
|
73
|
+
if (Object.keys(versions).length === 0) {
|
|
74
|
+
for (const v of this.supportedVersions) {
|
|
75
|
+
versions[v] = [v]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return versions
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get binary download URL - not applicable for MySQL (uses system binaries)
|
|
84
|
+
*/
|
|
85
|
+
getBinaryUrl(_version: string, _platform: string, _arch: string): string {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'MySQL uses system-installed binaries. ' + getInstallInstructions(),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Verify that MySQL binaries are available
|
|
93
|
+
*/
|
|
94
|
+
async verifyBinary(_binPath: string): Promise<boolean> {
|
|
95
|
+
const mysqld = await getMysqldPath()
|
|
96
|
+
return mysqld !== null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if MySQL is installed
|
|
101
|
+
*/
|
|
102
|
+
async isBinaryInstalled(_version: string): Promise<boolean> {
|
|
103
|
+
const mysqld = await getMysqldPath()
|
|
104
|
+
return mysqld !== null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ensure MySQL binaries are available (just checks system installation)
|
|
109
|
+
*/
|
|
110
|
+
async ensureBinaries(
|
|
111
|
+
_version: string,
|
|
112
|
+
_onProgress?: ProgressCallback,
|
|
113
|
+
): Promise<string> {
|
|
114
|
+
const mysqld = await getMysqldPath()
|
|
115
|
+
if (!mysqld) {
|
|
116
|
+
throw new Error(getInstallInstructions())
|
|
117
|
+
}
|
|
118
|
+
return mysqld
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Initialize a new MySQL/MariaDB data directory
|
|
123
|
+
* MySQL: mysqld --initialize-insecure --datadir={dir}
|
|
124
|
+
* MariaDB: mysql_install_db --datadir={dir} --auth-root-authentication-method=normal
|
|
125
|
+
*/
|
|
126
|
+
async initDataDir(
|
|
127
|
+
containerName: string,
|
|
128
|
+
_version: string,
|
|
129
|
+
_options: Record<string, unknown> = {},
|
|
130
|
+
): Promise<string> {
|
|
131
|
+
const dataDir = paths.getContainerDataPath(containerName, {
|
|
132
|
+
engine: ENGINE,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Create data directory if it doesn't exist
|
|
136
|
+
if (!existsSync(dataDir)) {
|
|
137
|
+
await mkdir(dataDir, { recursive: true })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check if we're using MariaDB or MySQL
|
|
141
|
+
const usingMariaDB = await isMariaDB()
|
|
142
|
+
|
|
143
|
+
if (usingMariaDB) {
|
|
144
|
+
// MariaDB uses mysql_install_db or mariadb-install-db
|
|
145
|
+
const installDb =
|
|
146
|
+
(await getMariadbInstallDbPath()) || (await getMysqlInstallDbPath())
|
|
147
|
+
if (!installDb) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'MariaDB detected but mysql_install_db not found.\n' +
|
|
150
|
+
'Install MariaDB server package which includes the initialization script.',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// MariaDB initialization
|
|
155
|
+
// --auth-root-authentication-method=normal allows passwordless root login via socket
|
|
156
|
+
const args = [
|
|
157
|
+
`--datadir=${dataDir}`,
|
|
158
|
+
`--user=${process.env.USER || 'mysql'}`,
|
|
159
|
+
'--auth-root-authentication-method=normal',
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const proc = spawn(installDb, args, {
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
let stdout = ''
|
|
168
|
+
let stderr = ''
|
|
169
|
+
|
|
170
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
171
|
+
stdout += data.toString()
|
|
172
|
+
})
|
|
173
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
174
|
+
stderr += data.toString()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
proc.on('close', (code) => {
|
|
178
|
+
if (code === 0) {
|
|
179
|
+
resolve(dataDir)
|
|
180
|
+
} else {
|
|
181
|
+
reject(
|
|
182
|
+
new Error(
|
|
183
|
+
`MariaDB initialization failed with code ${code}: ${stderr || stdout}`,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
proc.on('error', reject)
|
|
190
|
+
})
|
|
191
|
+
} else {
|
|
192
|
+
// MySQL uses mysqld --initialize-insecure
|
|
193
|
+
const mysqld = await getMysqldPath()
|
|
194
|
+
if (!mysqld) {
|
|
195
|
+
throw new Error(getInstallInstructions())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MySQL initialization
|
|
199
|
+
// --initialize-insecure creates root user without password (for local dev)
|
|
200
|
+
const args = [
|
|
201
|
+
'--initialize-insecure',
|
|
202
|
+
`--datadir=${dataDir}`,
|
|
203
|
+
`--user=${process.env.USER || 'mysql'}`,
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const proc = spawn(mysqld, args, {
|
|
208
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
let stdout = ''
|
|
212
|
+
let stderr = ''
|
|
213
|
+
|
|
214
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
215
|
+
stdout += data.toString()
|
|
216
|
+
})
|
|
217
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
218
|
+
stderr += data.toString()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
proc.on('close', (code) => {
|
|
222
|
+
if (code === 0) {
|
|
223
|
+
resolve(dataDir)
|
|
224
|
+
} else {
|
|
225
|
+
reject(
|
|
226
|
+
new Error(
|
|
227
|
+
`MySQL initialization failed with code ${code}: ${stderr || stdout}`,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
proc.on('error', reject)
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Start MySQL server
|
|
240
|
+
* CLI wrapper: mysqld_safe --datadir={dir} --port={port} &
|
|
241
|
+
*/
|
|
242
|
+
async start(
|
|
243
|
+
container: ContainerConfig,
|
|
244
|
+
onProgress?: ProgressCallback,
|
|
245
|
+
): Promise<{ port: number; connectionString: string }> {
|
|
246
|
+
const { name, port } = container
|
|
247
|
+
|
|
248
|
+
const mysqld = await getMysqldPath()
|
|
249
|
+
if (!mysqld) {
|
|
250
|
+
throw new Error(getInstallInstructions())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
|
|
254
|
+
const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
|
|
255
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
256
|
+
const socketFile = join(
|
|
257
|
+
paths.getContainerPath(name, { engine: ENGINE }),
|
|
258
|
+
'mysql.sock',
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
onProgress?.({ stage: 'starting', message: 'Starting MySQL...' })
|
|
262
|
+
|
|
263
|
+
// Start mysqld directly in background
|
|
264
|
+
// Note: We use --initialize-insecure during init which creates root without password
|
|
265
|
+
// This allows passwordless local connections without --skip-grant-tables
|
|
266
|
+
// (--skip-grant-tables disables TCP networking in MySQL 8+)
|
|
267
|
+
const args = [
|
|
268
|
+
`--datadir=${dataDir}`,
|
|
269
|
+
`--port=${port}`,
|
|
270
|
+
`--socket=${socketFile}`,
|
|
271
|
+
`--pid-file=${pidFile}`,
|
|
272
|
+
`--log-error=${logFile}`,
|
|
273
|
+
'--bind-address=127.0.0.1',
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const proc = spawn(mysqld, args, {
|
|
278
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
279
|
+
detached: true,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
proc.unref()
|
|
283
|
+
|
|
284
|
+
// Give MySQL a moment to start
|
|
285
|
+
setTimeout(async () => {
|
|
286
|
+
// Write PID file manually since we're running detached
|
|
287
|
+
try {
|
|
288
|
+
await writeFile(pidFile, String(proc.pid))
|
|
289
|
+
} catch {
|
|
290
|
+
// PID file might be written by mysqld itself
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Wait for MySQL to be ready
|
|
294
|
+
let attempts = 0
|
|
295
|
+
const maxAttempts = 30
|
|
296
|
+
const checkInterval = 500
|
|
297
|
+
|
|
298
|
+
const checkReady = async () => {
|
|
299
|
+
attempts++
|
|
300
|
+
try {
|
|
301
|
+
const mysqladmin = await getMysqladminPath()
|
|
302
|
+
if (mysqladmin) {
|
|
303
|
+
await execAsync(
|
|
304
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
305
|
+
)
|
|
306
|
+
resolve({
|
|
307
|
+
port,
|
|
308
|
+
connectionString: this.getConnectionString(container),
|
|
309
|
+
})
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
if (attempts < maxAttempts) {
|
|
314
|
+
setTimeout(checkReady, checkInterval)
|
|
315
|
+
} else {
|
|
316
|
+
reject(new Error('MySQL failed to start within timeout'))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
checkReady()
|
|
322
|
+
}, 1000)
|
|
323
|
+
|
|
324
|
+
proc.on('error', reject)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Stop MySQL server
|
|
330
|
+
* CLI wrapper: mysqladmin -u root -P {port} shutdown
|
|
331
|
+
*/
|
|
332
|
+
async stop(container: ContainerConfig): Promise<void> {
|
|
333
|
+
const { name, port } = container
|
|
334
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
335
|
+
|
|
336
|
+
logDebug(`Stopping MySQL container "${name}" on port ${port}`)
|
|
337
|
+
|
|
338
|
+
// Step 1: Get PID with validation
|
|
339
|
+
const pid = await this.getValidatedPid(pidFile)
|
|
340
|
+
if (pid === null) {
|
|
341
|
+
// No valid PID file - check if process might still be running on port
|
|
342
|
+
logDebug('No valid PID, checking if MySQL is responding on port')
|
|
343
|
+
const mysqladmin = await getMysqladminPath()
|
|
344
|
+
if (mysqladmin) {
|
|
345
|
+
try {
|
|
346
|
+
await execAsync(
|
|
347
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
348
|
+
{ timeout: 2000 },
|
|
349
|
+
)
|
|
350
|
+
// MySQL is responding - try graceful shutdown even without PID
|
|
351
|
+
logWarning(`MySQL responding on port ${port} but no valid PID file`)
|
|
352
|
+
await this.gracefulShutdown(port)
|
|
353
|
+
} catch {
|
|
354
|
+
// MySQL not responding, nothing to stop
|
|
355
|
+
logDebug('MySQL not responding, nothing to stop')
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Step 2: Try graceful shutdown
|
|
362
|
+
const gracefulSuccess = await this.gracefulShutdown(port, pid)
|
|
363
|
+
if (gracefulSuccess) {
|
|
364
|
+
await this.cleanupPidFile(pidFile)
|
|
365
|
+
logDebug('MySQL stopped gracefully')
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Step 3: Force kill with escalation
|
|
370
|
+
await this.forceKillWithEscalation(pid, pidFile)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Get and validate PID from PID file
|
|
375
|
+
* Returns null if PID file doesn't exist, is corrupt, or references dead process
|
|
376
|
+
*/
|
|
377
|
+
private async getValidatedPid(pidFile: string): Promise<number | null> {
|
|
378
|
+
if (!existsSync(pidFile)) {
|
|
379
|
+
logDebug('PID file does not exist')
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const content = await readFile(pidFile, 'utf8')
|
|
385
|
+
const pid = parseInt(content.trim(), 10)
|
|
386
|
+
|
|
387
|
+
if (isNaN(pid) || pid <= 0) {
|
|
388
|
+
logWarning(`PID file contains invalid value: "${content.trim()}"`, {
|
|
389
|
+
code: ErrorCodes.PID_FILE_CORRUPT,
|
|
390
|
+
pidFile,
|
|
391
|
+
})
|
|
392
|
+
// Clean up corrupt PID file
|
|
393
|
+
await this.cleanupPidFile(pidFile)
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Verify process exists
|
|
398
|
+
try {
|
|
399
|
+
process.kill(pid, 0) // Signal 0 = check existence
|
|
400
|
+
logDebug(`Validated PID ${pid}`)
|
|
401
|
+
return pid
|
|
402
|
+
} catch {
|
|
403
|
+
logWarning(`PID file references non-existent process ${pid}`, {
|
|
404
|
+
code: ErrorCodes.PID_FILE_STALE,
|
|
405
|
+
pidFile,
|
|
406
|
+
})
|
|
407
|
+
// Clean up stale PID file
|
|
408
|
+
await this.cleanupPidFile(pidFile)
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const e = err as NodeJS.ErrnoException
|
|
413
|
+
if (e.code !== 'ENOENT') {
|
|
414
|
+
logWarning(`Failed to read PID file: ${e.message}`, {
|
|
415
|
+
pidFile,
|
|
416
|
+
errorCode: e.code,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Attempt graceful shutdown via mysqladmin
|
|
425
|
+
*/
|
|
426
|
+
private async gracefulShutdown(
|
|
427
|
+
port: number,
|
|
428
|
+
pid?: number,
|
|
429
|
+
timeoutMs = 10000,
|
|
430
|
+
): Promise<boolean> {
|
|
431
|
+
const mysqladmin = await getMysqladminPath()
|
|
432
|
+
|
|
433
|
+
if (mysqladmin) {
|
|
434
|
+
try {
|
|
435
|
+
logDebug('Attempting mysqladmin shutdown')
|
|
436
|
+
await execAsync(
|
|
437
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
|
|
438
|
+
{ timeout: 5000 },
|
|
439
|
+
)
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const e = err as Error
|
|
442
|
+
logDebug(`mysqladmin shutdown failed: ${e.message}`)
|
|
443
|
+
// Continue to wait for process to die or send SIGTERM
|
|
444
|
+
}
|
|
445
|
+
} else if (pid) {
|
|
446
|
+
// No mysqladmin available, send SIGTERM
|
|
447
|
+
logDebug('No mysqladmin available, sending SIGTERM')
|
|
448
|
+
try {
|
|
449
|
+
process.kill(pid, 'SIGTERM')
|
|
450
|
+
} catch {
|
|
451
|
+
// Process may already be dead
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Wait for process to terminate
|
|
457
|
+
if (pid) {
|
|
458
|
+
const startTime = Date.now()
|
|
459
|
+
const checkIntervalMs = 200
|
|
460
|
+
|
|
461
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
462
|
+
try {
|
|
463
|
+
process.kill(pid, 0)
|
|
464
|
+
await this.sleep(checkIntervalMs)
|
|
465
|
+
} catch {
|
|
466
|
+
// Process is gone
|
|
467
|
+
logDebug(`Process ${pid} terminated after graceful shutdown`)
|
|
468
|
+
return true
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
logDebug(`Graceful shutdown timed out after ${timeoutMs}ms`)
|
|
473
|
+
return false
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// No PID to check, assume success if mysqladmin didn't throw
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Force kill with signal escalation (SIGTERM -> SIGKILL)
|
|
482
|
+
*/
|
|
483
|
+
private async forceKillWithEscalation(
|
|
484
|
+
pid: number,
|
|
485
|
+
pidFile: string,
|
|
486
|
+
): Promise<void> {
|
|
487
|
+
logWarning(`Graceful shutdown failed, force killing process ${pid}`)
|
|
488
|
+
|
|
489
|
+
// Try SIGTERM first (if not already sent in graceful shutdown)
|
|
490
|
+
try {
|
|
491
|
+
process.kill(pid, 'SIGTERM')
|
|
492
|
+
await this.sleep(2000)
|
|
493
|
+
|
|
494
|
+
// Check if still running
|
|
495
|
+
try {
|
|
496
|
+
process.kill(pid, 0)
|
|
497
|
+
} catch {
|
|
498
|
+
// Process terminated
|
|
499
|
+
logDebug(`Process ${pid} terminated after SIGTERM`)
|
|
500
|
+
await this.cleanupPidFile(pidFile)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
} catch (err) {
|
|
504
|
+
const e = err as NodeJS.ErrnoException
|
|
505
|
+
if (e.code === 'ESRCH') {
|
|
506
|
+
// Process already dead
|
|
507
|
+
await this.cleanupPidFile(pidFile)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
logDebug(`SIGTERM failed: ${e.message}`)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Escalate to SIGKILL
|
|
514
|
+
logWarning(`SIGTERM failed, escalating to SIGKILL for process ${pid}`)
|
|
515
|
+
try {
|
|
516
|
+
process.kill(pid, 'SIGKILL')
|
|
517
|
+
await this.sleep(1000)
|
|
518
|
+
|
|
519
|
+
// Verify process is gone
|
|
520
|
+
try {
|
|
521
|
+
process.kill(pid, 0)
|
|
522
|
+
// Process still running after SIGKILL - this is unexpected
|
|
523
|
+
throw new SpinDBError(
|
|
524
|
+
ErrorCodes.PROCESS_STOP_TIMEOUT,
|
|
525
|
+
`Failed to stop MySQL process ${pid} even with SIGKILL`,
|
|
526
|
+
'error',
|
|
527
|
+
`Try manually killing the process: kill -9 ${pid}`,
|
|
528
|
+
)
|
|
529
|
+
} catch (checkErr) {
|
|
530
|
+
const checkE = checkErr as NodeJS.ErrnoException
|
|
531
|
+
if (checkE instanceof SpinDBError) throw checkE
|
|
532
|
+
// Process is gone (ESRCH)
|
|
533
|
+
logDebug(`Process ${pid} terminated after SIGKILL`)
|
|
534
|
+
await this.cleanupPidFile(pidFile)
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if (err instanceof SpinDBError) throw err
|
|
538
|
+
const e = err as NodeJS.ErrnoException
|
|
539
|
+
if (e.code === 'ESRCH') {
|
|
540
|
+
// Process already dead
|
|
541
|
+
await this.cleanupPidFile(pidFile)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
logDebug(`SIGKILL failed: ${e.message}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Clean up PID file
|
|
550
|
+
*/
|
|
551
|
+
private async cleanupPidFile(pidFile: string): Promise<void> {
|
|
552
|
+
try {
|
|
553
|
+
await unlink(pidFile)
|
|
554
|
+
logDebug('PID file cleaned up')
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const e = err as NodeJS.ErrnoException
|
|
557
|
+
if (e.code !== 'ENOENT') {
|
|
558
|
+
logDebug(`Failed to clean up PID file: ${e.message}`)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Sleep helper
|
|
565
|
+
*/
|
|
566
|
+
private sleep(ms: number): Promise<void> {
|
|
567
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Get MySQL server status
|
|
572
|
+
*/
|
|
573
|
+
async status(container: ContainerConfig): Promise<StatusResult> {
|
|
574
|
+
const { name, port } = container
|
|
575
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
576
|
+
|
|
577
|
+
// Check if PID file exists
|
|
578
|
+
if (!existsSync(pidFile)) {
|
|
579
|
+
return { running: false, message: 'MySQL is not running' }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Try to ping MySQL
|
|
583
|
+
const mysqladmin = await getMysqladminPath()
|
|
584
|
+
if (mysqladmin) {
|
|
585
|
+
try {
|
|
586
|
+
await execAsync(`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`)
|
|
587
|
+
return { running: true, message: 'MySQL is running' }
|
|
588
|
+
} catch {
|
|
589
|
+
return { running: false, message: 'MySQL is not responding' }
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Fall back to checking PID
|
|
594
|
+
try {
|
|
595
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
596
|
+
process.kill(pid, 0) // Check if process exists
|
|
597
|
+
return { running: true, message: `MySQL is running (PID: ${pid})` }
|
|
598
|
+
} catch {
|
|
599
|
+
return { running: false, message: 'MySQL is not running' }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Detect backup format
|
|
605
|
+
* Delegates to restore.ts module
|
|
606
|
+
*/
|
|
607
|
+
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
608
|
+
return detectBackupFormatImpl(filePath)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Restore a backup
|
|
613
|
+
* Delegates to restore.ts module with version validation
|
|
614
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
615
|
+
*/
|
|
616
|
+
async restore(
|
|
617
|
+
container: ContainerConfig,
|
|
618
|
+
backupPath: string,
|
|
619
|
+
options: Record<string, unknown> = {},
|
|
620
|
+
): Promise<RestoreResult> {
|
|
621
|
+
const { port } = container
|
|
622
|
+
const database = (options.database as string) || container.database
|
|
623
|
+
|
|
624
|
+
// Create the database if it doesn't exist
|
|
625
|
+
if (options.createDatabase !== false) {
|
|
626
|
+
await this.createDatabase(container, database)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Use the restore module with version validation
|
|
630
|
+
return restoreBackup(backupPath, {
|
|
631
|
+
port,
|
|
632
|
+
database,
|
|
633
|
+
user: engineDef.superuser,
|
|
634
|
+
createDatabase: false, // Already created above
|
|
635
|
+
validateVersion: options.validateVersion !== false,
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Get connection string
|
|
641
|
+
*/
|
|
642
|
+
getConnectionString(container: ContainerConfig, database?: string): string {
|
|
643
|
+
const { port } = container
|
|
644
|
+
const db = database || container.database || 'mysql'
|
|
645
|
+
return `mysql://${engineDef.superuser}@127.0.0.1:${port}/${db}`
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Open mysql interactive shell
|
|
650
|
+
* Spawn interactive: mysql -h 127.0.0.1 -P {port} -u root {db}
|
|
651
|
+
*/
|
|
652
|
+
async connect(container: ContainerConfig, database?: string): Promise<void> {
|
|
653
|
+
const { port } = container
|
|
654
|
+
const db = database || container.database || 'mysql'
|
|
655
|
+
|
|
656
|
+
const mysql = await getMysqlClientPath()
|
|
657
|
+
if (!mysql) {
|
|
658
|
+
throw new Error(
|
|
659
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
660
|
+
' macOS: brew install mysql-client\n' +
|
|
661
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
const proc = spawn(
|
|
667
|
+
mysql,
|
|
668
|
+
['-h', '127.0.0.1', '-P', String(port), '-u', engineDef.superuser, db],
|
|
669
|
+
{ stdio: 'inherit' },
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
proc.on('error', reject)
|
|
673
|
+
proc.on('close', () => resolve())
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Create a new database
|
|
679
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'CREATE DATABASE `{db}`'
|
|
680
|
+
*/
|
|
681
|
+
async createDatabase(
|
|
682
|
+
container: ContainerConfig,
|
|
683
|
+
database: string,
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
const { port } = container
|
|
686
|
+
|
|
687
|
+
const mysql = await getMysqlClientPath()
|
|
688
|
+
if (!mysql) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
691
|
+
' macOS: brew install mysql-client\n' +
|
|
692
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
// Use backticks for MySQL database names
|
|
698
|
+
await execAsync(
|
|
699
|
+
`"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'CREATE DATABASE IF NOT EXISTS \`${database}\`'`,
|
|
700
|
+
)
|
|
701
|
+
} catch (error) {
|
|
702
|
+
const err = error as Error
|
|
703
|
+
// Ignore "database exists" error
|
|
704
|
+
if (!err.message.includes('database exists')) {
|
|
705
|
+
throw error
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Drop a database
|
|
712
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'DROP DATABASE IF EXISTS `{db}`'
|
|
713
|
+
*/
|
|
714
|
+
async dropDatabase(
|
|
715
|
+
container: ContainerConfig,
|
|
716
|
+
database: string,
|
|
717
|
+
): Promise<void> {
|
|
718
|
+
const { port } = container
|
|
719
|
+
|
|
720
|
+
const mysql = await getMysqlClientPath()
|
|
721
|
+
if (!mysql) {
|
|
722
|
+
throw new Error('mysql client not found.')
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
await execAsync(
|
|
727
|
+
`"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'DROP DATABASE IF EXISTS \`${database}\`'`,
|
|
728
|
+
)
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const err = error as Error
|
|
731
|
+
if (!err.message.includes("database doesn't exist")) {
|
|
732
|
+
throw error
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Create a dump from a remote database using a connection string
|
|
739
|
+
* CLI wrapper: mysqldump -h {host} -P {port} -u {user} -p{pass} {db} > {file}
|
|
740
|
+
*/
|
|
741
|
+
async dumpFromConnectionString(
|
|
742
|
+
connectionString: string,
|
|
743
|
+
outputPath: string,
|
|
744
|
+
): Promise<DumpResult> {
|
|
745
|
+
const mysqldump = await getMysqldumpPath()
|
|
746
|
+
if (!mysqldump) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
'mysqldump not found. Install MySQL client tools:\n' +
|
|
749
|
+
' macOS: brew install mysql-client\n' +
|
|
750
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Parse MySQL connection string using restore module helper
|
|
755
|
+
const { host, port, user, password, database } =
|
|
756
|
+
parseConnectionString(connectionString)
|
|
757
|
+
|
|
758
|
+
const args = [
|
|
759
|
+
'-h',
|
|
760
|
+
host,
|
|
761
|
+
'-P',
|
|
762
|
+
port,
|
|
763
|
+
'-u',
|
|
764
|
+
user,
|
|
765
|
+
'--result-file',
|
|
766
|
+
outputPath,
|
|
767
|
+
]
|
|
768
|
+
|
|
769
|
+
if (password) {
|
|
770
|
+
args.push(`-p${password}`)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
args.push(database)
|
|
774
|
+
|
|
775
|
+
return new Promise((resolve, reject) => {
|
|
776
|
+
const proc = spawn(mysqldump, args, {
|
|
777
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
let stdout = ''
|
|
781
|
+
let stderr = ''
|
|
782
|
+
|
|
783
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
784
|
+
stdout += data.toString()
|
|
785
|
+
})
|
|
786
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
787
|
+
stderr += data.toString()
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
proc.on('error', reject)
|
|
791
|
+
|
|
792
|
+
proc.on('close', (code) => {
|
|
793
|
+
if (code === 0) {
|
|
794
|
+
resolve({
|
|
795
|
+
filePath: outputPath,
|
|
796
|
+
stdout,
|
|
797
|
+
stderr,
|
|
798
|
+
code,
|
|
799
|
+
})
|
|
800
|
+
} else {
|
|
801
|
+
reject(new Error(stderr || `mysqldump exited with code ${code}`))
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export const mysqlEngine = new MySQLEngine()
|