- Added base interface and abstract class for platform service providers. - Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities. - Implemented MongoDBProvider class for MongoDB service with similar capabilities. - Introduced error handling utilities for better error management. - Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens.
930 lines
28 KiB
TypeScript
930 lines
28 KiB
TypeScript
/**
|
|
* Docker Manager for Onebox
|
|
*
|
|
* Handles all Docker operations: containers, images, networks, volumes
|
|
*/
|
|
|
|
import * as plugins from '../plugins.ts';
|
|
import type { IService, IContainerStats } from '../types.ts';
|
|
import { logger } from '../logging.ts';
|
|
|
|
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({
|
|
socketPath: 'unix:///var/run/docker.sock',
|
|
});
|
|
|
|
// Start the Docker client
|
|
await this.dockerClient.start();
|
|
|
|
logger.info('Docker client initialized');
|
|
|
|
// Ensure onebox network exists
|
|
await this.ensureNetwork();
|
|
} catch (error) {
|
|
logger.error(`Failed to initialize Docker client: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure onebox network exists
|
|
*/
|
|
private async ensureNetwork(): Promise<void> {
|
|
try {
|
|
const networks = await this.dockerClient!.listNetworks();
|
|
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
|
|
|
|
if (!existingNetwork) {
|
|
logger.info(`Creating Docker network: ${this.networkName}`);
|
|
|
|
// Check if Docker is in Swarm mode
|
|
let isSwarmMode = false;
|
|
try {
|
|
const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {});
|
|
isSwarmMode = swarmResponse.statusCode === 200;
|
|
} catch (error) {
|
|
isSwarmMode = false;
|
|
}
|
|
|
|
await this.dockerClient!.createNetwork({
|
|
Name: this.networkName,
|
|
Driver: isSwarmMode ? 'overlay' : 'bridge',
|
|
Attachable: isSwarmMode ? true : undefined, // Required for overlay networks to allow standalone containers
|
|
Labels: {
|
|
'managed-by': 'onebox',
|
|
},
|
|
});
|
|
logger.success(`Docker network created: ${this.networkName} (${isSwarmMode ? 'overlay' : 'bridge'})`);
|
|
} else {
|
|
logger.debug(`Docker network already exists: ${this.networkName}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to create Docker network: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull an image from a registry
|
|
*/
|
|
async pullImage(image: string, registry?: string): Promise<void> {
|
|
// Skip manual image pulling - Docker will automatically pull when creating container
|
|
const fullImage = registry ? `${registry}/${image}` : image;
|
|
logger.debug(`Skipping manual pull for ${fullImage} - Docker will auto-pull on container creation`);
|
|
}
|
|
|
|
/**
|
|
* Create and start a container or service (depending on Swarm mode)
|
|
*/
|
|
async createContainer(service: IService): Promise<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;
|
|
}
|
|
|
|
if (isSwarmMode) {
|
|
return await this.createSwarmService(service);
|
|
} else {
|
|
return await this.createStandaloneContainer(service);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to create container for ${service.name}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a standalone container (non-Swarm mode)
|
|
*/
|
|
private async createStandaloneContainer(service: IService): Promise<string> {
|
|
logger.info(`Creating standalone container for service: ${service.name}`);
|
|
|
|
const fullImage = service.registry
|
|
? `${service.registry}/${service.image}`
|
|
: service.image;
|
|
|
|
// Prepare environment variables
|
|
const env: string[] = [];
|
|
for (const [key, value] of Object.entries(service.envVars)) {
|
|
env.push(`${key}=${value}`);
|
|
}
|
|
|
|
// Create container using Docker REST API directly
|
|
const response = await this.dockerClient!.request('POST', `/containers/create?name=onebox-${service.name}`, {
|
|
Image: fullImage,
|
|
Env: env,
|
|
Labels: {
|
|
'managed-by': 'onebox',
|
|
'onebox-service': service.name,
|
|
},
|
|
ExposedPorts: {
|
|
[`${service.port}/tcp`]: {},
|
|
},
|
|
HostConfig: {
|
|
NetworkMode: this.networkName,
|
|
RestartPolicy: {
|
|
Name: 'unless-stopped',
|
|
},
|
|
PortBindings: {
|
|
// Don't bind to host ports - nginx will proxy
|
|
[`${service.port}/tcp`]: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
if (response.statusCode >= 300) {
|
|
throw new Error(`Failed to create container: HTTP ${response.statusCode}`);
|
|
}
|
|
|
|
const containerID = response.body.Id;
|
|
logger.success(`Standalone container created: ${containerID}`);
|
|
|
|
return containerID;
|
|
}
|
|
|
|
/**
|
|
* Create a Docker Swarm service
|
|
*/
|
|
private async createSwarmService(service: IService): Promise<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,
|
|
},
|
|
},
|
|
Networks: [
|
|
{
|
|
Target: await this.getNetworkID(this.networkName),
|
|
},
|
|
],
|
|
RestartPolicy: {
|
|
Condition: 'any',
|
|
MaxAttempts: 0,
|
|
},
|
|
},
|
|
Mode: {
|
|
Replicated: {
|
|
Replicas: 1,
|
|
},
|
|
},
|
|
EndpointSpec: {
|
|
Ports: [
|
|
{
|
|
Protocol: 'tcp',
|
|
TargetPort: service.port,
|
|
PublishMode: 'host',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
if (response.statusCode >= 300) {
|
|
throw new Error(`Failed to create service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
|
|
}
|
|
|
|
const serviceID = response.body.ID;
|
|
logger.success(`Swarm service created: ${serviceID}`);
|
|
|
|
return serviceID;
|
|
}
|
|
|
|
/**
|
|
* Get network ID by name
|
|
*/
|
|
private async getNetworkID(networkName: string): Promise<string> {
|
|
const networks = await this.dockerClient!.listNetworks();
|
|
const network = networks.find((n: any) => n.Name === networkName);
|
|
if (!network) {
|
|
throw new Error(`Network not found: ${networkName}`);
|
|
}
|
|
return network.Id;
|
|
}
|
|
|
|
/**
|
|
* Start a container or service by ID
|
|
*/
|
|
async startContainer(containerID: string): Promise<void> {
|
|
try {
|
|
// Try service first
|
|
if (await this.isService(containerID)) {
|
|
return await this.startService(containerID);
|
|
}
|
|
|
|
logger.info(`Starting container: ${containerID}`);
|
|
|
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {});
|
|
|
|
if (response.statusCode >= 300 && response.statusCode !== 304) {
|
|
throw new Error(`Failed to start container: HTTP ${response.statusCode}`);
|
|
}
|
|
|
|
logger.success(`Container started: ${containerID}`);
|
|
} catch (error) {
|
|
// Ignore "already started" errors (304 status)
|
|
if (error.message.includes('304')) {
|
|
logger.debug(`Container already running: ${containerID}`);
|
|
return;
|
|
}
|
|
logger.error(`Failed to start container ${containerID}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Try service first
|
|
if (await this.isService(containerID)) {
|
|
return await this.stopService(containerID);
|
|
}
|
|
|
|
logger.info(`Stopping container: ${containerID}`);
|
|
|
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/stop`, {});
|
|
|
|
if (response.statusCode >= 300 && response.statusCode !== 304) {
|
|
throw new Error(`Failed to stop container: HTTP ${response.statusCode}`);
|
|
}
|
|
|
|
logger.success(`Container stopped: ${containerID}`);
|
|
} catch (error) {
|
|
// Ignore "already stopped" errors (304 status)
|
|
if (error.message.includes('304')) {
|
|
logger.debug(`Container already stopped: ${containerID}`);
|
|
return;
|
|
}
|
|
logger.error(`Failed to stop container ${containerID}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Try service first
|
|
if (await this.isService(containerID)) {
|
|
return await this.restartService(containerID);
|
|
}
|
|
|
|
logger.info(`Restarting container: ${containerID}`);
|
|
|
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/restart`, {});
|
|
|
|
if (response.statusCode >= 300) {
|
|
throw new Error(`Failed to restart container: HTTP ${response.statusCode}`);
|
|
}
|
|
|
|
logger.success(`Container restarted: ${containerID}`);
|
|
} catch (error) {
|
|
logger.error(`Failed to restart container ${containerID}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Try service first
|
|
if (await this.isService(containerID)) {
|
|
return await this.removeService(containerID);
|
|
}
|
|
|
|
logger.info(`Removing container: ${containerID}`);
|
|
|
|
// Stop first if not forced
|
|
if (!force) {
|
|
try {
|
|
await this.stopContainer(containerID);
|
|
} catch (error) {
|
|
// Ignore stop errors
|
|
logger.debug(`Error stopping container before removal: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Try service first
|
|
if (await this.isService(containerID)) {
|
|
return await this.getServiceStatus(containerID);
|
|
}
|
|
|
|
const response = await this.dockerClient!.request('GET', `/containers/${containerID}/json`, {});
|
|
|
|
if (response.statusCode >= 300) {
|
|
return 'unknown';
|
|
}
|
|
|
|
return response.body.State?.Status || 'unknown';
|
|
} catch (error) {
|
|
logger.error(`Failed to get container status ${containerID}: ${error.message}`);
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const container = await this.dockerClient!.getContainerById(containerID);
|
|
|
|
if (!container) {
|
|
// Container not found - this is expected for Swarm services where we have service ID instead of container ID
|
|
// Return null silently
|
|
return null;
|
|
}
|
|
|
|
const stats = await container.stats({ stream: false });
|
|
|
|
// Validate stats structure
|
|
if (!stats || !stats.cpu_stats || !stats.cpu_stats.cpu_usage) {
|
|
logger.warn(`Invalid stats structure for container ${containerID}`);
|
|
return null;
|
|
}
|
|
|
|
// Calculate CPU percentage
|
|
const cpuDelta =
|
|
stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
|
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0);
|
|
const cpuPercent =
|
|
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
|
|
|
|
// Memory stats
|
|
const memoryUsed = stats.memory_stats?.usage || 0;
|
|
const memoryLimit = stats.memory_stats?.limit || 0;
|
|
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
|
|
|
|
// Network stats
|
|
let networkRx = 0;
|
|
let networkTx = 0;
|
|
if (stats.networks) {
|
|
for (const network of Object.values(stats.networks)) {
|
|
networkRx += (network as any).rx_bytes || 0;
|
|
networkTx += (network as any).tx_bytes || 0;
|
|
}
|
|
}
|
|
|
|
return {
|
|
cpuPercent,
|
|
memoryUsed,
|
|
memoryLimit,
|
|
memoryPercent,
|
|
networkRx,
|
|
networkTx,
|
|
};
|
|
} catch (error) {
|
|
// Don't log errors for container not found - this is expected for Swarm services
|
|
if (!error.message.includes('No such container') && !error.message.includes('not found')) {
|
|
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Get actual container ID for a Swarm service
|
|
* For Swarm services, we need to find the task/container that's actually running
|
|
*/
|
|
private async getContainerIdForService(serviceId: string): Promise<string | null> {
|
|
try {
|
|
// List all containers and find one with the service label matching our service ID
|
|
const containers = await this.dockerClient!.listContainers();
|
|
|
|
// Find a container that belongs to this service
|
|
const serviceContainer = containers.find((container: any) => {
|
|
const labels = container.Labels || {};
|
|
// Swarm services have a com.docker.swarm.service.id label
|
|
return labels['com.docker.swarm.service.id'] === serviceId;
|
|
});
|
|
|
|
if (serviceContainer) {
|
|
return serviceContainer.Id;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.warn(`Failed to get container ID for service ${serviceId}: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get container logs
|
|
* Handles both regular containers and Swarm services
|
|
*/
|
|
async getContainerLogs(
|
|
containerID: string,
|
|
tail = 100
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
try {
|
|
let actualContainerId = containerID;
|
|
|
|
// Try to get container directly first
|
|
let container = await this.dockerClient!.getContainerById(containerID);
|
|
|
|
// If not found, it might be a service ID - try to get the actual container ID
|
|
if (!container) {
|
|
const serviceContainerId = await this.getContainerIdForService(containerID);
|
|
if (serviceContainerId) {
|
|
actualContainerId = serviceContainerId;
|
|
container = await this.dockerClient!.getContainerById(serviceContainerId);
|
|
}
|
|
}
|
|
|
|
if (!container) {
|
|
throw new Error(`Container not found: ${containerID}`);
|
|
}
|
|
|
|
// Get logs as string (v5 handles demultiplexing automatically)
|
|
const logs = await container.logs({
|
|
stdout: true,
|
|
stderr: true,
|
|
tail: tail,
|
|
timestamps: true,
|
|
});
|
|
|
|
// v5 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}`);
|
|
}
|
|
|
|
// 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}: ${error.message}`);
|
|
return { stdout: '', stderr: '' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all onebox-managed containers
|
|
*/
|
|
async listContainers(): Promise<any[]> {
|
|
try {
|
|
const containers = await this.dockerClient!.listContainers();
|
|
// Filter for onebox-managed containers
|
|
return containers.filter((c: any) =>
|
|
c.Labels && c.Labels['managed-by'] === 'onebox'
|
|
);
|
|
} catch (error) {
|
|
logger.error(`Failed to list containers: ${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
|
|
* Note: v5 API doesn't expose version() method, so we return a placeholder
|
|
*/
|
|
async getDockerVersion(): Promise<any> {
|
|
// 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 {
|
|
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 {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|