cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
369 lines (321 loc) • 13.2 kB
JavaScript
/**
* CFN-Forge Validate Command
*
* Validates CloudFormation templates and parameter file compatibility
*/
const fs = require('fs').promises;
const chalk = require('chalk');
const yaml = require('js-yaml');
const { execSync } = require('child_process');
const inquirer = require('inquirer');
// CloudFormation schema for parsing templates with intrinsic functions
const CloudFormationSchema = yaml.DEFAULT_SCHEMA.extend([
new yaml.Type('!Ref', { kind: 'scalar', construct: (data) => ({ 'Fn::Ref': data }) }),
new yaml.Type('!Sub', { kind: 'scalar', construct: (data) => ({ 'Fn::Sub': data }) }),
new yaml.Type('!GetAtt', {
kind: 'scalar',
construct: (data) => {
// Handle dot notation (ResourceName.AttributeName)
if (typeof data === 'string' && data.includes('.')) {
const [resource, attribute] = data.split('.', 2);
return { 'Fn::GetAtt': [resource, attribute] };
}
return { 'Fn::GetAtt': data };
},
}),
new yaml.Type('!GetAZs', { kind: 'scalar', construct: (data) => ({ 'Fn::GetAZs': data }) }),
new yaml.Type('!ImportValue', { kind: 'scalar', construct: (data) => ({ 'Fn::ImportValue': data }) }),
new yaml.Type('!Join', { kind: 'sequence', construct: (data) => ({ 'Fn::Join': data }) }),
new yaml.Type('!Select', { kind: 'sequence', construct: (data) => ({ 'Fn::Select': data }) }),
new yaml.Type('!Split', { kind: 'sequence', construct: (data) => ({ 'Fn::Split': data }) }),
new yaml.Type('!Base64', { kind: 'scalar', construct: (data) => ({ 'Fn::Base64': data }) }),
new yaml.Type('!Cidr', { kind: 'sequence', construct: (data) => ({ 'Fn::Cidr': data }) }),
new yaml.Type('!FindInMap', { kind: 'sequence', construct: (data) => ({ 'Fn::FindInMap': data }) }),
new yaml.Type('!If', { kind: 'sequence', construct: (data) => ({ 'Fn::If': data }) }),
new yaml.Type('!Not', { kind: 'sequence', construct: (data) => ({ 'Fn::Not': data }) }),
new yaml.Type('!Equals', { kind: 'sequence', construct: (data) => ({ 'Fn::Equals': data }) }),
new yaml.Type('!And', { kind: 'sequence', construct: (data) => ({ 'Fn::And': data }) }),
new yaml.Type('!Or', { kind: 'sequence', construct: (data) => ({ 'Fn::Or': data }) }),
new yaml.Type('!Condition', { kind: 'scalar', construct: (data) => ({ Condition: data }) }),
]);
async function validateCommand(options) {
console.log(chalk.cyan('CloudFormation Template & Parameter Validation\n'));
try {
// Find template and parameter files
const { templateFile, parameterFiles } = await findTemplateAndParameterFiles(options);
if (!templateFile) {
console.log(chalk.magenta('No CloudFormation template found'));
console.log(chalk.white('Looking for: template.yaml, cloudformation.yaml, stack.yaml, etc.'));
console.log(chalk.cyan('\nCreate a template first, then run validation again.'));
return false;
}
// Validate template syntax first
const templateValidation = await validateTemplate(templateFile);
if (!templateValidation.valid) {
console.log(chalk.magenta('\nTemplate is not ready for deployment'));
console.log(chalk.white('Fix the template errors above, then run validation again.'));
return false;
}
// If no parameter files found, just validate template
if (parameterFiles.length === 0) {
console.log(chalk.yellow('No parameter files found - validating template only\n'));
console.log(chalk.greenBright('Template validation passed'));
displayTemplateSummary(templateValidation.template);
console.log(chalk.greenBright('\nTemplate ready for deployment!'));
await offerGitCommit();
return true;
}
// Validate each parameter file against template
let allValid = true;
for (const paramFile of parameterFiles) {
console.log(chalk.white(`\nValidating ${paramFile}:`));
const paramValidation = await validateParameterFile(templateValidation.template, paramFile);
if (paramValidation.valid) {
console.log(chalk.greenBright(`${paramFile} is compatible`));
if (paramValidation.warnings.length > 0) {
paramValidation.warnings.forEach((warning) => console.log(chalk.yellowBright(` ${warning}`)));
}
} else {
console.log(chalk.magenta(`${paramFile} has compatibility issues:`));
paramValidation.errors.forEach((error) => console.log(chalk.magenta(` ${error}`)));
allValid = false;
}
}
// Final summary
console.log(chalk.white('\n=== Validation Summary ==='));
console.log(chalk.greenBright(`Template: ${templateFile}`));
if (allValid) {
console.log(chalk.greenBright(`All ${parameterFiles.length} parameter file(s) compatible`));
console.log(chalk.greenBright('\nReady for deployment!'));
await offerGitCommit();
return true;
}
console.log(chalk.magenta('Some parameter file(s) have issues'));
console.log(chalk.magenta('\nNot ready for deployment - fix parameter issues above'));
return false;
} catch (error) {
console.log(chalk.magenta('\nValidation failed'));
console.log(chalk.white(`Error: ${error.message}`));
return false;
}
}
async function findTemplateAndParameterFiles(options) {
const templateCandidates = [
'template.yaml',
'template.yml',
'cloudformation.yaml',
'cloudformation.yml',
'stack.yaml',
'stack.yml',
];
const parameterCandidates = [
'parameters.json',
'parameters.yaml',
'parameters.yml',
'params.json',
'params.yaml',
'params.yml',
];
// Add environment-specific parameter files
['dev', 'staging', 'prod'].forEach((env) => {
parameterCandidates.push(`parameters-${env}.json`);
parameterCandidates.push(`parameters-${env}.yaml`);
parameterCandidates.push(`params-${env}.json`);
});
let templateFile = null;
// Check cfn-forge config first
try {
const configContent = await fs.readFile('.cfn-forge.yaml', 'utf8');
const config = yaml.load(configContent);
if (config.templates && config.templates.main) {
templateFile = config.templates.main;
}
} catch {
// No config file, continue with candidate search
}
// Use command line template if specified
if (options.template) {
templateFile = options.template;
}
// Find first existing template if none specified
if (!templateFile) {
for (const candidate of templateCandidates) {
try {
await fs.access(candidate);
templateFile = candidate;
break;
} catch {
// Continue searching
}
}
}
// Find existing parameter files
const parameterFiles = [];
for (const candidate of parameterCandidates) {
try {
await fs.access(candidate);
parameterFiles.push(candidate);
} catch {
// File doesn't exist, continue
}
}
// Use command line parameter file if specified
if (options.parameters) {
parameterFiles.length = 0; // Clear found files
parameterFiles.push(options.parameters);
}
return { templateFile, parameterFiles };
}
async function validateTemplate(templateFile) {
try {
console.log(chalk.white(`Validating template: ${templateFile}`));
const templateContent = await fs.readFile(templateFile, 'utf8');
const template = yaml.load(templateContent, { schema: CloudFormationSchema });
// Basic template structure validation
if (!template.AWSTemplateFormatVersion && !template.AWSTemplateFormatVersion) {
throw new Error('Missing AWSTemplateFormatVersion');
}
// AWS CLI validation (if available)
try {
execSync(
`aws cloudformation validate-template --template-body file://${templateFile}`,
{ stdio: 'pipe' },
);
console.log(chalk.greenBright('AWS CloudFormation validation passed'));
} catch (error) {
console.log(chalk.yellowBright('AWS validation unavailable (AWS CLI not configured)'));
}
return {
valid: true,
template,
};
} catch (error) {
console.log(chalk.magenta(`Template validation failed: ${error.message}`));
return {
valid: false,
error: error.message,
};
}
}
async function validateParameterFile(template, parameterFile) {
const errors = [];
const warnings = [];
try {
console.log(chalk.white(` Reading ${parameterFile}...`));
const paramContent = await fs.readFile(parameterFile, 'utf8');
let parameters;
// Parse parameter file based on extension
if (parameterFile.endsWith('.json')) {
parameters = JSON.parse(paramContent);
} else {
parameters = yaml.load(paramContent);
}
const templateParameters = template.Parameters || {};
const templateParamNames = Object.keys(templateParameters);
const providedParamNames = Object.keys(parameters);
// Check for missing required parameters
for (const paramName of templateParamNames) {
const paramDef = templateParameters[paramName];
const hasDefault = paramDef.Default !== undefined;
const isProvided = providedParamNames.includes(paramName);
if (!hasDefault && !isProvided) {
errors.push(`Missing required parameter: ${paramName}`);
} else if (!isProvided && hasDefault) {
warnings.push(`Parameter ${paramName} not provided, will use default: ${paramDef.Default}`);
}
}
// Check for unknown parameters
for (const paramName of providedParamNames) {
if (!templateParamNames.includes(paramName)) {
warnings.push(`Unknown parameter: ${paramName} (not defined in template)`);
}
}
// Validate parameter values against constraints
for (const paramName of providedParamNames) {
if (templateParameters[paramName]) {
const paramDef = templateParameters[paramName];
const value = parameters[paramName];
// Check AllowedValues
if (paramDef.AllowedValues && !paramDef.AllowedValues.includes(value)) {
errors.push(`Invalid value for ${paramName}: "${value}". Allowed: ${paramDef.AllowedValues.join(', ')}`);
}
// Check Type constraints
if (paramDef.Type === 'Number' && Number.isNaN(Number(value))) {
errors.push(`Parameter ${paramName} must be a number, got: "${value}"`);
}
// Check MinLength/MaxLength for strings
if (paramDef.Type === 'String') {
if (paramDef.MinLength && value.length < paramDef.MinLength) {
errors.push(`Parameter ${paramName} too short. Min length: ${paramDef.MinLength}, got: ${value.length}`);
}
if (paramDef.MaxLength && value.length > paramDef.MaxLength) {
errors.push(`Parameter ${paramName} too long. Max length: ${paramDef.MaxLength}, got: ${value.length}`);
}
}
// Check MinValue/MaxValue for numbers
if (paramDef.Type === 'Number') {
const numValue = Number(value);
if (paramDef.MinValue && numValue < paramDef.MinValue) {
errors.push(`Parameter ${paramName} too small. Min value: ${paramDef.MinValue}, got: ${numValue}`);
}
if (paramDef.MaxValue && numValue > paramDef.MaxValue) {
errors.push(`Parameter ${paramName} too large. Max value: ${paramDef.MaxValue}, got: ${numValue}`);
}
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
} catch (error) {
return {
valid: false,
errors: [`Failed to parse parameter file: ${error.message}`],
warnings: [],
};
}
}
function displayTemplateSummary(template) {
console.log(chalk.white('\n=== Template Summary ==='));
if (template.Description) {
console.log(chalk.white(`Description: ${template.Description}`));
}
const parameters = template.Parameters || {};
const resources = template.Resources || {};
const outputs = template.Outputs || {};
console.log(chalk.white(`Parameters: ${Object.keys(parameters).length}`));
console.log(chalk.white(`Resources: ${Object.keys(resources).length}`));
console.log(chalk.white(`Outputs: ${Object.keys(outputs).length}`));
if (Object.keys(parameters).length > 0) {
console.log(chalk.white('\nTemplate Parameters:'));
Object.keys(parameters).forEach((name) => {
const param = parameters[name];
const hasDefault = param.Default !== undefined;
console.log(chalk.white(` ${name} (${param.Type}${hasDefault ? `, default: ${param.Default}` : ', required'})`));
});
}
}
async function offerGitCommit() {
try {
const { shouldCommit } = await inquirer.prompt([{
type: 'confirm',
name: 'shouldCommit',
message: 'Commit changes?',
default: false,
}]);
if (shouldCommit) {
execSync('git add .', { stdio: 'pipe' });
execSync('git commit -m "Update CloudFormation files"', { stdio: 'pipe' });
console.log(chalk.green('Changes committed'));
}
} catch (error) {
if (error.message.includes('not a git repository')) {
console.log(chalk.yellow('Not a git repository - skipping commit'));
} else {
console.log(chalk.yellow('Git commit failed'));
}
}
}
module.exports = validateCommand;