diff --git a/examples/computer-example-ts/src/docker-example.ts b/examples/computer-example-ts/src/docker-example.ts new file mode 100644 index 000000000..53a084583 --- /dev/null +++ b/examples/computer-example-ts/src/docker-example.ts @@ -0,0 +1,53 @@ +import { DockerComputer, OSType } from "@trycua/computer"; +import { writeFileSync } from "node:fs"; + +async function main() { + console.log("Initializing Docker Computer..."); + + // Create a Docker computer instance + const computer = new DockerComputer({ + name: "my-cua-container", + osType: OSType.LINUX, + image: "trycua/cua-ubuntu:latest", + memory: "4GB", + cpu: 2, + port: 8000, + vncPort: 6901, + ephemeral: false, + }); + + try { + // Start the Docker container and connect to it + console.log("Starting Docker container..."); + await computer.run(); + console.log("Docker container is ready!"); + + // Take a screenshot to verify it's working + console.log("Taking screenshot..."); + const screenshot = await computer.interface.screenshot(); + writeFileSync("screenshot_docker.png", screenshot); + console.log("Screenshot saved as screenshot_docker.png"); + + // Example: Execute a command in the container + console.log("Executing command..."); + const result = await computer.interface.shell("echo 'Hello from Docker container!'"); + console.log("Command output:", result); + + // Keep the container running for demonstration + console.log("\nContainer is running! Press Ctrl+C to stop."); + console.log(`VNC: http://localhost:${6901}`); + console.log(`API: http://localhost:${8000}`); + + // Wait for user to stop + await new Promise(() => {}); + } catch (error) { + console.error("Error:", error); + } finally { + // Cleanup + console.log("\nStopping Docker container..."); + await computer.stop(); + console.log("Docker container stopped"); + } +} + +main().catch(console.error); diff --git a/libs/typescript/computer/src/computer/index.ts b/libs/typescript/computer/src/computer/index.ts index d7411b636..87bb36a35 100644 --- a/libs/typescript/computer/src/computer/index.ts +++ b/libs/typescript/computer/src/computer/index.ts @@ -1 +1 @@ -export { BaseComputer, CloudComputer } from './providers'; +export { BaseComputer, CloudComputer, DockerComputer } from './providers'; diff --git a/libs/typescript/computer/src/computer/providers/docker.ts b/libs/typescript/computer/src/computer/providers/docker.ts new file mode 100644 index 000000000..53a2ca956 --- /dev/null +++ b/libs/typescript/computer/src/computer/providers/docker.ts @@ -0,0 +1,346 @@ +import { spawn } from 'node:child_process'; +import pino from 'pino'; +import { + type BaseComputerInterface, + InterfaceFactory, +} from '../../interface/index'; +import { VMProviderType, type DockerComputerConfig } from '../types'; +import { BaseComputer } from './base'; + +interface DockerContainerInfo { + name: string; + status: string; + ip_address?: string; + ports: Record; + container_id?: string; +} + +/** + * Docker-specific computer implementation + */ +export class DockerComputer extends BaseComputer { + protected static vmProviderType = VMProviderType.DOCKER; + private image: string; + private port: number; + private vncPort: number; + private memory: string; + private cpu: number; + private storage?: string; + private sharedPath?: string; + private ephemeral: boolean; + private iface?: BaseComputerInterface; + private initialized = false; + private containerId?: string; + + protected logger = pino({ name: 'computer.provider_docker' }); + + constructor(config: DockerComputerConfig) { + super(config); + this.image = config.image || 'trycua/cua-ubuntu:latest'; + this.port = config.port || 8000; + this.vncPort = config.vncPort || 6901; + this.memory = config.memory || '4g'; + this.cpu = config.cpu || 2; + this.storage = config.storage; + this.sharedPath = config.sharedPath; + this.ephemeral = config.ephemeral || false; + } + + /** + * Check if Docker is available on the system + */ + private async hasDocker(): Promise { + return new Promise((resolve) => { + const proc = spawn('docker', ['--version']); + proc.on('error', () => resolve(false)); + proc.on('close', (code) => resolve(code === 0)); + }); + } + + /** + * Execute a Docker command and return the output + */ + private async execDocker(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const proc = spawn('docker', args); + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ stdout, stderr, code: code || 0 }); + }); + + proc.on('error', (err) => { + resolve({ stdout, stderr: err.message, code: 1 }); + }); + }); + } + + /** + * Parse memory string to Docker format + */ + private parseMemory(memoryStr: string): string { + const match = memoryStr.match(/^(\d+)([A-Za-z]*)$/); + if (!match) { + this.logger.warn(`Could not parse memory string '${memoryStr}', using 4g default`); + return '4g'; + } + + const [, value, unit] = match; + const upperUnit = unit.toUpperCase(); + + if (upperUnit === 'GB' || upperUnit === 'G') { + return `${value}g`; + } + if (upperUnit === 'MB' || upperUnit === 'M' || upperUnit === '') { + return `${value}m`; + } + + return '4g'; + } + + /** + * Get container information + */ + private async getContainerInfo(name: string): Promise { + const result = await this.execDocker(['inspect', name]); + + if (result.code !== 0) { + return null; + } + + try { + const containers = JSON.parse(result.stdout); + if (!containers || containers.length === 0) { + return null; + } + + const container = containers[0]; + const state = container.State; + const networkSettings = container.NetworkSettings; + + let status = 'stopped'; + if (state.Running) { + status = 'running'; + } else if (state.Paused) { + status = 'paused'; + } + + let ipAddress = networkSettings.IPAddress || ''; + if (!ipAddress && networkSettings.Networks) { + for (const network of Object.values(networkSettings.Networks)) { + const net = network as { IPAddress?: string }; + if (net.IPAddress) { + ipAddress = net.IPAddress; + break; + } + } + } + + const ports: Record = {}; + if (networkSettings.Ports) { + for (const [containerPort, portMappings] of Object.entries(networkSettings.Ports)) { + if (Array.isArray(portMappings) && portMappings.length > 0) { + const mapping = portMappings[0] as { HostPort?: string }; + if (mapping.HostPort) { + ports[containerPort] = mapping.HostPort; + } + } + } + } + + return { + name, + status, + ip_address: ipAddress || 'localhost', + ports, + container_id: container.Id.substring(0, 12), + }; + } catch (error) { + this.logger.error(`Failed to parse container info: ${error}`); + return null; + } + } + + /** + * Wait for container to be ready + */ + private async waitForContainerReady(name: string, timeout = 60): Promise { + this.logger.info(`Waiting for container ${name} to be ready...`); + const startTime = Date.now(); + + while (Date.now() - startTime < timeout * 1000) { + const info = await this.getContainerInfo(name); + if (info?.status === 'running') { + this.logger.info(`Container ${name} is running`); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + this.logger.warn(`Container ${name} did not become ready within ${timeout} seconds`); + return false; + } + + /** + * Run or start the Docker container + */ + private async runContainer(): Promise { + const existingInfo = await this.getContainerInfo(this.name); + + if (existingInfo?.status === 'running') { + this.logger.info(`Container ${this.name} is already running`); + this.containerId = existingInfo.container_id; + return; + } + + if (existingInfo && existingInfo.status !== 'running') { + this.logger.info(`Starting existing container ${this.name}`); + const result = await this.execDocker(['start', this.name]); + if (result.code !== 0) { + throw new Error(`Failed to start container: ${result.stderr}`); + } + await this.waitForContainerReady(this.name); + const info = await this.getContainerInfo(this.name); + this.containerId = info?.container_id; + return; + } + + this.logger.info(`Creating new container ${this.name} from image ${this.image}`); + + const args = ['run', '-d', '--name', this.name]; + + if (this.memory) { + const memoryLimit = this.parseMemory(this.memory); + args.push('--memory', memoryLimit); + } + + if (this.cpu) { + args.push('--cpus', this.cpu.toString()); + } + + if (this.vncPort) { + args.push('-p', `${this.vncPort}:6901`); + } + + if (this.port) { + args.push('-p', `${this.port}:8000`); + } + + if (this.storage && !this.ephemeral) { + args.push('-v', `${this.storage}:/home/kasm-user/storage`); + } + + if (this.sharedPath) { + args.push('-v', `${this.sharedPath}:/home/kasm-user/shared`); + } + + args.push('-e', 'VNC_PW=password'); + args.push('-e', 'VNCOPTIONS=-disableBasicAuth'); + + args.push(this.image); + + const result = await this.execDocker(args); + + if (result.code !== 0) { + throw new Error(`Failed to run container: ${result.stderr}`); + } + + this.containerId = result.stdout.trim(); + this.logger.info(`Container ${this.name} started with ID: ${this.containerId}`); + + await this.waitForContainerReady(this.name); + } + + /** + * Initialize the Docker container and interface + */ + async run(): Promise { + if (this.initialized) { + this.logger.info('Computer already initialized, skipping initialization'); + return; + } + + const dockerAvailable = await this.hasDocker(); + if (!dockerAvailable) { + throw new Error('Docker is not available. Please install Docker and ensure it is running.'); + } + + try { + await this.runContainer(); + + const ipAddress = 'localhost'; + this.logger.info(`Connecting to Docker container at ${ipAddress}`); + + this.iface = InterfaceFactory.createInterfaceForOS( + this.osType, + ipAddress, + undefined, + this.name + ); + + this.logger.info('Waiting for interface to be ready...'); + await this.iface.waitForReady(); + + this.initialized = true; + this.logger.info('Docker computer ready'); + } catch (error) { + this.logger.error(`Failed to initialize Docker computer: ${error}`); + throw new Error(`Failed to initialize Docker computer: ${error}`); + } + } + + /** + * Stop the Docker container + */ + async stop(): Promise { + this.logger.info('Stopping Docker container...'); + + if (this.iface) { + this.iface.disconnect(); + this.iface = undefined; + } + + if (this.name) { + const result = await this.execDocker(['stop', this.name]); + if (result.code !== 0) { + this.logger.error(`Failed to stop container: ${result.stderr}`); + } else { + this.logger.info(`Container ${this.name} stopped successfully`); + } + } + + this.initialized = false; + this.logger.info('Docker computer stopped'); + } + + /** + * Get the computer interface + */ + get interface(): BaseComputerInterface { + if (!this.iface) { + throw new Error('Computer not initialized. Call run() first.'); + } + return this.iface; + } + + /** + * Disconnect from the Docker container + */ + async disconnect(): Promise { + if (this.iface) { + this.iface.disconnect(); + this.iface = undefined; + } + this.initialized = false; + } +} diff --git a/libs/typescript/computer/src/computer/providers/index.ts b/libs/typescript/computer/src/computer/providers/index.ts index 27faf7d6d..fb946c3fe 100644 --- a/libs/typescript/computer/src/computer/providers/index.ts +++ b/libs/typescript/computer/src/computer/providers/index.ts @@ -1,2 +1,3 @@ export * from './base'; export * from './cloud'; +export * from './docker'; diff --git a/libs/typescript/computer/src/computer/types.ts b/libs/typescript/computer/src/computer/types.ts index 7bca918bf..d01244dcf 100644 --- a/libs/typescript/computer/src/computer/types.ts +++ b/libs/typescript/computer/src/computer/types.ts @@ -31,6 +31,55 @@ export interface CloudComputerConfig extends BaseComputerConfig { apiKey: string; } +export interface DockerComputerConfig extends BaseComputerConfig { + /** + * Docker image to use + * @default "trycua/cua-ubuntu:latest" + */ + image?: string; + + /** + * API server port + * @default 8000 + */ + port?: number; + + /** + * VNC port for remote desktop + * @default 6901 + */ + vncPort?: number; + + /** + * Memory limit (e.g., "4g", "2048m") + * @default "4g" + */ + memory?: string; + + /** + * CPU count + * @default 2 + */ + cpu?: number; + + /** + * Storage path for persistent data + */ + storage?: string; + + /** + * Shared directory path to mount in container + */ + sharedPath?: string; + + /** + * Use ephemeral (temporary) storage + * @default false + */ + ephemeral?: boolean; +} + export enum VMProviderType { CLOUD = 'cloud', + DOCKER = 'docker', } diff --git a/libs/typescript/computer/src/index.ts b/libs/typescript/computer/src/index.ts index 44b515fe2..4da62ff5f 100644 --- a/libs/typescript/computer/src/index.ts +++ b/libs/typescript/computer/src/index.ts @@ -1,6 +1,6 @@ // Export classes +export { CloudComputer, DockerComputer } from './computer'; +// Export CloudComputer as default Computer for backward compatibility export { CloudComputer as Computer } from './computer'; -//todo: figure out what types to export and how to do that -// export { OSType } from './types'; diff --git a/libs/typescript/computer/tests/computer/docker.test.ts b/libs/typescript/computer/tests/computer/docker.test.ts new file mode 100644 index 000000000..59f97b99a --- /dev/null +++ b/libs/typescript/computer/tests/computer/docker.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { DockerComputer } from '../../src'; +import { OSType } from '../../src/types'; + +describe('Computer Docker', () => { + it('Should create docker computer instance', () => { + const docker = new DockerComputer({ + name: 'test-container', + osType: OSType.LINUX, + image: 'trycua/cua-ubuntu:latest', + port: 8000, + vncPort: 6901, + }); + expect(docker).toBeInstanceOf(DockerComputer); + }); + + it('Should create docker computer instance with default values', () => { + const docker = new DockerComputer({ + name: 'test-container', + osType: OSType.LINUX, + }); + expect(docker).toBeInstanceOf(DockerComputer); + }); + + it('Should have correct name and OS type', () => { + const docker = new DockerComputer({ + name: 'my-test-container', + osType: OSType.LINUX, + }); + expect(docker.getName()).toBe('my-test-container'); + expect(docker.getOSType()).toBe(OSType.LINUX); + }); +});