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' ;
2026-05-24 07:28:18 +00:00
import type { IService , IContainerStats , IServicePublishedPort } from '../types.ts' ;
2025-11-18 00:03:24 +00:00
import { logger } from '../logging.ts' ;
2025-11-25 04:38:26 +00:00
import { getErrorMessage } from '../utils/error.ts' ;
2025-10-28 13:05:42 +00:00
2026-05-24 07:28:18 +00:00
type TExpandedPublishedPort = Required < Pick <
IServicePublishedPort ,
'targetPort' | 'publishedPort' | 'protocol' | 'hostIp'
> > ;
2025-10-28 13:05:42 +00:00
export class OneboxDockerManager {
2025-11-25 08:25:54 +00:00
private dockerClient : InstanceType < typeof plugins.docker.Docker > | null = null ;
2025-10-28 13:05:42 +00:00
private networkName = 'onebox-network' ;
2026-05-24 07:28:18 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ;
2025-11-26 18:20:02 +00:00
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 {
2026-05-24 07:28:18 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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
2026-05-24 07:28:18 +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 ,
} ,
2026-05-24 07:28:18 +00:00
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
} ,
2026-05-24 07:28:18 +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 } ` ) ;
}
2026-05-24 07:28:18 +00:00
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 ,
2026-05-24 07:28:18 +00:00
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 : {
2026-05-24 07:28:18 +00:00
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)
2025-11-25 04:38:26 +00:00
if ( getErrorMessage ( error ) . includes ( '304' ) ) {
2025-10-28 13:05:42 +00:00
logger . debug ( ` Container already running: ${ containerID } ` ) ;
return ;
}
2025-11-25 04:38:26 +00:00
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)
2025-11-25 04:38:26 +00:00
if ( getErrorMessage ( error ) . includes ( '304' ) ) {
2025-10-28 13:05:42 +00:00
logger . debug ( ` Container already stopped: ${ containerID } ` ) ;
return ;
}
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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)
2025-11-26 16:36:01 +00:00
* Handles both regular containers and Swarm services
2025-10-28 13:05:42 +00:00
*/
async getContainerStats ( containerID : string ) : Promise < IContainerStats | null > {
try {
2025-11-26 16:36:01 +00:00
// Try to get container directly first
2026-03-16 16:47:05 +00:00
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
2025-11-26 16:36:01 +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 ) {
2025-11-26 16:36:01 +00:00
const serviceContainerId = await this . getContainerIdForService ( containerID ) ;
if ( serviceContainerId ) {
2026-03-16 16:47:05 +00:00
try {
container = await this . dockerClient ! . getContainerById ( serviceContainerId ) ;
} catch {
// Service container also not found
}
2025-11-26 16:36:01 +00:00
}
}
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
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
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 ) {
2025-11-25 04:38:26 +00:00
logger . error ( ` Failed to get container IP ${ containerID } : ${ getErrorMessage ( error ) } ` ) ;
2025-10-28 13:05:42 +00:00
return null ;
}
}
2025-11-26 18:20:02 +00:00
/**
* 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 {
2026-03-18 02:22:45 +00:00
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
2025-11-25 08:25:54 +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 = '' ;
2025-11-25 08:25:54 +00:00
stream . on ( 'data' , ( chunk : Uint8Array ) = > {
2025-10-28 13:05:42 +00:00
const streamType = chunk [ 0 ] ;
2025-11-25 08:25:54 +00:00
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 ;
}
} ) ;
2025-11-26 18:20:02 +00:00
// 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
2025-11-25 08:25:54 +00:00
const execInfo = await inspect ( ) ;
2026-03-16 12:45:44 +00:00
const exitCode = execInfo . ExitCode ? ? - 1 ;
2025-10-28 13:05:42 +00:00
return { stdout , stderr , exitCode } ;
} catch ( error ) {
2025-11-25 04:38:26 +00:00
logger . error ( ` Failed to exec in container ${ containerID } : ${ getErrorMessage ( error ) } ` ) ;
2026-03-16 12:45:44 +00:00
return { stdout : '' , stderr : getErrorMessage ( error ) , exitCode : - 1 } ;
2025-10-28 13:05:42 +00:00
}
}
2025-11-25 04:20:19 +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 } ` ) ;
2025-11-26 18:20:02 +00:00
// 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 : [ ] ;
2025-11-25 04:20:19 +00:00
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 ` ] = { } ;
2025-11-26 18:20:02 +00:00
// Bind to random host port so we can access from host (for provisioning)
portBindings [ ` ${ port } /tcp ` ] = [ { HostIp : '127.0.0.1' , HostPort : '' } ] ;
2025-11-25 04:20:19 +00:00
}
// 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 ) {
2025-11-25 04:38:26 +00:00
logger . error ( ` Failed to create platform container ${ options . name } : ${ getErrorMessage ( error ) } ` ) ;
2025-11-25 04:20:19 +00:00
throw error ;
}
}
2025-11-25 08:25:54 +00:00
/**
* 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 ( ) ;
}
2025-11-25 08:34:10 +00:00
/**
* 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 {
2026-03-17 23:39:24 +00:00
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-25 08:34:10 +00:00
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
}