Files
onebox/ts/classes/platform-services/providers/clickhouse.ts

289 lines
10 KiB
TypeScript

/**
* 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
// 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<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();
// 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}'...`);
// Use docker exec to provision inside the container (avoids host port mapping issues)
const queries = [
`CREATE DATABASE IF NOT EXISTS ${dbName}`,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`,
`GRANT ALL ON ${dbName}.* TO ${username}`,
];
for (const query of queries) {
await this.execClickHouseQuery(platformService.containerId, adminCreds, query);
}
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);
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
try {
await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP USER IF EXISTS ${credentials.username}`);
await this.execClickHouseQuery(platformService.containerId, adminCreds, `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 docker exec inside the container
*/
private async execClickHouseQuery(
containerId: string,
adminCreds: { username: string; password: string },
query: string
): Promise<string> {
const result = await this.oneboxRef.docker.execInContainer(
containerId,
[
'clickhouse-client',
'--user', adminCreds.username,
'--password', adminCreds.password,
'--query', query,
]
);
if (result.exitCode !== 0) {
throw new Error(`ClickHouse query failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
}
return result.stdout;
}
}