6 Commits

13 changed files with 520 additions and 47 deletions

View File

@@ -1,5 +1,35 @@
# Changelog # Changelog
## 2025-11-26 - 1.4.0 - feat(platform-services)
Add ClickHouse platform service support and improve related healthchecks and tooling
- Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection
- Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123)
- Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows
- Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks
- Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/*) to improve local dev experience
## 2025-11-26 - 1.3.0 - feat(platform-services)
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources
- Expose ClickHouse default port (8123) in platform port mappings / network targets
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
- Add VS Code recommendations and launch/tasks for the UI development workflow
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
- MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them.
- If data exists but no credentials are stored, MinIO deployment will wipe the data directory to avoid credential mismatch and fail early with a clear error if wiping fails.
- Provisioning and deprovisioning now connect to MinIO via the container's host-mapped port (127.0.0.1:<hostPort>) instead of relying on overlay network addresses; an error is thrown when the host port mapping cannot be determined.
- Bucket provisioning creates policies and returns environment variables using container network hostnames for in-network access; a warning notes that per-service MinIO accounts are TODO and root credentials are used for now.
- Added logging improvements around MinIO deploy/provision/deprovision steps for easier debugging.
- Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience.
## 2025-11-26 - 1.2.0 - feat(ui) ## 2025-11-26 - 1.2.0 - feat(ui)
Sync UI tab state with URL and update routes/links Sync UI tab state with URL and update routes/links

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.2.0", "version": "1.4.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.2.0", "version": "1.4.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.2.0', version: '1.4.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }

View File

@@ -297,16 +297,16 @@ export class OneboxHttpServer {
// Platform Services endpoints // Platform Services endpoints
} else if (path === '/api/platform-services' && method === 'GET') { } else if (path === '/api/platform-services' && method === 'GET') {
return await this.handleListPlatformServicesRequest(); return await this.handleListPlatformServicesRequest();
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
const type = path.split('/').pop()! as TPlatformServiceType; const type = path.split('/').pop()! as TPlatformServiceType;
return await this.handleGetPlatformServiceRequest(type); return await this.handleGetPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStartPlatformServiceRequest(type); return await this.handleStartPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStopPlatformServiceRequest(type); return await this.handleStopPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleGetPlatformServiceStatsRequest(type); return await this.handleGetPlatformServiceStatsRequest(type);
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
@@ -1323,6 +1323,7 @@ export class OneboxHttpServer {
postgresql: 5432, postgresql: 5432,
rabbitmq: 5672, rabbitmq: 5672,
caddy: 80, caddy: 80,
clickhouse: 8123,
}; };
return ports[type] || 0; return ports[type] || 0;
} }

View File

@@ -15,6 +15,7 @@ import type { IPlatformServiceProvider } from './providers/base.ts';
import { MongoDBProvider } from './providers/mongodb.ts'; import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.ts'; import { MinioProvider } from './providers/minio.ts';
import { CaddyProvider } from './providers/caddy.ts'; import { CaddyProvider } from './providers/caddy.ts';
import { ClickHouseProvider } from './providers/clickhouse.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts'; import { getErrorMessage } from '../../utils/error.ts';
import { credentialEncryption } from '../encryption.ts'; import { credentialEncryption } from '../encryption.ts';
@@ -39,6 +40,7 @@ export class PlatformServicesManager {
this.registerProvider(new MongoDBProvider(this.oneboxRef)); this.registerProvider(new MongoDBProvider(this.oneboxRef));
this.registerProvider(new MinioProvider(this.oneboxRef)); this.registerProvider(new MinioProvider(this.oneboxRef));
this.registerProvider(new CaddyProvider(this.oneboxRef)); this.registerProvider(new CaddyProvider(this.oneboxRef));
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
logger.info(`Platform services manager initialized with ${this.providers.size} providers`); logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
} }
@@ -275,6 +277,33 @@ export class PlatformServicesManager {
logger.success(`S3 storage provisioned for service '${service.name}'`); logger.success(`S3 storage provisioned for service '${service.name}'`);
} }
// Provision ClickHouse if requested
if (requirements.clickhouse) {
logger.info(`Provisioning ClickHouse for service '${service.name}'...`);
// Ensure ClickHouse is running
const clickhouseService = await this.ensureRunning('clickhouse');
const provider = this.providers.get('clickhouse')!;
// Provision database
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: clickhouseService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`ClickHouse provisioned for service '${service.name}'`);
}
return allEnvVars; return allEnvVars;
} }

View File

@@ -0,0 +1,338 @@
/**
* 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();
// 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();
}
}

View File

@@ -57,22 +57,55 @@ export class MinioProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/minio';
// Generate admin credentials
const adminUser = 'admin';
const adminPassword = credentialEncryption.generatePassword(32);
const adminCredentials = {
username: adminUser,
password: adminPassword,
};
logger.info(`Deploying MinIO platform service as ${containerName}...`); logger.info(`Deploying MinIO 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 MinIO data
// MinIO creates .minio.sys directory on first startup
try {
const stat = await Deno.stat(`${dataDir}/.minio.sys`);
dataExists = stat.isDirectory;
logger.info(`MinIO data directory exists with .minio.sys folder`);
} catch {
// .minio.sys doesn't exist, this is a fresh install
dataExists = false;
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MinIO admin credentials');
adminCredentials = {
username: 'admin',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('MinIO data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe MinIO data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy MinIO: data directory exists without credentials');
}
}
}
// Ensure data directory exists // Ensure data directory exists
try { try {
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true }); await Deno.mkdir(dataDir, { recursive: true });
} catch (e) { } catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) { if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`); logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`);
} }
@@ -95,9 +128,8 @@ export class MinioProvider extends BasePlatformServiceProvider {
exposePorts: [9000, 9001], // API and Console ports exposePorts: [9000, 9001], // API and Console ports
}); });
// Store encrypted admin credentials // Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) { if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, { this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId, containerId,
@@ -118,41 +150,58 @@ export class MinioProvider extends BasePlatformServiceProvider {
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
logger.info('MinIO health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.containerId) { if (!platformService) {
logger.info('MinIO health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('MinIO health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('MinIO health check: no container ID in database record');
return false; return false;
} }
// Get container IP for health check (hostname won't resolve from host) logger.info(`MinIO health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const containerIP = await this.oneboxRef.docker.getContainerIP(platformService.containerId);
if (!containerIP) { // Use docker exec to run health check inside the container
logger.debug('MinIO health check: could not get container IP'); // This avoids network issues with overlay networks
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['curl', '-sf', 'http://localhost:9000/minio/health/live']
);
if (result.exitCode === 0) {
logger.info('MinIO health check: success');
return true;
} else {
logger.info(`MinIO health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
return false; return false;
} }
const endpoint = `http://${containerIP}:9000/minio/health/live`;
const response = await fetch(endpoint, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch (error) { } catch (error) {
logger.debug(`MinIO health check failed: ${getErrorMessage(error)}`); logger.info(`MinIO health check exception: ${getErrorMessage(error)}`);
return false; return false;
} }
} }
async provisionResource(userService: IService): Promise<IProvisionedResource> { async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) { if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MinIO platform service not found or not configured'); throw new Error('MinIO platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); 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, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
// Generate bucket name and credentials // Generate bucket name and credentials
const bucketName = this.generateBucketName(userService.name); const bucketName = this.generateBucketName(userService.name);
const accessKey = credentialEncryption.generateAccessKey(20); const accessKey = credentialEncryption.generateAccessKey(20);
@@ -160,14 +209,15 @@ export class MinioProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`); logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
const endpoint = `http://${containerName}:9000`; // Connect to MinIO via localhost and the mapped host port (for provisioning from host)
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`;
// Import AWS S3 client // Import AWS S3 client
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3'); const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
// Create S3 client with admin credentials // Create S3 client with admin credentials - connect via host port
const s3Client = new S3Client({ const s3Client = new S3Client({
endpoint, endpoint: provisioningEndpoint,
region: 'us-east-1', region: 'us-east-1',
credentials: { credentials: {
accessKeyId: adminCreds.username, accessKeyId: adminCreds.username,
@@ -225,8 +275,11 @@ export class MinioProvider extends BasePlatformServiceProvider {
// TODO: Implement MinIO service account creation // TODO: Implement MinIO service account creation
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.'); logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
// Use container name for the endpoint in credentials (user services run in same network)
const serviceEndpoint = `http://${containerName}:9000`;
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
endpoint, endpoint: serviceEndpoint,
bucket: bucketName, bucket: bucketName,
accessKey: adminCreds.username, // Using root for now accessKey: adminCreds.username, // Using root for now
secretKey: adminCreds.password, secretKey: adminCreds.password,
@@ -253,20 +306,24 @@ export class MinioProvider extends BasePlatformServiceProvider {
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> { async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) { if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MinIO platform service not found or not configured'); throw new Error('MinIO platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
const endpoint = `http://${containerName}:9000`; // Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`); logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3'); const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
const s3Client = new S3Client({ const s3Client = new S3Client({
endpoint, endpoint: `http://127.0.0.1:${hostPort}`,
region: 'us-east-1', region: 'us-east-1',
credentials: { credentials: {
accessKeyId: adminCreds.username, accessKeyId: adminCreds.username,

View File

@@ -49,10 +49,11 @@ export class OneboxServicesManager {
// Build platform requirements // Build platform requirements
const platformRequirements: IPlatformRequirements | undefined = const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3) (options.enableMongoDB || options.enableS3 || options.enableClickHouse)
? { ? {
mongodb: options.enableMongoDB, mongodb: options.enableMongoDB,
s3: options.enableS3, s3: options.enableS3,
clickhouse: options.enableClickHouse,
} }
: undefined; : undefined;

View File

@@ -73,7 +73,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
@@ -110,6 +110,7 @@ export interface IPlatformResource {
export interface IPlatformRequirements { export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean;
} }
export interface IProvisionedResource { export interface IProvisionedResource {
@@ -287,6 +288,7 @@ export interface IServiceDeployOptions {
// Platform service requirements // Platform service requirements
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean;
} }
// HTTP API request/response types // HTTP API request/response types

View File

@@ -16,13 +16,14 @@ export interface ILoginResponse {
} }
// Platform Service Types (defined early for use in ISystemStatus) // Platform Service Types (defined early for use in ISystemStatus)
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export interface IPlatformRequirements { export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean;
} }
export interface IService { export interface IService {
@@ -56,6 +57,7 @@ export interface IServiceCreate {
autoUpdateOnPush?: boolean; autoUpdateOnPush?: boolean;
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean; // ClickHouse analytics database
} }
export interface IServiceUpdate { export interface IServiceUpdate {

View File

@@ -357,6 +357,7 @@ export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.', postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.', rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.', caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
clickhouse: 'ClickHouse is a fast, open-source columnar database management system optimized for real-time analytics and data warehousing.',
}; };
return descriptions[type] || 'A platform service managed by Onebox.'; return descriptions[type] || 'A platform service managed by Onebox.';
} }

View File

@@ -215,9 +215,20 @@ interface EnvVar {
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p> <p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.enableClickHouse ?? false"
(checkedChange)="form.enableClickHouse = $event"
/>
<div>
<label uiLabel class="cursor-pointer">ClickHouse Database</label>
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as CLICKHOUSE_* env vars</p>
</div>
</div>
</div> </div>
@if (form.enableMongoDB || form.enableS3) { @if (form.enableMongoDB || form.enableS3 || form.enableClickHouse) {
<ui-alert variant="default"> <ui-alert variant="default">
<ui-alert-description> <ui-alert-description>
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables. Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
@@ -301,6 +312,7 @@ export class ServiceCreateComponent implements OnInit {
autoUpdateOnPush: false, autoUpdateOnPush: false,
enableMongoDB: false, enableMongoDB: false,
enableS3: false, enableS3: false,
enableClickHouse: false,
}; };
envVars = signal<EnvVar[]>([]); envVars = signal<EnvVar[]>([]);