/** * Docker Manager for Onebox * * Handles all Docker operations: containers, images, networks, volumes */ import * as plugins from '../plugins.ts'; import type { IService, IContainerStats, IServicePublishedPort } from '../types.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; type TExpandedPublishedPort = Required>; export class OneboxDockerManager { private dockerClient: InstanceType | null = null; private networkName = 'onebox-network'; private getDockerSafeName(valueArg: string, maxLengthArg = 120): string { const safeName = valueArg .replace(/[^a-zA-Z0-9_.-]+/g, '-') .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '') .slice(0, maxLengthArg) .replace(/[^a-zA-Z0-9]+$/g, ''); return safeName || 'data'; } private getServiceVolumeSource(serviceArg: IService, mountPathArg: string, requestedSourceArg?: string): string { if (requestedSourceArg) { return this.getDockerSafeName(requestedSourceArg); } const mountName = this.getDockerSafeName(mountPathArg.replace(/^\/+/, '').replace(/\/+$/g, ''), 40); return this.getDockerSafeName(`onebox-${serviceArg.name}-${mountName}`); } private getStandaloneVolumeBinds(serviceArg: IService): string[] { return (serviceArg.volumes || []).map((volumeArg) => { const source = this.getServiceVolumeSource(serviceArg, volumeArg.mountPath, volumeArg.source || volumeArg.name); return `${source}:${volumeArg.mountPath}${volumeArg.readOnly ? ':ro' : ''}`; }); } private getSwarmVolumeMounts(serviceArg: IService): Array> { return (serviceArg.volumes || []).map((volumeArg) => ({ Type: 'volume', Source: this.getServiceVolumeSource(serviceArg, volumeArg.mountPath, volumeArg.source || volumeArg.name), Target: volumeArg.mountPath, ReadOnly: Boolean(volumeArg.readOnly), VolumeOptions: { DriverConfig: { Name: volumeArg.driver || 'local', Options: volumeArg.options || {}, }, Labels: { 'managed-by': 'onebox', 'onebox-service': serviceArg.name, 'onebox-mount-path': volumeArg.mountPath, 'onebox-backup': String(volumeArg.backup !== false), }, }, })); } public validateServiceSpec(serviceArg: IService): void { this.assertValidPort(serviceArg.port, `service port for ${serviceArg.name}`); for (const volumeArg of serviceArg.volumes || []) { if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) { throw new Error(`Volume mountPath for service ${serviceArg.name} must be an absolute path`); } if (volumeArg.mountPath.includes(':')) { throw new Error(`Volume mountPath for service ${serviceArg.name} must not contain ':'`); } if ((volumeArg.source || volumeArg.name)?.includes(':')) { throw new Error(`Volume source/name for service ${serviceArg.name} must not contain ':'`); } } this.expandPublishedPorts(serviceArg); } private assertValidPort(portArg: number, labelArg: string): void { if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) { throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`); } } private expandPublishedPorts(serviceArg: IService): TExpandedPublishedPort[] { const expandedPorts: TExpandedPublishedPort[] = []; const seenPublishedPorts = new Set(); for (const portArg of serviceArg.publishedPorts || []) { const protocol = portArg.protocol || 'tcp'; const targetStart = portArg.targetPort; const targetEnd = portArg.targetPortEnd || targetStart; const publishedStart = portArg.publishedPort || targetStart; const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart)); const hostIp = portArg.hostIp || '0.0.0.0'; if (!['tcp', 'udp'].includes(protocol)) { throw new Error(`Invalid published port protocol for service ${serviceArg.name}: ${protocol}`); } this.assertValidPort(targetStart, `published targetPort for service ${serviceArg.name}`); this.assertValidPort(targetEnd, `published targetPortEnd for service ${serviceArg.name}`); this.assertValidPort(publishedStart, `published publishedPort for service ${serviceArg.name}`); this.assertValidPort(publishedEnd, `published publishedPortEnd for service ${serviceArg.name}`); if (targetEnd < targetStart) { throw new Error(`Invalid target port range for service ${serviceArg.name}: ${targetStart}-${targetEnd}`); } if (publishedEnd < publishedStart) { throw new Error(`Invalid published port range for service ${serviceArg.name}: ${publishedStart}-${publishedEnd}`); } if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) { throw new Error( `Published port range size must match target port range size for service ${serviceArg.name}`, ); } if (!this.isValidHostIp(hostIp)) { throw new Error(`Invalid hostIp for service ${serviceArg.name}: ${hostIp}`); } for (let offset = 0; offset <= targetEnd - targetStart; offset++) { const publishedPort = publishedStart + offset; const publishedKey = `${hostIp}/${protocol}/${publishedPort}`; const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`; const conflictsWithWildcard = hostIp === '0.0.0.0' ? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`)) : seenPublishedPorts.has(wildcardKey); if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) { throw new Error(`Duplicate published port for service ${serviceArg.name}: ${hostIp}:${publishedPort}/${protocol}`); } seenPublishedPorts.add(publishedKey); expandedPorts.push({ targetPort: targetStart + offset, publishedPort, protocol, hostIp, }); } } return expandedPorts; } private isValidHostIp(hostIpArg: string): boolean { if (['0.0.0.0', '127.0.0.1', '::', '::1', 'localhost'].includes(hostIpArg)) return true; if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostIpArg)) { return hostIpArg.split('.').every((partArg) => Number(partArg) >= 0 && Number(partArg) <= 255); } return /^[0-9a-fA-F:]+$/.test(hostIpArg); } private async assertPublishedPortsAvailable(serviceArg: IService): Promise { const publishedPorts = this.expandPublishedPorts(serviceArg); if (publishedPorts.length === 0) return; await this.assertPublishedPortsNotUsedByDocker(serviceArg, publishedPorts); await this.assertPublishedPortsNotUsedByHost(serviceArg, publishedPorts); } private async assertPublishedPortsNotUsedByDocker( serviceArg: IService, publishedPortsArg: TExpandedPublishedPort[], ): Promise { const requestedPorts = new Set( publishedPortsArg.map((portArg) => `${portArg.protocol}/${portArg.publishedPort}`), ); try { const containersResponse = await this.dockerClient!.request('GET', '/containers/json?all=true', {}); if (containersResponse.statusCode === 200 && Array.isArray(containersResponse.body)) { for (const containerArg of containersResponse.body) { const labels = containerArg.Labels || {}; if (labels['onebox-service'] === serviceArg.name) continue; for (const portArg of containerArg.Ports || []) { if (!portArg.PublicPort || !portArg.Type) continue; if (requestedPorts.has(`${portArg.Type}/${portArg.PublicPort}`)) { throw new Error( `Published port ${portArg.PublicPort}/${portArg.Type} is already used by container ${containerArg.Names?.[0] || containerArg.Id}`, ); } } } } const servicesResponse = await this.dockerClient!.request('GET', '/services', {}); if (servicesResponse.statusCode === 200 && Array.isArray(servicesResponse.body)) { for (const service of servicesResponse.body) { if (service.Spec?.Name === `onebox-${serviceArg.name}`) continue; for (const portArg of service.Endpoint?.Ports || []) { if (!portArg.PublishedPort || !portArg.Protocol) continue; if (requestedPorts.has(`${portArg.Protocol}/${portArg.PublishedPort}`)) { throw new Error( `Published port ${portArg.PublishedPort}/${portArg.Protocol} is already used by Docker service ${service.Spec?.Name || service.ID}`, ); } } } } } catch (error) { if (error instanceof Error && error.message.startsWith('Published port ')) throw error; logger.warn(`Could not complete Docker published-port preflight: ${getErrorMessage(error)}`); } } private async assertPublishedPortsNotUsedByHost( serviceArg: IService, publishedPortsArg: TExpandedPublishedPort[], ): Promise { for (const portArg of publishedPortsArg) { try { if (portArg.protocol === 'udp') { await this.assertUdpPortAvailable(portArg.hostIp, portArg.publishedPort); } else { const listener = Deno.listen({ hostname: portArg.hostIp, port: portArg.publishedPort }); listener.close(); } } catch (error) { throw new Error( `Published port ${portArg.hostIp}:${portArg.publishedPort}/${portArg.protocol} for service ${serviceArg.name} is not available: ${getErrorMessage(error)}`, ); } } } private async assertUdpPortAvailable(hostIpArg: string, portArg: number): Promise { const dgram = await import('node:dgram'); const socket = dgram.createSocket(hostIpArg.includes(':') ? 'udp6' : 'udp4'); await new Promise((resolve, reject) => { socket.once('error', reject); socket.bind(portArg, hostIpArg, () => { socket.close(); resolve(); }); }); } private getStandalonePortConfig(serviceArg: IService): { exposedPorts: Record>; portBindings: Record>; } { const exposedPorts: Record> = { [`${serviceArg.port}/tcp`]: {}, }; const portBindings: Record> = { [`${serviceArg.port}/tcp`]: [], }; for (const publishedPort of this.expandPublishedPorts(serviceArg)) { const key = `${publishedPort.targetPort}/${publishedPort.protocol}`; exposedPorts[key] = {}; portBindings[key] = [{ HostIp: publishedPort.hostIp, HostPort: String(publishedPort.publishedPort) }]; } return { exposedPorts, portBindings }; } /** * 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; } } /** * Release resources held by the Docker API client. */ async stop(): Promise { if (!this.dockerClient) { return; } try { await this.dockerClient.stop(); } catch (error) { logger.error(`Failed to stop Docker client: ${getErrorMessage(error)}`); } finally { this.dockerClient = null; } } /** * 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 { const fullImage = registry ? `${registry}/${image}` : image; logger.info(`Pulling image: ${fullImage}`); try { // Parse image name and tag (e.g., "nginx:alpine" -> imageUrl: "nginx", imageTag: "alpine") const [imageUrl, imageTag] = fullImage.includes(':') ? fullImage.split(':') : [fullImage, 'latest']; // Use the library's built-in createImageFromRegistry method await this.dockerClient!.createImageFromRegistry({ imageUrl, imageTag, }); logger.success(`Image pulled successfully: ${fullImage}`); } catch (error) { logger.error(`Failed to pull image ${fullImage}: ${getErrorMessage(error)}`); throw error; } } /** * Create and start a container or service (depending on Swarm mode) */ async createContainer(service: IService): Promise { try { this.validateServiceSpec(service); await this.assertPublishedPortsAvailable(service); // 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}`); } const portConfig = this.getStandalonePortConfig(service); // 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: portConfig.exposedPorts, HostConfig: { NetworkMode: this.networkName, RestartPolicy: { Name: 'unless-stopped', }, PortBindings: portConfig.portBindings, Binds: this.getStandaloneVolumeBinds(service), }, }); 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}`); } const expandedPublishedPorts = this.expandPublishedPorts(service); const endpointPorts: Array> = []; if (!expandedPublishedPorts.some((publishedPort) => publishedPort.protocol === 'tcp' && publishedPort.targetPort === service.port)) { endpointPorts.push({ Protocol: 'tcp', TargetPort: service.port, PublishMode: 'host', }); } for (const publishedPort of expandedPublishedPorts) { endpointPorts.push({ Protocol: publishedPort.protocol, TargetPort: publishedPort.targetPort, PublishedPort: publishedPort.publishedPort, PublishMode: 'host', }); } // 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, Mounts: this.getSwarmVolumeMounts(service), 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: endpointPorts, }, }); 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) * Handles both regular containers and Swarm services */ async getContainerStats(containerID: string): Promise { try { // Try to get container directly first let container: any = null; try { container = await this.dockerClient!.getContainerById(containerID); } catch { // Container not found by ID — might be a Swarm service ID } // 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) { try { container = await this.dockerClient!.getContainerById(serviceContainerId); } catch { // Service container also not found } } } if (!container) { 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; } } /** * Get host port binding for a container's exposed port * @returns The host port number, or null if not bound */ async getContainerHostPort(containerID: string, containerPort: number): Promise { try { const container = await this.dockerClient!.getContainerById(containerID); if (!container) { throw new Error(`Container not found: ${containerID}`); } const info = await container.inspect(); const portKey = `${containerPort}/tcp`; const bindings = info.NetworkSettings.Ports?.[portKey]; if (bindings && bindings.length > 0 && bindings[0].HostPort) { return parseInt(bindings[0].HostPort, 10); } return null; } catch (error) { logger.error(`Failed to get container host port ${containerID}:${containerPort}: ${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 { let container: any = null; try { container = await this.dockerClient!.getContainerById(containerID); } catch { // Not a direct container ID — try Swarm service lookup } if (!container) { const serviceContainerId = await this.getContainerIdForService(containerID); if (serviceContainerId) { try { container = await this.dockerClient!.getContainerById(serviceContainerId); } catch { // Service container also not found } } } 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 with timeout await Promise.race([ new Promise((resolve) => stream.on('end', resolve)), new Promise((_, reject) => setTimeout(() => reject(new Error('Exec timeout after 30s')), 30000)) ]); const execInfo = await inspect(); const exitCode = execInfo.ExitCode ?? -1; return { stdout, stderr, exitCode }; } catch (error) { logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`); return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 }; } } /** * 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}`); // Pull the image first to ensure it's available logger.info(`Pulling image for platform service: ${options.image}`); await this.pullImage(options.image); // Check running and stopped containers; stopped platform containers still reserve names. const existingContainersResponse = await this.dockerClient!.request('GET', '/containers/json?all=true', {}); const existingContainers = Array.isArray(existingContainersResponse.body) ? existingContainersResponse.body : []; 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`] = {}; // Bind to random host port so we can access from host (for provisioning) portBindings[`${port}/tcp`] = [{ HostIp: '127.0.0.1', HostPort: '' }]; } // 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(); } /** * Stream container logs continuously * @param containerID The container ID * @param callback Callback for each log line (line, isError) */ async streamContainerLogs( containerID: string, callback: (line: string, isError: boolean) => void ): Promise { try { let container: any = null; try { container = await this.dockerClient!.getContainerById(containerID); } catch { // Not a direct container ID — try Swarm service lookup } if (!container) { const serviceContainerId = await this.getContainerIdForService(containerID); if (serviceContainerId) { try { container = await this.dockerClient!.getContainerById(serviceContainerId); } catch { // Service container also not found } } } if (!container) { throw new Error(`Container not found: ${containerID}`); } const logStream = await container.streamLogs({ stdout: true, stderr: true, timestamps: true, tail: 100, }); logStream.on('data', (chunk: Uint8Array) => { // Docker multiplexes stdout/stderr with 8-byte header // Byte 0: stream type (1=stdout, 2=stderr) // Bytes 4-7: frame size (big-endian) // Rest: actual log data const streamType = chunk[0]; const isError = streamType === 2; const content = new TextDecoder().decode(chunk.slice(8)); if (content.trim()) { callback(content.trim(), isError); } }); logStream.on('error', (err: Error) => { logger.error(`Log stream error for ${containerID}: ${err.message}`); }); } catch (error) { logger.error(`Failed to stream logs for ${containerID}: ${getErrorMessage(error)}`); throw error; } } }