UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

369 lines (321 loc) 13.2 kB
/** * 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;