cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
558 lines (475 loc) • 15.2 kB
JavaScript
/**
* Deployment Engine Implementation
* Cross-platform deployment orchestration replacing bash scripts
*/
const EventEmitter = require('events');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const IDeploymentEngine = require('../interfaces/IDeploymentEngine');
const GitManager = require('../git/GitManager');
const FileWatcher = require('../watchers/FileWatcher');
const CloudFormationClient = require('../aws/CloudFormationClient');
const S3Client = require('../aws/S3Client');
const { logger } = require('../../../cli/utils/logger');
class DeploymentEngine extends IDeploymentEngine {
constructor(options = {}) {
super();
this.eventEmitter = new EventEmitter();
this.config = {};
this.isWatching = false;
// Core services
this.git = options.gitManager || new GitManager();
this.fileWatcher = options.fileWatcher || new FileWatcher();
this.cfn = options.cfnClient || new CloudFormationClient(options.aws);
this.s3 = options.s3Client || new S3Client(options.aws);
// Deployment state
this.stateFile = options.stateFile || path.join(process.cwd(), '.cfn-forge', 'deployment-state.json');
this.lastDeploymentHash = null;
// Configuration
this.deployOnTags = options.deployOnTags || false;
this.watchPaths = options.watchPaths || ['.'];
this.ignorePatterns = options.ignorePatterns || [
'node_modules/**',
'.git/**',
'dist/**',
'build/**',
'**/*.tmp',
'.cfn-forge/**',
];
}
/**
* Initialize the deployment engine
* @param {Object} config - Configuration object
* @returns {Promise<void>}
*/
async initialize(config) {
this.config = {
...this.config,
...config,
};
// Ensure state directory exists
const stateDir = path.dirname(this.stateFile);
await fs.mkdir(stateDir, { recursive: true });
// Load existing state
await this._loadState();
// Validate Git repository
const isGitRepo = await this.git.isGitRepository();
if (!isGitRepo) {
throw new Error('Not in a git repository. Please initialize git first.');
}
this.eventEmitter.emit('initialized', { config: this.config });
}
/**
* Execute a deployment
* @param {Object} options - Deployment options
* @returns {Promise<Object>} Deployment result
*/
async deploy(options) {
const {
environment = 'default',
stackName,
templatePath,
parameters = {},
tags = {},
dryRun = false,
force = false,
} = options;
try {
this.eventEmitter.emit('deploymentStarted', { stackName, environment });
// Validate deployment readiness
const validation = await this.validateDeployment({
templatePath,
parameters,
stackName,
});
if (!validation.valid) {
throw new Error(`Deployment validation failed: ${validation.errors.join(', ')}`);
}
// Check if deployment is needed
if (!force) {
const shouldDeploy = await this.shouldDeploy({ stackName });
if (!shouldDeploy) {
return {
status: 'SKIPPED',
message: 'No changes detected since last deployment',
};
}
}
// Get deployment metadata
const gitInfo = await this.git.getDeploymentTrigger();
const deploymentId = await this._generateDeploymentId();
// Read template
const templateContent = await fs.readFile(templatePath, 'utf8');
// Handle Lambda functions or other packaged resources
const processedTemplate = await this._processTemplate(templateContent, {
stackName,
environment,
});
// Prepare CloudFormation parameters
const cfnParams = {
stackName,
templateBody: processedTemplate.template,
parameters: this.cfn.formatParameters(parameters),
tags: this.cfn.formatTags({
...tags,
'cfn-forge:environment': environment,
'cfn-forge:deployment-id': deploymentId,
'cfn-forge:git-commit': gitInfo.commitHash,
'cfn-forge:git-branch': gitInfo.branch,
}),
capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'],
};
if (dryRun) {
this.eventEmitter.emit('deploymentCompleted', {
status: 'DRY_RUN',
stackName,
changes: 'Would deploy with above parameters',
});
return { status: 'DRY_RUN', parameters: cfnParams };
}
// Deploy stack
const deployResult = await this.cfn.deployStack(cfnParams);
// Wait for completion
if (deployResult.operation !== 'NO_CHANGE') {
this.eventEmitter.emit('waitingForStack', { stackName, operation: deployResult.operation });
const finalStatus = await this.cfn.waitForStack(
stackName,
deployResult.operation.toLowerCase(),
{ maxWaitTime: 1800 }, // 30 minutes
);
if (!this._isSuccessStatus(finalStatus.status)) {
throw new Error(`Deployment failed: ${finalStatus.statusReason || finalStatus.status}`);
}
}
// Save deployment state
await this.saveDeploymentState(stackName, {
deploymentId,
timestamp: new Date().toISOString(),
gitCommit: gitInfo.commitHash,
gitBranch: gitInfo.branch,
templatePath,
environment,
status: deployResult.operation,
});
this.eventEmitter.emit('deploymentCompleted', {
status: 'SUCCESS',
stackName,
operation: deployResult.operation,
deploymentId,
});
return {
status: 'SUCCESS',
stackName,
operation: deployResult.operation,
deploymentId,
outputs: await this.cfn.getStackOutputs(stackName),
};
} catch (error) {
this.eventEmitter.emit('deploymentFailed', {
stackName,
error: error.message,
});
throw error;
}
}
/**
* Start watching for deployment triggers
* @param {Object} options - Watch options
* @returns {Promise<void>}
*/
async startWatching(options = {}) {
if (this.isWatching) {
throw new Error('Already watching for changes');
}
const {
interval = 2000,
deploymentOptions = {},
} = options;
// Set up git change detection
const checkGitChanges = async () => {
try {
const currentHash = await this.git.getCommitHash();
if (this.lastDeploymentHash && currentHash !== this.lastDeploymentHash) {
const shouldDeploy = await this.git.shouldDeploy({
deployOnTags: this.deployOnTags,
});
if (shouldDeploy) {
logger.info('Git changes detected, triggering deployment...');
await this.deploy(deploymentOptions);
this.lastDeploymentHash = currentHash;
}
} else if (!this.lastDeploymentHash) {
this.lastDeploymentHash = currentHash;
}
} catch (error) {
logger.error('Error checking git changes:', error.message);
}
};
// Set up file watcher
await this.fileWatcher.start(this.watchPaths, {
ignorePatterns: this.ignorePatterns,
});
// Debounced handler for file changes
const handleFileChange = this.fileWatcher.createDebouncedHandler(async (filePath) => {
logger.debug(`File changed: ${filePath}`);
await checkGitChanges();
}, interval);
this.fileWatcher.on('change', handleFileChange);
this.fileWatcher.on('add', handleFileChange);
this.fileWatcher.on('unlink', handleFileChange);
// Initial check
await checkGitChanges();
// Set up periodic git check (in case of external git operations)
this.gitCheckInterval = setInterval(checkGitChanges, interval);
this.isWatching = true;
this.eventEmitter.emit('watchingStarted', { paths: this.watchPaths });
logger.info('Deployment watcher started');
logger.info(`Watching paths: ${this.watchPaths.join(', ')}`);
logger.info(`Deploy on tags only: ${this.deployOnTags}`);
}
/**
* Stop watching for deployment triggers
* @returns {Promise<void>}
*/
async stopWatching() {
if (!this.isWatching) {
return;
}
await this.fileWatcher.stop();
if (this.gitCheckInterval) {
clearInterval(this.gitCheckInterval);
this.gitCheckInterval = null;
}
this.isWatching = false;
this.eventEmitter.emit('watchingStopped');
logger.info('Deployment watcher stopped');
}
/**
* Validate deployment readiness
* @param {Object} options - Validation options
* @returns {Promise<Object>} Validation result
*/
async validateDeployment(options) {
const { templatePath, parameters, stackName } = options;
const errors = [];
// Check template exists
try {
await fs.access(templatePath);
} catch {
errors.push(`Template file not found: ${templatePath}`);
}
// Validate template if it exists
if (errors.length === 0) {
try {
const templateContent = await fs.readFile(templatePath, 'utf8');
const validationResult = await this.cfn.validateTemplate(templateContent);
if (!validationResult.valid) {
errors.push(`Template validation failed: ${validationResult.error}`);
}
// Check required parameters
if (validationResult.parameters) {
const requiredParams = validationResult.parameters
.filter((p) => !p.DefaultValue)
.map((p) => p.ParameterKey);
const missingParams = requiredParams.filter((p) => !parameters[p]);
if (missingParams.length > 0) {
errors.push(`Missing required parameters: ${missingParams.join(', ')}`);
}
}
} catch (error) {
errors.push(`Template validation error: ${error.message}`);
}
}
// Check AWS credentials
try {
await this.cfn.listStacks({ statusFilter: ['CREATE_COMPLETE'] });
} catch (error) {
errors.push(`AWS credentials error: ${error.message}`);
}
return {
valid: errors.length === 0,
errors,
warnings: [],
};
}
/**
* Get deployment history
* @param {string} stackName - Stack name
* @param {Object} options - Query options
* @returns {Promise<Array>} Deployment history
*/
async getDeploymentHistory(stackName, options = {}) {
const { limit = 10 } = options;
try {
const events = await this.cfn.getStackEvents(stackName);
// Filter for deployment-related events
const deploymentEvents = events.events
.filter((e) => e.resourceType === 'AWS::CloudFormation::Stack'
&& e.logicalResourceId === stackName)
.slice(0, limit)
.map((e) => ({
timestamp: e.timestamp,
status: e.resourceStatus,
reason: e.statusReason,
}));
return deploymentEvents;
} catch (error) {
return [];
}
}
/**
* Check if deployment is needed
* @param {Object} options - Check options
* @returns {Promise<boolean>} True if deployment needed
*/
async shouldDeploy(options = {}) {
const { stackName } = options;
// Check git-based deployment rules
const gitShouldDeploy = await this.git.shouldDeploy({
deployOnTags: this.deployOnTags,
});
if (!gitShouldDeploy) {
return false;
}
// Check if stack exists and get last deployment
try {
const stackStatus = await this.cfn.getStackStatus(stackName);
if (!stackStatus) {
// Stack doesn't exist, should deploy
return true;
}
// Check deployment state
const state = await this._loadState();
const stackState = state.stacks?.[stackName];
if (!stackState) {
// No previous deployment recorded
return true;
}
// Compare git commits
const currentCommit = await this.git.getCommitHash();
return currentCommit !== stackState.gitCommit;
} catch (error) {
// On error, assume deployment is needed
return true;
}
}
/**
* Get current deployment state
* @param {string} stackName - Stack name
* @returns {Promise<Object>} Current state
*/
async getDeploymentState(stackName) {
const state = await this._loadState();
return state.stacks?.[stackName] || null;
}
/**
* Save deployment state
* @param {string} stackName - Stack name
* @param {Object} stackState - State to save
* @returns {Promise<void>}
*/
async saveDeploymentState(stackName, stackState) {
const state = await this._loadState();
if (!state.stacks) {
state.stacks = {};
}
state.stacks[stackName] = {
...state.stacks[stackName],
...stackState,
lastUpdated: new Date().toISOString(),
};
await this._saveState(state);
}
/**
* Register deployment event handler
* @param {string} event - Event name
* @param {Function} handler - Event handler
* @returns {void}
*/
on(event, handler) {
this.eventEmitter.on(event, handler);
}
/**
* Get deployment configuration
* @returns {Object} Current configuration
*/
getConfiguration() {
return { ...this.config };
}
/**
* Update deployment configuration
* @param {Object} config - New configuration
* @returns {Promise<void>}
*/
async updateConfiguration(config) {
this.config = {
...this.config,
...config,
};
if (config.deployOnTags !== undefined) {
this.deployOnTags = config.deployOnTags;
}
if (config.watchPaths) {
this.watchPaths = config.watchPaths;
}
if (config.ignorePatterns) {
this.ignorePatterns = config.ignorePatterns;
}
this.eventEmitter.emit('configurationUpdated', { config: this.config });
}
/**
* Process template for deployment
* @private
*/
async _processTemplate(templateContent, context) {
// TODO: Add Lambda packaging support
// For now, return template as-is
return {
template: templateContent,
artifacts: [],
};
}
/**
* Generate deployment ID
* @private
*/
async _generateDeploymentId() {
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
return `deploy-${timestamp}-${random}`;
}
/**
* Check if CloudFormation status is successful
* @private
*/
_isSuccessStatus(status) {
return [
'CREATE_COMPLETE',
'UPDATE_COMPLETE',
'DELETE_COMPLETE',
].includes(status);
}
/**
* Load deployment state from file
* @private
*/
async _loadState() {
try {
const content = await fs.readFile(this.stateFile, 'utf8');
return JSON.parse(content);
} catch (error) {
// Return empty state if file doesn't exist
return { version: '1.0', stacks: {} };
}
}
/**
* Save deployment state to file
* @private
*/
async _saveState(state) {
const content = JSON.stringify(state, null, 2);
await fs.writeFile(this.stateFile, content, 'utf8');
}
}
module.exports = DeploymentEngine;