auto-publishing-mcp-server
Version:
Enterprise-grade MCP Server for Auto-Publishing with pre-publish validation, multi-cloud deployment, and monitoring
593 lines (510 loc) • 17.5 kB
JavaScript
/**
* Canary Deployment Strategy
* Gradual rollout with traffic splitting and automated rollback
*/
import Docker from 'dockerode';
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
export class CanaryDeployment {
constructor(config = {}) {
this.docker = new Docker();
this.deploymentPath = config.deploymentPath || '/root/projects/auto-publishing/deployments';
this.configPath = config.configPath || '/root/projects/auto-publishing/config';
// Canary defaults
this.defaultCanaryConfig = {
initialTrafficPercentage: 10,
incrementPercentage: 20,
incrementInterval: 300000, // 5 minutes
successThreshold: 0.95, // 95% success rate
errorThreshold: 0.05, // 5% error rate
responseTimeThreshold: 1000, // 1 second
minObservationTime: 60000 // 1 minute
};
}
/**
* Deploy using canary strategy
*/
async deployCanary(args) {
const {
dockerId,
newImage,
environment = 'prod',
canaryConfig = {},
healthEndpoint = '/health',
metricsEndpoint = '/metrics'
} = args;
const config = { ...this.defaultCanaryConfig, ...canaryConfig };
const deploymentId = `canary-${dockerId}-${Date.now()}`;
try {
// Validate prerequisites
await this.validateCanaryPrerequisites(dockerId, environment);
// Create canary container
console.log(`🐤 Starting canary deployment for ${dockerId}`);
const canaryContainer = await this.createCanaryContainer({
dockerId,
newImage,
environment,
trafficPercentage: config.initialTrafficPercentage
});
// Monitor and gradually increase traffic
const result = await this.performCanaryRollout({
dockerId,
canaryContainer,
config,
healthEndpoint,
metricsEndpoint,
deploymentId
});
if (result.success) {
// Complete deployment
await this.completeCanaryDeployment(dockerId, canaryContainer.id, environment);
return {
output: `Canary deployment completed successfully for ${dockerId}`,
data: {
deploymentId,
status: 'completed',
duration: result.duration,
finalTrafficPercentage: 100,
metrics: result.finalMetrics
}
};
} else {
// Rollback
await this.rollbackCanary(canaryContainer.id, dockerId);
return {
output: `Canary deployment rolled back for ${dockerId}`,
data: {
deploymentId,
status: 'rolled_back',
reason: result.reason,
lastTrafficPercentage: result.lastTrafficPercentage,
metrics: result.metrics
}
};
}
} catch (error) {
throw new Error(`Canary deployment failed: ${error.message}`);
}
}
/**
* Validate prerequisites for canary deployment
*/
async validateCanaryPrerequisites(dockerId, environment) {
// Check if main container exists
const containers = await this.docker.listContainers({
all: true,
filters: { name: [dockerId] }
});
if (containers.length === 0) {
throw new Error(`No existing container found with ID ${dockerId}`);
}
// Check if load balancer is configured
const nginxConfigPath = `/etc/nginx/sites-available/${dockerId}`;
try {
await fs.access(nginxConfigPath);
} catch {
throw new Error('Nginx load balancer not configured for canary deployment');
}
return true;
}
/**
* Create canary container
*/
async createCanaryContainer(params) {
const { dockerId, newImage, environment, trafficPercentage } = params;
// Get existing container config
const existingContainer = await this.docker.getContainer(dockerId).inspect();
const canaryName = `${dockerId}-canary`;
// Create canary container with same config but new image
const container = await this.docker.createContainer({
name: canaryName,
Image: newImage,
Env: [
...existingContainer.Config.Env,
`CANARY=true`,
`DEPLOYMENT_TYPE=canary`,
`TRAFFIC_PERCENTAGE=${trafficPercentage}`
],
HostConfig: {
...existingContainer.HostConfig,
PortBindings: {} // No direct port binding for canary
},
Labels: {
...existingContainer.Config.Labels,
'canary': 'true',
'canary.parent': dockerId,
'canary.traffic': String(trafficPercentage)
}
});
await container.start();
// Update nginx configuration for traffic splitting
await this.updateLoadBalancerConfig(dockerId, canaryName, trafficPercentage);
return container;
}
/**
* Update load balancer configuration for traffic splitting
*/
async updateLoadBalancerConfig(mainContainerId, canaryContainerId, percentage) {
const mainWeight = 100 - percentage;
const canaryWeight = percentage;
const nginxConfig = `
upstream ${mainContainerId}_backend {
server ${mainContainerId}:3000 weight=${mainWeight};
server ${canaryContainerId}:3000 weight=${canaryWeight};
}
server {
listen 80;
server_name ${mainContainerId}.local;
location / {
proxy_pass http://${mainContainerId}_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Add canary header for tracking
add_header X-Canary-Status $upstream_addr;
}
location /nginx_status {
stub_status on;
access_log off;
}
}`;
const configPath = `/etc/nginx/sites-available/${mainContainerId}`;
await fs.writeFile(configPath, nginxConfig);
// Reload nginx (보안 개선)
const { spawn } = require('child_process');
await new Promise((resolve, reject) => {
const nginx = spawn('nginx', ['-s', 'reload']);
nginx.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`nginx reload failed with code ${code}`));
}
});
nginx.on('error', reject);
});
}
/**
* Perform canary rollout with monitoring
*/
async performCanaryRollout(params) {
const {
dockerId,
canaryContainer,
config,
healthEndpoint,
metricsEndpoint,
deploymentId
} = params;
const startTime = Date.now();
let currentPercentage = config.initialTrafficPercentage;
let rolloutStep = 0;
while (currentPercentage < 100) {
console.log(`📊 Canary at ${currentPercentage}% traffic`);
// Wait for observation period
await new Promise(resolve => setTimeout(resolve, config.minObservationTime));
// Collect metrics
const metrics = await this.collectCanaryMetrics({
mainContainerId: dockerId,
canaryContainerId: canaryContainer.id,
healthEndpoint,
metricsEndpoint
});
// Analyze metrics
const analysis = this.analyzeCanaryMetrics(metrics, config);
if (!analysis.healthy) {
return {
success: false,
reason: analysis.reason,
lastTrafficPercentage: currentPercentage,
metrics: metrics,
duration: Date.now() - startTime
};
}
// Increase traffic if healthy
currentPercentage = Math.min(100, currentPercentage + config.incrementPercentage);
await this.updateLoadBalancerConfig(dockerId, canaryContainer.id, currentPercentage);
// Log progress
await this.logCanaryProgress({
deploymentId,
step: ++rolloutStep,
percentage: currentPercentage,
metrics,
timestamp: new Date().toISOString()
});
// Wait before next increment
if (currentPercentage < 100) {
await new Promise(resolve => setTimeout(resolve, config.incrementInterval));
}
}
return {
success: true,
duration: Date.now() - startTime,
finalMetrics: await this.collectCanaryMetrics({
mainContainerId: dockerId,
canaryContainerId: canaryContainer.id,
healthEndpoint,
metricsEndpoint
})
};
}
/**
* Collect metrics from both main and canary containers
*/
async collectCanaryMetrics(params) {
const { mainContainerId, canaryContainerId, healthEndpoint, metricsEndpoint } = params;
const metrics = {
main: {
healthy: true,
responseTime: 0,
errorRate: 0,
requestCount: 0
},
canary: {
healthy: true,
responseTime: 0,
errorRate: 0,
requestCount: 0
}
};
try {
// Check health endpoints
const mainHealth = await this.checkContainerHealth(mainContainerId, healthEndpoint);
const canaryHealth = await this.checkContainerHealth(canaryContainerId, healthEndpoint);
metrics.main.healthy = mainHealth.healthy;
metrics.canary.healthy = canaryHealth.healthy;
// Get nginx statistics
const nginxStats = await this.getNginxStats(mainContainerId);
// Parse upstream statistics
if (nginxStats.upstreams) {
const mainStats = nginxStats.upstreams.find(u => u.server.includes(mainContainerId));
const canaryStats = nginxStats.upstreams.find(u => u.server.includes(canaryContainerId));
if (mainStats) {
metrics.main.requestCount = mainStats.requests || 0;
metrics.main.responseTime = mainStats.response_time || 0;
metrics.main.errorRate = mainStats.fails / (mainStats.requests || 1);
}
if (canaryStats) {
metrics.canary.requestCount = canaryStats.requests || 0;
metrics.canary.responseTime = canaryStats.response_time || 0;
metrics.canary.errorRate = canaryStats.fails / (canaryStats.requests || 1);
}
}
} catch (error) {
console.error('Error collecting metrics:', error);
}
return metrics;
}
/**
* Check container health
*/
async checkContainerHealth(containerId, endpoint) {
try {
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
AttachStdout: true,
AttachStderr: true,
Cmd: ['curl', '-f', `http://localhost:3000${endpoint}`]
});
const stream = await exec.start();
const output = await new Promise((resolve, reject) => {
let data = '';
stream.on('data', chunk => data += chunk.toString());
stream.on('end', () => resolve(data));
stream.on('error', reject);
});
return { healthy: true, output };
} catch (error) {
return { healthy: false, error: error.message };
}
}
/**
* Get nginx statistics
*/
async getNginxStats(mainContainerId) {
try {
// 보안 개선: execSync 대신 spawn 사용
const { spawn } = require('child_process');
const response = await new Promise((resolve, reject) => {
let data = '';
const curl = spawn('curl', ['-s', 'http://localhost/nginx_status']);
curl.stdout.on('data', chunk => data += chunk.toString());
curl.on('close', (code) => {
if (code === 0) {
resolve(data);
} else {
reject(new Error(`curl failed with code ${code}`));
}
});
curl.on('error', reject);
});
// Parse nginx stub_status output
const stats = {
activeConnections: 0,
accepts: 0,
handled: 0,
requests: 0,
upstreams: []
};
const lines = response.split('\n');
const activeMatch = lines[0].match(/Active connections: (\d+)/);
if (activeMatch) stats.activeConnections = parseInt(activeMatch[1]);
return stats;
} catch (error) {
return { error: error.message };
}
}
/**
* Analyze canary metrics
*/
analyzeCanaryMetrics(metrics, config) {
const analysis = {
healthy: true,
reason: null
};
// Check if canary is healthy
if (!metrics.canary.healthy) {
analysis.healthy = false;
analysis.reason = 'Canary health check failed';
return analysis;
}
// Check error rate
if (metrics.canary.errorRate > config.errorThreshold) {
analysis.healthy = false;
analysis.reason = `Canary error rate ${(metrics.canary.errorRate * 100).toFixed(2)}% exceeds threshold ${config.errorThreshold * 100}%`;
return analysis;
}
// Check response time
if (metrics.canary.responseTime > config.responseTimeThreshold) {
analysis.healthy = false;
analysis.reason = `Canary response time ${metrics.canary.responseTime}ms exceeds threshold ${config.responseTimeThreshold}ms`;
return analysis;
}
// Compare with main container
if (metrics.canary.errorRate > metrics.main.errorRate * 1.5) {
analysis.healthy = false;
analysis.reason = 'Canary error rate significantly higher than main container';
return analysis;
}
return analysis;
}
/**
* Complete canary deployment
*/
async completeCanaryDeployment(mainContainerId, canaryContainerId, environment) {
console.log(`✅ Completing canary deployment for ${mainContainerId}`);
// Stop old container
const oldContainer = this.docker.getContainer(mainContainerId);
await oldContainer.stop();
// Rename canary to main
const canaryContainer = this.docker.getContainer(canaryContainerId);
await canaryContainer.rename({ name: mainContainerId });
// Update labels (보안 개선)
const { spawn } = require('child_process');
await new Promise((resolve, reject) => {
const docker = spawn('docker', ['update', '--label', 'canary=false', mainContainerId]);
docker.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`docker update failed with code ${code}`));
}
});
docker.on('error', reject);
});
// Remove old container
await oldContainer.remove();
// Update nginx to single upstream
const nginxConfig = `
upstream ${mainContainerId}_backend {
server ${mainContainerId}:3000;
}
server {
listen 80;
server_name ${mainContainerId}.local;
location / {
proxy_pass http://${mainContainerId}_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}`;
await fs.writeFile(`/etc/nginx/sites-available/${mainContainerId}`, nginxConfig);
execSync('nginx -s reload');
}
/**
* Rollback canary deployment
*/
async rollbackCanary(canaryContainerId, mainContainerId) {
console.log(`⚠️ Rolling back canary deployment for ${mainContainerId}`);
try {
// Stop and remove canary container
const canaryContainer = this.docker.getContainer(canaryContainerId);
await canaryContainer.stop();
await canaryContainer.remove();
// Restore nginx configuration
const nginxConfig = `
upstream ${mainContainerId}_backend {
server ${mainContainerId}:3000;
}
server {
listen 80;
server_name ${mainContainerId}.local;
location / {
proxy_pass http://${mainContainerId}_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}`;
await fs.writeFile(`/etc/nginx/sites-available/${mainContainerId}`, nginxConfig);
execSync('nginx -s reload');
} catch (error) {
console.error('Error during rollback:', error);
}
}
/**
* Log canary progress
*/
async logCanaryProgress(data) {
const logPath = path.join(this.deploymentPath, 'canary-logs', `${data.deploymentId}.json`);
await fs.mkdir(path.dirname(logPath), { recursive: true });
let logs = [];
try {
const existing = await fs.readFile(logPath, 'utf8');
logs = JSON.parse(existing);
} catch {
// File doesn't exist yet
}
logs.push(data);
await fs.writeFile(logPath, JSON.stringify(logs, null, 2));
}
/**
* Get canary deployment status
*/
async getCanaryStatus(deploymentId) {
const logPath = path.join(this.deploymentPath, 'canary-logs', `${deploymentId}.json`);
try {
const logs = JSON.parse(await fs.readFile(logPath, 'utf8'));
const latest = logs[logs.length - 1];
return {
output: `Canary deployment ${deploymentId} status`,
data: {
deploymentId,
currentPercentage: latest.percentage,
steps: logs.length,
metrics: latest.metrics,
startTime: logs[0].timestamp,
lastUpdate: latest.timestamp
}
};
} catch (error) {
throw new Error(`Canary deployment ${deploymentId} not found`);
}
}
}
export default CanaryDeployment;