cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
325 lines (272 loc) • 10 kB
JavaScript
/**
* 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;