Files
onebox/ts/classes/docker.ts

997 lines
30 KiB
TypeScript
Raw Normal View History

/**
* Docker Manager for Onebox
*
* Handles all Docker operations: containers, images, networks, volumes
*/
2025-11-18 00:03:24 +00:00
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<typeof plugins.docker.Docker> | null = null;
private networkName = 'onebox-network';
/**
* Initialize Docker client and create onebox network
*/
async init(): Promise<void> {
try {
// Initialize Docker client (connects to /var/run/docker.sock by default)
this.dockerClient = new plugins.docker.Docker({
2025-11-18 14:16:27 +00:00
socketPath: 'unix:///var/run/docker.sock',
});
2025-11-18 00:03:24 +00:00
// 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<void> {
try {
2025-11-24 19:52:35 +00:00
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}`);
2025-11-18 14:16:27 +00:00
// 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,
2025-11-18 14:16:27 +00:00
Driver: isSwarmMode ? 'overlay' : 'bridge',
Attachable: isSwarmMode ? true : undefined, // Required for overlay networks to allow standalone containers
Labels: {
'managed-by': 'onebox',
},
});
2025-11-18 14:16:27 +00:00
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<void> {
2025-11-18 14:16:27 +00:00
// 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`);
}
2025-11-18 14:16:27 +00:00
/**
* Create and start a container or service (depending on Swarm mode)
*/
async createContainer(service: IService): Promise<string> {
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;
}
2025-11-18 14:16:27 +00:00
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;
}
}
/**
2025-11-18 14:16:27 +00:00
* Create a standalone container (non-Swarm mode)
*/
2025-11-18 14:16:27 +00:00
private async createStandaloneContainer(service: IService): Promise<string> {
logger.info(`Creating standalone container for service: ${service.name}`);
2025-11-18 14:16:27 +00:00
const fullImage = service.registry
? `${service.registry}/${service.image}`
: service.image;
2025-11-18 14:16:27 +00:00
// Prepare environment variables
const env: string[] = [];
for (const [key, value] of Object.entries(service.envVars)) {
env.push(`${key}=${value}`);
}
2025-11-18 14:16:27 +00:00
// 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',
},
2025-11-18 14:16:27 +00:00
PortBindings: {
// Don't bind to host ports - nginx will proxy
[`${service.port}/tcp`]: [],
},
2025-11-18 14:16:27 +00:00
},
});
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<string> {
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,
},
2025-11-18 14:16:27 +00:00
},
Networks: [
{
Target: await this.getNetworkID(this.networkName),
},
2025-11-18 14:16:27 +00:00
],
RestartPolicy: {
Condition: 'any',
MaxAttempts: 0,
},
2025-11-18 14:16:27 +00:00
},
Mode: {
Replicated: {
Replicas: 1,
},
},
EndpointSpec: {
Ports: [
{
Protocol: 'tcp',
TargetPort: service.port,
PublishMode: 'host',
},
],
},
});
2025-11-18 14:16:27 +00:00
if (response.statusCode >= 300) {
throw new Error(`Failed to create service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
2025-11-18 14:16:27 +00:00
const serviceID = response.body.ID;
logger.success(`Swarm service created: ${serviceID}`);
return serviceID;
}
/**
* Get network ID by name
*/
private async getNetworkID(networkName: string): Promise<string> {
2025-11-24 19:52:35 +00:00
const networks = await this.dockerClient!.listNetworks();
const network = networks.find((n: any) => n.Name === networkName);
2025-11-18 14:16:27 +00:00
if (!network) {
throw new Error(`Network not found: ${networkName}`);
}
2025-11-24 19:52:35 +00:00
return network.Id;
}
/**
2025-11-18 14:16:27 +00:00
* Start a container or service by ID
*/
async startContainer(containerID: string): Promise<void> {
try {
2025-11-18 14:16:27 +00:00
// Try service first
if (await this.isService(containerID)) {
return await this.startService(containerID);
}
logger.info(`Starting container: ${containerID}`);
2025-11-18 14:16:27 +00:00
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) {
2025-11-18 14:16:27 +00:00
// 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;
}
}
/**
2025-11-18 14:16:27 +00:00
* Start a Swarm service (scale to 1 replica)
*/
private async startService(serviceID: string): Promise<void> {
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<boolean> {
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<void> {
try {
2025-11-18 14:16:27 +00:00
// Try service first
if (await this.isService(containerID)) {
return await this.stopService(containerID);
}
logger.info(`Stopping container: ${containerID}`);
2025-11-18 14:16:27 +00:00
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) {
2025-11-18 14:16:27 +00:00
// 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;
}
}
/**
2025-11-18 14:16:27 +00:00
* Stop a Swarm service (scale to 0 replicas)
*/
private async stopService(serviceID: string): Promise<void> {
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<void> {
try {
2025-11-18 14:16:27 +00:00
// Try service first
if (await this.isService(containerID)) {
return await this.restartService(containerID);
}
logger.info(`Restarting container: ${containerID}`);
2025-11-18 14:16:27 +00:00
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;
}
}
/**
2025-11-18 14:16:27 +00:00
* Restart a Swarm service (force update with same spec)
*/
private async restartService(serviceID: string): Promise<void> {
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<void> {
try {
2025-11-18 14:16:27 +00:00
// Try service first
if (await this.isService(containerID)) {
return await this.removeService(containerID);
}
2025-11-18 14:16:27 +00:00
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)}`);
}
}
2025-11-18 14:16:27 +00:00
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;
}
}
/**
2025-11-18 14:16:27 +00:00
* Remove a Swarm service
*/
private async removeService(serviceID: string): Promise<void> {
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<string> {
try {
2025-11-18 14:16:27 +00:00
// Try service first
if (await this.isService(containerID)) {
return await this.getServiceStatus(containerID);
}
const response = await this.dockerClient!.request('GET', `/containers/${containerID}/json`, {});
2025-11-18 14:16:27 +00:00
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';
}
}
2025-11-18 14:16:27 +00:00
/**
* Get Swarm service status
*/
private async getServiceStatus(serviceID: string): Promise<string> {
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)}`);
2025-11-18 14:16:27 +00:00
return 'unknown';
}
}
/**
* Get container stats (CPU, memory, network)
* Handles both regular containers and Swarm services
*/
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try {
// Try to get container directly first
let container = await this.dockerClient!.getContainerById(containerID);
2025-11-24 19:52:35 +00:00
// If not found, it might be a service ID - try to get the actual container ID
2025-11-24 19:52:35 +00:00
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
container = await this.dockerClient!.getContainerById(serviceContainerId);
}
}
if (!container) {
// Container/service not found
2025-11-24 19:52:35 +00:00
return null;
}
const stats = await container.stats({ stream: false });
2025-11-24 19:52:35 +00:00
// 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 =
2025-11-24 19:52:35 +00:00
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
2025-11-24 19:52:35 +00:00
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) {
2025-11-24 19:52:35 +00:00
// 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}`);
2025-11-24 19:52:35 +00:00
}
return null;
}
}
/**
2025-11-24 19:52:35 +00:00
* Helper: Get actual container ID for a Swarm service
* For Swarm services, we need to find the task/container that's actually running
*/
2025-11-24 19:52:35 +00:00
private async getContainerIdForService(serviceId: string): Promise<string | null> {
try {
2025-11-24 19:52:35 +00:00
// 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;
});
2025-11-24 19:52:35 +00:00
if (serviceContainer) {
return serviceContainer.Id;
}
2025-11-24 19:52:35 +00:00
return null;
} catch (error) {
logger.warn(`Failed to get container ID for service ${serviceId}: ${getErrorMessage(error)}`);
2025-11-24 19:52:35 +00:00
return null;
}
}
/**
2025-11-24 19:52:35 +00:00
* Get container logs
* Handles both regular containers and Swarm services
*/
2025-11-24 19:52:35 +00:00
async getContainerLogs(
containerID: string,
2025-11-24 19:52:35 +00:00
tail = 100
): Promise<{ stdout: string; stderr: string }> {
try {
2025-11-24 19:52:35 +00:00
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,
2025-11-24 19:52:35 +00:00
tail: tail,
timestamps: true,
});
2025-11-24 19:52:35 +00:00
// 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)}`);
2025-11-24 19:52:35 +00:00
return { stdout: '', stderr: '' };
}
}
/**
* List all onebox-managed containers
*/
async listContainers(): Promise<any[]> {
try {
2025-11-24 19:52:35 +00:00
const containers = await this.dockerClient!.listContainers();
2025-11-18 00:03:24 +00:00
// Filter for onebox-managed containers
return containers.filter((c: any) =>
2025-11-24 19:52:35 +00:00
c.Labels && c.Labels['managed-by'] === 'onebox'
2025-11-18 00:03:24 +00:00
);
} catch (error) {
logger.error(`Failed to list containers: ${getErrorMessage(error)}`);
return [];
}
}
/**
* Check if Docker is running
*/
async isDockerRunning(): Promise<boolean> {
try {
await this.dockerClient!.ping();
return true;
} catch (error) {
return false;
}
}
/**
* Get Docker version info
2025-11-24 19:52:35 +00:00
* Note: v5 API doesn't expose version() method, so we return a placeholder
*/
async getDockerVersion(): Promise<any> {
2025-11-24 19:52:35 +00:00
// 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<void> {
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<string | null> {
try {
2025-11-24 19:52:35 +00:00
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 {
2025-11-24 19:52:35 +00:00
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<string> {
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<string, Record<string, never>> = {};
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
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<any> {
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<any[]> {
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<void> {
try {
const container = await this.dockerClient!.getContainerById(containerID);
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;
}
}
}