cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
1,031 lines (896 loc) ⢠32.6 kB
JavaScript
/**
* CFN-Forge Deploy Command
* Orchestrates CloudFormation deployments using cross-platform Node.js architecture
*/
const { execSync } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
const { logger } = require('../utils/logger');
const { getAWSConfig, setAWSEnvironmentVariables } = require('./config');
const { getProjectAWSProfile, validateAWSAccount } = require('../utils/config');
const { withAWSErrorHandling } = require('../utils/aws-error-parser');
const DeploymentEngine = require('../../lib/core/deployment/DeploymentEngine');
/**
* Validate that we're in a CFN-Forge project
*/
async function validateProject() {
const op = logger.trackOperation('Project Validation', {
cwd: process.cwd(),
});
const startTime = op.start();
try {
await fs.access('.cfn-forge.yaml');
op.success(startTime, 'Project validation passed');
} 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';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
}
/**
* Load and validate project configuration
*/
async function loadProjectConfig() {
const op = logger.trackOperation('Configuration Loading', {
configFile: '.cfn-forge.yaml',
});
const startTime = op.start();
try {
const content = await fs.readFile('.cfn-forge.yaml', 'utf8');
const config = yaml.load(content);
if (!config.project) {
const error = new Error('Invalid .cfn-forge.yaml: missing project section');
error.code = 'INVALID_CONFIG';
throw error;
}
if (!config.environments) {
const error = new Error('Invalid .cfn-forge.yaml: missing environments section');
error.code = 'INVALID_CONFIG';
throw error;
}
logger.debug(`Loaded config with ${Object.keys(config.environments).length} environments`);
op.success(startTime, 'Configuration loaded successfully');
return config;
} catch (error) {
if (error.code === 'ENOENT') {
const enhancedError = new Error('.cfn-forge.yaml not found. Run `cfn-forge init` first.');
enhancedError.code = 'CONFIG_NOT_FOUND';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
if (error.name === 'YAMLException') {
const enhancedError = new Error(`YAML syntax error in .cfn-forge.yaml: ${error.message}`);
enhancedError.code = 'YAML_SYNTAX_ERROR';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
op.error(error, startTime);
throw error;
}
}
/**
* Resolve target environment for deployment
*/
function resolveEnvironment(options, config) {
// Priority order:
// 1. Command line --env flag
// 2. Git branch mapping
// 3. Default to 'dev'
if (options.environment) {
logger.info(`Using environment from command line: ${options.environment}`);
return options.environment;
}
// Get current git branch for automatic environment mapping
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
// Common branch -> environment mappings
const branchMappings = {
main: 'prod',
master: 'prod',
production: 'prod',
staging: 'staging',
develop: 'staging',
development: 'dev',
dev: 'dev',
};
const mappedEnv = branchMappings[branch];
if (mappedEnv && config.environments[mappedEnv]) {
logger.info(`šæ Detected branch '${branch}' ā environment '${mappedEnv}'`);
return mappedEnv;
}
} catch (error) {
logger.debug('Could not detect git branch for environment mapping');
}
// Default to dev
return 'dev';
}
/**
* Validate environment configuration
*/
function validateEnvironment(config, environment) {
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];
// Check for stack name - either directly specified or base + suffix
const hasDirectStackName = envConfig.stackName;
const hasBaseStackName = config.cloudformation?.stackName;
const hasStackSuffix = envConfig.stackSuffix;
if (!hasDirectStackName && (!hasBaseStackName || !hasStackSuffix)) {
throw new Error(
`Environment '${environment}' missing stackName configuration.\n`
+ `Either specify 'stackName' directly in the environment, or use 'cloudformation.stackName' + 'environments.${environment}.stackSuffix'`,
);
}
// Construct final stack name
if (hasDirectStackName) {
envConfig.finalStackName = envConfig.stackName;
} else {
envConfig.finalStackName = `${hasBaseStackName}${hasStackSuffix}`;
}
// Check for template - either environment-specific or global
const templatePath = envConfig.template || config.cloudformation?.template;
if (!templatePath) {
throw new Error(`Environment '${environment}' missing template configuration`);
}
envConfig.finalTemplate = templatePath;
return envConfig;
}
/**
* Validate CloudFormation template exists and is valid
*/
async function validateTemplate(templatePath) {
const op = logger.trackOperation('Template Validation', {
templatePath,
});
const startTime = op.start();
try {
await fs.access(templatePath);
} catch (error) {
const enhancedError = new Error(`CloudFormation template not found: ${templatePath}`);
enhancedError.code = 'TEMPLATE_NOT_FOUND';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
// Validate template syntax with CloudFormation intrinsic functions support
try {
const content = await fs.readFile(templatePath, 'utf8');
// Try to parse as YAML with CloudFormation support or JSON
try {
// Define CloudFormation YAML schema (same as cost command)
const cfnSchema = yaml.DEFAULT_SCHEMA.extend([
new yaml.Type('!Ref', {
kind: 'scalar',
construct(data) { return { Ref: data }; },
}),
new yaml.Type('!GetAtt', {
kind: 'scalar',
construct(data) { return { 'Fn::GetAtt': data.split('.') }; },
}),
new yaml.Type('!Sub', {
kind: 'scalar',
construct(data) { return { 'Fn::Sub': data }; },
}),
new yaml.Type('!Join', {
kind: 'sequence',
construct(data) { return { 'Fn::Join': data }; },
}),
new yaml.Type('!Select', {
kind: 'sequence',
construct(data) { return { 'Fn::Select': data }; },
}),
new yaml.Type('!Split', {
kind: 'sequence',
construct(data) { return { 'Fn::Split': data }; },
}),
new yaml.Type('!Equals', {
kind: 'sequence',
construct(data) { return { 'Fn::Equals': data }; },
}),
new yaml.Type('!And', {
kind: 'sequence',
construct(data) { return { 'Fn::And': data }; },
}),
new yaml.Type('!Or', {
kind: 'sequence',
construct(data) { return { 'Fn::Or': data }; },
}),
new yaml.Type('!Not', {
kind: 'sequence',
construct(data) { return { 'Fn::Not': data }; },
}),
new yaml.Type('!If', {
kind: 'sequence',
construct(data) { return { 'Fn::If': data }; },
}),
new yaml.Type('!Condition', {
kind: 'scalar',
construct(data) { return { Condition: data }; },
}),
new yaml.Type('!GetAZs', {
kind: 'scalar',
construct(data) { return { 'Fn::GetAZs': data }; },
}),
new yaml.Type('!Cidr', {
kind: 'sequence',
construct(data) { return { 'Fn::Cidr': data }; },
}),
]);
yaml.load(content, { schema: cfnSchema });
} catch (yamlError) {
try {
JSON.parse(content);
logger.debug('Template parsed as JSON successfully');
} catch (jsonError) {
const enhancedError = new Error(`Template syntax error: ${yamlError.message}`);
enhancedError.code = 'TEMPLATE_SYNTAX_ERROR';
enhancedError.originalError = yamlError;
enhancedError.templatePath = templatePath;
op.error(enhancedError, startTime);
throw enhancedError;
}
}
op.success(startTime, `Template validation passed: ${templatePath}`);
} catch (error) {
if (!error.code) {
const enhancedError = new Error(`Template validation failed: ${error.message}`);
enhancedError.code = 'TEMPLATE_VALIDATION_FAILED';
enhancedError.originalError = error;
enhancedError.templatePath = templatePath;
op.error(enhancedError, startTime);
throw enhancedError;
}
throw error;
}
}
/**
* Validate AWS access and permissions
*/
const validateAWSAccess = withAWSErrorHandling('AWS Access Validation', async () => {
const op = logger.trackOperation('AWS Access Validation', {
region: process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || 'default',
});
const startTime = op.start();
try {
// Check AWS credentials
const identity = execSync('aws sts get-caller-identity --output json', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const callerInfo = JSON.parse(identity);
logger.debug(`AWS access validated: ${callerInfo.Arn}`);
op.success(startTime, `AWS access validated for ${callerInfo.Arn}`);
return callerInfo;
} catch (error) {
error.stderr = error.stderr || error.message;
op.error(error, startTime);
throw error;
}
});
/**
* Check git repository status
*/
function validateGitStatus(options) {
const op = logger.trackOperation('Git Status Validation', {
force: options.force || false,
});
const startTime = op.start();
try {
// Check if we're in a git repository
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
// Check for uncommitted changes (unless forced)
if (!options.force) {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
if (status.trim()) {
logger.warn('Uncommitted changes detected:');
console.log(status);
logger.info('Commit changes or use --force to deploy anyway');
const error = new Error('Uncommitted changes detected');
error.code = 'UNCOMMITTED_CHANGES';
error.statusOutput = status;
op.error(error, startTime);
return false;
}
} else {
logger.info('Force deployment enabled - ignoring uncommitted changes');
}
op.success(startTime, 'Git status validation passed');
return true;
} catch (error) {
const enhancedError = new Error(
'Not in a git repository.\n'
+ 'Initialize git with: git init',
);
enhancedError.code = 'NOT_GIT_REPOSITORY';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
}
/**
* Setup AWS environment for deployment
*/
async function setupAWSEnvironment(config, environment, options) {
// Resolve AWS profile in priority order:
// 1. Command line --aws-profile
// 2. Environment-specific config
// 3. Global project config
// 4. Default profile
let targetProfile = null;
if (options.awsProfile) {
targetProfile = options.awsProfile;
logger.info(`Using AWS profile from command line: ${targetProfile}`);
} else {
const projectProfile = getProjectAWSProfile(environment);
if (projectProfile && projectProfile.profile) {
targetProfile = projectProfile.profile;
logger.info(`Using AWS profile from project config: ${targetProfile}`);
// Validate account ID if specified
if (projectProfile.accountId) {
logger.info(`Validating AWS account: ${projectProfile.accountId}`);
await validateAWSAccount(projectProfile.accountId);
logger.success('AWS account validation passed');
}
}
}
// Set up AWS environment variables
if (targetProfile) {
setAWSEnvironmentVariables(targetProfile);
}
// Set region from environment config if specified
const envConfig = config.environments[environment];
if (envConfig.region) {
process.env.AWS_DEFAULT_REGION = envConfig.region;
process.env.AWS_REGION = envConfig.region;
logger.debug(`AWS region set to: ${envConfig.region}`);
}
}
/**
* Prepare parameters for deployment (synchronous version for new engine)
*/
function prepareParameters(envConfig, options) {
let allParameters = {};
// 1. Start with environment-specific parameters from .cfn-forge.yaml
if (envConfig.parameters) {
allParameters = { ...envConfig.parameters };
}
// 2. Parse CLI parameters (highest priority)
if (options.parameters) {
const cliParams = parseParameterString(options.parameters);
Object.assign(allParameters, cliParams);
}
return allParameters;
}
/**
* Parse CloudFormation parameters from various sources
*/
async function parseParameters(envConfig, options) {
const op = logger.trackOperation('Parameter Parsing', {
hasEnvParams: !!(envConfig.parameters),
hasCLIParams: !!(options.parameters),
hasParamFile: !!(options.parameterFile),
});
const startTime = op.start();
try {
let allParameters = {};
// 1. Start with environment-specific parameters from .cfn-forge.yaml
if (envConfig.parameters) {
allParameters = { ...envConfig.parameters };
logger.debug(`Loaded ${Object.keys(envConfig.parameters).length} parameters from environment config`);
}
// 2. Load parameters from file if specified
if (options.parameterFile) {
try {
const paramFileContent = await fs.readFile(options.parameterFile, 'utf8');
const fileParams = JSON.parse(paramFileContent);
if (typeof fileParams !== 'object' || Array.isArray(fileParams)) {
throw new Error('Parameter file must contain a JSON object');
}
// Filter out comment fields (starting with _) and merge file parameters
const actualFileParams = {};
Object.entries(fileParams).forEach(([key, value]) => {
if (!key.startsWith('_')) {
actualFileParams[key] = value;
}
});
Object.assign(allParameters, actualFileParams);
logger.debug(`Loaded ${Object.keys(actualFileParams).length} parameters from file: ${options.parameterFile}`);
} catch (error) {
const enhancedError = new Error(`Failed to load parameter file: ${options.parameterFile}\n${error.message}`);
enhancedError.code = 'PARAMETER_FILE_ERROR';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
}
// 3. Parse CLI parameters (highest priority)
if (options.parameters) {
try {
const cliParams = parseParameterString(options.parameters);
// Merge CLI parameters (override file and environment config)
Object.assign(allParameters, cliParams);
logger.debug(`Loaded ${Object.keys(cliParams).length} parameters from CLI`);
} catch (error) {
const enhancedError = new Error(`Invalid CLI parameters format: ${options.parameters}\n${error.message}`);
enhancedError.code = 'CLI_PARAMETERS_ERROR';
enhancedError.originalError = error;
op.error(enhancedError, startTime);
throw enhancedError;
}
}
// Convert to CloudFormation parameter override format
const parameterString = Object.entries(allParameters)
.map(([key, value]) => `${key}=${value}`)
.join(',');
logger.debug(`Final parameter string: ${parameterString}`);
op.success(startTime, `Parsed ${Object.keys(allParameters).length} total parameters`);
return {
parameters: allParameters,
parameterString,
};
} catch (error) {
if (!error.code) {
op.error(error, startTime);
}
throw error;
}
}
/**
* Parse parameter string from CLI (key=value,key2=value2)
*/
function parseParameterString(paramString) {
const parameters = {};
// Split by comma and parse key=value pairs
const pairs = paramString.split(',').map((pair) => pair.trim()).filter((pair) => pair);
for (const pair of pairs) {
const equalIndex = pair.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid parameter format: "${pair}" (expected key=value)`);
}
const key = pair.substring(0, equalIndex).trim();
const value = pair.substring(equalIndex + 1).trim();
if (!key) {
throw new Error(`Empty parameter key in: "${pair}"`);
}
// Validate parameter key format (CloudFormation rules)
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key)) {
throw new Error(`Invalid parameter key: "${key}" (must start with letter, alphanumeric only)`);
}
// Handle quoted values
let parsedValue = value;
if ((value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))) {
parsedValue = value.slice(1, -1);
}
parameters[key] = parsedValue;
}
return parameters;
}
/**
* Validate parameters against CloudFormation template
*/
async function validateParameters(templatePath, parameters) {
const op = logger.trackOperation('Parameter Validation', {
templatePath,
parameterCount: Object.keys(parameters).length,
});
const startTime = op.start();
try {
// Read and parse template to get parameter definitions
const templateContent = await fs.readFile(templatePath, 'utf8');
let template;
try {
// Try YAML first (with CloudFormation schema)
const cfnSchema = yaml.DEFAULT_SCHEMA.extend([
new yaml.Type('!Ref', {
kind: 'scalar',
construct(data) { return { Ref: data }; },
}),
new yaml.Type('!GetAtt', {
kind: 'scalar',
construct(data) { return { 'Fn::GetAtt': data.split('.') }; },
}),
new yaml.Type('!Sub', {
kind: 'scalar',
construct(data) { return { 'Fn::Sub': data }; },
}),
new yaml.Type('!Join', {
kind: 'sequence',
construct(data) { return { 'Fn::Join': data }; },
}),
new yaml.Type('!Select', {
kind: 'sequence',
construct(data) { return { 'Fn::Select': data }; },
}),
new yaml.Type('!Split', {
kind: 'sequence',
construct(data) { return { 'Fn::Split': data }; },
}),
new yaml.Type('!Equals', {
kind: 'sequence',
construct(data) { return { 'Fn::Equals': data }; },
}),
new yaml.Type('!And', {
kind: 'sequence',
construct(data) { return { 'Fn::And': data }; },
}),
new yaml.Type('!Or', {
kind: 'sequence',
construct(data) { return { 'Fn::Or': data }; },
}),
new yaml.Type('!Not', {
kind: 'sequence',
construct(data) { return { 'Fn::Not': data }; },
}),
new yaml.Type('!If', {
kind: 'sequence',
construct(data) { return { 'Fn::If': data }; },
}),
new yaml.Type('!Condition', {
kind: 'scalar',
construct(data) { return { Condition: data }; },
}),
new yaml.Type('!GetAZs', {
kind: 'scalar',
construct(data) { return { 'Fn::GetAZs': data }; },
}),
new yaml.Type('!Cidr', {
kind: 'sequence',
construct(data) { return { 'Fn::Cidr': data }; },
}),
]);
template = yaml.load(templateContent, { schema: cfnSchema });
} catch (yamlError) {
try {
// Try JSON fallback
template = JSON.parse(templateContent);
} catch (jsonError) {
throw new Error(`Template parsing failed: ${yamlError.message}`);
}
}
const templateParameters = template.Parameters || {};
const issues = [];
// Check for required parameters without defaults
for (const [paramName, paramDef] of Object.entries(templateParameters)) {
if (!paramDef.Default && !parameters.hasOwnProperty(paramName)) {
issues.push(`Required parameter missing: ${paramName}`);
}
// Validate parameter values against constraints
if (parameters.hasOwnProperty(paramName)) {
const value = parameters[paramName];
// Check AllowedValues constraint
if (paramDef.AllowedValues && !paramDef.AllowedValues.includes(value)) {
issues.push(`Parameter ${paramName}: "${value}" not in allowed values [${paramDef.AllowedValues.join(', ')}]`);
}
// Check MinLength constraint
if (paramDef.MinLength && value.length < paramDef.MinLength) {
issues.push(`Parameter ${paramName}: "${value}" too short (minimum ${paramDef.MinLength} characters)`);
}
// Check MaxLength constraint
if (paramDef.MaxLength && value.length > paramDef.MaxLength) {
issues.push(`Parameter ${paramName}: "${value}" too long (maximum ${paramDef.MaxLength} characters)`);
}
// Check AllowedPattern constraint
if (paramDef.AllowedPattern) {
const pattern = new RegExp(paramDef.AllowedPattern);
if (!pattern.test(value)) {
issues.push(`Parameter ${paramName}: "${value}" does not match required pattern: ${paramDef.AllowedPattern}`);
}
}
}
}
// Check for unexpected parameters
for (const paramName of Object.keys(parameters)) {
if (!templateParameters.hasOwnProperty(paramName)) {
issues.push(`Unexpected parameter: ${paramName} (not defined in template)`);
}
}
if (issues.length > 0) {
const error = new Error(`Parameter validation failed:\n ⢠${issues.join('\n ⢠')}`);
error.code = 'PARAMETER_VALIDATION_FAILED';
error.issues = issues;
op.error(error, startTime);
throw error;
}
op.success(startTime, `Validated ${Object.keys(parameters).length} parameters`);
return {
templateParameters,
validatedParameters: parameters,
issues: [],
};
} catch (error) {
if (!error.code) {
op.error(error, startTime);
}
throw error;
}
}
/**
* Create environment variables for bash deployment scripts
*/
async function createDeploymentEnvironment(config, environment, options) {
const envConfig = config.environments[environment];
// Parse and merge all parameter sources
const { parameterString } = await parseParameters(envConfig, options);
const deployEnv = {
// CFN-Forge configuration
CFN_FORGE_ENVIRONMENT: environment,
CFN_FORGE_STACK_NAME: envConfig.finalStackName,
CFN_FORGE_TEMPLATE: envConfig.finalTemplate,
CFN_FORGE_REGION: envConfig.region || process.env.AWS_DEFAULT_REGION || 'us-east-1',
// Deployment options
CFN_FORGE_ONCE: options.once ? 'true' : 'false',
CFN_FORGE_WAIT: options.wait ? 'true' : 'false',
CFN_FORGE_DRY_RUN: options.dryRun ? 'true' : 'false',
CFN_FORGE_NO_ROLLBACK: options.noRollback ? 'true' : 'false',
// Git information
CFN_FORGE_TAG: options.tag || '',
// S3 packaging
CFN_FORGE_PACKAGING_BUCKET: config.deployment?.packagingBucket || '',
CFN_FORGE_CREATE_BUCKET: config.deployment?.createBucket ? 'true' : 'false',
// CloudFormation parameters (enhanced)
CFN_FORGE_PARAMETERS: parameterString,
// Inherit all current environment variables
...process.env,
};
return deployEnv;
}
/**
* Execute one-time deployment using Node.js deployment engine
*/
async function executeOnceDeployment(config, environment, options) {
logger.info(`Starting deployment to '${environment}' environment`);
const envConfig = config.environments[environment];
logger.info(`Stack: ${envConfig.finalStackName}`);
logger.info(`Region: ${envConfig.region || 'default'}`);
if (options.dryRun) {
logger.info('Dry run mode - showing deployment plan');
}
try {
// Create deployment engine with AWS config
const deploymentEngine = new DeploymentEngine({
aws: {
region: envConfig.region || process.env.AWS_REGION,
profile: options.profile || envConfig.profile,
},
deployOnTags: config.deployOnTags || false,
});
// Initialize engine
await deploymentEngine.initialize({
environment,
...config,
});
// Set up event listeners for progress
deploymentEngine.on('deploymentStarted', ({ stackName }) => {
logger.info(`Deploying stack: ${stackName}`);
});
deploymentEngine.on('waitingForStack', ({ stackName, operation }) => {
logger.info(`Waiting for stack ${operation}...`);
});
deploymentEngine.on('deploymentCompleted', ({ status, operation }) => {
if (status === 'SUCCESS') {
logger.success(`Deployment ${operation} completed successfully!`);
} else if (operation === 'NO_CHANGE') {
logger.info('No changes detected - stack is up to date');
}
});
deploymentEngine.on('deploymentFailed', ({ error }) => {
logger.error(`Deployment failed: ${error}`);
});
// Prepare deployment options
const deployOptions = {
environment,
stackName: envConfig.finalStackName,
templatePath: envConfig.finalTemplate,
parameters: prepareParameters(envConfig, options),
tags: {
...config.tags,
...envConfig.tags,
Environment: environment,
},
dryRun: options.dryRun || false,
force: options.force || false,
};
// Execute deployment
const result = await deploymentEngine.deploy(deployOptions);
// Display outputs if available
if (result.outputs && Object.keys(result.outputs).length > 0) {
logger.info('\nStack Outputs:');
Object.entries(result.outputs).forEach(([key, output]) => {
logger.info(` ${key}: ${output.value}`);
});
}
return result;
} catch (error) {
logger.error(`Deployment failed: ${error.message}`);
throw error;
}
}
/**
* Start watch mode deployment using Node.js deployment engine
*/
async function startWatchDeployment(config, environment, options) {
logger.info(`š Starting deployment watcher for '${environment}' environment`);
logger.info('š Watching for git changes...');
try {
const envConfig = config.environments[environment];
// Create deployment engine with AWS config
const deploymentEngine = new DeploymentEngine({
aws: {
region: envConfig.region || process.env.AWS_REGION,
profile: options.profile || envConfig.profile,
},
deployOnTags: config.deployOnTags || false,
watchPaths: ['.'],
ignorePatterns: [
'node_modules/**',
'.git/**',
'dist/**',
'build/**',
'**/*.tmp',
'.cfn-forge/**',
'deploy/**',
],
});
// Initialize engine
await deploymentEngine.initialize({
environment,
...config,
});
// Set up event listeners
deploymentEngine.on('watchingStarted', ({ paths }) => {
logger.info(`š Watching paths: ${paths.join(', ')}`);
});
deploymentEngine.on('deploymentStarted', ({ stackName }) => {
logger.info(`\nDeploying stack: ${stackName}`);
});
deploymentEngine.on('waitingForStack', ({ stackName, operation }) => {
logger.info(`Waiting for stack ${operation}...`);
});
deploymentEngine.on('deploymentCompleted', ({ status, operation, stackName }) => {
if (status === 'SUCCESS') {
logger.success(`Deployment ${operation} completed successfully!`);
} else if (status === 'SKIPPED') {
logger.info('āļø No changes detected - skipping deployment');
}
});
deploymentEngine.on('deploymentFailed', ({ error }) => {
logger.error(`Deployment failed: ${error}`);
});
// Prepare deployment options
const deployOptions = {
environment,
stackName: envConfig.finalStackName,
templatePath: envConfig.finalTemplate,
parameters: prepareParameters(envConfig, options),
tags: {
...config.tags,
...envConfig.tags,
Environment: environment,
},
force: options.force || false,
};
// Start watching
await deploymentEngine.startWatching({
interval: 2000,
deploymentOptions: deployOptions,
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
logger.info('\nš Shutting down deployment watcher...');
await deploymentEngine.stopWatching();
process.exit(0);
});
// Keep process alive
process.stdin.resume();
} catch (error) {
logger.error(`Failed to start deployment watcher: ${error.message}`);
throw error;
}
}
/**
* Main deploy command handler
*/
async function deployCommand(options) {
logger.setContext('command', 'deploy');
logger.setContext('options', options);
// Enable timestamps for deployment output
logger.enableTimestamps();
try {
logger.info('CFN-Forge Deployment');
logger.info('');
// 1. Validate project and environment
const { config, environment, envConfig } = await logger.withErrorHandling('Project Setup', async () => {
await validateProject();
const config = await loadProjectConfig();
// 2. Resolve target environment
logger.debug(`Deploy options received: ${JSON.stringify(options)}`);
const environment = resolveEnvironment(options, config);
const envConfig = validateEnvironment(config, environment);
logger.info(`Target: ${environment} environment`);
return { config, environment, envConfig };
}, {
configFile: '.cfn-forge.yaml',
cwd: process.cwd(),
});
// 3. Validate template and git status
await logger.withErrorHandling('Pre-deployment Validation', async () => {
await validateTemplate(envConfig.finalTemplate);
// Only validate git status if git-based deployment is enabled
if (config.deployment?.gitBased !== false) {
if (!validateGitStatus(options)) {
const error = new Error('Git validation failed');
error.code = 'GIT_VALIDATION_FAILED';
throw error;
}
} else {
logger.debug('Skipping git validation (gitBased: false)');
}
}, {
template: envConfig.finalTemplate,
environment,
});
// 4. Parse and validate parameters
const { parameters } = await logger.withErrorHandling('Parameter Processing', async () => {
const { parameters, parameterString } = await parseParameters(envConfig, options);
// Validate parameters against template
await validateParameters(envConfig.finalTemplate, parameters);
// Display parameter summary
if (Object.keys(parameters).length > 0) {
logger.info('CloudFormation Parameters:');
Object.entries(parameters).forEach(([key, value]) => {
logger.info(` ⢠${key} = ${value}`);
});
} else {
logger.info('No CloudFormation parameters specified');
}
return { parameters, parameterString };
}, {
template: envConfig.finalTemplate,
environment,
cliParams: options.parameters,
paramFile: options.parameterFile,
});
// 5. Validate AWS access
await logger.withErrorHandling('AWS Validation', async () => {
await validateAWSAccess();
await setupAWSEnvironment(config, environment, options);
}, {
environment,
awsProfile: options.awsProfile,
});
// 6. Execute deployment based on mode
await logger.withErrorHandling('Deployment Execution', async () => {
if (options.once) {
await executeOnceDeployment(config, environment, options);
} else {
await startWatchDeployment(config, environment, options);
}
}, {
mode: options.once ? 'once' : 'watch',
environment,
stack: envConfig.finalStackName,
});
} catch (error) {
logger.errorWithContext(error, 'Deploy Command');
// Show error summary and exit
logger.reportErrorSummary();
process.exit(1);
} finally {
logger.clearContext();
}
}
module.exports = deployCommand;