Files
onebox/ts/classes/docker.ts

845 lines
25 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';
export class OneboxDockerManager {
private dockerClient: 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: ${error.message}`);
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: ${error.message}`);
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) {
2025-11-18 14:16:27 +00:00
logger.error(`Failed to create container for ${service.name}: ${error.message}`);
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 (error.message.includes('304')) {
logger.debug(`Container already running: ${containerID}`);
return;
}
logger.error(`Failed to start container ${containerID}: ${error.message}`);
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 (error.message.includes('304')) {
logger.debug(`Container already stopped: ${containerID}`);
return;
}
logger.error(`Failed to stop container ${containerID}: ${error.message}`);
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}: ${error.message}`);
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: ${error.message}`);
}
}
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}: ${error.message}`);
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}: ${error.message}`);
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}: ${error.message}`);
return 'unknown';
}
}
/**
* Get container stats (CPU, memory, network)
*/
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try {
2025-11-24 19:52:35 +00:00
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 });
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
if (!error.message.includes('No such container') && !error.message.includes('not found')) {
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
}
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) {
2025-11-24 19:52:35 +00:00
logger.warn(`Failed to get container ID for service ${serviceId}: ${error.message}`);
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 should return a string, but let's handle edge cases
if (typeof logs !== 'string') {
logger.error(`Unexpected logs type: ${typeof logs}, constructor: ${logs?.constructor?.name}`);
logger.error(`Logs content: ${JSON.stringify(logs).slice(0, 500)}`);
// If it's not a string, something went wrong
throw new Error(`Unexpected log format: expected string, got ${typeof logs}`);
}
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) {
2025-11-24 19:52:35 +00:00
logger.error(`Failed to get container logs ${containerID}: ${error.message}`);
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: ${error.message}`);
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: ${error.message}`);
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}: ${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 {
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 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;
}
}
}