/** * 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'; import { getErrorMessage } from '../utils/error.ts'; export class OneboxDockerManager { private dockerClient: InstanceType | 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: ${getErrorMessage(error)}`); 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}`); // 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: ${getErrorMessage(error)}`); 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}: ${getErrorMessage(error)}`); 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!.listNetworks(); const network = networks.find((n: any) => n.Name === networkName); if (!network) { throw new Error(`Network not found: ${networkName}`); } return 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 (getErrorMessage(error).includes('304')) { logger.debug(`Container already running: ${containerID}`); return; } logger.error(`Failed to start container ${containerID}: ${getErrorMessage(error)}`); 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 (getErrorMessage(error).includes('304')) { logger.debug(`Container already stopped: ${containerID}`); return; } logger.error(`Failed to stop container ${containerID}: ${getErrorMessage(error)}`); 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}: ${getErrorMessage(error)}`); 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: ${getErrorMessage(error)}`); } } 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}: ${getErrorMessage(error)}`); 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}: ${getErrorMessage(error)}`); 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}: ${getErrorMessage(error)}`); return 'unknown'; } } /** * Get container stats (CPU, memory, network) */ async getContainerStats(containerID: string): Promise { try { const container = await this.dockerClient!.getContainerById(containerID); if (!container) { // Container not found - this is expected for Swarm services where we have service ID instead of container ID // Return null silently return null; } const stats = await container.stats({ stream: false }); // Validate stats structure if (!stats || !stats.cpu_stats || !stats.cpu_stats.cpu_usage) { logger.warn(`Invalid stats structure for container ${containerID}`); return null; } // Calculate CPU percentage const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage || 0); const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0); 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) { // Don't log errors for container not found - this is expected for Swarm services const errMsg = getErrorMessage(error); if (!errMsg.includes('No such container') && !errMsg.includes('not found')) { logger.error(`Failed to get container stats ${containerID}: ${errMsg}`); } return null; } } /** * Helper: Get actual container ID for a Swarm service * For Swarm services, we need to find the task/container that's actually running */ private async getContainerIdForService(serviceId: string): Promise { try { // List all containers and find one with the service label matching our service ID const containers = await this.dockerClient!.listContainers(); // Find a container that belongs to this service const serviceContainer = containers.find((container: any) => { const labels = container.Labels || {}; // Swarm services have a com.docker.swarm.service.id label return labels['com.docker.swarm.service.id'] === serviceId; }); if (serviceContainer) { return serviceContainer.Id; } return null; } catch (error) { logger.warn(`Failed to get container ID for service ${serviceId}: ${getErrorMessage(error)}`); return null; } } /** * Get container logs * Handles both regular containers and Swarm services */ async getContainerLogs( containerID: string, tail = 100 ): Promise<{ stdout: string; stderr: string }> { try { let actualContainerId = containerID; // Try to get container directly first let container = await this.dockerClient!.getContainerById(containerID); // If not found, it might be a service ID - try to get the actual container ID if (!container) { const serviceContainerId = await this.getContainerIdForService(containerID); if (serviceContainerId) { actualContainerId = serviceContainerId; container = await this.dockerClient!.getContainerById(serviceContainerId); } } if (!container) { throw new Error(`Container not found: ${containerID}`); } // Get logs as string (v5 handles demultiplexing automatically) const logs = await container.logs({ stdout: true, stderr: true, tail: tail, timestamps: true, }); // v5 returns already-parsed logs as a string return { stdout: logs, stderr: '', // v5 combines stdout/stderr into single string }; } catch (error) { logger.error(`Failed to get container logs ${containerID}: ${getErrorMessage(error)}`); return { stdout: '', stderr: '' }; } } /** * List all onebox-managed containers */ async listContainers(): Promise { try { const containers = await this.dockerClient!.listContainers(); // 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: ${getErrorMessage(error)}`); return []; } } /** * Check if Docker is running */ async isDockerRunning(): Promise { try { await this.dockerClient!.ping(); return true; } catch (error) { return false; } } /** * Get Docker version info * Note: v5 API doesn't expose version() method, so we return a placeholder */ async getDockerVersion(): Promise { // v5 API doesn't have a version() method // Return a basic structure for compatibility return { Version: 'N/A', ApiVersion: 'N/A', Note: 'Version info not available in @apiclient.xyz/docker v5' }; } /** * 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: ${getErrorMessage(error)}`); throw error; } } /** * Get container IP address in onebox network */ async getContainerIP(containerID: string): Promise { try { const container = await this.dockerClient!.getContainerById(containerID); if (!container) { throw new Error(`Container not found: ${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}: ${getErrorMessage(error)}`); 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 = await this.dockerClient!.getContainerById(containerID); if (!container) { throw new Error(`Container not found: ${containerID}`); } const { stream, inspect } = await container.exec(cmd, { attachStdout: true, attachStderr: true, }); let stdout = ''; let stderr = ''; stream.on('data', (chunk: Uint8Array) => { const streamType = chunk[0]; const content = new TextDecoder().decode(chunk.slice(8)); if (streamType === 1) { stdout += content; } else if (streamType === 2) { stderr += content; } }); // Wait for completion await new Promise((resolve) => stream.on('end', resolve)); const execInfo = await inspect(); const exitCode = execInfo.ExitCode || 0; return { stdout, stderr, exitCode }; } catch (error) { logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`); throw error; } } /** * Create a platform service container (MongoDB, MinIO, etc.) * Platform containers are long-running infrastructure services */ async createPlatformContainer(options: { name: string; image: string; port: number; env: string[]; volumes?: string[]; network: string; command?: string[]; exposePorts?: number[]; }): Promise { try { logger.info(`Creating platform container: ${options.name}`); // Check if container already exists const existingContainers = await this.dockerClient!.listContainers(); const existing = existingContainers.find((c: any) => c.Names?.some((n: string) => n === `/${options.name}` || n === options.name) ); if (existing) { logger.info(`Platform container ${options.name} already exists, removing old container...`); await this.removeContainer(existing.Id, true); } // Prepare exposed ports const exposedPorts: Record> = {}; const portBindings: Record> = {}; const portsToExpose = options.exposePorts || [options.port]; for (const port of portsToExpose) { exposedPorts[`${port}/tcp`] = {}; // Don't bind to host ports by default - services communicate via Docker network portBindings[`${port}/tcp`] = []; } // Prepare volume bindings const binds: string[] = options.volumes || []; // Create the container const response = await this.dockerClient!.request('POST', `/containers/create?name=${options.name}`, { Image: options.image, Cmd: options.command, Env: options.env, Labels: { 'managed-by': 'onebox', 'onebox-platform-service': options.name, }, ExposedPorts: exposedPorts, HostConfig: { NetworkMode: options.network, RestartPolicy: { Name: 'unless-stopped', }, PortBindings: portBindings, Binds: binds, }, }); if (response.statusCode >= 300) { const errorMsg = response.body?.message || `HTTP ${response.statusCode}`; throw new Error(`Failed to create platform container: ${errorMsg}`); } const containerID = response.body.Id; logger.info(`Platform container created: ${containerID}`); // Start the container const startResponse = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {}); if (startResponse.statusCode >= 300 && startResponse.statusCode !== 304) { throw new Error(`Failed to start platform container: HTTP ${startResponse.statusCode}`); } logger.success(`Platform container ${options.name} started successfully`); return containerID; } catch (error) { logger.error(`Failed to create platform container ${options.name}: ${getErrorMessage(error)}`); throw error; } } /** * Get a container by ID * Public wrapper for Docker client method */ async getContainerById(containerID: string): Promise { if (!this.dockerClient) { throw new Error('Docker client not initialized'); } return this.dockerClient.getContainerById(containerID); } /** * List all containers * Public wrapper for Docker client method */ async listAllContainers(): Promise { if (!this.dockerClient) { throw new Error('Docker client not initialized'); } return this.dockerClient.listContainers(); } }