spindb 0.31.4 → 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 +107 -826
- package/cli/commands/create.ts +5 -1
- package/cli/commands/engines.ts +256 -1
- package/cli/commands/menu/backup-handlers.ts +16 -0
- package/cli/commands/menu/container-handlers.ts +170 -17
- package/cli/commands/menu/engine-handlers.ts +6 -0
- package/cli/commands/menu/settings-handlers.ts +6 -0
- package/cli/commands/menu/shell-handlers.ts +74 -14
- package/cli/commands/menu/sql-handlers.ts +8 -50
- package/cli/commands/menu/validators.ts +8 -0
- package/cli/commands/users.ts +264 -0
- package/cli/constants.ts +8 -0
- package/cli/helpers.ts +140 -0
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +24 -20
- package/config/backup-formats.ts +28 -0
- package/config/engine-defaults.ts +26 -0
- package/config/engines-registry.ts +1 -0
- package/config/engines.json +50 -0
- package/config/engines.schema.json +6 -1
- package/core/base-binary-manager.ts +6 -1
- package/core/config-manager.ts +20 -0
- package/core/credential-manager.ts +257 -0
- package/core/dependency-manager.ts +5 -0
- package/core/docker-exporter.ts +30 -0
- package/core/error-handler.ts +19 -0
- package/engines/base-engine.ts +32 -1
- package/engines/clickhouse/index.ts +99 -3
- package/engines/cockroachdb/index.ts +69 -2
- package/engines/couchdb/index.ts +149 -1
- package/engines/ferretdb/README.md +4 -0
- package/engines/ferretdb/index.ts +342 -13
- package/engines/index.ts +8 -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/engines/mariadb/index.ts +96 -1
- package/engines/meilisearch/index.ts +97 -1
- package/engines/mongodb/index.ts +82 -0
- package/engines/mysql/index.ts +105 -1
- package/engines/postgresql/index.ts +92 -0
- package/engines/qdrant/index.ts +107 -2
- package/engines/redis/index.ts +106 -12
- package/engines/surrealdb/index.ts +102 -2
- package/engines/typedb/backup.ts +167 -0
- package/engines/typedb/binary-manager.ts +200 -0
- package/engines/typedb/binary-urls.ts +38 -0
- package/engines/typedb/cli-utils.ts +210 -0
- package/engines/typedb/hostdb-releases.ts +118 -0
- package/engines/typedb/index.ts +1275 -0
- package/engines/typedb/restore.ts +377 -0
- package/engines/typedb/version-maps.ts +48 -0
- package/engines/typedb/version-validator.ts +127 -0
- package/engines/valkey/index.ts +70 -2
- package/package.json +4 -1
- package/types/index.ts +37 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages saved database credentials on disk.
|
|
5
|
+
* Credentials are stored as .env files in the container's credentials/ directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { readFile, writeFile, readdir, mkdir, chmod } from 'fs/promises'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { paths } from '../config/paths'
|
|
12
|
+
import { Engine, type UserCredentials } from '../types'
|
|
13
|
+
import { isValidUsername } from './error-handler'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the credentials directory for a container.
|
|
17
|
+
*/
|
|
18
|
+
function getCredentialsDir(containerName: string, engine: Engine): string {
|
|
19
|
+
const containerPath = paths.getContainerPath(containerName, { engine })
|
|
20
|
+
return join(containerPath, 'credentials')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the credential file path for a specific username.
|
|
25
|
+
* Validates the username to prevent path traversal (same rules as assertValidUsername).
|
|
26
|
+
*/
|
|
27
|
+
function getCredentialFilePath(
|
|
28
|
+
containerName: string,
|
|
29
|
+
engine: Engine,
|
|
30
|
+
username: string,
|
|
31
|
+
): string {
|
|
32
|
+
if (!isValidUsername(username)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid username for credential file: "${username}". Must match ^[a-zA-Z][a-zA-Z0-9_]{0,62}$`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
return join(getCredentialsDir(containerName, engine), `.env.${username}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format credentials as .env file content.
|
|
42
|
+
*/
|
|
43
|
+
function encodeEnvValue(value: string): string {
|
|
44
|
+
if (/[\n\r=\\]/.test(value)) {
|
|
45
|
+
return JSON.stringify(value)
|
|
46
|
+
}
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decodeEnvValue(raw: string): string {
|
|
51
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(raw) as string
|
|
54
|
+
} catch {
|
|
55
|
+
return raw
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return raw
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatCredentials(credentials: UserCredentials): string {
|
|
62
|
+
const lines: string[] = []
|
|
63
|
+
|
|
64
|
+
if (credentials.apiKey) {
|
|
65
|
+
lines.push(`API_KEY_NAME=${encodeEnvValue(credentials.username)}`)
|
|
66
|
+
lines.push(`API_KEY=${encodeEnvValue(credentials.apiKey)}`)
|
|
67
|
+
lines.push(`API_URL=${encodeEnvValue(credentials.connectionString)}`)
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(`DB_USER=${encodeEnvValue(credentials.username)}`)
|
|
70
|
+
lines.push(`DB_PASSWORD=${encodeEnvValue(credentials.password)}`)
|
|
71
|
+
// Extract host and port from the connection string.
|
|
72
|
+
// Use URL parsing when possible; fall back to a regex targeting host:port.
|
|
73
|
+
let extractedHost: string | undefined
|
|
74
|
+
let extractedPort: string | undefined
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(credentials.connectionString)
|
|
77
|
+
if (url.hostname) {
|
|
78
|
+
extractedHost = url.hostname
|
|
79
|
+
}
|
|
80
|
+
if (url.port) {
|
|
81
|
+
extractedPort = url.port
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Not a valid URL (e.g. custom scheme). Use regex targeting host:port segment.
|
|
85
|
+
const hostPortMatch = credentials.connectionString.match(
|
|
86
|
+
/@(\[[^\]]+\]|[^:/?#]+):(\d+)(?:\/|$)/,
|
|
87
|
+
)
|
|
88
|
+
if (hostPortMatch) {
|
|
89
|
+
extractedHost = hostPortMatch[1].replace(/^\[|\]$/g, '')
|
|
90
|
+
extractedPort = hostPortMatch[2]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
lines.push(`DB_HOST=${extractedHost || '127.0.0.1'}`)
|
|
94
|
+
if (extractedPort) {
|
|
95
|
+
lines.push(`DB_PORT=${extractedPort}`)
|
|
96
|
+
}
|
|
97
|
+
if (credentials.database) {
|
|
98
|
+
lines.push(`DB_NAME=${encodeEnvValue(credentials.database)}`)
|
|
99
|
+
}
|
|
100
|
+
lines.push(`DB_URL=${encodeEnvValue(credentials.connectionString)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return lines.join('\n') + '\n'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a .env credential file back into UserCredentials.
|
|
108
|
+
*/
|
|
109
|
+
function parseCredentialFile(
|
|
110
|
+
content: string,
|
|
111
|
+
containerName: string,
|
|
112
|
+
engine: Engine,
|
|
113
|
+
): UserCredentials {
|
|
114
|
+
const vars: Record<string, string> = {}
|
|
115
|
+
for (const line of content.split('\n')) {
|
|
116
|
+
const trimmed = line.trim()
|
|
117
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
118
|
+
const eqIdx = trimmed.indexOf('=')
|
|
119
|
+
if (eqIdx === -1) continue
|
|
120
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
121
|
+
const rawValue = trimmed.slice(eqIdx + 1).trim()
|
|
122
|
+
vars[key] = decodeEnvValue(rawValue)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// API key credentials: password is intentionally empty (auth uses API key, not password)
|
|
126
|
+
if (vars.API_KEY) {
|
|
127
|
+
if (!vars.API_KEY_NAME || !vars.API_URL) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Corrupt credential file for container "${containerName}": missing API_KEY_NAME or API_URL`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
username: vars.API_KEY_NAME,
|
|
134
|
+
password: '', // API-based auth does not use a password
|
|
135
|
+
connectionString: vars.API_URL,
|
|
136
|
+
engine,
|
|
137
|
+
container: containerName,
|
|
138
|
+
apiKey: vars.API_KEY,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Empty string DB_PASSWORD is intentionally allowed (some DBs permit passwordless connections)
|
|
143
|
+
if (!vars.DB_USER || vars.DB_PASSWORD === undefined || !vars.DB_URL) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Corrupt credential file for container "${containerName}": missing DB_USER, DB_PASSWORD, or DB_URL`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
username: vars.DB_USER,
|
|
151
|
+
password: vars.DB_PASSWORD,
|
|
152
|
+
connectionString: vars.DB_URL,
|
|
153
|
+
engine,
|
|
154
|
+
container: containerName,
|
|
155
|
+
database: vars.DB_NAME,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Save credentials to disk as a .env file.
|
|
161
|
+
* Creates the credentials/ directory if it doesn't exist.
|
|
162
|
+
* @returns The path to the saved credential file.
|
|
163
|
+
*/
|
|
164
|
+
export async function saveCredentials(
|
|
165
|
+
containerName: string,
|
|
166
|
+
engine: Engine,
|
|
167
|
+
credentials: UserCredentials,
|
|
168
|
+
): Promise<string> {
|
|
169
|
+
const credDir = getCredentialsDir(containerName, engine)
|
|
170
|
+
if (!existsSync(credDir)) {
|
|
171
|
+
await mkdir(credDir, { recursive: true, mode: 0o700 })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const filePath = getCredentialFilePath(
|
|
175
|
+
containerName,
|
|
176
|
+
engine,
|
|
177
|
+
credentials.username,
|
|
178
|
+
)
|
|
179
|
+
await writeFile(filePath, formatCredentials(credentials), {
|
|
180
|
+
encoding: 'utf-8',
|
|
181
|
+
mode: 0o600,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// POSIX file permissions are no-ops on Windows
|
|
185
|
+
if (process.platform !== 'win32') {
|
|
186
|
+
await chmod(credDir, 0o700)
|
|
187
|
+
await chmod(filePath, 0o600)
|
|
188
|
+
}
|
|
189
|
+
return filePath
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Load credentials for a specific username from disk.
|
|
194
|
+
* Returns null if the credential file doesn't exist.
|
|
195
|
+
*/
|
|
196
|
+
export async function loadCredentials(
|
|
197
|
+
containerName: string,
|
|
198
|
+
engine: Engine,
|
|
199
|
+
username: string,
|
|
200
|
+
): Promise<UserCredentials | null> {
|
|
201
|
+
const filePath = getCredentialFilePath(containerName, engine, username)
|
|
202
|
+
try {
|
|
203
|
+
const content = await readFile(filePath, 'utf-8')
|
|
204
|
+
return parseCredentialFile(content, containerName, engine)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* List all saved credential usernames for a container.
|
|
215
|
+
* Returns an empty array if no credentials directory exists.
|
|
216
|
+
*/
|
|
217
|
+
export async function listCredentials(
|
|
218
|
+
containerName: string,
|
|
219
|
+
engine: Engine,
|
|
220
|
+
): Promise<string[]> {
|
|
221
|
+
const credDir = getCredentialsDir(containerName, engine)
|
|
222
|
+
if (!existsSync(credDir)) {
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const files = await readdir(credDir)
|
|
227
|
+
return files
|
|
228
|
+
.filter((f) => f.startsWith('.env.'))
|
|
229
|
+
.map((f) => f.slice(5)) // Remove '.env.' prefix
|
|
230
|
+
.sort()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if credentials exist for a specific username.
|
|
235
|
+
*/
|
|
236
|
+
export function credentialsExist(
|
|
237
|
+
containerName: string,
|
|
238
|
+
engine: Engine,
|
|
239
|
+
username: string,
|
|
240
|
+
): boolean {
|
|
241
|
+
return existsSync(getCredentialFilePath(containerName, engine, username))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the default username for a given engine.
|
|
246
|
+
* API key engines use 'search_key' or 'api_key', all others use 'spindb'.
|
|
247
|
+
*/
|
|
248
|
+
export function getDefaultUsername(engine: Engine): string {
|
|
249
|
+
switch (engine) {
|
|
250
|
+
case Engine.Meilisearch:
|
|
251
|
+
return 'search_key'
|
|
252
|
+
case Engine.Qdrant:
|
|
253
|
+
return 'api_key'
|
|
254
|
+
default:
|
|
255
|
+
return 'spindb'
|
|
256
|
+
}
|
|
257
|
+
}
|
package/core/docker-exporter.ts
CHANGED
|
@@ -70,6 +70,8 @@ function getEngineDisplayName(engine: Engine): string {
|
|
|
70
70
|
[Engine.CockroachDB]: 'CockroachDB',
|
|
71
71
|
[Engine.SurrealDB]: 'SurrealDB',
|
|
72
72
|
[Engine.QuestDB]: 'QuestDB',
|
|
73
|
+
[Engine.TypeDB]: 'TypeDB',
|
|
74
|
+
[Engine.InfluxDB]: 'InfluxDB',
|
|
73
75
|
}
|
|
74
76
|
return displayNames[engine] || engine
|
|
75
77
|
}
|
|
@@ -137,6 +139,12 @@ const _ENGINE_BINARY_CONFIG: Record<
|
|
|
137
139
|
[Engine.QuestDB]: {
|
|
138
140
|
primaryBinaries: [], // Uses psql from PostgreSQL for connections
|
|
139
141
|
},
|
|
142
|
+
[Engine.TypeDB]: {
|
|
143
|
+
primaryBinaries: ['typedb', 'typedb_console_bin'],
|
|
144
|
+
},
|
|
145
|
+
[Engine.InfluxDB]: {
|
|
146
|
+
primaryBinaries: [], // REST API only, no CLI tools
|
|
147
|
+
},
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
/**
|
|
@@ -189,6 +197,7 @@ function getConnectionStringTemplate(
|
|
|
189
197
|
return useTLS ? `https://<host>:${port}` : `http://<host>:${port}`
|
|
190
198
|
|
|
191
199
|
case Engine.Meilisearch:
|
|
200
|
+
case Engine.InfluxDB:
|
|
192
201
|
return useTLS ? `https://<host>:${port}` : `http://<host>:${port}`
|
|
193
202
|
|
|
194
203
|
case Engine.CouchDB:
|
|
@@ -201,6 +210,9 @@ function getConnectionStringTemplate(
|
|
|
201
210
|
? `wss://\${SPINDB_USER}:\${SPINDB_PASSWORD}@<host>:${port}`
|
|
202
211
|
: `ws://\${SPINDB_USER}:\${SPINDB_PASSWORD}@<host>:${port}`
|
|
203
212
|
|
|
213
|
+
case Engine.TypeDB:
|
|
214
|
+
return `typedb://<host>:${port}`
|
|
215
|
+
|
|
204
216
|
case Engine.SQLite:
|
|
205
217
|
case Engine.DuckDB:
|
|
206
218
|
return `File-based database (no network connection)`
|
|
@@ -485,6 +497,20 @@ echo "User configured via server settings"
|
|
|
485
497
|
userCreationCommands = `
|
|
486
498
|
# API key is configured at server start
|
|
487
499
|
echo "API key configured via server settings"
|
|
500
|
+
`
|
|
501
|
+
break
|
|
502
|
+
|
|
503
|
+
case Engine.InfluxDB:
|
|
504
|
+
userCreationCommands = `
|
|
505
|
+
# InfluxDB 3.x local dev runs without authentication
|
|
506
|
+
echo "No authentication required for local InfluxDB 3.x"
|
|
507
|
+
`
|
|
508
|
+
break
|
|
509
|
+
|
|
510
|
+
case Engine.TypeDB:
|
|
511
|
+
userCreationCommands = `
|
|
512
|
+
# TypeDB community edition does not support user management
|
|
513
|
+
echo "No authentication required"
|
|
488
514
|
`
|
|
489
515
|
break
|
|
490
516
|
|
|
@@ -1270,6 +1296,7 @@ export async function getDockerConnectionString(
|
|
|
1270
1296
|
return `http://${host}:${port}`
|
|
1271
1297
|
|
|
1272
1298
|
case Engine.Meilisearch:
|
|
1299
|
+
case Engine.InfluxDB:
|
|
1273
1300
|
return `http://${host}:${port}`
|
|
1274
1301
|
|
|
1275
1302
|
case Engine.CouchDB:
|
|
@@ -1278,6 +1305,9 @@ export async function getDockerConnectionString(
|
|
|
1278
1305
|
case Engine.SurrealDB:
|
|
1279
1306
|
return `ws://${username}:${password}@${host}:${port}`
|
|
1280
1307
|
|
|
1308
|
+
case Engine.TypeDB:
|
|
1309
|
+
return `typedb://${host}:${port}`
|
|
1310
|
+
|
|
1281
1311
|
case Engine.SQLite:
|
|
1282
1312
|
case Engine.DuckDB:
|
|
1283
1313
|
return `File-based database (no network connection)`
|
package/core/error-handler.ts
CHANGED
|
@@ -57,6 +57,8 @@ export const ErrorCodes = {
|
|
|
57
57
|
INIT_FAILED: 'INIT_FAILED',
|
|
58
58
|
DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
|
|
59
59
|
INVALID_DATABASE_NAME: 'INVALID_DATABASE_NAME',
|
|
60
|
+
INVALID_USERNAME: 'INVALID_USERNAME',
|
|
61
|
+
USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS',
|
|
60
62
|
|
|
61
63
|
// Dependency errors
|
|
62
64
|
DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
|
|
@@ -333,6 +335,23 @@ export function assertValidDatabaseName(name: string): void {
|
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
|
|
338
|
+
// Validates a username to prevent SQL injection.
|
|
339
|
+
export function isValidUsername(name: string): boolean {
|
|
340
|
+
return /^[a-zA-Z][a-zA-Z0-9_]{0,62}$/.test(name)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function assertValidUsername(name: string): void {
|
|
344
|
+
if (!isValidUsername(name)) {
|
|
345
|
+
throw new SpinDBError(
|
|
346
|
+
ErrorCodes.INVALID_USERNAME,
|
|
347
|
+
`Invalid username: "${name}"`,
|
|
348
|
+
'error',
|
|
349
|
+
'Usernames must start with a letter, contain only letters, numbers, and underscores, and be at most 63 characters',
|
|
350
|
+
{ username: name },
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
336
355
|
/**
|
|
337
356
|
* Check if the current process is running in an interactive terminal.
|
|
338
357
|
* Returns true if stdin is a TTY (user can interact with prompts).
|
package/engines/base-engine.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
StatusResult,
|
|
10
10
|
QueryResult,
|
|
11
11
|
QueryOptions,
|
|
12
|
+
CreateUserOptions,
|
|
13
|
+
UserCredentials,
|
|
12
14
|
} from '../types'
|
|
13
15
|
import { UnsupportedOperationError } from '../core/error-handler'
|
|
14
16
|
|
|
@@ -150,6 +152,14 @@ export abstract class BaseEngine {
|
|
|
150
152
|
throw new Error('surreal not found')
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Get the path to the typedb console binary if available
|
|
157
|
+
* Default implementation throws; TypeDB engine overrides this method.
|
|
158
|
+
*/
|
|
159
|
+
async getTypeDBConsolePath(_version?: string): Promise<string> {
|
|
160
|
+
throw new Error('typedb_console_bin not found')
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
/**
|
|
154
164
|
* Get the path to the sqlite3 client if available
|
|
155
165
|
* Default implementation returns null; SQLite engine overrides this method.
|
|
@@ -235,7 +245,12 @@ export abstract class BaseEngine {
|
|
|
235
245
|
*/
|
|
236
246
|
abstract runScript(
|
|
237
247
|
container: ContainerConfig,
|
|
238
|
-
options: {
|
|
248
|
+
options: {
|
|
249
|
+
file?: string
|
|
250
|
+
sql?: string
|
|
251
|
+
database?: string
|
|
252
|
+
transactionType?: 'read' | 'write' | 'schema'
|
|
253
|
+
},
|
|
239
254
|
): Promise<void>
|
|
240
255
|
|
|
241
256
|
/**
|
|
@@ -281,4 +296,20 @@ export abstract class BaseEngine {
|
|
|
281
296
|
async listDatabases(_container: ContainerConfig): Promise<string[]> {
|
|
282
297
|
throw new UnsupportedOperationError('listDatabases', this.displayName)
|
|
283
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a database user with the given credentials.
|
|
302
|
+
* Returns credentials including connection string.
|
|
303
|
+
*
|
|
304
|
+
* @param container - The container configuration
|
|
305
|
+
* @param options - Username, password, and optional target database
|
|
306
|
+
* @returns UserCredentials with connection info
|
|
307
|
+
* @throws UnsupportedOperationError for engines that don't support users (SQLite, DuckDB, QuestDB)
|
|
308
|
+
*/
|
|
309
|
+
async createUser(
|
|
310
|
+
_container: ContainerConfig,
|
|
311
|
+
_options: CreateUserOptions,
|
|
312
|
+
): Promise<UserCredentials> {
|
|
313
|
+
throw new UnsupportedOperationError('createUser', this.displayName)
|
|
314
|
+
}
|
|
284
315
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawn, type SpawnOptions } from 'child_process'
|
|
2
2
|
import { existsSync } from 'fs'
|
|
3
|
-
import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
3
|
+
import { mkdir, writeFile, readFile, unlink, chmod } from 'fs/promises'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import { BaseEngine } from '../base-engine'
|
|
6
6
|
import { paths } from '../../config/paths'
|
|
7
7
|
import { getEngineDefaults } from '../../config/defaults'
|
|
8
8
|
import { platformService } from '../../core/platform-service'
|
|
9
9
|
import { configManager } from '../../core/config-manager'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
logDebug,
|
|
12
|
+
logWarning,
|
|
13
|
+
assertValidUsername,
|
|
14
|
+
} from '../../core/error-handler'
|
|
11
15
|
import { processManager } from '../../core/process-manager'
|
|
12
16
|
import { clickhouseBinaryManager } from './binary-manager'
|
|
13
17
|
import { getBinaryUrl } from './binary-urls'
|
|
@@ -39,6 +43,8 @@ import {
|
|
|
39
43
|
type StatusResult,
|
|
40
44
|
type QueryResult,
|
|
41
45
|
type QueryOptions,
|
|
46
|
+
type CreateUserOptions,
|
|
47
|
+
type UserCredentials,
|
|
42
48
|
} from '../../types'
|
|
43
49
|
import { parseClickHouseJSONResult } from '../../core/query-parser'
|
|
44
50
|
|
|
@@ -86,6 +92,15 @@ function generateClickHouseConfig(options: {
|
|
|
86
92
|
|
|
87
93
|
<mark_cache_size>5368709120</mark_cache_size>
|
|
88
94
|
<max_concurrent_queries>100</max_concurrent_queries>
|
|
95
|
+
|
|
96
|
+
<user_directories>
|
|
97
|
+
<users_xml>
|
|
98
|
+
<path>users.xml</path>
|
|
99
|
+
</users_xml>
|
|
100
|
+
<local_directory>
|
|
101
|
+
<path>${dataDir}/access/</path>
|
|
102
|
+
</local_directory>
|
|
103
|
+
</user_directories>
|
|
89
104
|
</clickhouse>
|
|
90
105
|
`
|
|
91
106
|
}
|
|
@@ -297,6 +312,11 @@ export class ClickHouseEngine extends BaseEngine {
|
|
|
297
312
|
await mkdir(dataDir, { recursive: true })
|
|
298
313
|
await mkdir(tmpDir, { recursive: true })
|
|
299
314
|
await mkdir(join(dataDir, 'user_files'), { recursive: true })
|
|
315
|
+
const accessDir = join(dataDir, 'access')
|
|
316
|
+
await mkdir(accessDir, { recursive: true, mode: 0o700 })
|
|
317
|
+
await chmod(accessDir, 0o700).catch((err) => {
|
|
318
|
+
logDebug(`Failed to chmod ${accessDir}: ${err}`)
|
|
319
|
+
})
|
|
300
320
|
|
|
301
321
|
logDebug(`Created ClickHouse data directory: ${dataDir}`)
|
|
302
322
|
|
|
@@ -338,6 +358,18 @@ export class ClickHouseEngine extends BaseEngine {
|
|
|
338
358
|
const tmpDir = join(dataDir, 'tmp')
|
|
339
359
|
const httpPort = port + 1
|
|
340
360
|
|
|
361
|
+
const accessDir = join(dataDir, 'access')
|
|
362
|
+
try {
|
|
363
|
+
await mkdir(accessDir, { recursive: true, mode: 0o700 })
|
|
364
|
+
await chmod(accessDir, 0o700).catch((err) => {
|
|
365
|
+
logDebug(`Failed to chmod ${accessDir}: ${err}`)
|
|
366
|
+
})
|
|
367
|
+
} catch (error) {
|
|
368
|
+
logWarning(
|
|
369
|
+
`Failed to create ClickHouse access directory ${accessDir}: ${error}`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
341
373
|
const configPath = join(containerDir, 'config.xml')
|
|
342
374
|
const pidFile = join(containerDir, engineDef.pidFileName)
|
|
343
375
|
const configContent = generateClickHouseConfig({
|
|
@@ -528,7 +560,7 @@ export class ClickHouseEngine extends BaseEngine {
|
|
|
528
560
|
private async waitForReady(
|
|
529
561
|
port: number,
|
|
530
562
|
version: string,
|
|
531
|
-
timeoutMs =
|
|
563
|
+
timeoutMs = 120000,
|
|
532
564
|
): Promise<boolean> {
|
|
533
565
|
logDebug(`waitForReady called for port ${port}, version ${version}`)
|
|
534
566
|
const startTime = Date.now()
|
|
@@ -1244,6 +1276,70 @@ export class ClickHouseEngine extends BaseEngine {
|
|
|
1244
1276
|
})
|
|
1245
1277
|
})
|
|
1246
1278
|
}
|
|
1279
|
+
|
|
1280
|
+
async createUser(
|
|
1281
|
+
container: ContainerConfig,
|
|
1282
|
+
options: CreateUserOptions,
|
|
1283
|
+
): Promise<UserCredentials> {
|
|
1284
|
+
const { username, password, database } = options
|
|
1285
|
+
assertValidUsername(username)
|
|
1286
|
+
const { port, version } = container
|
|
1287
|
+
const db = database || container.database || 'default'
|
|
1288
|
+
|
|
1289
|
+
validateClickHouseIdentifier(username, 'username')
|
|
1290
|
+
validateClickHouseIdentifier(db, 'database')
|
|
1291
|
+
const escapedUser = escapeClickHouseIdentifier(username)
|
|
1292
|
+
const escapedDb = escapeClickHouseIdentifier(db)
|
|
1293
|
+
|
|
1294
|
+
const clickhouse = await this.getClickHouseClientPath(version)
|
|
1295
|
+
|
|
1296
|
+
const escapedPass = password.replace(/\\/g, '\\\\').replace(/'/g, "''")
|
|
1297
|
+
const sql = `CREATE USER IF NOT EXISTS ${escapedUser} IDENTIFIED BY '${escapedPass}'; ALTER USER ${escapedUser} IDENTIFIED BY '${escapedPass}'; GRANT ALL ON ${escapedDb}.* TO ${escapedUser};`
|
|
1298
|
+
|
|
1299
|
+
const args = [
|
|
1300
|
+
'client',
|
|
1301
|
+
'--host',
|
|
1302
|
+
'127.0.0.1',
|
|
1303
|
+
'--port',
|
|
1304
|
+
String(port),
|
|
1305
|
+
'--multiquery',
|
|
1306
|
+
]
|
|
1307
|
+
|
|
1308
|
+
await new Promise<void>((resolve, reject) => {
|
|
1309
|
+
const proc = spawn(clickhouse, args, {
|
|
1310
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
let stderr = ''
|
|
1314
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
1315
|
+
stderr += data.toString()
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
proc.on('close', (code) => {
|
|
1319
|
+
if (code === 0) {
|
|
1320
|
+
logDebug(`Created ClickHouse user: ${username}`)
|
|
1321
|
+
resolve()
|
|
1322
|
+
} else {
|
|
1323
|
+
reject(new Error(`Failed to create user: ${stderr}`))
|
|
1324
|
+
}
|
|
1325
|
+
})
|
|
1326
|
+
proc.on('error', reject)
|
|
1327
|
+
|
|
1328
|
+
proc.stdin?.write(sql)
|
|
1329
|
+
proc.stdin?.end()
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
const connectionString = `clickhouse://${encodeURIComponent(username)}:${encodeURIComponent(password)}@127.0.0.1:${port}/${db}`
|
|
1333
|
+
|
|
1334
|
+
return {
|
|
1335
|
+
username,
|
|
1336
|
+
password,
|
|
1337
|
+
connectionString,
|
|
1338
|
+
engine: container.engine,
|
|
1339
|
+
container: container.name,
|
|
1340
|
+
database: db,
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1247
1343
|
}
|
|
1248
1344
|
|
|
1249
1345
|
export const clickhouseEngine = new ClickHouseEngine()
|
|
@@ -22,7 +22,11 @@ import { paths } from '../../config/paths'
|
|
|
22
22
|
import { getEngineDefaults } from '../../config/defaults'
|
|
23
23
|
import { platformService } from '../../core/platform-service'
|
|
24
24
|
import { configManager } from '../../core/config-manager'
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
logDebug,
|
|
27
|
+
logWarning,
|
|
28
|
+
assertValidUsername,
|
|
29
|
+
} from '../../core/error-handler'
|
|
26
30
|
import { findBinary } from '../../core/dependency-manager'
|
|
27
31
|
import { processManager } from '../../core/process-manager'
|
|
28
32
|
import { cockroachdbBinaryManager } from './binary-manager'
|
|
@@ -59,6 +63,8 @@ import {
|
|
|
59
63
|
type StatusResult,
|
|
60
64
|
type QueryResult,
|
|
61
65
|
type QueryOptions,
|
|
66
|
+
type CreateUserOptions,
|
|
67
|
+
type UserCredentials,
|
|
62
68
|
} from '../../types'
|
|
63
69
|
import { parseCSVToQueryResult } from '../../core/query-parser'
|
|
64
70
|
|
|
@@ -347,7 +353,7 @@ export class CockroachDBEngine extends BaseEngine {
|
|
|
347
353
|
|
|
348
354
|
// Wait for server to be ready
|
|
349
355
|
// Windows needs a longer timeout since CockroachDB initialization takes more time
|
|
350
|
-
const timeout = isWindows ?
|
|
356
|
+
const timeout = isWindows ? 120000 : 60000
|
|
351
357
|
logDebug(
|
|
352
358
|
`Waiting for CockroachDB server to be ready on port ${port}... (timeout: ${timeout}ms)`,
|
|
353
359
|
)
|
|
@@ -1201,6 +1207,67 @@ export class CockroachDBEngine extends BaseEngine {
|
|
|
1201
1207
|
})
|
|
1202
1208
|
})
|
|
1203
1209
|
}
|
|
1210
|
+
|
|
1211
|
+
async createUser(
|
|
1212
|
+
container: ContainerConfig,
|
|
1213
|
+
options: CreateUserOptions,
|
|
1214
|
+
): Promise<UserCredentials> {
|
|
1215
|
+
const { username, password, database } = options
|
|
1216
|
+
assertValidUsername(username)
|
|
1217
|
+
const { port, version } = container
|
|
1218
|
+
const db = database || container.database || 'defaultdb'
|
|
1219
|
+
|
|
1220
|
+
validateCockroachIdentifier(username, 'user')
|
|
1221
|
+
validateCockroachIdentifier(db, 'database')
|
|
1222
|
+
const escapedUser = escapeCockroachIdentifier(username)
|
|
1223
|
+
const escapedDb = escapeCockroachIdentifier(db)
|
|
1224
|
+
|
|
1225
|
+
const cockroach = await this.getCockroachPath(version)
|
|
1226
|
+
|
|
1227
|
+
// CockroachDB in insecure mode doesn't support passwords.
|
|
1228
|
+
// Create user without password and grant privileges.
|
|
1229
|
+
// SQL is sent via stdin to avoid shell escaping issues with --execute.
|
|
1230
|
+
const sql = `CREATE USER IF NOT EXISTS ${escapedUser}; GRANT ALL ON DATABASE ${escapedDb} TO ${escapedUser};`
|
|
1231
|
+
|
|
1232
|
+
const args = ['sql', '--insecure', '--host', `127.0.0.1:${port}`]
|
|
1233
|
+
|
|
1234
|
+
await new Promise<void>((resolve, reject) => {
|
|
1235
|
+
const proc = spawn(cockroach, args, {
|
|
1236
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
let stderr = ''
|
|
1240
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
1241
|
+
stderr += data.toString()
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
proc.on('close', (code) => {
|
|
1245
|
+
if (code === 0) {
|
|
1246
|
+
resolve()
|
|
1247
|
+
} else {
|
|
1248
|
+
reject(new Error(`Failed to create user: ${stderr}`))
|
|
1249
|
+
}
|
|
1250
|
+
})
|
|
1251
|
+
proc.on('error', reject)
|
|
1252
|
+
|
|
1253
|
+
proc.stdin?.write(sql)
|
|
1254
|
+
proc.stdin?.end()
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
// In insecure mode, connections don't use passwords
|
|
1258
|
+
const connectionString = `postgresql://${encodeURIComponent(username)}@127.0.0.1:${port}/${db}?sslmode=disable`
|
|
1259
|
+
|
|
1260
|
+
return {
|
|
1261
|
+
username,
|
|
1262
|
+
// CockroachDB insecure mode does not enforce password authentication,
|
|
1263
|
+
// but we return the caller-provided password for credential file consistency.
|
|
1264
|
+
password,
|
|
1265
|
+
connectionString,
|
|
1266
|
+
engine: container.engine,
|
|
1267
|
+
container: container.name,
|
|
1268
|
+
database: db,
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1204
1271
|
}
|
|
1205
1272
|
|
|
1206
1273
|
export const cockroachdbEngine = new CockroachDBEngine()
|