UNPKG

auto-publishing-mcp-server

Version:

Enterprise-grade MCP Server for Auto-Publishing with pre-publish validation, multi-cloud deployment, and monitoring

686 lines (578 loc) 22 kB
/** * 실제 배포 실행 시스템 * validate.js와 config.js를 통합한 실제 배포 로직 */ import { DeploymentValidator } from './validate.js'; import { DeploymentConfig } from './config.js'; import { CanaryDeployment } from './canary-deploy.js'; import Docker from 'dockerode'; import { execSync, spawn } from 'child_process'; import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; import { join, resolve } from 'path'; import { ErrorCreators } from '../../utils/errors.js'; export class DeploymentManager { constructor() { this.validator = new DeploymentValidator(); this.config = new DeploymentConfig(); this.canaryDeployment = new CanaryDeployment(); this.docker = new Docker(); // 배포 상태 추적 this.deployments = new Map(); // deploymentId -> deployment info this.deploymentHistory = []; // 배포 로그 디렉토리 this.logsPath = '/root/projects/auto-publishing/logs/deployments'; this.backupsPath = '/root/projects/auto-publishing/backups'; this.initializeDirectories(); } /** * 필요한 디렉토리 초기화 */ initializeDirectories() { const dirs = [this.logsPath, this.backupsPath]; dirs.forEach(dir => { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); console.log(`📁 Created directory: ${dir}`); } }); } /** * 통합 배포 실행 */ async deployToEnvironment(args) { const { dockerId, domain, internalIp, environment = 'dev', image, buildContext = '.', ports = {}, environment_vars = {}, volumes = {}, resourceLimits = null, skipValidation = false, skipBackup = false } = args; if (!dockerId || !domain || !internalIp) { throw new Error('dockerId, domain, internalIp are required'); } const deploymentId = `deploy-${dockerId}-${Date.now()}`; const logFile = join(this.logsPath, `${deploymentId}.log`); try { console.log(`🚀 Starting deployment: ${deploymentId}`); this.logDeployment(logFile, `Starting deployment for ${dockerId} to ${environment}`); // 배포 상태 초기화 const deploymentInfo = { id: deploymentId, dockerId, domain, internalIp, environment, status: 'initializing', startTime: new Date().toISOString(), steps: [ { name: 'validation', status: 'pending' }, { name: 'configuration', status: 'pending' }, { name: 'backup', status: 'pending' }, { name: 'build', status: 'pending' }, { name: 'deploy', status: 'pending' }, { name: 'health-check', status: 'pending' } ], logs: [] }; this.deployments.set(deploymentId, deploymentInfo); // Step 1: 배포 전 검증 if (!skipValidation) { this.updateDeploymentStep(deploymentId, 'validation', 'in-progress'); this.logDeployment(logFile, '🔍 Running pre-deployment validation...'); const validation = await this.validator.validateDeployment({ dockerId, domain, internalIp, environment }); if (!validation.data.valid) { throw new Error(`Validation failed: ${validation.data.issues.join(', ')}`); } this.updateDeploymentStep(deploymentId, 'validation', 'completed'); this.logDeployment(logFile, '✅ Validation completed successfully'); } else { this.updateDeploymentStep(deploymentId, 'validation', 'skipped'); this.logDeployment(logFile, '⏭️ Validation skipped'); } // Step 2: 배포 구성 생성 this.updateDeploymentStep(deploymentId, 'configuration', 'in-progress'); this.logDeployment(logFile, '⚙️ Creating deployment configuration...'); const deploymentConfig = await this.config.createDeploymentConfig({ dockerId, domain, internalIp, environment, image, ports, environment_vars, volumes, resourceLimits }); const config = deploymentConfig.data.config; this.updateDeploymentStep(deploymentId, 'configuration', 'completed'); this.logDeployment(logFile, '✅ Configuration created successfully'); // Step 3: 기존 컨테이너 백업 (존재하는 경우) if (!skipBackup) { this.updateDeploymentStep(deploymentId, 'backup', 'in-progress'); this.logDeployment(logFile, '💾 Creating backup of existing container...'); await this.createBackup(dockerId, deploymentId); this.updateDeploymentStep(deploymentId, 'backup', 'completed'); this.logDeployment(logFile, '✅ Backup completed'); } else { this.updateDeploymentStep(deploymentId, 'backup', 'skipped'); this.logDeployment(logFile, '⏭️ Backup skipped'); } // Step 4: Docker 이미지 빌드 (필요한 경우) this.updateDeploymentStep(deploymentId, 'build', 'in-progress'); let finalImage = image || `${dockerId}:latest`; if (buildContext && buildContext !== 'skip') { this.logDeployment(logFile, `🔨 Building Docker image: ${finalImage}...`); finalImage = await this.buildDockerImage(dockerId, buildContext, logFile); this.logDeployment(logFile, `✅ Image built successfully: ${finalImage}`); } else { this.logDeployment(logFile, `⏭️ Using existing image: ${finalImage}`); } this.updateDeploymentStep(deploymentId, 'build', 'completed'); // Step 5: 실제 컨테이너 배포 this.updateDeploymentStep(deploymentId, 'deploy', 'in-progress'); this.logDeployment(logFile, '🚀 Deploying container...'); const containerInfo = await this.deployContainer(config, finalImage, logFile); this.updateDeploymentStep(deploymentId, 'deploy', 'completed'); this.logDeployment(logFile, `✅ Container deployed: ${containerInfo.id}`); // Step 6: 헬스체크 및 검증 this.updateDeploymentStep(deploymentId, 'health-check', 'in-progress'); this.logDeployment(logFile, '🩺 Running health checks...'); const healthCheck = await this.performHealthCheck(config, logFile); if (!healthCheck.healthy) { // 헬스체크 실패 시 롤백 this.logDeployment(logFile, '❌ Health check failed, rolling back...'); await this.rollbackDeployment(dockerId, deploymentId); throw new Error(`Health check failed: ${healthCheck.reason}`); } this.updateDeploymentStep(deploymentId, 'health-check', 'completed'); this.logDeployment(logFile, '✅ Health check passed'); // 배포 완료 const finalDeployment = this.deployments.get(deploymentId); finalDeployment.status = 'completed'; finalDeployment.endTime = new Date().toISOString(); finalDeployment.containerInfo = containerInfo; finalDeployment.healthCheck = healthCheck; // 배포 히스토리에 추가 this.deploymentHistory.push({ deploymentId, dockerId, environment, status: 'completed', timestamp: new Date().toISOString(), duration: Date.now() - new Date(finalDeployment.startTime).getTime() }); this.logDeployment(logFile, `🎉 Deployment completed successfully: ${deploymentId}`); return { output: `Deployment completed successfully: ${dockerId}`, data: { deploymentId, dockerId, environment, status: 'completed', containerInfo, config: { internalIp: config.network.ip, domain: config.domain, ports: config.network.ports }, healthCheck, duration: Date.now() - new Date(finalDeployment.startTime).getTime(), logFile } }; } catch (error) { // 배포 실패 처리 const deployment = this.deployments.get(deploymentId); if (deployment) { deployment.status = 'failed'; deployment.error = error.message; deployment.endTime = new Date().toISOString(); } this.logDeployment(logFile, `❌ Deployment failed: ${error.message}`); this.deploymentHistory.push({ deploymentId, dockerId, environment, status: 'failed', error: error.message, timestamp: new Date().toISOString() }); throw ErrorCreators.toolExecutionError('deploy/to-environment', error); } } /** * Docker 이미지 빌드 */ async buildDockerImage(dockerId, buildContext, logFile) { try { const imageTag = `${dockerId}:${Date.now()}`; // Dockerfile 존재 확인 const dockerfilePath = join(resolve(buildContext), 'Dockerfile'); if (!existsSync(dockerfilePath)) { throw new Error(`Dockerfile not found in ${buildContext}`); } // Docker 빌드 실행 const buildArgs = { context: resolve(buildContext), src: ['.'], t: imageTag }; const stream = await this.docker.buildImage(buildArgs); return new Promise((resolve, reject) => { this.docker.modem.followProgress(stream, (err, result) => { if (err) { this.logDeployment(logFile, `❌ Build failed: ${err.message}`); reject(err); } else { this.logDeployment(logFile, `✅ Build completed: ${imageTag}`); resolve(imageTag); } }, (event) => { if (event.stream) { this.logDeployment(logFile, `Build: ${event.stream.trim()}`); } } ); }); } catch (error) { throw new Error(`Docker build failed: ${error.message}`); } } /** * 컨테이너 배포 실행 */ async deployContainer(config, image, logFile) { try { // 기존 컨테이너 중지 및 제거 await this.stopExistingContainer(config.dockerId, logFile); // 새 컨테이너 생성 옵션 const createOptions = { Image: image, name: config.dockerId, Env: Object.entries(config.container.environment).map(([key, value]) => `${key}=${value}`), ExposedPorts: {}, HostConfig: { PortBindings: {}, RestartPolicy: { Name: config.container.restart }, Memory: this.parseMemoryLimit(config.container.resources.memory), CpuQuota: this.parseCpuLimit(config.container.resources.cpus), Binds: Object.entries(config.container.volumes).map(([host, container]) => `${host}:${container}`), LogConfig: { Type: config.logging.driver, Config: config.logging.options } }, Labels: config.container.labels, Healthcheck: { Test: [`CMD-SHELL`, `curl -f ${config.healthCheck.endpoint} || exit 1`], Interval: config.healthCheck.interval * 1000000000, // nanoseconds Timeout: config.healthCheck.timeout * 1000000000, Retries: config.healthCheck.retries } }; // 포트 매핑 설정 Object.entries(config.network.ports).forEach(([type, port]) => { createOptions.ExposedPorts[`${port}/tcp`] = {}; createOptions.HostConfig.PortBindings[`${port}/tcp`] = [{ HostPort: port.toString() }]; }); // 네트워크 설정 (IP 할당) if (config.network.ip) { createOptions.HostConfig.NetworkMode = this.getDockerNetworkName(config.network.subnet); // IP 할당은 컨테이너 시작 후 별도로 처리 } this.logDeployment(logFile, `Creating container with options: ${JSON.stringify(createOptions, null, 2)}`); // 컨테이너 생성 및 시작 const container = await this.docker.createContainer(createOptions); await container.start(); // IP 할당 (custom network가 있는 경우) if (config.network.ip) { await this.assignContainerIP(container, config.network.ip, logFile); } // 컨테이너 정보 조회 const containerInfo = await container.inspect(); this.logDeployment(logFile, `✅ Container created and started: ${container.id}`); return { id: container.id, name: containerInfo.Name, status: containerInfo.State.Status, ip: config.network.ip, ports: config.network.ports, startedAt: containerInfo.State.StartedAt }; } catch (error) { throw new Error(`Container deployment failed: ${error.message}`); } } /** * 기존 컨테이너 중지 및 제거 */ async stopExistingContainer(dockerId, logFile) { try { const container = this.docker.getContainer(dockerId); const containerInfo = await container.inspect(); if (containerInfo.State.Running) { this.logDeployment(logFile, `🛑 Stopping existing container: ${dockerId}`); await container.stop({ t: 10 }); // 10초 대기 } this.logDeployment(logFile, `🗑️ Removing existing container: ${dockerId}`); await container.remove(); } catch (error) { if (error.statusCode === 404) { this.logDeployment(logFile, `ℹ️ No existing container found: ${dockerId}`); } else { this.logDeployment(logFile, `⚠️ Error stopping existing container: ${error.message}`); } } } /** * 컨테이너 IP 할당 */ async assignContainerIP(container, targetIP, logFile) { try { // Docker 네트워크에서 IP 할당 시도 const networkName = this.getDockerNetworkName(targetIP); this.logDeployment(logFile, `🌐 Assigning IP ${targetIP} to container in network ${networkName}`); // Docker network connect with specific IP await this.docker.getNetwork(networkName).connect({ Container: container.id, EndpointConfig: { IPAMConfig: { IPv4Address: targetIP } } }); this.logDeployment(logFile, `✅ IP assigned successfully: ${targetIP}`); } catch (error) { this.logDeployment(logFile, `⚠️ IP assignment warning: ${error.message}`); // IP 할당 실패는 치명적이지 않으므로 계속 진행 } } /** * 헬스체크 수행 */ async performHealthCheck(config, logFile) { const maxAttempts = config.healthCheck.retries; const interval = config.healthCheck.interval * 1000; // milliseconds const endpoint = config.healthCheck.endpoint; this.logDeployment(logFile, `🩺 Starting health check: ${endpoint}`); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { this.logDeployment(logFile, `Health check attempt ${attempt}/${maxAttempts}`); // curl을 사용한 헬스체크 (보안 개선) const { spawn } = require('child_process'); await new Promise((resolve, reject) => { const curl = spawn('curl', ['-f', '-s', '--max-time', '10', endpoint], { stdio: 'ignore' }); curl.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Health check failed with code ${code}`)); } }); curl.on('error', reject); }); this.logDeployment(logFile, `✅ Health check passed on attempt ${attempt}`); return { healthy: true, attempts: attempt, endpoint: endpoint, timestamp: new Date().toISOString() }; } catch (error) { this.logDeployment(logFile, `❌ Health check failed attempt ${attempt}: ${error.message}`); if (attempt < maxAttempts) { this.logDeployment(logFile, `⏳ Waiting ${interval/1000}s before retry...`); await new Promise(resolve => setTimeout(resolve, interval)); } } } return { healthy: false, attempts: maxAttempts, endpoint: endpoint, reason: `Failed after ${maxAttempts} attempts`, timestamp: new Date().toISOString() }; } /** * 배포 롤백 */ async rollbackDeployment(dockerId, deploymentId) { try { console.log(`🔄 Rolling back deployment: ${deploymentId}`); // 최신 백업 찾기 const backupImage = await this.getLatestBackup(dockerId); if (!backupImage) { throw new Error(`No backup found for ${dockerId}`); } // 실패한 컨테이너 중지 및 제거 await this.stopExistingContainer(dockerId); // 백업에서 복구 const restoreOptions = { Image: backupImage, name: dockerId, HostConfig: { RestartPolicy: { Name: 'unless-stopped' } } }; const container = await this.docker.createContainer(restoreOptions); await container.start(); console.log(`✅ Rollback completed: ${dockerId} restored from ${backupImage}`); return true; } catch (error) { console.error(`❌ Rollback failed: ${error.message}`); return false; } } /** * 기존 컨테이너 백업 생성 */ async createBackup(dockerId, deploymentId) { try { const container = this.docker.getContainer(dockerId); const backupTag = `${dockerId}-backup-${deploymentId}`; await container.commit({ repo: backupTag.split(':')[0], tag: backupTag.split(':')[1] || 'latest' }); console.log(`💾 Backup created: ${backupTag}`); return backupTag; } catch (error) { if (error.statusCode === 404) { console.log(`ℹ️ No existing container to backup: ${dockerId}`); return null; } throw error; } } /** * 최신 백업 이미지 조회 */ async getLatestBackup(dockerId) { try { const images = await this.docker.listImages(); const backupImages = images.filter(image => image.RepoTags && image.RepoTags.some(tag => tag.includes(`${dockerId}-backup`)) ); if (backupImages.length === 0) { return null; } // 가장 최근 백업 선택 (Created 시간 기준) backupImages.sort((a, b) => b.Created - a.Created); return backupImages[0].RepoTags[0]; } catch (error) { console.error(`Error finding backup: ${error.message}`); return null; } } /** * 배포 상태 조회 */ async getDeploymentStatus(args) { const { deploymentId, dockerId } = args; try { if (deploymentId) { const deployment = this.deployments.get(deploymentId); if (!deployment) { throw new Error(`Deployment not found: ${deploymentId}`); } return { output: `Deployment status: ${deployment.status}`, data: deployment }; } if (dockerId) { // dockerId로 최신 배포 상태 조회 const latestDeployment = Array.from(this.deployments.values()) .filter(d => d.dockerId === dockerId) .sort((a, b) => new Date(b.startTime) - new Date(a.startTime))[0]; if (!latestDeployment) { throw new Error(`No deployments found for: ${dockerId}`); } return { output: `Latest deployment status for ${dockerId}: ${latestDeployment.status}`, data: latestDeployment }; } // 모든 배포 상태 반환 return { output: `All deployment statuses`, data: { active: Array.from(this.deployments.values()), history: this.deploymentHistory.slice(-10) // 최근 10개 } }; } catch (error) { throw ErrorCreators.toolExecutionError('deploy/get-status', error); } } /** * 배포 단계 업데이트 */ updateDeploymentStep(deploymentId, stepName, status) { const deployment = this.deployments.get(deploymentId); if (deployment) { const step = deployment.steps.find(s => s.name === stepName); if (step) { step.status = status; step.timestamp = new Date().toISOString(); } } } /** * 배포 로그 기록 */ logDeployment(logFile, message) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}\n`; console.log(message); try { writeFileSync(logFile, logMessage, { flag: 'a' }); } catch (error) { console.error(`Failed to write log: ${error.message}`); } } /** * 유틸리티 메소드들 */ parseMemoryLimit(memoryStr) { // "512m", "1g" 형태를 bytes로 변환 const match = memoryStr.match(/^(\d+)([kmg]?)$/i); if (!match) return undefined; const value = parseInt(match[1]); const unit = match[2].toLowerCase(); switch (unit) { case 'k': return value * 1024; case 'm': return value * 1024 * 1024; case 'g': return value * 1024 * 1024 * 1024; default: return value; } } parseCpuLimit(cpuStr) { // "0.5", "1.0" 형태를 Docker CPU quota로 변환 const cpuFloat = parseFloat(cpuStr); return Math.floor(cpuFloat * 100000); // Docker uses CPU quota in microseconds } getDockerNetworkName(subnetOrIP) { // IP 주소나 서브넷에서 네트워크 이름 결정 if (subnetOrIP.includes('192.168.100.')) return 'wordpress-network'; if (subnetOrIP.includes('192.168.101.')) return 'webapp-network'; if (subnetOrIP.includes('192.168.102.')) return 'microservice-network'; return 'bridge'; // 기본 네트워크 } /** * Canary deployment methods */ async deployCanary(args) { return await this.canaryDeployment.deployCanary(args); } async getCanaryStatus(deploymentId) { return await this.canaryDeployment.getCanaryStatus(deploymentId); } } export default DeploymentManager;