/** * Docker Manager for Onebox * * Handles all Docker operations: containers, images, networks, volumes */ import * as plugins from './onebox.plugins.ts'; import type { IService, IContainerStats } from './onebox.types.ts'; import { logger } from './onebox.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: '/var/run/docker.sock', }); 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!.listNetworks(); const existingNetwork = networks.find((n: any) => n.Name === this.networkName); if (!existingNetwork) { logger.info(`Creating Docker network: ${this.networkName}`); await this.dockerClient!.createNetwork({ Name: this.networkName, Driver: 'bridge', Labels: { 'managed-by': 'onebox', }, }); logger.success(`Docker network created: ${this.networkName}`); } 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 { try { logger.info(`Pulling Docker image: ${image}`); const fullImage = registry ? `${registry}/${image}` : image; await this.dockerClient!.pull(fullImage, (error: any, stream: any) => { if (error) { throw error; } // Follow progress this.dockerClient!.modem.followProgress(stream, (err: any, output: any) => { if (err) { throw err; } logger.debug('Pull complete:', output); }); }); logger.success(`Image pulled successfully: ${fullImage}`); } catch (error) { logger.error(`Failed to pull image ${image}: ${error.message}`); throw error; } } /** * Create and start a container */ async createContainer(service: IService): Promise { try { logger.info(`Creating 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 const container = await this.dockerClient!.createContainer({ Image: fullImage, name: `onebox-${service.name}`, 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`]: [], }, }, }); const containerID = container.id; logger.success(`Container created: ${containerID}`); return containerID; } catch (error) { logger.error(`Failed to create container for ${service.name}: ${error.message}`); throw error; } } /** * Start a container by ID */ async startContainer(containerID: string): Promise { try { logger.info(`Starting container: ${containerID}`); const container = this.dockerClient!.getContainer(containerID); await container.start(); logger.success(`Container started: ${containerID}`); } catch (error) { // Ignore "already started" errors if (error.message.includes('already started')) { logger.debug(`Container already running: ${containerID}`); return; } logger.error(`Failed to start container ${containerID}: ${error.message}`); throw error; } } /** * Stop a container by ID */ async stopContainer(containerID: string): Promise { try { logger.info(`Stopping container: ${containerID}`); const container = this.dockerClient!.getContainer(containerID); await container.stop(); logger.success(`Container stopped: ${containerID}`); } catch (error) { // Ignore "already stopped" errors if (error.message.includes('already stopped') || error.statusCode === 304) { logger.debug(`Container already stopped: ${containerID}`); return; } logger.error(`Failed to stop container ${containerID}: ${error.message}`); throw error; } } /** * Restart a container by ID */ async restartContainer(containerID: string): Promise { try { logger.info(`Restarting container: ${containerID}`); const container = this.dockerClient!.getContainer(containerID); await container.restart(); logger.success(`Container restarted: ${containerID}`); } catch (error) { logger.error(`Failed to restart container ${containerID}: ${error.message}`); throw error; } } /** * Remove a container by ID */ async removeContainer(containerID: string, force = false): Promise { try { logger.info(`Removing container: ${containerID}`); const container = this.dockerClient!.getContainer(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}`); } } await container.remove({ force }); logger.success(`Container removed: ${containerID}`); } catch (error) { logger.error(`Failed to remove container ${containerID}: ${error.message}`); throw error; } } /** * Get container status */ async getContainerStatus(containerID: string): Promise { try { const container = this.dockerClient!.getContainer(containerID); const info = await container.inspect(); return info.State.Status; } catch (error) { logger.error(`Failed to get container status ${containerID}: ${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!.listContainers({ all: true, filters: { label: ['managed-by=onebox'], }, }); return containers; } 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; } } }