Files
onebox/ts/classes/services.ts
Juergen Kunz c59d56e70a Refactor error logging to use getErrorMessage utility
- Updated multiple classes to replace direct error message access with getErrorMessage function for improved error handling and consistency.
- Modified error logging in CertRequirementManager, CloudflareDomainSync, OneboxDnsManager, OneboxDockerManager, OneboxHttpServer, Onebox, OneboxRegistriesManager, and OneboxServicesManager.
- Ensured that all error messages logged provide a standardized format and improved clarity.
2025-11-25 04:38:26 +00:00

754 lines
24 KiB
TypeScript

/**
* Services Manager for Onebox
*
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
*/
import type { IService, IServiceDeployOptions, IPlatformRequirements } from '../types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
import type { PlatformServicesManager } from './platform-services/index.ts';
export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance
private database: OneboxDatabase;
private docker: OneboxDockerManager;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
this.docker = oneboxRef.docker;
}
/**
* Deploy a new service (full workflow)
*/
async deployService(options: IServiceDeployOptions): Promise<IService> {
try {
logger.info(`Deploying service: ${options.name}`);
// Check if service already exists
const existing = this.database.getServiceByName(options.name);
if (existing) {
throw new Error(`Service already exists: ${options.name}`);
}
// Handle Onebox Registry setup
let imageToPull: string;
if (options.useOneboxRegistry) {
// Use onebox registry image name
const tag = options.registryImageTag || 'latest';
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
} else {
// Use external image
imageToPull = options.image;
}
// Build platform requirements
const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3)
? {
mongodb: options.enableMongoDB,
s3: options.enableS3,
}
: undefined;
// Create service record in database
const service = await this.database.createService({
name: options.name,
image: options.useOneboxRegistry ? imageToPull : options.image,
registry: options.registry,
envVars: options.envVars || {},
port: options.port,
domain: options.domain,
status: 'stopped',
createdAt: Date.now(),
updatedAt: Date.now(),
// Onebox Registry fields
useOneboxRegistry: options.useOneboxRegistry,
registryRepository: options.useOneboxRegistry ? options.name : undefined,
registryImageTag: options.registryImageTag || 'latest',
autoUpdateOnPush: options.autoUpdateOnPush,
// Platform requirements
platformRequirements,
});
// Provision platform resources if needed
let platformEnvVars: Record<string, string> = {};
if (platformRequirements) {
try {
logger.info(`Provisioning platform resources for service '${options.name}'...`);
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
platformEnvVars = await platformServices.provisionForService(service);
logger.success(`Platform resources provisioned for service '${options.name}'`);
} catch (error) {
logger.error(`Failed to provision platform resources: ${getErrorMessage(error)}`);
// Clean up the service record on failure
this.database.deleteService(service.id!);
throw error;
}
}
// Merge platform env vars with user-specified env vars (user vars take precedence)
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) };
// Update service with merged env vars
if (Object.keys(platformEnvVars).length > 0) {
this.database.updateService(service.id!, { envVars: mergedEnvVars });
}
// Get updated service with merged env vars
const serviceWithEnvVars = this.database.getServiceByName(options.name)!;
// Pull image (skip if using onebox registry - image might not exist yet)
if (!options.useOneboxRegistry) {
await this.docker.pullImage(imageToPull, options.registry);
}
// Create container (uses the updated service with merged env vars)
const containerID = await this.docker.createContainer(serviceWithEnvVars);
// Update service with container ID
this.database.updateService(service.id!, {
containerID,
status: 'starting',
});
// Start container
await this.docker.startContainer(containerID);
// Update status
this.database.updateService(service.id!, { status: 'running' });
// If domain is specified, configure nginx, DNS, and SSL
if (options.domain) {
logger.info(`Configuring domain: ${options.domain}`);
// Validate domain and create CertRequirement
try {
// Extract base domain (e.g., "api.example.com" -> "example.com")
const domainParts = options.domain.split('.');
const baseDomain = domainParts.slice(-2).join('.');
const subdomain = domainParts.length > 2 ? domainParts.slice(0, -2).join('.') : '';
// Check if base domain exists in Domain table
const domainRecord = this.database.getDomainByName(baseDomain);
if (!domainRecord) {
logger.warn(
`Domain ${baseDomain} not found in Domain table. ` +
`Service will deploy but certificate management may not work. ` +
`Run Cloudflare domain sync or manually add the domain.`
);
} else if (domainRecord.isObsolete) {
logger.warn(
`Domain ${baseDomain} is marked as obsolete. ` +
`Certificate management may not work properly.`
);
} else {
// Create CertRequirement for automatic certificate management
const now = Date.now();
this.database.createCertRequirement({
serviceId: service.id!,
domainId: domainRecord.id!,
subdomain: subdomain,
status: 'pending',
createdAt: now,
updatedAt: now,
});
logger.info(
`Created certificate requirement for ${options.domain} ` +
`(domain: ${baseDomain}, subdomain: ${subdomain || 'none'})`
);
}
} catch (error) {
logger.warn(`Failed to create certificate requirement: ${getErrorMessage(error)}`);
}
// Configure DNS (if autoDNS is enabled)
if (options.autoDNS !== false) {
try {
await this.oneboxRef.dns.addDNSRecord(options.domain);
} catch (error) {
logger.warn(`Failed to configure DNS for ${options.domain}: ${getErrorMessage(error)}`);
}
}
// Configure reverse proxy
try {
await this.oneboxRef.reverseProxy.addRoute(service.id!, options.domain, options.port);
} catch (error) {
logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${getErrorMessage(error)}`);
}
// Configure SSL (if autoSSL is enabled)
// Note: With CertRequirement system, certificates are managed automatically
// but we still support the old direct obtainCertificate for backward compatibility
if (options.autoSSL !== false) {
try {
await this.oneboxRef.ssl.obtainCertificate(options.domain);
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${getErrorMessage(error)}`);
}
}
}
logger.success(`Service deployed successfully: ${options.name}`);
return this.database.getServiceByName(options.name)!;
} catch (error) {
logger.error(`Failed to deploy service ${options.name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Start a service
*/
async startService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Starting service: ${name}`);
this.database.updateService(service.id!, { status: 'starting' });
await this.docker.startContainer(service.containerID);
this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service started: ${name}`);
} catch (error) {
logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`);
this.database.updateService(
this.database.getServiceByName(name)?.id!,
{ status: 'failed' }
);
throw error;
}
}
/**
* Stop a service
*/
async stopService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Stopping service: ${name}`);
this.database.updateService(service.id!, { status: 'stopping' });
await this.docker.stopContainer(service.containerID);
this.database.updateService(service.id!, { status: 'stopped' });
logger.success(`Service stopped: ${name}`);
} catch (error) {
logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Restart a service
*/
async restartService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Restarting service: ${name}`);
await this.docker.restartContainer(service.containerID);
this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service restarted: ${name}`);
} catch (error) {
logger.error(`Failed to restart service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Remove a service (full cleanup)
*/
async removeService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
logger.info(`Removing service: ${name}`);
// Stop and remove container
if (service.containerID) {
try {
await this.docker.removeContainer(service.containerID, true);
} catch (error) {
logger.warn(`Failed to remove container: ${getErrorMessage(error)}`);
}
}
// Remove reverse proxy route
if (service.domain) {
try {
this.oneboxRef.reverseProxy.removeRoute(service.domain);
} catch (error) {
logger.warn(`Failed to remove reverse proxy route: ${getErrorMessage(error)}`);
}
// Note: We don't remove DNS records or SSL certs automatically
// as they might be used by other services or need manual cleanup
}
// Cleanup platform resources (MongoDB databases, S3 buckets, etc.)
if (service.platformRequirements) {
try {
logger.info(`Cleaning up platform resources for service '${name}'...`);
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
await platformServices.cleanupForService(service.id!);
logger.success(`Platform resources cleaned up for service '${name}'`);
} catch (error) {
logger.warn(`Failed to cleanup platform resources: ${getErrorMessage(error)}`);
// Continue with service deletion even if cleanup fails
}
}
// Remove from database
this.database.deleteService(service.id!);
logger.success(`Service removed: ${name}`);
} catch (error) {
logger.error(`Failed to remove service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* List all services
*/
listServices(): IService[] {
return this.database.getAllServices();
}
/**
* Get service by name
*/
getService(name: string): IService | null {
return this.database.getServiceByName(name);
}
/**
* Get service logs
*/
async getServiceLogs(name: string, tail = 100): Promise<string> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
const logs = await this.docker.getContainerLogs(service.containerID, tail);
// Debug: check what we got
logger.log(`getServiceLogs: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
logger.log(`getServiceLogs: logs.stdout type = ${typeof logs.stdout}`);
logger.log(`getServiceLogs: logs.stdout value = ${String(logs.stdout).slice(0, 100)}`);
// v5 API returns combined stdout/stderr with proper formatting
return logs.stdout;
} catch (error) {
logger.error(`Failed to get logs for service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Stream service logs (real-time)
*/
async streamServiceLogs(
name: string,
callback: (line: string, isError: boolean) => void
): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
await this.docker.streamContainerLogs(service.containerID, callback);
} catch (error) {
logger.error(`Failed to stream logs for service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Get service metrics
*/
async getServiceMetrics(name: string) {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
const stats = await this.docker.getContainerStats(service.containerID);
return stats;
} catch (error) {
logger.error(`Failed to get metrics for service ${name}: ${getErrorMessage(error)}`);
return null;
}
}
/**
* Get platform resources for a service
*/
async getServicePlatformResources(name: string) {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.platformRequirements) {
return [];
}
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
return await platformServices.getResourcesForService(service.id!);
} catch (error) {
logger.error(`Failed to get platform resources for service ${name}: ${getErrorMessage(error)}`);
return [];
}
}
/**
* Get service status
*/
async getServiceStatus(name: string): Promise<string> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
return 'not-found';
}
if (!service.containerID) {
return service.status;
}
const status = await this.docker.getContainerStatus(service.containerID);
return status;
} catch (error) {
logger.error(`Failed to get status for service ${name}: ${getErrorMessage(error)}`);
return 'unknown';
}
}
/**
* Update service environment variables
*/
async updateServiceEnv(name: string, envVars: Record<string, string>): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
// Update database
this.database.updateService(service.id!, { envVars });
// Note: Requires container restart to take effect
logger.info(`Environment variables updated for ${name}. Restart service to apply changes.`);
} catch (error) {
logger.error(`Failed to update env vars for service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Update service configuration (image, port, domain, env vars)
* Recreates the container with new configuration and auto-restarts
*/
async updateService(
name: string,
updates: {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}
): Promise<IService> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
logger.info(`Updating service: ${name}`);
const wasRunning = service.status === 'running';
const oldContainerID = service.containerID;
const oldDomain = service.domain;
// Stop the container if running
if (wasRunning && oldContainerID) {
logger.info(`Stopping service ${name} for updates...`);
try {
await this.docker.stopContainer(oldContainerID);
} catch (error) {
logger.warn(`Failed to stop container: ${getErrorMessage(error)}`);
}
}
// Pull new image if changed
if (updates.image && updates.image !== service.image) {
logger.info(`Pulling new image: ${updates.image}`);
await this.docker.pullImage(updates.image, updates.registry || service.registry);
}
// Update service in database
const updateData: any = {
updatedAt: Date.now(),
};
if (updates.image !== undefined) updateData.image = updates.image;
if (updates.registry !== undefined) updateData.registry = updates.registry;
if (updates.port !== undefined) updateData.port = updates.port;
if (updates.domain !== undefined) updateData.domain = updates.domain;
if (updates.envVars !== undefined) updateData.envVars = updates.envVars;
this.database.updateService(service.id!, updateData);
// Get updated service
const updatedService = this.database.getServiceByName(name)!;
// Remove old container
if (oldContainerID) {
try {
await this.docker.removeContainer(oldContainerID, true);
logger.info(`Removed old container for ${name}`);
} catch (error) {
logger.warn(`Failed to remove old container: ${getErrorMessage(error)}`);
}
}
// Create new container with updated config
logger.info(`Creating new container for ${name}...`);
const containerID = await this.docker.createContainer(updatedService);
this.database.updateService(service.id!, { containerID });
// Update reverse proxy if domain changed
if (updates.domain !== undefined && updates.domain !== oldDomain) {
// Remove old route if it existed
if (oldDomain) {
try {
this.oneboxRef.reverseProxy.removeRoute(oldDomain);
} catch (error) {
logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`);
}
}
// Add new route if domain specified
if (updates.domain) {
try {
await this.oneboxRef.reverseProxy.addRoute(
service.id!,
updates.domain,
updates.port || service.port
);
} catch (error) {
logger.warn(`Failed to configure reverse proxy: ${getErrorMessage(error)}`);
}
}
}
// Restart the container if it was running
if (wasRunning) {
logger.info(`Starting updated service ${name}...`);
this.database.updateService(service.id!, { status: 'starting' });
await this.docker.startContainer(containerID);
this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service ${name} updated and restarted`);
} else {
this.database.updateService(service.id!, { status: 'stopped' });
logger.success(`Service ${name} updated (not started)`);
}
return this.database.getServiceByName(name)!;
} catch (error) {
logger.error(`Failed to update service ${name}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Sync service status from Docker
*/
async syncServiceStatus(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service || !service.containerID) {
return;
}
const status = await this.docker.getContainerStatus(service.containerID);
// Map Docker status to our status
let ourStatus: IService['status'] = 'stopped';
if (status === 'running') {
ourStatus = 'running';
} else if (status === 'exited' || status === 'dead') {
ourStatus = 'stopped';
} else if (status === 'created') {
ourStatus = 'stopped';
} else if (status === 'restarting') {
ourStatus = 'starting';
}
// Only update and broadcast if status changed
if (service.status !== ourStatus) {
this.database.updateService(service.id!, { status: ourStatus });
// Broadcast status change via WebSocket
if (this.oneboxRef.httpServer) {
this.oneboxRef.httpServer.broadcastServiceStatus(name, ourStatus);
}
}
} catch (error) {
logger.debug(`Failed to sync status for service ${name}: ${getErrorMessage(error)}`);
}
}
/**
* Sync all service statuses from Docker
*/
async syncAllServiceStatuses(): Promise<void> {
const services = this.listServices();
for (const service of services) {
await this.syncServiceStatus(service.name);
}
}
/**
* Start auto-update monitoring for registry services
* Polls every 30 seconds for digest changes and restarts services if needed
*/
startAutoUpdateMonitoring(): void {
// Check every 30 seconds
setInterval(async () => {
try {
await this.checkForRegistryUpdates();
} catch (error) {
logger.error(`Auto-update check failed: ${getErrorMessage(error)}`);
}
}, 30000);
logger.info('Auto-update monitoring started (30s interval)');
}
/**
* Check all services using onebox registry for updates
*/
private async checkForRegistryUpdates(): Promise<void> {
const services = this.listServices();
for (const service of services) {
// Skip if not using onebox registry or auto-update is disabled
if (!service.useOneboxRegistry || !service.autoUpdateOnPush) {
continue;
}
try {
// Get current digest from registry
const currentDigest = await this.oneboxRef.registry.getImageDigest(
service.registryRepository!,
service.registryImageTag || 'latest'
);
// Skip if no digest found (image might not exist yet)
if (!currentDigest) {
continue;
}
// Check if digest has changed
if (service.imageDigest && service.imageDigest !== currentDigest) {
logger.info(
`Digest changed for ${service.name}: ${service.imageDigest} -> ${currentDigest}`
);
// Update digest in database
this.database.updateService(service.id!, {
imageDigest: currentDigest,
});
// Pull new image
const imageName = this.oneboxRef.registry.getImageName(
service.registryRepository!,
service.registryImageTag || 'latest'
);
logger.info(`Pulling updated image: ${imageName}`);
await this.docker.pullImage(imageName);
// Restart service
logger.info(`Auto-restarting service: ${service.name}`);
await this.restartService(service.name);
// Broadcast update via WebSocket
this.oneboxRef.httpServer.broadcastServiceUpdate({
action: 'updated',
service: this.database.getServiceByName(service.name)!,
});
} else if (!service.imageDigest) {
// First time - just store the digest
this.database.updateService(service.id!, {
imageDigest: currentDigest,
});
}
} catch (error) {
logger.error(`Failed to check updates for ${service.name}: ${error.message}`);
}
}
}
}