UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

641 lines (566 loc) 19 kB
/** * CloudFormation Client Implementation * Cross-platform AWS CloudFormation operations using AWS SDK v3 */ const { CloudFormationClient: AWSCloudFormationClient, CreateStackCommand, UpdateStackCommand, DeleteStackCommand, DescribeStacksCommand, DescribeStackEventsCommand, DescribeStackResourcesCommand, ValidateTemplateCommand, ListStacksCommand, GetStackPolicyCommand, SetStackPolicyCommand, GetTemplateCommand, DetectStackDriftCommand, DescribeStackDriftDetectionStatusCommand, waitUntilStackCreateComplete, waitUntilStackUpdateComplete, waitUntilStackDeleteComplete, } = require('@aws-sdk/client-cloudformation'); const ICloudFormation = require('../interfaces/ICloudFormation'); class CloudFormationClient extends ICloudFormation { constructor(config = {}) { super(); this.client = new AWSCloudFormationClient({ region: config.region || process.env.AWS_REGION || 'us-east-1', ...config, }); this.config = config; } /** * Deploy a CloudFormation stack * @param {Object} params - Deployment parameters * @returns {Promise<Object>} Deployment result */ async deployStack(params) { const { stackName, templateBody, templateUrl, parameters = [], capabilities = [], tags = [], roleArn, onFailure = 'ROLLBACK', } = params; try { // Check if stack exists const stackExists = await this._stackExists(stackName); if (stackExists) { return await this.updateStack({ stackName, templateBody, templateUrl, parameters, capabilities, tags, roleArn, }); } const command = new CreateStackCommand({ StackName: stackName, TemplateBody: templateBody, TemplateURL: templateUrl, Parameters: parameters, Capabilities: capabilities, Tags: tags, RoleARN: roleArn, OnFailure: onFailure, }); const result = await this.client.send(command); return { operation: 'CREATE', stackId: result.StackId, stackName, }; } catch (error) { throw new Error(`Failed to deploy stack ${stackName}: ${error.message}`); } } /** * Update an existing CloudFormation stack * @param {Object} params - Update parameters * @returns {Promise<Object>} Update result */ async updateStack(params) { const { stackName, templateBody, templateUrl, parameters = [], capabilities = [], tags = [], roleArn, } = params; try { const command = new UpdateStackCommand({ StackName: stackName, TemplateBody: templateBody, TemplateURL: templateUrl, Parameters: parameters, Capabilities: capabilities, Tags: tags, RoleARN: roleArn, }); const result = await this.client.send(command); return { operation: 'UPDATE', stackId: result.StackId, stackName, }; } catch (error) { if (error.name === 'ValidationError' && error.message.includes('No updates')) { return { operation: 'NO_CHANGE', stackName, message: 'No updates are to be performed.', }; } throw new Error(`Failed to update stack ${stackName}: ${error.message}`); } } /** * Delete a CloudFormation stack * @param {string} stackName - Stack name to delete * @returns {Promise<void>} */ async deleteStack(stackName) { try { const command = new DeleteStackCommand({ StackName: stackName, }); await this.client.send(command); } catch (error) { throw new Error(`Failed to delete stack ${stackName}: ${error.message}`); } } /** * Get stack status * @param {string} stackName - Stack name * @returns {Promise<Object>} Stack status information */ async getStackStatus(stackName) { try { const command = new DescribeStacksCommand({ StackName: stackName, }); const result = await this.client.send(command); const stack = result.Stacks[0]; if (!stack) { throw new Error(`Stack ${stackName} not found`); } return { stackName: stack.StackName, stackId: stack.StackId, status: stack.StackStatus, statusReason: stack.StackStatusReason, creationTime: stack.CreationTime, lastUpdatedTime: stack.LastUpdatedTime, outputs: stack.Outputs || [], parameters: stack.Parameters || [], tags: stack.Tags || [], }; } catch (error) { if (error.name === 'ValidationError' && error.message.includes('does not exist')) { return null; } throw new Error(`Failed to get stack status for ${stackName}: ${error.message}`); } } /** * Validate a CloudFormation template * @param {string} templateBody - Template content * @returns {Promise<Object>} Validation result */ async validateTemplate(templateBody) { try { const command = new ValidateTemplateCommand({ TemplateBody: templateBody, }); const result = await this.client.send(command); return { valid: true, description: result.Description, parameters: result.Parameters || [], capabilities: result.Capabilities || [], capabilitiesReason: result.CapabilitiesReason, }; } catch (error) { return { valid: false, error: error.message, }; } } /** * List all stacks * @param {Object} filters - Optional filters * @returns {Promise<Array>} Array of stack summaries */ async listStacks(filters = {}) { try { const command = new ListStacksCommand({ StackStatusFilter: filters.statusFilter, }); const result = await this.client.send(command); return result.StackSummaries.map((stack) => ({ stackName: stack.StackName, stackId: stack.StackId, status: stack.StackStatus, statusReason: stack.StackStatusReason, creationTime: stack.CreationTime, lastUpdatedTime: stack.LastUpdatedTime, templateDescription: stack.TemplateDescription, })); } catch (error) { throw new Error(`Failed to list stacks: ${error.message}`); } } /** * Get stack outputs * @param {string} stackName - Stack name * @returns {Promise<Object>} Stack outputs */ async getStackOutputs(stackName) { try { const status = await this.getStackStatus(stackName); if (!status) { return {}; } const outputs = {}; status.outputs.forEach((output) => { outputs[output.OutputKey] = { value: output.OutputValue, description: output.Description, exportName: output.ExportName, }; }); return outputs; } catch (error) { throw new Error(`Failed to get stack outputs for ${stackName}: ${error.message}`); } } /** * Get stack resources * @param {string} stackName - Stack name * @returns {Promise<Array>} Array of stack resources */ async getStackResources(stackName) { try { const command = new DescribeStackResourcesCommand({ StackName: stackName, }); const result = await this.client.send(command); return result.StackResources.map((resource) => ({ logicalResourceId: resource.LogicalResourceId, physicalResourceId: resource.PhysicalResourceId, resourceType: resource.ResourceType, resourceStatus: resource.ResourceStatus, statusReason: resource.ResourceStatusReason, timestamp: resource.Timestamp, })); } catch (error) { throw new Error(`Failed to get stack resources for ${stackName}: ${error.message}`); } } /** * Get stack events * @param {string} stackName - Stack name * @param {Object} options - Query options * @returns {Promise<Array>} Array of stack events */ async getStackEvents(stackName, options = {}) { try { const command = new DescribeStackEventsCommand({ StackName: stackName, NextToken: options.nextToken, }); const result = await this.client.send(command); return { events: result.StackEvents.map((event) => ({ eventId: event.EventId, stackId: event.StackId, stackName: event.StackName, logicalResourceId: event.LogicalResourceId, physicalResourceId: event.PhysicalResourceId, resourceType: event.ResourceType, timestamp: event.Timestamp, resourceStatus: event.ResourceStatus, statusReason: event.ResourceStatusReason, })), nextToken: result.NextToken, }; } catch (error) { throw new Error(`Failed to get stack events for ${stackName}: ${error.message}`); } } /** * Wait for stack operation to complete * @param {string} stackName - Stack name * @param {string} operation - Operation type (create, update, delete) * @param {Object} options - Wait options * @returns {Promise<Object>} Final stack status */ async waitForStack(stackName, operation, options = {}) { const maxWaitTime = options.maxWaitTime || 3600; // 1 hour default const config = { client: this.client, maxWaitTime, }; try { switch (operation.toLowerCase()) { case 'create': await waitUntilStackCreateComplete(config, { StackName: stackName }); break; case 'update': await waitUntilStackUpdateComplete(config, { StackName: stackName }); break; case 'delete': await waitUntilStackDeleteComplete(config, { StackName: stackName }); break; default: throw new Error(`Unknown operation: ${operation}`); } return await this.getStackStatus(stackName); } catch (error) { throw new Error(`Failed to wait for stack ${operation}: ${error.message}`); } } /** * Detect stack drift * @param {string} stackName - Stack name * @returns {Promise<Object>} Drift detection result */ async detectStackDrift(stackName) { try { const detectCommand = new DetectStackDriftCommand({ StackName: stackName, }); const detectResult = await this.client.send(detectCommand); const detectionId = detectResult.StackDriftDetectionId; // Wait for detection to complete let status = 'DETECTION_IN_PROGRESS'; while (status === 'DETECTION_IN_PROGRESS') { await new Promise((resolve) => setTimeout(resolve, 5000)); const statusCommand = new DescribeStackDriftDetectionStatusCommand({ StackDriftDetectionId: detectionId, }); const statusResult = await this.client.send(statusCommand); status = statusResult.DetectionStatus; } return { detectionId, status, driftStatus: status === 'DETECTION_COMPLETE' ? 'DRIFTED' : 'UNKNOWN', }; } catch (error) { throw new Error(`Failed to detect stack drift for ${stackName}: ${error.message}`); } } /** * Get stack policy * @param {string} stackName - Stack name * @returns {Promise<string>} Stack policy document */ async getStackPolicy(stackName) { try { const command = new GetStackPolicyCommand({ StackName: stackName, }); const result = await this.client.send(command); return result.StackPolicyBody; } catch (error) { throw new Error(`Failed to get stack policy for ${stackName}: ${error.message}`); } } /** * Set stack policy * @param {string} stackName - Stack name * @param {string} policyBody - Policy document * @returns {Promise<void>} */ async setStackPolicy(stackName, policyBody) { try { const command = new SetStackPolicyCommand({ StackName: stackName, StackPolicyBody: policyBody, }); await this.client.send(command); } catch (error) { throw new Error(`Failed to set stack policy for ${stackName}: ${error.message}`); } } /** * Get deployed template * @param {string} stackName - Stack name * @param {Object} options - Options for template retrieval * @returns {Promise<Object>} Template information */ async getTemplate(stackName, options = {}) { try { const command = new GetTemplateCommand({ StackName: stackName, TemplateStage: options.stage || 'Original', // Original, Processed }); const result = await this.client.send(command); return { templateBody: result.TemplateBody, stage: result.StagesAvailable || ['Original'], }; } catch (error) { throw new Error(`Failed to get template for ${stackName}: ${error.message}`); } } /** * Compare local template with deployed template * @param {string} stackName - Stack name * @param {string} localTemplateBody - Local template content * @returns {Promise<Object>} Comparison result */ async compareTemplates(stackName, localTemplateBody) { try { // Get deployed template const deployedTemplate = await this.getTemplate(stackName); // Validate local template using AWS CloudFormation const localValidation = await this.validateTemplate(localTemplateBody); if (!localValidation.valid) { throw new Error(`Local template validation failed: ${localValidation.error}`); } // Use AWS to parse the local template by validating it // This ensures we use the same parsing logic as CloudFormation let localParsed; let deployedParsed; // Define CloudFormation intrinsic function types (shared for both local and deployed templates) const cfnTypes = [ 'Ref', 'GetAtt', 'GetAZs', 'ImportValue', 'Join', 'Select', 'Split', 'Sub', 'Base64', 'Cidr', 'FindInMap', 'Transform', 'And', 'Equals', 'If', 'Not', 'Or', 'Condition', ]; // For local template, we need to parse it to get a normalized JSON representation // We'll use a simple approach - if validation passed, try JSON first, then basic YAML try { localParsed = JSON.parse(localTemplateBody); } catch (jsonError) { // Must be YAML, use YAML parsing with CloudFormation intrinsic function support const yaml = require('js-yaml'); // Create custom YAML types for CloudFormation functions const cfnYamlTypes = []; cfnTypes.forEach((type) => { // Handle scalar values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'scalar', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); // Handle sequence values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'sequence', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); // Handle mapping values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'mapping', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); }); // Create schema with CloudFormation types const cfnSchema = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes); try { localParsed = yaml.load(localTemplateBody, { schema: cfnSchema }); } catch (yamlError) { throw new Error(`Template parsing failed despite AWS validation: ${yamlError.message}`); } } // Parse deployed template (CloudFormation can return JSON or YAML) if (typeof deployedTemplate.templateBody === 'string') { try { deployedParsed = JSON.parse(deployedTemplate.templateBody); } catch (jsonError) { // Must be YAML, use the same parsing logic as local template const yaml = require('js-yaml'); // Create custom YAML types for CloudFormation functions const cfnYamlTypes = []; cfnTypes.forEach((type) => { // Handle scalar values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'scalar', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); // Handle sequence values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'sequence', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); // Handle mapping values cfnYamlTypes.push(new yaml.Type(`!${type}`, { kind: 'mapping', construct: (data) => ({ [`Fn::${type === 'Ref' ? 'Ref' : type}`]: data }), })); }); // Create schema with CloudFormation types const cfnSchema = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes); try { deployedParsed = yaml.load(deployedTemplate.templateBody, { schema: cfnSchema }); } catch (yamlError) { throw new Error(`Deployed template parsing failed: ${yamlError.message}`); } } } else { deployedParsed = deployedTemplate.templateBody; } // Normalize both templates for comparison const localNormalized = JSON.stringify(localParsed, null, 2); const deployedNormalized = JSON.stringify(deployedParsed, null, 2); const hasChanges = localNormalized !== deployedNormalized; return { hasChanges, localTemplate: localParsed, deployedTemplate: deployedParsed, localTemplateString: localNormalized, deployedTemplateString: deployedNormalized, }; } catch (error) { throw new Error(`Failed to compare templates for ${stackName}: ${error.message}`); } } /** * Check if stack exists * @private * @param {string} stackName - Stack name * @returns {Promise<boolean>} True if stack exists */ async _stackExists(stackName) { try { const status = await this.getStackStatus(stackName); return status !== null; } catch (error) { return false; } } /** * Format parameters for CloudFormation API * @param {Object} parameters - Key-value parameter object * @returns {Array} Formatted parameters array */ formatParameters(parameters) { return Object.entries(parameters).map(([key, value]) => ({ ParameterKey: key, ParameterValue: String(value), })); } /** * Format tags for CloudFormation API * @param {Object} tags - Key-value tag object * @returns {Array} Formatted tags array */ formatTags(tags) { return Object.entries(tags).map(([key, value]) => ({ Key: key, Value: String(value), })); } } module.exports = CloudFormationClient;