/** * Docker Manager for Onebox * * Handles all Docker operations: containers, images, networks, volumes */ import * as plugins from '../plugins.ts'; import type { IService, IContainerStats } from '../types.ts'; import { logger } from '../logging.ts'; export class OneboxDockerManager { private dockerClient: plugins.docker.Docker | null = null; private networkName = 'onebox-network'; /** * Initialize Docker client and create onebox network */ async init(): Promise { try { // Initialize Docker client (connects to /var/run/docker.sock by default) this.dockerClient = new plugins.docker.Docker({ socketPath: 'unix:///var/run/docker.sock', }); // Start the Docker client await this.dockerClient.start(); logger.info('Docker client initialized'); // Ensure onebox network exists await this.ensureNetwork(); } catch (error) { logger.error(`Failed to initialize Docker client: ${error.message}`); throw error; } } /** * Ensure onebox network exists */ private async ensureNetwork(): Promise { try { const networks = await this.dockerClient!.getNetworks(); const existingNetwork = networks.find((n: any) => n.name === this.networkName); if (!existingNetwork) { logger.info(`Creating Docker network: ${this.networkName}`); // Check if Docker is in Swarm mode let isSwarmMode = false; try { const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {}); isSwarmMode = swarmResponse.statusCode === 200; } catch (error) { isSwarmMode = false; } await this.dockerClient!.createNetwork({ Name: this.networkName, Driver: isSwarmMode ? 'overlay' : 'bridge', Attachable: isSwarmMode ? true : undefined, // Required for overlay networks to allow standalone containers Labels: { 'managed-by': 'onebox', }, }); logger.success(`Docker network created: ${this.networkName} (${isSwarmMode ? 'overlay' : 'bridge'})`); } else { logger.debug(`Docker network already exists: ${this.networkName}`); } } catch (error) { logger.error(`Failed to create Docker network: ${error.message}`); throw error; } } /** * Pull an image from a registry */ async pullImage(image: string, registry?: string): Promise { // Skip manual image pulling - Docker will automatically pull when creating container const fullImage = registry ? `${registry}/${image}` : image; logger.debug(`Skipping manual pull for ${fullImage} - Docker will auto-pull on container creation`); } /** * Create and start a container or service (depending on Swarm mode) */ async createContainer(service: IService): Promise { try { // Check if Docker is in Swarm mode let isSwarmMode = false; try { const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {}); isSwarmMode = swarmResponse.statusCode === 200; } catch (error) { isSwarmMode = false; } if (isSwarmMode) { return await this.createSwarmService(service); } else { return await this.createStandaloneContainer(service); } } catch (error) { logger.error(`Failed to create container for ${service.name}: ${error.message}`); throw error; } } /** * Create a standalone container (non-Swarm mode) */ private async createStandaloneContainer(service: IService): Promise { logger.info(`Creating standalone container for service: ${service.name}`); const fullImage = service.registry ? `${service.registry}/${service.image}` : service.image; // Prepare environment variables const env: string[] = []; for (const [key, value] of Object.entries(service.envVars)) { env.push(`${key}=${value}`); } // Create container using Docker REST API directly const response = await this.dockerClient!.request('POST', `/containers/create?name=onebox-${service.name}`, { Image: fullImage, Env: env, Labels: { 'managed-by': 'onebox', 'onebox-service': service.name, }, ExposedPorts: { [`${service.port}/tcp`]: {}, }, HostConfig: { NetworkMode: this.networkName, RestartPolicy: { Name: 'unless-stopped', }, PortBindings: { // Don't bind to host ports - nginx will proxy [`${service.port}/tcp`]: [], }, }, }); if (response.statusCode >= 300) { throw new Error(`Failed to create container: HTTP ${response.statusCode}`); } const containerID = response.body.Id; logger.success(`Standalone container created: ${containerID}`); return containerID; } /** * Create a Docker Swarm service */ private async createSwarmService(service: IService): Promise { logger.info(`Creating Swarm service for: ${service.name}`); const fullImage = service.registry ? `${service.registry}/${service.image}` : service.image; // Prepare environment variables const env: string[] = []; for (const [key, value] of Object.entries(service.envVars)) { env.push(`${key}=${value}`); } // Create Swarm service using Docker REST API const response = await this.dockerClient!.request('POST', '/services/create', { Name: `onebox-${service.name}`, Labels: { 'managed-by': 'onebox', 'onebox-service': service.name, }, TaskTemplate: { ContainerSpec: { Image: fullImage, Env: env, Labels: { 'managed-by': 'onebox', 'onebox-service': service.name, }, }, Networks: [ { Target: await this.getNetworkID(this.networkName), }, ], RestartPolicy: { Condition: 'any', MaxAttempts: 0, }, }, Mode: { Replicated: { Replicas: 1, }, }, EndpointSpec: { Ports: [ { Protocol: 'tcp', TargetPort: service.port, PublishMode: 'host', }, ], }, }); if (response.statusCode >= 300) { throw new Error(`Failed to create service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`); } const serviceID = response.body.ID; logger.success(`Swarm service created: ${serviceID}`); return serviceID; } /** * Get network ID by name */ private async getNetworkID(networkName: string): Promise { const networks = await this.dockerClient!.getNetworks(); const network = networks.find((n: any) => (n.name || n.Name) === networkName ); if (!network) { throw new Error(`Network not found: ${networkName}`); } return network.id || network.Id; } /** * Start a container or service by ID */ async startContainer(containerID: string): Promise { try { // Try service first if (await this.isService(containerID)) { return await this.startService(containerID); } logger.info(`Starting container: ${containerID}`); const response = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {}); if (response.statusCode >= 300 && response.statusCode !== 304) { throw new Error(`Failed to start container: HTTP ${response.statusCode}`); } logger.success(`Container started: ${containerID}`); } catch (error) { // Ignore "already started" errors (304 status) if (error.message.includes('304')) { logger.debug(`Container already running: ${containerID}`); return; } logger.error(`Failed to start container ${containerID}: ${error.message}`); throw error; } } /** * Start a Swarm service (scale to 1 replica) */ private async startService(serviceID: string): Promise { logger.info(`Starting service: ${serviceID}`); // Get current service spec const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); if (getResponse.statusCode >= 300) { throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); } const service = getResponse.body; const version = service.Version.Index; // Update service to scale to 1 replica const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { ...service.Spec, Mode: { Replicated: { Replicas: 1, }, }, }); if (updateResponse.statusCode >= 300) { throw new Error(`Failed to start service: HTTP ${updateResponse.statusCode}`); } logger.success(`Service started (scaled to 1 replica): ${serviceID}`); } /** * Check if ID is a service (not a container) */ private async isService(id: string): Promise { try { const response = await this.dockerClient!.request('GET', `/services/${id}`, {}); return response.statusCode === 200; } catch (error) { return false; } } /** * Stop a container or service by ID */ async stopContainer(containerID: string): Promise { try { // Try service first if (await this.isService(containerID)) { return await this.stopService(containerID); } logger.info(`Stopping container: ${containerID}`); const response = await this.dockerClient!.request('POST', `/containers/${containerID}/stop`, {}); if (response.statusCode >= 300 && response.statusCode !== 304) { throw new Error(`Failed to stop container: HTTP ${response.statusCode}`); } logger.success(`Container stopped: ${containerID}`); } catch (error) { // Ignore "already stopped" errors (304 status) if (error.message.includes('304')) { logger.debug(`Container already stopped: ${containerID}`); return; } logger.error(`Failed to stop container ${containerID}: ${error.message}`); throw error; } } /** * Stop a Swarm service (scale to 0 replicas) */ private async stopService(serviceID: string): Promise { logger.info(`Stopping service: ${serviceID}`); // Get current service spec const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); if (getResponse.statusCode >= 300) { throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); } const service = getResponse.body; const version = service.Version.Index; // Update service to scale to 0 replicas const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { ...service.Spec, Mode: { Replicated: { Replicas: 0, }, }, }); if (updateResponse.statusCode >= 300) { throw new Error(`Failed to stop service: HTTP ${updateResponse.statusCode}`); } logger.success(`Service stopped (scaled to 0 replicas): ${serviceID}`); } /** * Restart a container or service by ID */ async restartContainer(containerID: string): Promise { try { // Try service first if (await this.isService(containerID)) { return await this.restartService(containerID); } logger.info(`Restarting container: ${containerID}`); const response = await this.dockerClient!.request('POST', `/containers/${containerID}/restart`, {}); if (response.statusCode >= 300) { throw new Error(`Failed to restart container: HTTP ${response.statusCode}`); } logger.success(`Container restarted: ${containerID}`); } catch (error) { logger.error(`Failed to restart container ${containerID}: ${error.message}`); throw error; } } /** * Restart a Swarm service (force update with same spec) */ private async restartService(serviceID: string): Promise { logger.info(`Restarting service: ${serviceID}`); // Get current service spec const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); if (getResponse.statusCode >= 300) { throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); } const service = getResponse.body; const version = service.Version.Index; // Force update to trigger restart const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { ...service.Spec, TaskTemplate: { ...service.Spec.TaskTemplate, ForceUpdate: (service.Spec.TaskTemplate.ForceUpdate || 0) + 1, }, }); if (updateResponse.statusCode >= 300) { throw new Error(`Failed to restart service: HTTP ${updateResponse.statusCode}`); } logger.success(`Service restarted: ${serviceID}`); } /** * Remove a container or service by ID */ async removeContainer(containerID: string, force = false): Promise { try { // Try service first if (await this.isService(containerID)) { return await this.removeService(containerID); } logger.info(`Removing container: ${containerID}`); // Stop first if not forced if (!force) { try { await this.stopContainer(containerID); } catch (error) { // Ignore stop errors logger.debug(`Error stopping container before removal: ${error.message}`); } } const url = force ? `/containers/${containerID}?force=true` : `/containers/${containerID}`; const response = await this.dockerClient!.request('DELETE', url, {}); if (response.statusCode >= 300) { throw new Error(`Failed to remove container: HTTP ${response.statusCode}`); } logger.success(`Container removed: ${containerID}`); } catch (error) { logger.error(`Failed to remove container ${containerID}: ${error.message}`); throw error; } } /** * Remove a Swarm service */ private async removeService(serviceID: string): Promise { logger.info(`Removing service: ${serviceID}`); const response = await this.dockerClient!.request('DELETE', `/services/${serviceID}`, {}); if (response.statusCode >= 300) { throw new Error(`Failed to remove service: HTTP ${response.statusCode}`); } logger.success(`Service removed: ${serviceID}`); } /** * Get container or service status */ async getContainerStatus(containerID: string): Promise { try { // Try service first if (await this.isService(containerID)) { return await this.getServiceStatus(containerID); } const response = await this.dockerClient!.request('GET', `/containers/${containerID}/json`, {}); if (response.statusCode >= 300) { return 'unknown'; } return response.body.State?.Status || 'unknown'; } catch (error) { logger.error(`Failed to get container status ${containerID}: ${error.message}`); return 'unknown'; } } /** * Get Swarm service status */ private async getServiceStatus(serviceID: string): Promise { try { // Get service details const serviceResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); if (serviceResponse.statusCode >= 300) { return 'unknown'; } const service = serviceResponse.body; const replicas = service.Spec?.Mode?.Replicated?.Replicas || 0; if (replicas === 0) { return 'stopped'; } // Get tasks for this service to check if they're running const tasksResponse = await this.dockerClient!.request('GET', `/tasks?filters=${encodeURIComponent(JSON.stringify({service: [serviceID]}))}`, {}); if (tasksResponse.statusCode >= 300) { return 'unknown'; } const tasks = tasksResponse.body; if (tasks.length === 0) { return 'starting'; } // Check if any task is running const hasRunning = tasks.some((task: any) => task.Status?.State === 'running'); if (hasRunning) { return 'running'; } // Check task states const latestTask = tasks[0]; const taskState = latestTask?.Status?.State || 'unknown'; // Map Swarm task states to container-like states switch (taskState) { case 'new': case 'allocated': case 'pending': case 'assigned': case 'accepted': case 'preparing': case 'ready': case 'starting': return 'starting'; case 'running': return 'running'; case 'complete': return 'exited'; case 'failed': case 'shutdown': case 'rejected': case 'orphaned': case 'remove': return 'stopped'; default: return 'unknown'; } } catch (error) { logger.error(`Failed to get service status ${serviceID}: ${error.message}`); return 'unknown'; } } /** * Get container stats (CPU, memory, network) */ async getContainerStats(containerID: string): Promise { try { const container = this.dockerClient!.getContainer(containerID); const stats = await container.stats({ stream: false }); // Calculate CPU percentage const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0; // Memory stats const memoryUsed = stats.memory_stats.usage || 0; const memoryLimit = stats.memory_stats.limit || 0; const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0; // Network stats let networkRx = 0; let networkTx = 0; if (stats.networks) { for (const network of Object.values(stats.networks)) { networkRx += (network as any).rx_bytes || 0; networkTx += (network as any).tx_bytes || 0; } } return { cpuPercent, memoryUsed, memoryLimit, memoryPercent, networkRx, networkTx, }; } catch (error) { logger.error(`Failed to get container stats ${containerID}: ${error.message}`); return null; } } /** * Get container logs */ async getContainerLogs( containerID: string, tail = 100 ): Promise<{ stdout: string; stderr: string }> { try { const container = this.dockerClient!.getContainer(containerID); const logs = await container.logs({ stdout: true, stderr: true, tail, timestamps: true, }); // Parse logs (Docker returns them in a special format) const stdout: string[] = []; const stderr: string[] = []; const lines = logs.toString().split('\n'); for (const line of lines) { if (line.length === 0) continue; // Docker log format: first byte indicates stream (1=stdout, 2=stderr) const streamType = line.charCodeAt(0); const content = line.slice(8); // Skip header (8 bytes) if (streamType === 1) { stdout.push(content); } else if (streamType === 2) { stderr.push(content); } } return { stdout: stdout.join('\n'), stderr: stderr.join('\n'), }; } catch (error) { logger.error(`Failed to get container logs ${containerID}: ${error.message}`); return { stdout: '', stderr: '' }; } } /** * Stream container logs (real-time) */ async streamContainerLogs( containerID: string, callback: (line: string, isError: boolean) => void ): Promise { try { const container = this.dockerClient!.getContainer(containerID); const stream = await container.logs({ stdout: true, stderr: true, follow: true, tail: 0, timestamps: true, }); stream.on('data', (chunk: Buffer) => { const streamType = chunk[0]; const content = chunk.slice(8).toString(); callback(content, streamType === 2); }); stream.on('error', (error: Error) => { logger.error(`Log stream error for ${containerID}: ${error.message}`); }); } catch (error) { logger.error(`Failed to stream container logs ${containerID}: ${error.message}`); throw error; } } /** * List all onebox-managed containers */ async listContainers(): Promise { try { const containers = await this.dockerClient!.getContainers(); // Filter for onebox-managed containers return containers.filter((c: any) => c.labels && c.labels['managed-by'] === 'onebox' ); } catch (error) { logger.error(`Failed to list containers: ${error.message}`); return []; } } /** * Check if Docker is running */ async isDockerRunning(): Promise { try { await this.dockerClient!.ping(); return true; } catch (error) { return false; } } /** * Get Docker version info */ async getDockerVersion(): Promise { try { return await this.dockerClient!.version(); } catch (error) { logger.error(`Failed to get Docker version: ${error.message}`); return null; } } /** * Prune unused images */ async pruneImages(): Promise { try { logger.info('Pruning unused Docker images...'); await this.dockerClient!.pruneImages(); logger.success('Unused images pruned successfully'); } catch (error) { logger.error(`Failed to prune images: ${error.message}`); throw error; } } /** * Get container IP address in onebox network */ async getContainerIP(containerID: string): Promise { try { const container = this.dockerClient!.getContainer(containerID); const info = await container.inspect(); const networks = info.NetworkSettings.Networks; if (networks && networks[this.networkName]) { return networks[this.networkName].IPAddress; } return null; } catch (error) { logger.error(`Failed to get container IP ${containerID}: ${error.message}`); return null; } } /** * Execute a command in a running container */ async execInContainer( containerID: string, cmd: string[] ): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { const container = this.dockerClient!.getContainer(containerID); const exec = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, }); const stream = await exec.start({ Detach: false }); let stdout = ''; let stderr = ''; stream.on('data', (chunk: Buffer) => { const streamType = chunk[0]; const content = chunk.slice(8).toString(); if (streamType === 1) { stdout += content; } else if (streamType === 2) { stderr += content; } }); // Wait for completion await new Promise((resolve) => stream.on('end', resolve)); const inspect = await exec.inspect(); const exitCode = inspect.ExitCode || 0; return { stdout, stderr, exitCode }; } catch (error) { logger.error(`Failed to exec in container ${containerID}: ${error.message}`); throw error; } } }