Files
onebox/ts/classes/docker.ts
T

1366 lines
44 KiB
TypeScript
Raw Permalink Normal View History

2025-10-28 13:05:42 +00:00
/**
* 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, IServicePublishedPort } from '../types.ts';
2025-11-18 00:03:24 +00:00
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
2025-10-28 13:05:42 +00:00
type TExpandedPublishedPort = Required<Pick<
IServicePublishedPort,
'targetPort' | 'publishedPort' | 'protocol' | 'hostIp'
>>;
2025-10-28 13:05:42 +00:00
export class OneboxDockerManager {
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
2025-10-28 13:05:42 +00:00
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<Record<string, unknown>> {
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<string>();
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<void> {
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<void> {
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<void> {
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<void> {
const dgram = await import('node:dgram');
const socket = dgram.createSocket(hostIpArg.includes(':') ? 'udp6' : 'udp4');
await new Promise<void>((resolve, reject) => {
socket.once('error', reject);
socket.bind(portArg, hostIpArg, () => {
socket.close();
resolve();
});
});
}
private getStandalonePortConfig(serviceArg: IService): {
exposedPorts: Record<string, Record<string, never>>;
portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>>;
} {
const exposedPorts: Record<string, Record<string, never>> = {
[`${serviceArg.port}/tcp`]: {},
};
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {
[`${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 };
}
2025-10-28 13:05:42 +00:00
/**
* 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-10-28 13:05:42 +00:00
});
2025-11-18 00:03:24 +00:00
// Start the Docker client
await this.dockerClient.start();
2025-10-28 13:05:42 +00:00
logger.info('Docker client initialized');
// Ensure onebox network exists
await this.ensureNetwork();
} catch (error) {
logger.error(`Failed to initialize Docker client: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
throw error;
}
}
2026-05-08 15:39:02 +00:00
/**
* Release resources held by the Docker API client.
*/
async stop(): Promise<void> {
if (!this.dockerClient) {
return;
}
try {
await this.dockerClient.stop();
} catch (error) {
logger.error(`Failed to stop Docker client: ${getErrorMessage(error)}`);
} finally {
this.dockerClient = null;
}
}
2025-10-28 13:05:42 +00:00
/**
* 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);
2025-10-28 13:05:42 +00:00
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;
}
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
Labels: {
'managed-by': 'onebox',
},
});
2025-11-18 14:16:27 +00:00
logger.success(`Docker network created: ${this.networkName} (${isSwarmMode ? 'overlay' : 'bridge'})`);
2025-10-28 13:05:42 +00:00
} else {
logger.debug(`Docker network already exists: ${this.networkName}`);
}
} catch (error) {
logger.error(`Failed to create Docker network: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
throw error;
}
}
/**
* Pull an image from a registry
*/
async pullImage(image: string, registry?: string): Promise<void> {
2025-11-18 14:16:27 +00:00
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;
}
2025-11-18 14:16:27 +00:00
}
2025-10-28 13:05:42 +00:00
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 {
this.validateServiceSpec(service);
await this.assertPublishedPortsAvailable(service);
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;
}
2025-10-28 13:05:42 +00:00
2025-11-18 14:16:27 +00:00
if (isSwarmMode) {
return await this.createSwarmService(service);
} else {
return await this.createStandaloneContainer(service);
}
2025-10-28 13:05:42 +00:00
} catch (error) {
logger.error(`Failed to create container for ${service.name}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
throw error;
}
}
/**
2025-11-18 14:16:27 +00:00
* Create a standalone container (non-Swarm mode)
2025-10-28 13:05:42 +00:00
*/
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-10-28 13:05:42 +00:00
2025-11-18 14:16:27 +00:00
const fullImage = service.registry
? `${service.registry}/${service.image}`
: service.image;
2025-10-28 13:05:42 +00:00
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-10-28 13:05:42 +00:00
const portConfig = this.getStandalonePortConfig(service);
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: portConfig.exposedPorts,
2025-11-18 14:16:27 +00:00
HostConfig: {
NetworkMode: this.networkName,
RestartPolicy: {
Name: 'unless-stopped',
2025-10-28 13:05:42 +00:00
},
PortBindings: portConfig.portBindings,
Binds: this.getStandaloneVolumeBinds(service),
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}`);
}
const expandedPublishedPorts = this.expandPublishedPorts(service);
const endpointPorts: Array<Record<string, unknown>> = [];
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',
});
}
2025-11-18 14:16:27 +00:00
// 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),
2025-11-18 14:16:27 +00:00
Labels: {
'managed-by': 'onebox',
'onebox-service': service.name,
2025-10-28 13:05:42 +00:00
},
2025-11-18 14:16:27 +00:00
},
Networks: [
{
Target: await this.getNetworkID(this.networkName),
2025-10-28 13:05:42 +00:00
},
2025-11-18 14:16:27 +00:00
],
RestartPolicy: {
Condition: 'any',
MaxAttempts: 0,
2025-10-28 13:05:42 +00:00
},
2025-11-18 14:16:27 +00:00
},
Mode: {
Replicated: {
Replicas: 1,
},
},
EndpointSpec: {
Ports: endpointPorts,
2025-11-18 14:16:27 +00:00
},
});
2025-10-28 13:05:42 +00:00
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-10-28 13:05:42 +00:00
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-10-28 13:05:42 +00:00
}
2025-11-24 19:52:35 +00:00
return network.Id;
2025-10-28 13:05:42 +00:00
}
/**
2025-11-18 14:16:27 +00:00
* Start a container or service by ID
2025-10-28 13:05:42 +00:00
*/
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);
}
2025-10-28 13:05:42 +00:00
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}`);
}
2025-10-28 13:05:42 +00:00
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')) {
2025-10-28 13:05:42 +00:00
logger.debug(`Container already running: ${containerID}`);
return;
}
logger.error(`Failed to start container ${containerID}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
*/
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);
}
2025-10-28 13:05:42 +00:00
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}`);
}
2025-10-28 13:05:42 +00:00
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')) {
2025-10-28 13:05:42 +00:00
logger.debug(`Container already stopped: ${containerID}`);
return;
}
logger.error(`Failed to stop container ${containerID}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
*/
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);
}
2025-10-28 13:05:42 +00:00
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}`);
}
2025-10-28 13:05:42 +00:00
logger.success(`Container restarted: ${containerID}`);
} catch (error) {
logger.error(`Failed to restart container ${containerID}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
*/
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-10-28 13:05:42 +00:00
2025-11-18 14:16:27 +00:00
logger.info(`Removing container: ${containerID}`);
2025-10-28 13:05:42 +00:00
// 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-10-28 13:05:42 +00:00
}
}
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}`);
}
2025-10-28 13:05:42 +00:00
logger.success(`Container removed: ${containerID}`);
} catch (error) {
logger.error(`Failed to remove container ${containerID}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
*/
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-10-28 13:05:42 +00:00
2025-11-18 14:16:27 +00:00
if (response.statusCode >= 300) {
return 'unknown';
}
return response.body.State?.Status || 'unknown';
2025-10-28 13:05:42 +00:00
} catch (error) {
logger.error(`Failed to get container status ${containerID}: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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';
}
}
2025-10-28 13:05:42 +00:00
/**
* Get container stats (CPU, memory, network)
* Handles both regular containers and Swarm services
2025-10-28 13:05:42 +00:00
*/
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
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
}
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) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) {
2025-11-24 19:52:35 +00:00
return null;
}
2025-10-28 13:05:42 +00:00
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;
}
2025-10-28 13:05:42 +00:00
// 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);
2025-10-28 13:05:42 +00:00
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;
2025-10-28 13:05:42 +00:00
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
}
2025-10-28 13:05:42 +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-10-28 13:05:42 +00:00
*/
2025-11-24 19:52:35 +00:00
private async getContainerIdForService(serviceId: string): Promise<string | null> {
2025-10-28 13:05:42 +00:00
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-10-28 13:05:42 +00:00
});
2025-11-24 19:52:35 +00:00
if (serviceContainer) {
return serviceContainer.Id;
2025-10-28 13:05:42 +00:00
}
2025-11-24 19:52:35 +00:00
return null;
2025-10-28 13:05:42 +00:00
} 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-10-28 13:05:42 +00:00
}
}
/**
2025-11-24 19:52:35 +00:00
* Get container logs
* Handles both regular containers and Swarm services
2025-10-28 13:05:42 +00:00
*/
2025-11-24 19:52:35 +00:00
async getContainerLogs(
2025-10-28 13:05:42 +00:00
containerID: string,
2025-11-24 19:52:35 +00:00
tail = 100
): Promise<{ stdout: string; stderr: string }> {
2025-10-28 13:05:42 +00:00
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({
2025-10-28 13:05:42 +00:00
stdout: true,
stderr: true,
2025-11-24 19:52:35 +00:00
tail: tail,
2025-10-28 13:05:42 +00:00
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
};
2025-10-28 13:05:42 +00:00
} catch (error) {
logger.error(`Failed to get container logs ${containerID}: ${getErrorMessage(error)}`);
2025-11-24 19:52:35 +00:00
return { stdout: '', stderr: '' };
2025-10-28 13:05:42 +00:00
}
}
/**
* 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
);
2025-10-28 13:05:42 +00:00
} catch (error) {
logger.error(`Failed to list containers: ${getErrorMessage(error)}`);
2025-10-28 13:05:42 +00:00
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
2025-10-28 13:05:42 +00:00
*/
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'
};
2025-10-28 13:05:42 +00:00
}
/**
* 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)}`);
2025-10-28 13:05:42 +00:00
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}`);
}
2025-10-28 13:05:42 +00:00
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)}`);
2025-10-28 13:05:42 +00:00
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<number | null> {
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;
}
}
2025-10-28 13:05:42 +00:00
/**
* 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
}
}
}
2025-11-24 19:52:35 +00:00
if (!container) {
throw new Error(`Container not found: ${containerID}`);
}
2025-10-28 13:05:42 +00:00
const { stream, inspect } = await container.exec(cmd, {
attachStdout: true,
attachStderr: true,
2025-10-28 13:05:42 +00:00
});
let stdout = '';
let stderr = '';
stream.on('data', (chunk: Uint8Array) => {
2025-10-28 13:05:42 +00:00
const streamType = chunk[0];
const content = new TextDecoder().decode(chunk.slice(8));
2025-10-28 13:05:42 +00:00
if (streamType === 1) {
stdout += content;
} else if (streamType === 2) {
stderr += content;
}
});
// Wait for completion with timeout
await Promise.race([
new Promise<void>((resolve) => stream.on('end', resolve)),
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('Exec timeout after 30s')), 30000))
]);
2025-10-28 13:05:42 +00:00
const execInfo = await inspect();
const exitCode = execInfo.ExitCode ?? -1;
2025-10-28 13:05:42 +00:00
return { stdout, stderr, exitCode };
} catch (error) {
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 };
2025-10-28 13:05:42 +00:00
}
}
/**
* 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}`);
// Pull the image first to ensure it's available
logger.info(`Pulling image for platform service: ${options.image}`);
await this.pullImage(options.image);
2026-04-29 07:39:42 +00:00
// 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<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`] = {};
// 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<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 {
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;
}
}
2025-10-28 13:05:42 +00:00
}