369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
/**
|
|
* 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 { getErrorMessage } from '../../../utils/error.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();
|
|
const dataDir = '/var/lib/onebox/minio';
|
|
|
|
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
|
|
try {
|
|
await Deno.mkdir(dataDir, { recursive: true });
|
|
} catch (e) {
|
|
// Directory might already exist
|
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
|
logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`);
|
|
}
|
|
}
|
|
|
|
// 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 (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(`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 {
|
|
logger.info('MinIO health check: starting...');
|
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
|
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;
|
|
}
|
|
|
|
logger.info(`MinIO 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
|
|
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;
|
|
}
|
|
} catch (error) {
|
|
logger.info(`MinIO 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('MinIO 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, 9000);
|
|
if (!hostPort) {
|
|
throw new Error('Could not get MinIO container host port');
|
|
}
|
|
|
|
// 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}'...`);
|
|
|
|
// 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
|
|
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
|
|
|
// Create S3 client with admin credentials - connect via host port
|
|
const s3Client = new S3Client({
|
|
endpoint: provisioningEndpoint,
|
|
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: ${getErrorMessage(e)}`);
|
|
}
|
|
|
|
// 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.');
|
|
|
|
// Use container name for the endpoint in credentials (user services run in same network)
|
|
const serviceEndpoint = `http://${containerName}:9000`;
|
|
|
|
const credentials: Record<string, string> = {
|
|
endpoint: serviceEndpoint,
|
|
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 || !platformService.containerId) {
|
|
throw new Error('MinIO 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, 9000);
|
|
if (!hostPort) {
|
|
throw new Error('Could not get MinIO container host port');
|
|
}
|
|
|
|
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: `http://127.0.0.1:${hostPort}`,
|
|
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: ${getErrorMessage(e)}`);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|