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