UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

325 lines (272 loc) 10 kB
/** * CFN-Forge Drift Command * Detects drift in CloudFormation stacks and compares local templates with deployed templates */ const fs = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); const chalk = require('chalk'); const { logger } = require('../utils/logger'); const { withAWSErrorHandling } = require('../utils/aws-error-parser'); const { loadConfig } = require('../utils/config'); const CloudFormationClient = require('../../lib/core/aws/CloudFormationClient'); /** * Validate that we're in a CFN-Forge project */ async function validateProject() { try { await fs.access('.cfn-forge.yaml'); } catch (error) { const enhancedError = new Error( 'Not in a CFN-Forge project directory.\n' + 'Run `cfn-forge init` to initialize a new project.', ); enhancedError.code = 'PROJECT_NOT_FOUND'; throw enhancedError; } } /** * Load and validate project configuration */ async function loadProjectConfig() { try { const content = await fs.readFile('.cfn-forge.yaml', 'utf8'); const config = yaml.load(content); if (!config.project) { throw new Error('Invalid .cfn-forge.yaml: missing project section'); } if (!config.environments) { throw new Error('Invalid .cfn-forge.yaml: missing environments section'); } return config; } catch (error) { if (error.code === 'ENOENT') { throw new Error('.cfn-forge.yaml not found. Run `cfn-forge init` first.'); } throw error; } } /** * Resolve environment and validate configuration */ function resolveAndValidateEnvironment(options, config) { const environment = options.env || 'dev'; if (!config.environments[environment]) { const available = Object.keys(config.environments).join(', '); throw new Error( `Environment '${environment}' not found in .cfn-forge.yaml.\n` + `Available environments: ${available}`, ); } const envConfig = config.environments[environment]; // Construct stack name const hasDirectStackName = envConfig.stackName; const hasBaseStackName = config.cloudformation?.stackName; const hasStackSuffix = envConfig.stackSuffix; if (hasDirectStackName) { envConfig.finalStackName = envConfig.stackName; } else if (hasBaseStackName && hasStackSuffix) { envConfig.finalStackName = `${hasBaseStackName}${hasStackSuffix}`; } else { throw new Error(`Environment '${environment}' missing stackName configuration`); } // Template path envConfig.finalTemplate = envConfig.template || config.cloudformation?.template; if (!envConfig.finalTemplate) { throw new Error(`Environment '${environment}' missing template configuration`); } return { environment, envConfig }; } /** * Perform stack drift detection */ async function performStackDrift(cfnClient, stackName) { logger.info('Running CloudFormation drift detection...'); try { const driftResult = await cfnClient.detectStackDrift(stackName); if (driftResult.status === 'DETECTION_COMPLETE') { if (driftResult.driftStatus === 'DRIFTED') { logger.warn('Stack has drifted from its original configuration'); return { hasDrift: true, details: driftResult }; } logger.success('No resource drift detected'); return { hasDrift: false, details: driftResult }; } logger.warn(`Drift detection status: ${driftResult.status}`); return { hasDrift: null, details: driftResult }; } catch (error) { logger.error(`Drift detection failed: ${error.message}`); return { hasDrift: null, error: error.message }; } } /** * Compare local template with deployed template */ async function compareTemplates(cfnClient, stackName, templatePath) { logger.info('Comparing local template with deployed template...'); try { // Read local template const localTemplate = await fs.readFile(templatePath, 'utf8'); // Compare with deployed template const comparison = await cfnClient.compareTemplates(stackName, localTemplate); if (comparison.hasChanges) { logger.warn('Local template differs from deployed template'); return { hasChanges: true, comparison }; } logger.success('Local template matches deployed template'); return { hasChanges: false, comparison }; } catch (error) { logger.error(`Template comparison failed: ${error.message}`); return { hasChanges: null, error: error.message }; } } /** * Show template differences */ function showTemplateDiff(comparison) { if (!comparison.hasChanges) { return; } logger.info('\nTemplate Differences:'); logger.info('====================\n'); // For now, show a simple summary. In the future, we could add a proper diff library logger.info('Local template structure:'); const localKeys = Object.keys(comparison.localTemplate); localKeys.forEach((key) => { logger.info(` ${chalk.green('+')} ${key}`); }); logger.info('\nDeployed template structure:'); const deployedKeys = Object.keys(comparison.deployedTemplate); deployedKeys.forEach((key) => { logger.info(` ${chalk.blue('=')} ${key}`); }); logger.info('\nFor detailed differences, use:'); logger.info(` ${chalk.cyan('cfn-forge drift --save-templates')}`); } /** * Save templates to files for manual comparison */ async function saveTemplatesForComparison(comparison, stackName) { const driftDir = path.join('.cfn-forge', 'drift'); await fs.mkdir(driftDir, { recursive: true }); const localFile = path.join(driftDir, `${stackName}-local.json`); const deployedFile = path.join(driftDir, `${stackName}-deployed.json`); await fs.writeFile(localFile, comparison.localTemplateString, 'utf8'); await fs.writeFile(deployedFile, comparison.deployedTemplateString, 'utf8'); logger.info('\nTemplates saved for comparison:'); logger.info(` Local: ${localFile}`); logger.info(` Deployed: ${deployedFile}`); logger.info(`\nCompare with: diff ${localFile} ${deployedFile}`); } /** * Get deployed template and save to file */ async function getDeployedTemplate(cfnClient, stackName, outputPath) { try { const template = await cfnClient.getTemplate(stackName); let content; if (typeof template.templateBody === 'string') { content = template.templateBody; } else { content = JSON.stringify(template.templateBody, null, 2); } if (outputPath) { await fs.writeFile(outputPath, content, 'utf8'); logger.success(`Deployed template saved to: ${outputPath}`); } else { console.log(`\n${chalk.cyan('=== Deployed Template ===')}\n`); console.log(content); } return template; } catch (error) { throw new Error(`Failed to retrieve deployed template: ${error.message}`); } } /** * Main drift command handler */ async function driftCommand(options) { logger.setContext('command', 'drift'); // Enable timestamps for drift output logger.enableTimestamps(); try { logger.info('CFN-Forge Drift Detection'); logger.info(''); // Validate project await validateProject(); const config = await loadProjectConfig(); // Resolve environment const { environment, envConfig } = resolveAndValidateEnvironment(options, config); logger.info(`Environment: ${environment}`); logger.info(`Stack: ${envConfig.finalStackName}`); logger.info(`Template: ${envConfig.finalTemplate}`); logger.info(''); // Create CloudFormation client const cfnClient = new CloudFormationClient({ region: envConfig.region || process.env.AWS_REGION, }); // Check if stack exists const stackStatus = await cfnClient.getStackStatus(envConfig.finalStackName); if (!stackStatus) { logger.error(`Stack '${envConfig.finalStackName}' not found`); process.exit(1); } logger.info(`Stack Status: ${stackStatus.status}`); logger.info(''); // Handle specific command options if (options.getTemplate) { await getDeployedTemplate(cfnClient, envConfig.finalStackName, options.output); return; } // Perform drift detection (if not disabled) let driftResult = null; if (!options.skipDrift) { driftResult = await performStackDrift(cfnClient, envConfig.finalStackName); } // Perform template comparison (if not disabled) let templateResult = null; if (!options.skipTemplate) { templateResult = await compareTemplates(cfnClient, envConfig.finalStackName, envConfig.finalTemplate); } // Show results summary logger.info('\nDrift Detection Summary:'); logger.info('========================'); if (driftResult) { if (driftResult.hasDrift === true) { logger.warn('✗ Resource drift detected'); } else if (driftResult.hasDrift === false) { logger.success('✓ No resource drift detected'); } else { logger.info('? Resource drift detection inconclusive'); } } if (templateResult) { if (templateResult.hasChanges === true) { logger.warn('✗ Template changes detected'); } else if (templateResult.hasChanges === false) { logger.success('✓ Template matches deployed version'); } else { logger.info('? Template comparison inconclusive'); } } // Show template differences if requested if (templateResult?.hasChanges && options.showDiff) { showTemplateDiff(templateResult.comparison); } // Save templates for comparison if requested if (templateResult?.hasChanges && options.saveTemplates) { await saveTemplatesForComparison(templateResult.comparison, envConfig.finalStackName); } // Exit with appropriate code const hasIssues = (driftResult?.hasDrift === true) || (templateResult?.hasChanges === true); if (hasIssues && !options.ignoreExitCode) { process.exit(1); } } catch (error) { logger.errorWithContext(error, 'Drift Command'); process.exit(1); } finally { logger.clearContext(); } } module.exports = driftCommand;