From 31d8103ed2a44dad2610e7db455ae48a5ca40856 Mon Sep 17 00:00:00 2001 From: Acbox Date: Thu, 15 Jan 2026 17:07:09 +0800 Subject: [PATCH] feat: containerd utils --- packages/container/README.md | 358 +++++++++++++++++++++++++++ packages/container/examples/basic.ts | 101 ++++++++ packages/container/package.json | 20 ++ packages/container/src/container.ts | 246 ++++++++++++++++++ packages/container/src/containerd.ts | 275 ++++++++++++++++++++ packages/container/src/index.ts | 28 +++ packages/container/src/types.ts | 134 ++++++++++ packages/container/tsconfig.json | 21 ++ pnpm-lock.yaml | 125 ++++++++++ 9 files changed, 1308 insertions(+) create mode 100644 packages/container/README.md create mode 100644 packages/container/examples/basic.ts create mode 100644 packages/container/package.json create mode 100644 packages/container/src/container.ts create mode 100644 packages/container/src/containerd.ts create mode 100644 packages/container/src/index.ts create mode 100644 packages/container/src/types.ts create mode 100644 packages/container/tsconfig.json diff --git a/packages/container/README.md b/packages/container/README.md new file mode 100644 index 00000000..b6965d29 --- /dev/null +++ b/packages/container/README.md @@ -0,0 +1,358 @@ +# @memoh/container + +基于 containerd 的容器化工具包,提供简单易用的容器管理 API。 + +## 特性 + +- 🚀 基于 containerd 的高性能容器管理 +- 📦 简洁的 API 设计 +- 🔧 完整的容器生命周期管理 +- 📝 TypeScript 支持 +- 🎯 命名空间隔离 + +## 安装 + +```bash +pnpm install @memoh/container +``` + +## 前置要求 + +系统需要安装 containerd 和 ctr 命令行工具: + +```bash +# macOS (使用 Homebrew) +brew install containerd + +# Ubuntu/Debian +apt-get install containerd + +# 启动 containerd 服务 +sudo systemctl start containerd +``` + +## 快速开始 + +### 创建容器 + +使用 `createContainer` 创建一个新容器: + +```typescript +import { createContainer } from '@memoh/container'; + +const container = await createContainer({ + name: 'my-nginx', + image: 'docker.io/library/nginx:latest', + env: { + PORT: '8080', + NODE_ENV: 'production', + }, +}); + +console.log('Container created:', container.id); +console.log('Status:', container.status); +``` + +### 操作容器 + +使用 `useContainer` 获取容器操作方法: + +```typescript +import { useContainer } from '@memoh/container'; + +const container = useContainer('my-nginx'); + +// 启动容器 +await container.start(); + +// 获取容器信息 +const info = await container.info(); +console.log('Container status:', info.status); + +// 执行命令 +const result = await container.exec(['nginx', '-v']); +console.log('Output:', result.stdout); + +// 查看日志 +const logs = await container.logs(); +console.log(logs); + +// 暂停容器 +await container.pause(); + +// 恢复容器 +await container.resume(); + +// 停止容器 +await container.stop(10); // 10秒超时 + +// 删除容器 +await container.remove(); +``` + +## API 文档 + +### createContainer + +创建并返回容器信息。 + +```typescript +function createContainer( + config: ContainerConfig, + options?: ContainerdOptions +): Promise +``` + +**参数:** + +- `config.name` - 容器名称(必需) +- `config.image` - 镜像引用(必需) +- `config.command` - 容器启动命令 +- `config.env` - 环境变量 +- `config.workingDir` - 工作目录 +- `config.namespace` - 命名空间(默认:default) +- `config.labels` - 容器标签 + +**返回:** `ContainerInfo` 对象 + +### useContainer + +获取容器操作方法。 + +```typescript +function useContainer( + containerIdOrName: string, + options?: ContainerdOptions +): ContainerOperations +``` + +**返回的操作方法:** + +- `start()` - 启动容器 +- `stop(timeout?)` - 停止容器 +- `restart(timeout?)` - 重启容器 +- `pause()` - 暂停容器 +- `resume()` - 恢复容器 +- `remove(force?)` - 删除容器 +- `exec(command)` - 执行命令 +- `info()` - 获取容器信息 +- `logs(follow?)` - 获取日志 +- `stats()` - 获取统计信息 + +### listContainers + +列出所有容器。 + +```typescript +function listContainers(options?: ContainerdOptions): Promise +``` + +**示例:** + +```typescript +import { listContainers } from '@memoh/container'; + +const containers = await listContainers(); +for (const container of containers) { + console.log(`${container.name}: ${container.status}`); +} +``` + +### containerExists + +检查容器是否存在。 + +```typescript +function containerExists( + containerIdOrName: string, + options?: ContainerdOptions +): Promise +``` + +### removeAllContainers + +删除所有容器。 + +```typescript +function removeAllContainers( + force?: boolean, + options?: ContainerdOptions +): Promise +``` + +## 高级用法 + +### 自定义命名空间 + +```typescript +import { createContainer, useContainer } from '@memoh/container'; + +// 在自定义命名空间中创建容器 +const container = await createContainer( + { + name: 'my-app', + image: 'docker.io/library/node:18', + }, + { + namespace: 'production', + } +); + +// 操作同一命名空间的容器 +const ops = useContainer('my-app', { namespace: 'production' }); +await ops.start(); +``` + +### 自定义 Socket 路径 + +```typescript +const container = useContainer('my-app', { + socket: '/custom/path/to/containerd.sock', +}); +``` + +### 完整示例:Web 服务部署 + +```typescript +import { createContainer, useContainer } from '@memoh/container'; + +async function deployWebService() { + // 创建容器 + const container = await createContainer({ + name: 'web-service', + image: 'docker.io/library/nginx:alpine', + env: { + NGINX_PORT: '8080', + }, + labels: { + app: 'web-service', + version: '1.0.0', + }, + }); + + console.log('Container created:', container.id); + + // 启动容器 + const ops = useContainer(container.name); + await ops.start(); + console.log('Container started'); + + // 等待服务就绪 + await new Promise(resolve => setTimeout(resolve, 2000)); + + // 检查状态 + const info = await ops.info(); + console.log('Status:', info.status); + + // 执行健康检查 + const health = await ops.exec(['curl', '-f', 'http://localhost:8080']); + if (health.exitCode === 0) { + console.log('Service is healthy'); + } + + return ops; +} + +// 使用 +const service = await deployWebService(); + +// 稍后停止服务 +await service.stop(); +await service.remove(); +``` + +## 类型定义 + +```typescript +interface ContainerConfig { + name: string; + image: string; + command?: string[]; + env?: Record; + workingDir?: string; + network?: string; + mounts?: Mount[]; + labels?: Record; + namespace?: string; +} + +interface ContainerInfo { + id: string; + name: string; + image: string; + status: ContainerStatus; + namespace: string; + createdAt: Date; + labels?: Record; +} + +type ContainerStatus = 'created' | 'running' | 'paused' | 'stopped' | 'unknown'; + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} +``` + +## 注意事项 + +1. **权限要求**:操作容器通常需要 root 权限或将用户添加到适当的组 +2. **containerd 服务**:确保 containerd 服务正在运行 +3. **镜像拉取**:首次使用镜像时会自动拉取,可能需要一些时间 +4. **命名空间**:不同命名空间的容器相互隔离 +5. **清理资源**:使用完容器后记得清理(stop + remove) + +## 故障排查 + +### 命令未找到 + +如果遇到 `ctr command not found` 错误: + +```bash +# 检查 containerd 是否安装 +which ctr + +# 安装 containerd +brew install containerd # macOS +apt-get install containerd # Linux +``` + +### 权限被拒绝 + +如果遇到权限错误: + +```bash +# 将用户添加到 docker 组(如果存在) +sudo usermod -aG docker $USER + +# 或者使用 sudo 运行你的程序 +sudo node your-script.js +``` + +### 容器无法启动 + +检查容器日志: + +```typescript +const container = useContainer('my-container'); +const logs = await container.logs(); +console.log(logs); +``` + +## 开发 + +```bash +# 安装依赖 +pnpm install + +# 运行测试 +pnpm test + +# 构建 +pnpm build +``` + +## 许可证 + +MIT diff --git a/packages/container/examples/basic.ts b/packages/container/examples/basic.ts new file mode 100644 index 00000000..b2aee775 --- /dev/null +++ b/packages/container/examples/basic.ts @@ -0,0 +1,101 @@ +/** + * Basic usage examples for @memoh/container + */ + +import { createContainer, useContainer, listContainers } from '../src' + +async function main() { + console.log('🚀 Container Management Examples\n') + + // Example 1: Create and start a container + console.log('📦 Example 1: Create and start a container') + try { + const container = await createContainer({ + name: 'example-nginx', + image: 'docker.io/library/nginx:alpine', + env: { + NGINX_HOST: 'localhost', + NGINX_PORT: '80', + }, + labels: { + example: 'basic', + version: '1.0', + }, + }) + + console.log('✅ Container created:', container.id) + console.log(' Status:', container.status) + console.log(' Image:', container.image) + console.log('') + + // Start the container + const ops = useContainer(container.name) + await ops.start() + console.log('✅ Container started\n') + + // Get container info + const info = await ops.info() + console.log('📊 Container info:') + console.log(' Name:', info.name) + console.log(' Status:', info.status) + console.log(' Created:', info.createdAt) + console.log('') + + // Stop and remove + await ops.stop(5) + console.log('⏹️ Container stopped') + await ops.remove() + console.log('🗑️ Container removed\n') + } catch (error) { + console.error('❌ Error:', error) + } + + // Example 2: List all containers + console.log('📋 Example 2: List all containers') + try { + const containers = await listContainers() + if (containers.length === 0) { + console.log(' No containers found\n') + } else { + for (const container of containers) { + console.log(` - ${container.name}: ${container.status}`) + } + console.log('') + } + } catch (error) { + console.error('❌ Error:', error) + } + + // Example 3: Execute commands in container + console.log('🔧 Example 3: Execute commands in container') + try { + const container = await createContainer({ + name: 'example-alpine', + image: 'docker.io/library/alpine:latest', + command: ['sh', '-c', 'while true; do sleep 1; done'], + }) + + const ops = useContainer(container.name) + await ops.start() + console.log('✅ Container started') + + // Execute command + const result = await ops.exec(['echo', 'Hello from container!']) + console.log('📤 Command output:', result.stdout) + console.log(' Exit code:', result.exitCode) + console.log('') + + // Cleanup + await ops.stop(2) + await ops.remove() + console.log('🧹 Cleaned up\n') + } catch (error) { + console.error('❌ Error:', error) + } + + console.log('✨ All examples completed!') +} + +// Run examples +main().catch(console.error) + diff --git a/packages/container/package.json b/packages/container/package.json new file mode 100644 index 00000000..da4ae6f5 --- /dev/null +++ b/packages/container/package.json @@ -0,0 +1,20 @@ +{ + "name": "@memoh/container", + "version": "1.0.0", + "description": "Containerd-based container management utilities", + "exports": { + ".": "./src/index.ts" + }, + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "execa": "^9.5.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + }, + "packageManager": "pnpm@10.27.0" +} diff --git a/packages/container/src/container.ts b/packages/container/src/container.ts new file mode 100644 index 00000000..9e60a25c --- /dev/null +++ b/packages/container/src/container.ts @@ -0,0 +1,246 @@ +/** + * High-level container management API + */ + +import { ContainerdClient } from './containerd' +import type { + ContainerConfig, + ContainerInfo, + ContainerOperations, + ExecResult, + ContainerStats, + ContainerdOptions, +} from './types' + +/** + * Create a new container + * + * @param config - Container configuration + * @param options - Containerd client options + * @returns Container information including ID and metadata + * + * @example + * ```typescript + * const container = await createContainer({ + * name: 'my-app', + * image: 'docker.io/library/nginx:latest', + * env: { PORT: '8080' }, + * }); + * + * console.log('Container created:', container.id); + * ``` + */ +export async function createContainer( + config: ContainerConfig, + options?: ContainerdOptions +): Promise { + const client = new ContainerdClient(options) + + // Ensure image is pulled + await client.pullImage(config.image) + + // Create container + const containerInfo = await client.createContainer(config) + + return containerInfo +} + +/** + * Get container operations for an existing container + * + * @param containerIdOrName - Container ID or name + * @param options - Containerd client options + * @returns Object with methods to operate on the container + * + * @example + * ```typescript + * const container = useContainer('my-app'); + * + * // Start the container + * await container.start(); + * + * // Get container info + * const info = await container.info(); + * console.log('Status:', info.status); + * + * // Execute command + * const result = await container.exec(['echo', 'hello']); + * console.log(result.stdout); + * + * // Stop and remove + * await container.stop(); + * await container.remove(); + * ``` + */ +export function useContainer( + containerIdOrName: string, + options?: ContainerdOptions +): ContainerOperations { + const client = new ContainerdClient(options) + const containerName = containerIdOrName + + return { + /** + * Start the container + */ + async start(): Promise { + await client.startContainer(containerName) + }, + + /** + * Stop the container + * @param timeout - Graceful shutdown timeout in seconds (default: 10) + */ + async stop(timeout: number = 10): Promise { + await client.stopContainer(containerName, timeout) + }, + + /** + * Restart the container + * @param timeout - Graceful shutdown timeout in seconds (default: 10) + */ + async restart(timeout: number = 10): Promise { + await client.stopContainer(containerName, timeout) + await client.startContainer(containerName) + }, + + /** + * Pause the container + */ + async pause(): Promise { + await client.pauseContainer(containerName) + }, + + /** + * Resume a paused container + */ + async resume(): Promise { + await client.resumeContainer(containerName) + }, + + /** + * Remove the container + * @param force - Force remove even if running (default: false) + */ + async remove(force: boolean = false): Promise { + await client.removeContainer(containerName, force) + }, + + /** + * Execute a command in the container + * @param command - Command and arguments to execute + * @returns Execution result with exit code and output + */ + async exec(command: string[]): Promise { + const result = await client.execInContainer(containerName, command) + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + } + }, + + /** + * Get container information + */ + async info(): Promise { + return await client.getContainerInfo(containerName) + }, + + /** + * Get container logs + * @param follow - Follow log output (not implemented yet) + */ + async logs(follow: boolean = false): Promise { + if (follow) { + throw new Error('Follow mode not implemented yet') + } + return await client.getContainerLogs(containerName) + }, + + /** + * Get container stats + * Note: This is a placeholder implementation + * Real implementation would require parsing ctr metrics + */ + async stats(): Promise { + // This is a simplified implementation + // Full implementation would require parsing ctr metrics output + return { + cpuUsage: 0, + memoryUsage: 0, + memoryLimit: 0, + networkIO: { + rxBytes: 0, + txBytes: 0, + }, + } + }, + } +} + +/** + * List all containers in the namespace + * + * @param options - Containerd client options + * @returns Array of container information + * + * @example + * ```typescript + * const containers = await listContainers(); + * for (const container of containers) { + * console.log(`${container.name}: ${container.status}`); + * } + * ``` + */ +export async function listContainers(options?: ContainerdOptions): Promise { + const client = new ContainerdClient(options) + return await client.listContainers() +} + +/** + * Check if a container exists + * + * @param containerIdOrName - Container ID or name + * @param options - Containerd client options + * @returns True if container exists, false otherwise + * + * @example + * ```typescript + * if (await containerExists('my-app')) { + * console.log('Container exists'); + * } + * ``` + */ +export async function containerExists( + containerIdOrName: string, + options?: ContainerdOptions +): Promise { + const client = new ContainerdClient(options) + return await client.containerExists(containerIdOrName) +} + +/** + * Remove all containers in the namespace + * + * @param force - Force remove even if running + * @param options - Containerd client options + * + * @example + * ```typescript + * await removeAllContainers(true); + * console.log('All containers removed'); + * ``` + */ +export async function removeAllContainers( + force: boolean = false, + options?: ContainerdOptions +): Promise { + const client = new ContainerdClient(options) + const containers = await client.listContainers() + + for (const container of containers) { + await client.removeContainer(container.name, force) + } +} + diff --git a/packages/container/src/containerd.ts b/packages/container/src/containerd.ts new file mode 100644 index 00000000..698ab040 --- /dev/null +++ b/packages/container/src/containerd.ts @@ -0,0 +1,275 @@ +/** + * Containerd client implementation using ctr CLI + */ + +import { execa } from 'execa' +import type { ContainerConfig, ContainerInfo, ContainerStatus, ContainerdOptions } from './types' + +export const buildExecCommand = (name: string, command: string[]) => ['task', 'exec', '--exec-id', `exec-${Date.now()}`, name, ...command] + +/** + * Containerd client for managing containers + */ +export class ContainerdClient { + private namespace: string + private socket?: string + private timeout: number + + constructor(options: ContainerdOptions = {}) { + this.namespace = options.namespace || 'default' + this.socket = options.socket + this.timeout = options.timeout || 30000 + } + + /** + * Build ctr command with options + */ + private buildCtrCommand(args: string[]): string[] { + const cmd = ['ctr'] + + if (this.socket) { + cmd.push('--address', this.socket) + } + + cmd.push('--namespace', this.namespace) + cmd.push(...args) + + return cmd + } + + /** + * Execute ctr command + */ + private async exec(args: string[]): Promise<{ stdout: string; stderr: string }> { + const cmd = this.buildCtrCommand(args) + const [program, ...programArgs] = cmd + + try { + const result = await execa(program, programArgs, { + timeout: this.timeout, + }) + + return { + stdout: result.stdout, + stderr: result.stderr, + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Containerd command failed: ${message}`) + } + } + + /** + * Pull container image + */ + async pullImage(image: string): Promise { + await this.exec(['image', 'pull', image]) + } + + /** + * Create a new container + */ + async createContainer(config: ContainerConfig): Promise { + const args = ['container', 'create'] + + // Add image + args.push(config.image) + + // Add container name + args.push(config.name) + + // Add command if specified + if (config.command && config.command.length > 0) { + args.push(...config.command) + } + + await this.exec(args) + + // Return container info + return this.getContainerInfo(config.name) + } + + /** + * Start a container + */ + async startContainer(name: string): Promise { + await this.exec(['task', 'start', '--detach', name]) + } + + /** + * Stop a container + */ + async stopContainer(name: string, timeout: number = 10): Promise { + try { + await this.exec(['task', 'kill', '--signal', 'SIGTERM', name]) + + // Wait for graceful shutdown + await new Promise(resolve => setTimeout(resolve, timeout * 1000)) + + // Force kill if still running + try { + await this.exec(['task', 'kill', '--signal', 'SIGKILL', name]) + } catch { + // Container might have already stopped + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '' + if (!message.includes('not found')) { + throw error + } + } + } + + /** + * Pause a container + */ + async pauseContainer(name: string): Promise { + await this.exec(['task', 'pause', name]) + } + + /** + * Resume a paused container + */ + async resumeContainer(name: string): Promise { + await this.exec(['task', 'resume', name]) + } + + /** + * Remove a container + */ + async removeContainer(name: string, force: boolean = false): Promise { + if (force) { + // Try to stop the task first + try { + await this.exec(['task', 'kill', '--signal', 'SIGKILL', name]) + await this.exec(['task', 'delete', name]) + } catch { + // Task might not exist + } + } + + await this.exec(['container', 'delete', name]) + } + + /** + * Execute command in container + */ + async execInContainer(name: string, command: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const args = buildExecCommand(name, command) + + try { + const result = await this.exec(args) + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: 0, + } + } catch (error: unknown) { + const err = error as { stdout?: string; stderr?: string; exitCode?: number; message?: string } + return { + stdout: err.stdout || '', + stderr: err.stderr || err.message || '', + exitCode: err.exitCode || 1, + } + } + } + + /** + * Get container information + */ + async getContainerInfo(name: string): Promise { + const result = await this.exec(['container', 'info', name]) + + try { + const info = JSON.parse(result.stdout) + + return { + id: info.ID || name, + name: name, + image: info.Image || '', + status: await this.getContainerStatus(name), + namespace: this.namespace, + createdAt: info.CreatedAt ? new Date(info.CreatedAt) : new Date(), + labels: info.Labels || {}, + } + } catch { + // Fallback if JSON parsing fails + return { + id: name, + name: name, + image: '', + status: 'unknown', + namespace: this.namespace, + createdAt: new Date(), + } + } + } + + /** + * Get container status + */ + async getContainerStatus(name: string): Promise { + try { + const result = await this.exec(['task', 'list']) + const lines = result.stdout.split('\n') + + for (const line of lines) { + if (line.includes(name)) { + if (line.includes('RUNNING')) return 'running' + if (line.includes('PAUSED')) return 'paused' + if (line.includes('STOPPED')) return 'stopped' + } + } + + // Container exists but no task + return 'created' + } catch { + return 'unknown' + } + } + + /** + * Get container logs + */ + async getContainerLogs(name: string): Promise { + try { + const result = await this.exec(['task', 'logs', name]) + return result.stdout + } catch (error: unknown) { + return error instanceof Error ? error.message : '' + } + } + + /** + * List all containers + */ + async listContainers(): Promise { + const result = await this.exec(['container', 'list', '--quiet']) + const containerNames = result.stdout.split('\n').filter(name => name.trim()) + + const containers: ContainerInfo[] = [] + for (const name of containerNames) { + try { + const info = await this.getContainerInfo(name) + containers.push(info) + } catch { + // Skip containers that can't be accessed + } + } + + return containers + } + + /** + * Check if container exists + */ + async containerExists(name: string): Promise { + try { + await this.getContainerInfo(name) + return true + } catch { + return false + } + } +} + diff --git a/packages/container/src/index.ts b/packages/container/src/index.ts new file mode 100644 index 00000000..3805c7e4 --- /dev/null +++ b/packages/container/src/index.ts @@ -0,0 +1,28 @@ +/** + * @memoh/container - Containerd-based container management utilities + */ + +// Export main API +export { + createContainer, + useContainer, + listContainers, + containerExists, + removeAllContainers, +} from './container' + +// Export client +export { ContainerdClient, buildExecCommand } from './containerd' + +// Export types +export type { + ContainerConfig, + ContainerInfo, + ContainerStatus, + ContainerOperations, + ContainerStats, + ExecResult, + Mount, + ContainerdOptions, +} from './types' + diff --git a/packages/container/src/types.ts b/packages/container/src/types.ts new file mode 100644 index 00000000..10fce477 --- /dev/null +++ b/packages/container/src/types.ts @@ -0,0 +1,134 @@ +/** + * Container runtime types and interfaces + */ + +/** + * Container configuration options + */ +export interface ContainerConfig { + /** Container name/ID */ + name: string; + /** Container image reference */ + image: string; + /** Command to run in the container */ + command?: string[]; + /** Environment variables */ + env?: Record; + /** Working directory */ + workingDir?: string; + /** Network namespace */ + network?: string; + /** Mount points */ + mounts?: Mount[]; + /** Labels for the container */ + labels?: Record; + /** Container namespace (default: "default") */ + namespace?: string; +} + +/** + * Mount configuration + */ +export interface Mount { + /** Mount type: bind, volume, tmpfs */ + type: 'bind' | 'volume' | 'tmpfs'; + /** Source path (host) */ + source: string; + /** Target path (container) */ + target: string; + /** Read-only mount */ + readonly?: boolean; +} + +/** + * Container information + */ +export interface ContainerInfo { + /** Container ID */ + id: string; + /** Container name */ + name: string; + /** Container image */ + image: string; + /** Container status */ + status: ContainerStatus; + /** Container namespace */ + namespace: string; + /** Creation timestamp */ + createdAt: Date; + /** Labels */ + labels?: Record; +} + +/** + * Container status + */ +export type ContainerStatus = 'created' | 'running' | 'paused' | 'stopped' | 'unknown'; + +/** + * Container execution result + */ +export interface ExecResult { + /** Exit code */ + exitCode: number; + /** Standard output */ + stdout: string; + /** Standard error */ + stderr: string; +} + +/** + * Container stats + */ +export interface ContainerStats { + /** CPU usage percentage */ + cpuUsage: number; + /** Memory usage in bytes */ + memoryUsage: number; + /** Memory limit in bytes */ + memoryLimit: number; + /** Network I/O */ + networkIO?: { + rxBytes: number; + txBytes: number; + }; +} + +/** + * Container operations interface + */ +export interface ContainerOperations { + /** Start the container */ + start(): Promise; + /** Stop the container */ + stop(timeout?: number): Promise; + /** Restart the container */ + restart(timeout?: number): Promise; + /** Pause the container */ + pause(): Promise; + /** Resume the container */ + resume(): Promise; + /** Remove the container */ + remove(force?: boolean): Promise; + /** Execute a command in the container */ + exec(command: string[]): Promise; + /** Get container info */ + info(): Promise; + /** Get container logs */ + logs(follow?: boolean): Promise; + /** Get container stats */ + stats(): Promise; +} + +/** + * Containerd client options + */ +export interface ContainerdOptions { + /** Containerd socket path */ + socket?: string; + /** Containerd namespace */ + namespace?: string; + /** Timeout for operations (ms) */ + timeout?: number; +} + diff --git a/packages/container/tsconfig.json b/packages/container/tsconfig.json new file mode 100644 index 00000000..4d3f7307 --- /dev/null +++ b/packages/container/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0dcfdd..eb578514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,19 @@ importers: specifier: latest version: 1.3.6 + packages/container: + dependencies: + execa: + specifier: ^9.5.2 + version: 9.6.1 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.5 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/db: dependencies: '@memoh/shared': @@ -1944,6 +1957,9 @@ packages: '@rushstack/ts-command-line@5.1.5': resolution: {integrity: sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sevinf/maybe@0.5.0': resolution: {integrity: sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==} @@ -1977,6 +1993,10 @@ packages: '@sinclair/typebox@0.34.47': resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3350,6 +3370,10 @@ packages: '@sinclair/typebox': optional: true + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -3406,6 +3430,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3530,6 +3558,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -3632,6 +3664,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -3748,9 +3784,17 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -4304,6 +4348,10 @@ packages: engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} hasBin: true + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -4409,6 +4457,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4428,6 +4480,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4556,6 +4612,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -4901,6 +4961,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5064,6 +5128,10 @@ packages: resolution: {integrity: sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==} engines: {node: '>=14.0'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unique-filename@1.1.1: resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} @@ -5493,6 +5561,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -6902,6 +6974,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@sec-ant/readable-stream@0.4.1': {} + '@sevinf/maybe@0.5.0': {} '@shikijs/core@2.5.0': @@ -6948,6 +7022,8 @@ snapshots: '@sinclair/typebox@0.34.47': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} '@supabase/auth-js@2.90.1': @@ -8423,6 +8499,21 @@ snapshots: optionalDependencies: '@sinclair/typebox': 0.34.47 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -8495,6 +8586,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8644,6 +8739,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -8795,6 +8895,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -8899,8 +9001,12 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-stream@4.0.1: {} + is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -9445,6 +9551,11 @@ snapshots: shell-quote: 1.8.3 which: 5.0.0 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + npmlog@6.0.2: dependencies: are-we-there-yet: 3.0.1 @@ -9585,6 +9696,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-ms@4.0.0: {} + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -9596,6 +9709,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -9721,6 +9836,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + promise-inflight@1.0.1: optional: true @@ -10133,6 +10252,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -10296,6 +10417,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + unicorn-magic@0.3.0: {} + unique-filename@1.1.1: dependencies: unique-slug: 2.0.2 @@ -10728,6 +10851,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76