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
JavaScript
/**
* 실제 배포 실행 시스템
* 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;