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

@@ -7,7 +7,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from './onebox.ts';
import type { IApiResponse } from '../types.ts';
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts';
export class OneboxHttpServer {
private oneboxRef: Onebox;
@@ -263,9 +263,28 @@ export class OneboxHttpServer {
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
const serviceName = path.split('/').pop()!;
return await this.handleGetRegistryTagsRequest(serviceName);
} else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) {
const serviceName = path.split('/').pop()!;
return await this.handleGetRegistryTokenRequest(serviceName);
} else if (path === '/api/registry/tokens' && method === 'GET') {
return await this.handleListRegistryTokensRequest(req);
} else if (path === '/api/registry/tokens' && method === 'POST') {
return await this.handleCreateRegistryTokenRequest(req);
} else if (path.match(/^\/api\/registry\/tokens\/\d+$/) && method === 'DELETE') {
const tokenId = Number(path.split('/').pop());
return await this.handleDeleteRegistryTokenRequest(tokenId);
// Platform Services endpoints
} else if (path === '/api/platform-services' && method === 'GET') {
return await this.handleListPlatformServicesRequest();
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') {
const type = path.split('/').pop()!;
return await this.handleGetPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') {
const type = path.split('/')[3];
return await this.handleStartPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') {
const type = path.split('/')[3];
return await this.handleStopPlatformServiceRequest(type);
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
const serviceName = path.split('/')[3];
return await this.handleGetServicePlatformResourcesRequest(serviceName);
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -1032,6 +1051,183 @@ export class OneboxHttpServer {
});
}
// ============ Platform Services Endpoints ============
private async handleListPlatformServicesRequest(): Promise<Response> {
try {
const platformServices = this.oneboxRef.platformServices.getAllPlatformServices();
const providers = this.oneboxRef.platformServices.getAllProviders();
// Build response with provider info
const result = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type);
return {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed',
containerId: service?.containerId,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
};
});
return this.jsonResponse({ success: true, data: result });
} catch (error) {
logger.error(`Failed to list platform services: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to list platform services',
}, 500);
}
}
private async handleGetPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
const service = this.oneboxRef.database.getPlatformServiceByType(type);
// Get resource count
const allResources = service?.id
? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id)
: [];
return this.jsonResponse({
success: true,
data: {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed',
containerId: service?.containerId,
config: provider.getDefaultConfig(),
resourceCount: allResources.length,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
},
});
} catch (error) {
logger.error(`Failed to get platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to get platform service',
}, 500);
}
}
private async handleStartPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
logger.info(`Starting platform service: ${type}`);
const service = await this.oneboxRef.platformServices.ensureRunning(type);
return this.jsonResponse({
success: true,
message: `Platform service ${provider.displayName} started`,
data: {
type: service.type,
status: service.status,
containerId: service.containerId,
},
});
} catch (error) {
logger.error(`Failed to start platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to start platform service',
}, 500);
}
}
private async handleStopPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
logger.info(`Stopping platform service: ${type}`);
await this.oneboxRef.platformServices.stopPlatformService(type);
return this.jsonResponse({
success: true,
message: `Platform service ${provider.displayName} stopped`,
});
} catch (error) {
logger.error(`Failed to stop platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to stop platform service',
}, 500);
}
}
private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
try {
const service = this.oneboxRef.services.getService(serviceName);
if (!service) {
return this.jsonResponse({
success: false,
error: 'Service not found',
}, 404);
}
const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName);
// Format resources for API response (mask sensitive credentials)
const formattedResources = resources.map((r) => ({
id: r.resource.id,
resourceType: r.resource.resourceType,
resourceName: r.resource.resourceName,
platformService: {
type: r.platformService.type,
name: r.platformService.name,
status: r.platformService.status,
},
// Include env var mappings (show keys, not values)
envVars: Object.keys(r.credentials).reduce((acc, key) => {
// Mask sensitive values
const value = r.credentials[key];
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
acc[key] = '********';
} else {
acc[key] = value;
}
return acc;
}, {} as Record<string, string>),
createdAt: r.resource.createdAt,
}));
return this.jsonResponse({
success: true,
data: formattedResources,
});
} catch (error) {
logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to get platform resources',
}, 500);
}
}
// ============ Registry Endpoints ============
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
@@ -1047,51 +1243,206 @@ export class OneboxHttpServer {
}
}
private async handleGetRegistryTokenRequest(serviceName: string): Promise<Response> {
// ============ Registry Token Management Endpoints ============
private async handleListRegistryTokensRequest(req: Request): Promise<Response> {
try {
// Get the service to verify it exists
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
const tokens = this.oneboxRef.database.getAllRegistryTokens();
// Convert to view format (mask token hash, add computed fields)
const tokenViews: IRegistryTokenView[] = tokens.map(token => {
const now = Date.now();
const isExpired = token.expiresAt !== null && token.expiresAt < now;
// Generate scope display string
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1
? token.scope[0]
: `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
return {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired,
};
});
return this.jsonResponse({ success: true, data: tokenViews });
} catch (error) {
logger.error(`Failed to list registry tokens: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to list registry tokens',
}, 500);
}
}
private async handleCreateRegistryTokenRequest(req: Request): Promise<Response> {
try {
const body = await req.json() as ICreateRegistryTokenRequest;
// Validate request
if (!body.name || !body.type || !body.scope || !body.expiresIn) {
return this.jsonResponse({
success: false,
error: 'Service not found',
}, 404);
error: 'Missing required fields: name, type, scope, expiresIn',
}, 400);
}
// If service already has a token, return it
if (service.registryToken) {
if (body.type !== 'global' && body.type !== 'ci') {
return this.jsonResponse({
success: true,
data: {
token: service.registryToken,
repository: serviceName,
baseUrl: this.oneboxRef.registry.getBaseUrl(),
},
});
success: false,
error: 'Invalid token type. Must be "global" or "ci"',
}, 400);
}
// Generate new token
const token = await this.oneboxRef.registry.createServiceToken(serviceName);
// Validate scope
if (body.scope !== 'all' && !Array.isArray(body.scope)) {
return this.jsonResponse({
success: false,
error: 'Scope must be "all" or an array of service names',
}, 400);
}
// Save token to database
this.oneboxRef.database.updateService(service.id!, {
registryToken: token,
registryRepository: serviceName,
// If scope is array of services, validate they exist
if (Array.isArray(body.scope)) {
for (const serviceName of body.scope) {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
return this.jsonResponse({
success: false,
error: `Service not found: ${serviceName}`,
}, 400);
}
}
}
// Calculate expiration timestamp
const now = Date.now();
let expiresAt: number | null = null;
if (body.expiresIn !== 'never') {
const daysMap: Record<string, number> = {
'30d': 30,
'90d': 90,
'365d': 365,
};
const days = daysMap[body.expiresIn];
if (days) {
expiresAt = now + (days * 24 * 60 * 60 * 1000);
}
}
// Generate token (random 32 bytes as hex)
const plainToken = crypto.randomUUID() + crypto.randomUUID();
// Hash the token for storage (using simple hash for now)
const encoder = new TextEncoder();
const data = encoder.encode(plainToken);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const tokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Get username from auth token
const authHeader = req.headers.get('Authorization');
let createdBy = 'system';
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const decoded = atob(authHeader.slice(7));
createdBy = decoded.split(':')[0];
} catch {
// Keep default
}
}
// Create token in database
const token = this.oneboxRef.database.createRegistryToken({
name: body.name,
tokenHash,
type: body.type,
scope: body.scope,
expiresAt,
createdAt: now,
lastUsedAt: null,
createdBy,
});
// Build view response
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1
? token.scope[0]
: `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
const tokenView: IRegistryTokenView = {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired: false,
};
return this.jsonResponse({
success: true,
data: {
token: token,
repository: serviceName,
baseUrl: this.oneboxRef.registry.getBaseUrl(),
token: tokenView,
plainToken, // Only returned once at creation
},
});
} catch (error) {
logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`);
logger.error(`Failed to create registry token: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to get registry token',
error: error.message || 'Failed to create registry token',
}, 500);
}
}
private async handleDeleteRegistryTokenRequest(tokenId: number): Promise<Response> {
try {
// Check if token exists
const token = this.oneboxRef.database.getRegistryTokenById(tokenId);
if (!token) {
return this.jsonResponse({
success: false,
error: 'Token not found',
}, 404);
}
// Delete the token
this.oneboxRef.database.deleteRegistryToken(tokenId);
return this.jsonResponse({
success: true,
message: 'Token deleted successfully',
});
} catch (error) {
logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to delete registry token',
}, 500);
}
}