UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

558 lines (475 loc) 15.2 kB
/** * 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;