2025-11-26 18:54:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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<string> {
|
|
|
|
|
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<void> {
|
|
|
|
|
logger.info(`Stopping ClickHouse container ${containerId}...`);
|
|
|
|
|
await this.oneboxRef.docker.stopContainer(containerId);
|
|
|
|
|
logger.success('ClickHouse container stopped');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async healthCheck(): Promise<boolean> {
|
|
|
|
|
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
|
2025-11-26 22:05:25 +00:00
|
|
|
// Note: ClickHouse image has wget but not curl - use full path for reliability
|
2025-11-26 18:54:20 +00:00
|
|
|
const result = await this.oneboxRef.docker.execInContainer(
|
|
|
|
|
platformService.containerId,
|
2025-11-26 22:05:25 +00:00
|
|
|
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
2025-11-26 18:54:20 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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<IProvisionedResource> {
|
|
|
|
|
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<string, string> = {
|
|
|
|
|
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<string, string> = {};
|
|
|
|
|
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<string, string>): Promise<void> {
|
|
|
|
|
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<string> {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|