/** * Services Manager for Onebox * * Orchestrates service deployment: Docker + Nginx + DNS + SSL */ import type { IService, IServiceDeployOptions } from '../types.ts'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; import { OneboxDockerManager } from './docker.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 { 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 registryToken: string | undefined; let imageToPull: string; if (options.useOneboxRegistry) { // Generate registry token registryToken = await this.oneboxRef.registry.createServiceToken(options.name); // 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; } // 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, registryToken: registryToken, registryImageTag: options.registryImageTag || 'latest', autoUpdateOnPush: options.autoUpdateOnPush, }); // Pull image (skip if using onebox registry - image might not exist yet) if (!options.useOneboxRegistry) { await this.docker.pullImage(imageToPull, options.registry); } // Create container const containerID = await this.docker.createContainer(service); // 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: ${error.message}`); } // 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}: ${error.message}`); } } // 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}: ${error.message}`); } // 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}: ${error.message}`); } } } logger.success(`Service deployed successfully: ${options.name}`); return this.database.getServiceByName(options.name)!; } catch (error) { logger.error(`Failed to deploy service ${options.name}: ${error.message}`); throw error; } } /** * Start a service */ async startService(name: string): Promise { 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}: ${error.message}`); this.database.updateService( this.database.getServiceByName(name)?.id!, { status: 'failed' } ); throw error; } } /** * Stop a service */ async stopService(name: string): Promise { 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}: ${error.message}`); throw error; } } /** * Restart a service */ async restartService(name: string): Promise { 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}: ${error.message}`); throw error; } } /** * Remove a service (full cleanup) */ async removeService(name: string): Promise { 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: ${error.message}`); } } // Remove reverse proxy route if (service.domain) { try { this.oneboxRef.reverseProxy.removeRoute(service.domain); } catch (error) { logger.warn(`Failed to remove reverse proxy route: ${error.message}`); } // Note: We don't remove DNS records or SSL certs automatically // as they might be used by other services or need manual cleanup } // Remove from database this.database.deleteService(service.id!); logger.success(`Service removed: ${name}`); } catch (error) { logger.error(`Failed to remove service ${name}: ${error.message}`); 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 { 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}: ${error.message}`); throw error; } } /** * Stream service logs (real-time) */ async streamServiceLogs( name: string, callback: (line: string, isError: boolean) => void ): Promise { 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}: ${error.message}`); 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}: ${error.message}`); return null; } } /** * Get service status */ async getServiceStatus(name: string): Promise { 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}: ${error.message}`); return 'unknown'; } } /** * Update service environment variables */ async updateServiceEnv(name: string, envVars: Record): Promise { 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}: ${error.message}`); 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; } ): Promise { 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: ${error.message}`); } } // 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: ${error.message}`); } } // 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: ${error.message}`); } } // 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: ${error.message}`); } } } // 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}: ${error.message}`); throw error; } } /** * Sync service status from Docker */ async syncServiceStatus(name: string): Promise { 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}: ${error.message}`); } } /** * Sync all service statuses from Docker */ async syncAllServiceStatuses(): Promise { 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: ${error.message}`); } }, 30000); logger.info('Auto-update monitoring started (30s interval)'); } /** * Check all services using onebox registry for updates */ private async checkForRegistryUpdates(): Promise { 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}`); } } } }