feat: Implement platform service providers for MinIO and MongoDB

- Added base interface and abstract class for platform service providers.
- Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities.
- Implemented MongoDBProvider class for MongoDB service with similar capabilities.
- Introduced error handling utilities for better error management.
- Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens.
This commit is contained in:
2025-11-25 04:20:19 +00:00
parent 9aa6906ca5
commit 8ebd677478
28 changed files with 3462 additions and 490 deletions

View File

@@ -0,0 +1,123 @@
/**
* Base interface and types for platform service providers
*/
import type {
IService,
IPlatformService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import type { Onebox } from '../../onebox.ts';
/**
* Interface that all platform service providers must implement
*/
export interface IPlatformServiceProvider {
/** Unique identifier for this provider type */
readonly type: TPlatformServiceType;
/** Human-readable display name */
readonly displayName: string;
/** Resource types this provider can provision */
readonly resourceTypes: TPlatformResourceType[];
/**
* Get the default configuration for this platform service
*/
getDefaultConfig(): IPlatformServiceConfig;
/**
* Deploy the platform service container
* @returns The container ID
*/
deployContainer(): Promise<string>;
/**
* Stop the platform service container
*/
stopContainer(containerId: string): Promise<void>;
/**
* Check if the platform service is healthy and ready to accept connections
*/
healthCheck(): Promise<boolean>;
/**
* Provision a resource for a user service (e.g., create database, bucket)
* @param userService The user service requesting the resource
* @returns Provisioned resource with credentials and env var mappings
*/
provisionResource(userService: IService): Promise<IProvisionedResource>;
/**
* Deprovision a resource (e.g., drop database, delete bucket)
* @param resource The resource to deprovision
*/
deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
/**
* Get the environment variable mappings for this provider
*/
getEnvVarMappings(): IEnvVarMapping[];
}
/**
* Base class for platform service providers with common functionality
*/
export abstract class BasePlatformServiceProvider implements IPlatformServiceProvider {
abstract readonly type: TPlatformServiceType;
abstract readonly displayName: string;
abstract readonly resourceTypes: TPlatformResourceType[];
protected oneboxRef: Onebox;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
abstract getDefaultConfig(): IPlatformServiceConfig;
abstract deployContainer(): Promise<string>;
abstract stopContainer(containerId: string): Promise<void>;
abstract healthCheck(): Promise<boolean>;
abstract provisionResource(userService: IService): Promise<IProvisionedResource>;
abstract deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
abstract getEnvVarMappings(): IEnvVarMapping[];
/**
* Get the internal Docker network name for platform services
*/
protected getNetworkName(): string {
return 'onebox-network';
}
/**
* Get the container name for this platform service
*/
protected getContainerName(): string {
return `onebox-${this.type}`;
}
/**
* Generate a resource name from a user service name
*/
protected generateResourceName(serviceName: string, prefix: string = 'onebox'): string {
// Replace dashes with underscores for database compatibility
const sanitized = serviceName.replace(/-/g, '_');
return `${prefix}_${sanitized}`;
}
/**
* Generate a bucket name from a user service name
*/
protected generateBucketName(serviceName: string, prefix: string = 'onebox'): string {
// Buckets use dashes, lowercase
const sanitized = serviceName.toLowerCase().replace(/_/g, '-');
return `${prefix}-${sanitized}`;
}
}

View File

@@ -0,0 +1,299 @@
/**
* MinIO (S3-compatible) 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 { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class MinioProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'minio';
readonly displayName = 'S3 Storage (MinIO)';
readonly resourceTypes: TPlatformResourceType[] = ['bucket'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'minio/minio:latest',
port: 9000,
volumes: ['/var/lib/onebox/minio:/data'],
command: 'server /data --console-address :9001',
environment: {
MINIO_ROOT_USER: 'admin',
// Password will be generated and stored encrypted
},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'S3_ENDPOINT', credentialPath: 'endpoint' },
{ envVar: 'S3_BUCKET', credentialPath: 'bucket' },
{ envVar: 'S3_ACCESS_KEY', credentialPath: 'accessKey' },
{ envVar: 'S3_SECRET_KEY', credentialPath: 'secretKey' },
{ envVar: 'S3_REGION', credentialPath: 'region' },
// AWS SDK compatible names
{ envVar: 'AWS_ACCESS_KEY_ID', credentialPath: 'accessKey' },
{ envVar: 'AWS_SECRET_ACCESS_KEY', credentialPath: 'secretKey' },
{ envVar: 'AWS_ENDPOINT_URL', credentialPath: 'endpoint' },
{ envVar: 'AWS_REGION', credentialPath: 'region' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
// 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}...`);
// Ensure data directory exists
try {
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true });
} catch (e) {
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MinIO data directory: ${e.message}`);
}
}
// Create container using Docker API
const envVars = [
`MINIO_ROOT_USER=${adminCredentials.username}`,
`MINIO_ROOT_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(),
command: config.command?.split(' '),
exposePorts: [9000, 9001], // API and Console ports
});
// Store encrypted admin credentials
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`MinIO container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping MinIO container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MinIO container stopped');
}
async healthCheck(): Promise<boolean> {
try {
const containerName = this.getContainerName();
const endpoint = `http://${containerName}:9000/minio/health/live`;
const response = await fetch(endpoint, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch (error) {
logger.debug(`MinIO health check failed: ${error.message}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
throw new Error('MinIO platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Generate bucket name and credentials
const bucketName = this.generateBucketName(userService.name);
const accessKey = credentialEncryption.generateAccessKey(20);
const secretKey = credentialEncryption.generateSecretKey(40);
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
const endpoint = `http://${containerName}:9000`;
// Import AWS S3 client
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
// Create S3 client with admin credentials
const s3Client = new S3Client({
endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
// Create the bucket
try {
await s3Client.send(new CreateBucketCommand({
Bucket: bucketName,
}));
logger.info(`Created MinIO bucket '${bucketName}'`);
} catch (e: any) {
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
throw e;
}
logger.warn(`Bucket '${bucketName}' already exists`);
}
// Create service account/access key using MinIO Admin API
// MinIO Admin API requires mc client or direct API calls
// For simplicity, we'll use root credentials and bucket policy isolation
// In production, you'd use MinIO's Admin API to create service accounts
// Set bucket policy to allow access only with this bucket's credentials
const bucketPolicy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`,
],
},
],
};
try {
await s3Client.send(new PutBucketPolicyCommand({
Bucket: bucketName,
Policy: JSON.stringify(bucketPolicy),
}));
logger.info(`Set bucket policy for '${bucketName}'`);
} catch (e) {
logger.warn(`Could not set bucket policy: ${e.message}`);
}
// Note: For proper per-service credentials, MinIO Admin API should be used
// For now, we're providing the bucket with root access
// TODO: Implement MinIO service account creation
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
const credentials: Record<string, string> = {
endpoint,
bucket: bucketName,
accessKey: adminCreds.username, // Using root for now
secretKey: adminCreds.password,
region: 'us-east-1',
};
// 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];
}
}
logger.success(`MinIO bucket '${bucketName}' provisioned`);
return {
type: 'bucket',
name: bucketName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
throw new Error('MinIO platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
const endpoint = `http://${containerName}:9000`;
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
const s3Client = new S3Client({
endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
try {
// First, delete all objects in the bucket
let continuationToken: string | undefined;
do {
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: resource.resourceName,
ContinuationToken: continuationToken,
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: resource.resourceName,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
},
}));
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
}
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
} while (continuationToken);
// Now delete the bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: resource.resourceName,
}));
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
} catch (e) {
logger.error(`Failed to delete MinIO bucket: ${e.message}`);
throw e;
}
}
}

View File

@@ -0,0 +1,246 @@
/**
* MongoDB 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 { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class MongoDBProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'mongodb';
readonly displayName = 'MongoDB';
readonly resourceTypes: TPlatformResourceType[] = ['database'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'mongo:7',
port: 27017,
volumes: ['/var/lib/onebox/mongodb:/data/db'],
environment: {
MONGO_INITDB_ROOT_USERNAME: 'admin',
// Password will be generated and stored encrypted
},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'MONGODB_URI', credentialPath: 'connectionString' },
{ envVar: 'MONGODB_HOST', credentialPath: 'host' },
{ envVar: 'MONGODB_PORT', credentialPath: 'port' },
{ envVar: 'MONGODB_DATABASE', credentialPath: 'database' },
{ envVar: 'MONGODB_USERNAME', credentialPath: 'username' },
{ envVar: 'MONGODB_PASSWORD', credentialPath: 'password' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
// Generate admin password
const adminPassword = credentialEncryption.generatePassword(32);
// Store admin credentials encrypted in the platform service record
const adminCredentials = {
username: 'admin',
password: adminPassword,
};
logger.info(`Deploying MongoDB platform service as ${containerName}...`);
// Ensure data directory exists
try {
await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MongoDB data directory: ${e.message}`);
}
}
// Create container using Docker API
const envVars = [
`MONGO_INITDB_ROOT_USERNAME=${adminCredentials.username}`,
`MONGO_INITDB_ROOT_PASSWORD=${adminCredentials.password}`,
];
// Use Docker to create the container
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: envVars,
volumes: config.volumes,
network: this.getNetworkName(),
});
// Store encrypted admin credentials
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`MongoDB container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping MongoDB container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MongoDB container stopped');
}
async healthCheck(): Promise<boolean> {
try {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
return false;
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Try to connect to MongoDB using mongosh ping
const { MongoClient } = await import('npm:mongodb@6');
const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
const client = new MongoClient(uri, {
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 5000,
});
await client.connect();
await client.db('admin').command({ ping: 1 });
await client.close();
return true;
} catch (error) {
logger.debug(`MongoDB health check failed: ${error.message}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
throw new Error('MongoDB 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 MongoDB database '${dbName}' for service '${userService.name}'...`);
// Connect to MongoDB and create database/user
const { MongoClient } = await import('npm:mongodb@6');
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
const client = new MongoClient(adminUri);
await client.connect();
try {
// Create the database by switching to it (MongoDB creates on first write)
const db = client.db(dbName);
// Create a collection to ensure the database exists
await db.createCollection('_onebox_init');
// Create user with readWrite access to this database
await db.command({
createUser: username,
pwd: password,
roles: [{ role: 'readWrite', db: dbName }],
});
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
} finally {
await client.close();
}
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '27017',
database: dbName,
username,
password,
connectionString: `mongodb://${username}:${password}@${containerName}:27017/${dbName}?authSource=${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) {
throw new Error('MongoDB platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
const { MongoClient } = await import('npm:mongodb@6');
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
const client = new MongoClient(adminUri);
await client.connect();
try {
const db = client.db(resource.resourceName);
// Drop the user
try {
await db.command({ dropUser: credentials.username });
logger.info(`Dropped MongoDB user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop MongoDB user: ${e.message}`);
}
// Drop the database
await db.dropDatabase();
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
} finally {
await client.close();
}
}
}