/** * ClickHouse Platform Service Provider */ import { BasePlatformServiceProvider } from './base.ts'; import type { IService, IPlatformResource, IPlatformServiceConfig, IProvisionedResource, IEnvVarMapping, TPlatformServiceType, TPlatformResourceType, } from '../../../types.ts'; import { logger } from '../../../logging.ts'; import { getErrorMessage } from '../../../utils/error.ts'; import { credentialEncryption } from '../../encryption.ts'; import type { Onebox } from '../../onebox.ts'; export class ClickHouseProvider extends BasePlatformServiceProvider { readonly type: TPlatformServiceType = 'clickhouse'; readonly displayName = 'ClickHouse'; readonly resourceTypes: TPlatformResourceType[] = ['database']; constructor(oneboxRef: Onebox) { super(oneboxRef); } getDefaultConfig(): IPlatformServiceConfig { return { image: 'clickhouse/clickhouse-server:latest', port: 8123, // HTTP interface volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'], environment: { CLICKHOUSE_DB: 'default', // Password will be generated and stored encrypted }, }; } getEnvVarMappings(): IEnvVarMapping[] { return [ { envVar: 'CLICKHOUSE_HOST', credentialPath: 'host' }, { envVar: 'CLICKHOUSE_PORT', credentialPath: 'port' }, { envVar: 'CLICKHOUSE_HTTP_PORT', credentialPath: 'httpPort' }, { envVar: 'CLICKHOUSE_DATABASE', credentialPath: 'database' }, { envVar: 'CLICKHOUSE_USER', credentialPath: 'username' }, { envVar: 'CLICKHOUSE_PASSWORD', credentialPath: 'password' }, { envVar: 'CLICKHOUSE_URL', credentialPath: 'connectionUrl' }, ]; } async deployContainer(): Promise { const config = this.getDefaultConfig(); const containerName = this.getContainerName(); const dataDir = '/var/lib/onebox/clickhouse'; logger.info(`Deploying ClickHouse platform service as ${containerName}...`); // Check if we have existing data and stored credentials const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); let adminCredentials: { username: string; password: string }; let dataExists = false; // Check if data directory has existing ClickHouse data // ClickHouse creates 'metadata' directory on first startup try { const stat = await Deno.stat(`${dataDir}/metadata`); dataExists = stat.isDirectory; logger.info(`ClickHouse data directory exists with metadata folder`); } catch { // metadata directory doesn't exist, this is a fresh install dataExists = false; } if (dataExists && platformService?.adminCredentialsEncrypted) { // Reuse existing credentials from database logger.info('Reusing existing ClickHouse credentials (data directory already initialized)'); adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); } else { // Generate new credentials for fresh deployment logger.info('Generating new ClickHouse admin credentials'); adminCredentials = { username: 'default', password: credentialEncryption.generatePassword(32), }; // If data exists but we don't have credentials, we need to wipe the data if (dataExists) { logger.warn('ClickHouse data exists but no credentials in database - wiping data directory'); try { await Deno.remove(dataDir, { recursive: true }); } catch (e) { logger.error(`Failed to wipe ClickHouse data directory: ${getErrorMessage(e)}`); throw new Error('Cannot deploy ClickHouse: data directory exists without credentials'); } } } // Ensure data directory exists try { await Deno.mkdir(dataDir, { recursive: true }); } catch (e) { // Directory might already exist if (!(e instanceof Deno.errors.AlreadyExists)) { logger.warn(`Could not create ClickHouse data directory: ${getErrorMessage(e)}`); } } // Create container using Docker API // ClickHouse uses environment variables for initial setup const envVars = [ `CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1`, `CLICKHOUSE_USER=${adminCredentials.username}`, `CLICKHOUSE_PASSWORD=${adminCredentials.password}`, ]; const containerId = await this.oneboxRef.docker.createPlatformContainer({ name: containerName, image: config.image, port: config.port, env: envVars, volumes: config.volumes, network: this.getNetworkName(), exposePorts: [8123, 9000], // HTTP and native TCP ports }); // Store encrypted admin credentials (only update if new or changed) const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); if (platformService) { this.oneboxRef.database.updatePlatformService(platformService.id!, { containerId, adminCredentialsEncrypted: encryptedCreds, status: 'starting', }); } logger.success(`ClickHouse container created: ${containerId}`); return containerId; } async stopContainer(containerId: string): Promise { logger.info(`Stopping ClickHouse container ${containerId}...`); await this.oneboxRef.docker.stopContainer(containerId); logger.success('ClickHouse container stopped'); } async healthCheck(): Promise { try { logger.info('ClickHouse health check: starting...'); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService) { logger.info('ClickHouse health check: platform service not found in database'); return false; } if (!platformService.adminCredentialsEncrypted) { logger.info('ClickHouse health check: no admin credentials stored'); return false; } if (!platformService.containerId) { logger.info('ClickHouse health check: no container ID in database record'); return false; } logger.info(`ClickHouse health check: using container ID ${platformService.containerId.substring(0, 12)}...`); // Use docker exec to run health check inside the container // This avoids network issues with overlay networks // Note: ClickHouse image has wget but not curl - use full path for reliability const result = await this.oneboxRef.docker.execInContainer( platformService.containerId, ['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping'] ); if (result.exitCode === 0) { logger.info('ClickHouse health check: success'); return true; } else { logger.info(`ClickHouse health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`); return false; } } catch (error) { logger.info(`ClickHouse health check exception: ${getErrorMessage(error)}`); return false; } } async provisionResource(userService: IService): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) { throw new Error('ClickHouse platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const containerName = this.getContainerName(); // Get container host port for connection from host (overlay network IPs not accessible from host) const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123); if (!hostPort) { throw new Error('Could not get ClickHouse container host port'); } // Generate resource names and credentials const dbName = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name); const password = credentialEncryption.generatePassword(32); logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`); // Connect to ClickHouse via localhost and the mapped host port const baseUrl = `http://127.0.0.1:${hostPort}`; // Create database await this.executeQuery( baseUrl, adminCreds.username, adminCreds.password, `CREATE DATABASE IF NOT EXISTS ${dbName}` ); logger.info(`Created ClickHouse database '${dbName}'`); // Create user with access to this database await this.executeQuery( baseUrl, adminCreds.username, adminCreds.password, `CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'` ); logger.info(`Created ClickHouse user '${username}'`); // Grant permissions on the database await this.executeQuery( baseUrl, adminCreds.username, adminCreds.password, `GRANT ALL ON ${dbName}.* TO ${username}` ); logger.info(`Granted permissions to user '${username}' on database '${dbName}'`); logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`); // Build the credentials and env vars const credentials: Record = { host: containerName, port: '9000', // Native TCP port httpPort: '8123', database: dbName, username, password, connectionUrl: `http://${username}:${password}@${containerName}:8123/?database=${dbName}`, }; // Map credentials to env vars const envVars: Record = {}; for (const mapping of this.getEnvVarMappings()) { if (credentials[mapping.credentialPath]) { envVars[mapping.envVar] = credentials[mapping.credentialPath]; } } return { type: 'database', name: dbName, credentials, envVars, }; } async deprovisionResource(resource: IPlatformResource, credentials: Record): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) { throw new Error('ClickHouse platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); // Get container host port for connection from host (overlay network IPs not accessible from host) const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123); if (!hostPort) { throw new Error('Could not get ClickHouse container host port'); } logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`); const baseUrl = `http://127.0.0.1:${hostPort}`; try { // Drop the user try { await this.executeQuery( baseUrl, adminCreds.username, adminCreds.password, `DROP USER IF EXISTS ${credentials.username}` ); logger.info(`Dropped ClickHouse user '${credentials.username}'`); } catch (e) { logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`); } // Drop the database await this.executeQuery( baseUrl, adminCreds.username, adminCreds.password, `DROP DATABASE IF EXISTS ${resource.resourceName}` ); logger.success(`ClickHouse database '${resource.resourceName}' dropped`); } catch (e) { logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`); throw e; } } /** * Execute a ClickHouse SQL query via HTTP interface */ private async executeQuery( baseUrl: string, username: string, password: string, query: string ): Promise { const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; const response = await fetch(url, { method: 'POST', body: query, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`ClickHouse query failed: ${errorText}`); } return await response.text(); } }