diff --git a/packages/devtools/frigg-cli/deploy-command/index.js b/packages/devtools/frigg-cli/deploy-command/index.js index 5f56d0da6..7c29d3f10 100644 --- a/packages/devtools/frigg-cli/deploy-command/index.js +++ b/packages/devtools/frigg-cli/deploy-command/index.js @@ -2,9 +2,14 @@ const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); -// Import doctor command for post-deployment health check const { doctorCommand } = require('../doctor-command'); +const RunPreDeploymentHealthCheckUseCase = require('@friggframework/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case'); +const AWSResourceDetector = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector'); +const PreDeploymentCategorizer = require('@friggframework/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer'); +const { TemplateParser } = require('@friggframework/devtools/infrastructure/domains/health/domain/services/template-parser'); +const StackIdentifier = require('@friggframework/devtools/infrastructure/domains/health/domain/value-objects/stack-identifier'); + // Configuration constants const PATHS = { APP_DEFINITION: 'index.js', @@ -211,6 +216,98 @@ function getStackName(appDefinition, options) { return null; } +/** + * Run pre-deployment health check + * @param {string} stackName - CloudFormation stack name + * @param {Object} options - Deploy options + * @returns {Promise} True if deployment can proceed, false if blocked + */ +async function runPreDeploymentHealthCheck(stackName, options) { + console.log('\n' + '═'.repeat(80)); + console.log('Running pre-deployment health check...'); + console.log('═'.repeat(80)); + + try { + const region = options.region || process.env.AWS_REGION || 'us-east-1'; + const stackIdentifier = new StackIdentifier({ stackName, region }); + + const templatePath = TemplateParser.getBuildTemplatePath(process.cwd()); + + if (!TemplateParser.buildTemplateExists(process.cwd())) { + console.log('\nāš ļø Build template not found - run build first'); + console.log(' Skipping pre-deployment health check'); + return true; + } + + const AWSStackRepository = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository'); + + const stackRepository = new AWSStackRepository({ region }); + const resourceDetector = new AWSResourceDetector({ region }); + const categorizer = new PreDeploymentCategorizer(); + const templateParser = new TemplateParser(); + + const useCase = new RunPreDeploymentHealthCheckUseCase({ + stackRepository, + resourceDetector, + preDeploymentCategorizer: categorizer, + templateParser, + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath, + onProgress: (step, message) => console.log(step, message), + }); + + console.log('\nšŸ“Š Pre-Deployment Health Check Results:'); + console.log(` Total issues: ${result.summary.total}`); + console.log(` 🚫 Blocking: ${result.summary.blocking}`); + console.log(` āš ļø Warnings: ${result.summary.warnings}`); + + if (result.blockingIssues.length > 0) { + console.log('\n🚫 BLOCKING ISSUES (deployment will fail):'); + result.blockingIssues.forEach((item, index) => { + console.log(`\n ${index + 1}. ${item.issue.description}`); + console.log(` Type: ${item.issue.resourceType}`); + if (item.issue.resolution) { + console.log(` Resolution: ${item.issue.resolution}`); + } + if (item.issue.canAutoFix) { + console.log(` āœ“ Can be auto-fixed with: frigg repair --import`); + } + }); + + console.log('\nāœ— Deployment blocked due to critical issues'); + console.log(' Fix these issues and run deploy again'); + return false; + } + + if (result.warningIssues.length > 0) { + console.log('\nāš ļø WARNINGS (non-blocking):'); + result.warningIssues.forEach((item, index) => { + console.log(`\n ${index + 1}. ${item.issue.description}`); + console.log(` Type: ${item.issue.resourceType}`); + }); + + console.log('\nāš ļø Warnings detected but deployment can proceed'); + console.log(' Run "frigg doctor" after deployment to address warnings'); + } else { + console.log('\nāœ“ No issues detected - deployment can proceed'); + } + + return true; + + } catch (error) { + console.log(`\nāš ļø Pre-deployment health check failed: ${error.message}`); + if (options.verbose) { + console.error(error.stack); + } + + console.log(' Proceeding with deployment...'); + return true; + } +} + /** * Run post-deployment health check * @param {string} stackName - CloudFormation stack name @@ -271,9 +368,21 @@ async function deployCommand(options) { console.log('Deploying the serverless application...'); const appDefinition = loadAppDefinition(); + const stackName = getStackName(appDefinition, options); + + if (!options.skipPreCheck && stackName) { + const canDeploy = await runPreDeploymentHealthCheck(stackName, options); + + if (!canDeploy) { + console.error('\nāœ— Deployment aborted due to blocking issues'); + process.exit(1); + } + } else if (options.skipPreCheck) { + console.log('\nā­ļø Skipping pre-deployment health check (--skip-pre-check)'); + } + const environment = validateAndBuildEnvironment(appDefinition, options); - // Execute deployment const exitCode = await executeServerlessDeployment(environment, options); // Check if deployment was successful diff --git a/packages/devtools/frigg-cli/index.js b/packages/devtools/frigg-cli/index.js index 12d9d712d..84f2025d2 100755 --- a/packages/devtools/frigg-cli/index.js +++ b/packages/devtools/frigg-cli/index.js @@ -122,6 +122,7 @@ program .option('-s, --stage ', 'deployment stage', 'dev') .option('-v, --verbose', 'enable verbose output') .option('-f, --force', 'force deployment (bypasses caching for layers and functions)') + .option('--skip-pre-check', 'skip pre-deployment health check') .option('--skip-doctor', 'skip post-deployment health check') .action(deployCommand); diff --git a/packages/devtools/infrastructure/domains/health/application/ports/IResourceDetector.js b/packages/devtools/infrastructure/domains/health/application/ports/IResourceDetector.js index 8b6b61baf..801893581 100644 --- a/packages/devtools/infrastructure/domains/health/application/ports/IResourceDetector.js +++ b/packages/devtools/infrastructure/domains/health/application/ports/IResourceDetector.js @@ -107,9 +107,9 @@ class IResourceDetector { * Find orphaned resources (exist in cloud but not in any stack) * * @param {Object} params - * @param {string} params.region - AWS region - * @param {string[]} [params.resourceTypes] - Optional: limit to specific resource types - * @param {string[]} [params.excludePhysicalIds=[]] - Physical IDs to exclude from orphan check + * @param {StackIdentifier} params.stackIdentifier - Target stack + * @param {Object} [params.expectedResources] - Resources from template (logical ID -> resource def) + * @param {Array} [params.stackResources] - Resources currently in stack (with physicalIds) * @returns {Promise>} Array of orphaned resources * @returns {Promise>} Resources with properties: * - physicalId: string @@ -119,11 +119,25 @@ class IResourceDetector { * - isOrphaned: boolean (always true) * - reason: string (explanation of why it's orphaned) */ - async findOrphanedResources({ region, resourceTypes = [], excludePhysicalIds = [] }) { + async findOrphanedResources({ stackIdentifier, expectedResources, stackResources }) { throw new Error( 'IResourceDetector.findOrphanedResources() must be implemented by adapter' ); } + + /** + * Check service quotas for resources in template + * + * @param {Object} params + * @param {StackIdentifier} params.stackIdentifier - Target stack + * @param {Object} params.expectedResources - Resources from template (logical ID -> resource def) + * @returns {Promise>} Array of quota-related issues + */ + async checkServiceQuotas({ stackIdentifier, expectedResources }) { + throw new Error( + 'IResourceDetector.checkServiceQuotas() must be implemented by adapter' + ); + } } module.exports = IResourceDetector; diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/pre-deployment-health-check-integration.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/pre-deployment-health-check-integration.test.js new file mode 100644 index 000000000..4c022fc92 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/pre-deployment-health-check-integration.test.js @@ -0,0 +1,172 @@ +const RunPreDeploymentHealthCheckUseCase = require('../run-pre-deployment-health-check-use-case'); +const PreDeploymentCategorizer = require('../../../domain/services/pre-deployment-categorizer'); +const StackIdentifier = require('../../../domain/value-objects/stack-identifier'); +const { TemplateParser } = require('../../../domain/services/template-parser'); + +describe('Pre-Deployment Health Check Integration', () => { + let useCase; + let mockStackRepository; + let mockResourceDetector; + let categorizer; + let templateParser; + + beforeEach(() => { + mockStackRepository = { + getStack: jest.fn(), + }; + + mockResourceDetector = { + findOrphanedResources: jest.fn().mockResolvedValue([]), + checkServiceQuotas: jest.fn().mockResolvedValue([]), + }; + + categorizer = new PreDeploymentCategorizer(); + templateParser = new TemplateParser(); + + useCase = new RunPreDeploymentHealthCheckUseCase({ + stackRepository: mockStackRepository, + resourceDetector: mockResourceDetector, + preDeploymentCategorizer: categorizer, + templateParser: { parseTemplate: jest.fn() }, + }); + }); + + describe('end-to-end scenarios', () => { + it('should detect and block orphaned KMS alias before deployment', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-dev', + stackStatus: 'UPDATE_COMPLETE', + }); + + const template = { + resources: { + MyKmsAlias: { + Type: 'AWS::KMS::Alias', + Properties: { + AliasName: 'alias/my-app-dev-kms', + }, + }, + }, + }; + + useCase.templateParser.parseTemplate.mockResolvedValue(template); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([ + { + physicalId: 'alias/my-app-dev-kms', + resourceType: 'AWS::KMS::Alias', + properties: { AliasName: 'alias/my-app-dev-kms' }, + }, + ]); + + const stackIdentifier = new StackIdentifier({ + stackName: 'my-app-dev', + region: 'us-east-1', + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/fake/path/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + expect(result.blockingIssues[0].issue.resourceType).toBe('AWS::KMS::Alias'); + expect(result.blockingIssues[0].category.isBlocking()).toBe(true); + }); + + it('should allow deployment when no issues found', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + const template = { + resources: { + MyLambda: { + Type: 'AWS::Lambda::Function', + Properties: { + FunctionName: 'my-function', + }, + }, + }, + }; + + useCase.templateParser.parseTemplate.mockResolvedValue(template); + + const stackIdentifier = new StackIdentifier({ + stackName: 'my-app-prod', + region: 'us-east-1', + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/fake/path/template.json', + }); + + expect(result.canDeploy).toBe(true); + expect(result.blockingIssues).toHaveLength(0); + expect(result.summary).toEqual({ + total: 0, + blocking: 0, + warnings: 0, + }); + }); + + it('should block deployment when stack in ROLLBACK_COMPLETE', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-broken', + stackStatus: 'ROLLBACK_COMPLETE', + }); + + const template = { resources: {} }; + useCase.templateParser.parseTemplate.mockResolvedValue(template); + + const stackIdentifier = new StackIdentifier({ + stackName: 'my-app-broken', + region: 'us-east-1', + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/fake/path/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + expect(result.blockingIssues[0].issue.stackStatus).toBe('ROLLBACK_COMPLETE'); + }); + + it('should handle first-time deployment (stack does not exist)', async () => { + mockStackRepository.getStack.mockRejectedValue({ + code: 'ValidationError', + message: 'Stack does not exist', + }); + + const template = { + resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'my-new-bucket' }, + }, + }, + }; + + useCase.templateParser.parseTemplate.mockResolvedValue(template); + + const stackIdentifier = new StackIdentifier({ + stackName: 'my-new-stack', + region: 'us-east-1', + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/fake/path/template.json', + }); + + expect(result.canDeploy).toBe(true); + expect(result.stackExists).toBe(false); + expect(result.blockingIssues).toHaveLength(0); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.js b/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.js new file mode 100644 index 000000000..df46d6ec5 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.js @@ -0,0 +1,142 @@ +const Issue = require('../../domain/entities/issue'); + +class RunPreDeploymentHealthCheckUseCase { + static DEPLOYABLE_STATES = [ + 'CREATE_COMPLETE', + 'UPDATE_COMPLETE', + 'UPDATE_ROLLBACK_COMPLETE', + 'IMPORT_COMPLETE', + 'IMPORT_ROLLBACK_COMPLETE', + ]; + + constructor({ + stackRepository, + resourceDetector, + preDeploymentCategorizer, + templateParser, + }) { + if (!stackRepository) { + throw new Error('stackRepository is required'); + } + if (!resourceDetector) { + throw new Error('resourceDetector is required'); + } + if (!preDeploymentCategorizer) { + throw new Error('preDeploymentCategorizer is required'); + } + if (!templateParser) { + throw new Error('templateParser is required'); + } + + this.stackRepository = stackRepository; + this.resourceDetector = resourceDetector; + this.categorizer = preDeploymentCategorizer; + this.templateParser = templateParser; + } + + async execute({ stackIdentifier, templatePath, onProgress }) { + const progress = (step, message) => onProgress?.(step, message); + + progress('šŸ“‹ Step 1/6:', 'Checking stack status...'); + let stackExists = true; + let stack = null; + + try { + stack = await this.stackRepository.getStack(stackIdentifier); + } catch (error) { + if (error.code === 'ValidationError') { + stackExists = false; + progress(' Stack does not exist (first deployment)'); + } else { + throw error; + } + } + + const issues = []; + + if (stackExists) { + progress('šŸ” Step 2/6:', 'Validating stack state...'); + + if (!this._isDeployableState(stack.stackStatus)) { + issues.push({ + type: Issue.TYPES.INVALID_STACK_STATE, + severity: Issue.SEVERITIES.CRITICAL, + resourceType: 'AWS::CloudFormation::Stack', + resourceId: stackIdentifier.stackName, + stackStatus: stack.stackStatus, + description: `Stack is in ${stack.stackStatus} state and cannot be updated`, + resolution: this._getStackStateResolution(stack.stackStatus), + canAutoFix: false, + }); + } + } else { + progress('ā­ļø Step 2/6:', 'Skipping state validation (new stack)'); + } + + progress('šŸ“„ Step 3/6:', 'Parsing deployment template...'); + const parsedTemplate = await this.templateParser.parseTemplate(templatePath); + const expectedResources = parsedTemplate.resources || {}; + + progress('šŸ”Ž Step 4/6:', 'Checking for orphaned resources...'); + const orphanedResources = await this.resourceDetector.findOrphanedResources({ + stackIdentifier, + expectedResources, + }); + + orphanedResources.forEach(orphan => { + issues.push(Issue.orphanedResource({ + resourceType: orphan.resourceType, + resourceId: orphan.physicalId, + description: `Resource exists in AWS but not tracked by stack: ${orphan.physicalId}`, + })); + }); + + progress('šŸ“Š Step 5/6:', 'Checking service quotas...'); + const quotaIssues = await this.resourceDetector.checkServiceQuotas({ + stackIdentifier, + expectedResources, + }); + + issues.push(...quotaIssues); + + progress('šŸ·ļø Step 6/6:', 'Categorizing issues...'); + const categorizedIssues = issues.map(issue => ({ + issue, + category: this.categorizer.categorize(issue), + })); + + const blockingIssues = categorizedIssues.filter(i => i.category.isBlocking()); + const warningIssues = categorizedIssues.filter(i => i.category.isWarning()); + + return { + canDeploy: blockingIssues.length === 0, + blockingIssues, + warningIssues, + stackExists, + stackStatus: stack?.stackStatus, + summary: { + total: issues.length, + blocking: blockingIssues.length, + warnings: warningIssues.length, + }, + }; + } + + _isDeployableState(stackStatus) { + return RunPreDeploymentHealthCheckUseCase.DEPLOYABLE_STATES.includes(stackStatus); + } + + _getStackStateResolution(stackStatus) { + const resolutions = { + 'ROLLBACK_COMPLETE': 'Delete stack with: aws cloudformation delete-stack --stack-name ${stackName}', + 'CREATE_FAILED': 'Delete stack with: aws cloudformation delete-stack --stack-name ${stackName}', + 'UPDATE_ROLLBACK_FAILED': 'Continue rollback with: aws cloudformation continue-update-rollback --stack-name ${stackName}', + 'DELETE_FAILED': 'Force delete with: aws cloudformation delete-stack --stack-name ${stackName} --force', + 'DELETE_IN_PROGRESS': 'Wait for deletion to complete', + }; + + return resolutions[stackStatus] || 'Manual intervention required'; + } +} + +module.exports = RunPreDeploymentHealthCheckUseCase; diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.test.js new file mode 100644 index 000000000..4200c9e13 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case.test.js @@ -0,0 +1,453 @@ +const RunPreDeploymentHealthCheckUseCase = require('./run-pre-deployment-health-check-use-case'); +const StackIdentifier = require('../../domain/value-objects/stack-identifier'); +const Issue = require('../../domain/entities/issue'); +const BlockingCategory = require('../../domain/value-objects/blocking-category'); + +describe('RunPreDeploymentHealthCheckUseCase', () => { + let useCase; + let mockStackRepository; + let mockResourceDetector; + let mockCategorizer; + let mockTemplateParser; + let stackIdentifier; + + beforeEach(() => { + stackIdentifier = new StackIdentifier({ + stackName: 'my-app-prod', + region: 'us-east-1', + }); + + mockStackRepository = { + getStack: jest.fn(), + }; + + mockResourceDetector = { + findOrphanedResources: jest.fn(), + checkServiceQuotas: jest.fn(), + }; + + mockCategorizer = { + categorize: jest.fn(), + }; + + mockTemplateParser = { + parseTemplate: jest.fn(), + }; + + useCase = new RunPreDeploymentHealthCheckUseCase({ + stackRepository: mockStackRepository, + resourceDetector: mockResourceDetector, + preDeploymentCategorizer: mockCategorizer, + templateParser: mockTemplateParser, + }); + }); + + describe('constructor', () => { + it('should require stackRepository', () => { + expect(() => { + new RunPreDeploymentHealthCheckUseCase({ + resourceDetector: mockResourceDetector, + preDeploymentCategorizer: mockCategorizer, + templateParser: mockTemplateParser, + }); + }).toThrow('stackRepository is required'); + }); + + it('should require resourceDetector', () => { + expect(() => { + new RunPreDeploymentHealthCheckUseCase({ + stackRepository: mockStackRepository, + preDeploymentCategorizer: mockCategorizer, + templateParser: mockTemplateParser, + }); + }).toThrow('resourceDetector is required'); + }); + + it('should require preDeploymentCategorizer', () => { + expect(() => { + new RunPreDeploymentHealthCheckUseCase({ + stackRepository: mockStackRepository, + resourceDetector: mockResourceDetector, + templateParser: mockTemplateParser, + }); + }).toThrow('preDeploymentCategorizer is required'); + }); + + it('should require templateParser', () => { + expect(() => { + new RunPreDeploymentHealthCheckUseCase({ + stackRepository: mockStackRepository, + resourceDetector: mockResourceDetector, + preDeploymentCategorizer: mockCategorizer, + }); + }).toThrow('templateParser is required'); + }); + }); + + describe('execute', () => { + describe('when stack does not exist', () => { + it('should allow deployment for first-time stack creation', async () => { + mockStackRepository.getStack.mockRejectedValue({ + code: 'ValidationError', + message: 'Stack does not exist', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(true); + expect(result.stackExists).toBe(false); + expect(result.blockingIssues).toHaveLength(0); + }); + + it('should still check for orphaned resources even if stack does not exist', async () => { + mockStackRepository.getStack.mockRejectedValue({ + code: 'ValidationError', + message: 'Stack does not exist', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: { + MyKmsKey: { + Type: 'AWS::KMS::Alias', + Properties: { AliasName: 'alias/my-key' }, + }, + }, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([ + { + resourceType: 'AWS::KMS::Alias', + physicalId: 'alias/my-key', + }, + ]); + + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + mockCategorizer.categorize.mockReturnValue( + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.ORPHANED_RESOURCE, + description: 'Orphaned KMS alias', + }) + ); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + }); + }); + + describe('when stack exists', () => { + it('should allow deployment when stack is in UPDATE_COMPLETE state', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(true); + expect(result.stackExists).toBe(true); + expect(result.stackStatus).toBe('UPDATE_COMPLETE'); + expect(result.blockingIssues).toHaveLength(0); + }); + + it('should block deployment when stack is in ROLLBACK_COMPLETE state', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'ROLLBACK_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + mockCategorizer.categorize.mockReturnValue( + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Stack in ROLLBACK_COMPLETE', + }) + ); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + expect(result.blockingIssues[0].issue.type).toBe(Issue.TYPES.INVALID_STACK_STATE); + }); + + it('should allow deployment when stack is in UPDATE_ROLLBACK_COMPLETE state', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_ROLLBACK_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(true); + expect(result.blockingIssues).toHaveLength(0); + }); + }); + + describe('orphaned resources', () => { + it('should block deployment when orphaned KMS alias found', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: { + MyKmsAlias: { + Type: 'AWS::KMS::Alias', + Properties: { AliasName: 'alias/my-app-prod-kms' }, + }, + }, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([ + { + resourceType: 'AWS::KMS::Alias', + physicalId: 'alias/my-app-prod-kms', + }, + ]); + + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + mockCategorizer.categorize.mockReturnValue( + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.ORPHANED_RESOURCE, + description: 'Orphaned KMS alias', + }) + ); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + expect(result.blockingIssues[0].issue.resourceType).toBe('AWS::KMS::Alias'); + }); + + it('should pass expected resources to orphan detector', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + const expectedResources = { + MyKmsKey: { + Type: 'AWS::KMS::Key', + Properties: { Description: 'My key' }, + }, + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'my-bucket' }, + }, + }; + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: expectedResources, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(mockResourceDetector.findOrphanedResources).toHaveBeenCalledWith({ + stackIdentifier, + expectedResources, + }); + }); + }); + + describe('quota checks', () => { + it('should block deployment when quota exceeded', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + + const quotaIssue = { + type: Issue.TYPES.QUOTA_EXCEEDED, + severity: Issue.SEVERITIES.CRITICAL, + resourceType: 'AWS::EC2::EIP', + resourceId: 'MyEIP', + description: 'EIP quota exceeded', + }; + + mockResourceDetector.checkServiceQuotas.mockResolvedValue([quotaIssue]); + + mockCategorizer.categorize.mockReturnValue( + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.QUOTA_EXCEEDED, + description: 'EIP quota exceeded', + }) + ); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(false); + expect(result.blockingIssues).toHaveLength(1); + }); + }); + + describe('progress callback', () => { + it('should call onProgress callback at each step', async () => { + const onProgress = jest.fn(); + + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + onProgress, + }); + + expect(onProgress).toHaveBeenCalledWith('šŸ“‹ Step 1/6:', expect.any(String)); + expect(onProgress).toHaveBeenCalledWith('šŸ” Step 2/6:', expect.any(String)); + expect(onProgress).toHaveBeenCalledWith('šŸ“„ Step 3/6:', expect.any(String)); + expect(onProgress).toHaveBeenCalledWith('šŸ”Ž Step 4/6:', expect.any(String)); + expect(onProgress).toHaveBeenCalledWith('šŸ“Š Step 5/6:', expect.any(String)); + expect(onProgress).toHaveBeenCalledWith('šŸ·ļø Step 6/6:', expect.any(String)); + }); + + it('should work without onProgress callback', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'UPDATE_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.canDeploy).toBe(true); + }); + }); + + describe('result structure', () => { + it('should return correct summary counts', async () => { + mockStackRepository.getStack.mockResolvedValue({ + stackName: 'my-app-prod', + stackStatus: 'ROLLBACK_COMPLETE', + }); + + mockTemplateParser.parseTemplate.mockResolvedValue({ + resources: {}, + }); + + mockResourceDetector.findOrphanedResources.mockResolvedValue([ + { + resourceType: 'AWS::KMS::Alias', + physicalId: 'alias/test', + }, + ]); + + mockResourceDetector.checkServiceQuotas.mockResolvedValue([]); + + mockCategorizer.categorize.mockImplementation((issue) => { + if (issue.stackStatus) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Stack state issue', + }); + } + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'OTHER', + description: 'Warning', + }); + }); + + const result = await useCase.execute({ + stackIdentifier, + templatePath: '/path/to/template.json', + }); + + expect(result.summary).toEqual({ + total: 2, + blocking: 1, + warnings: 1, + }); + }); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/domain/entities/issue.js b/packages/devtools/infrastructure/domains/health/domain/entities/issue.js index 820733a93..88d6e8909 100644 --- a/packages/devtools/infrastructure/domains/health/domain/entities/issue.js +++ b/packages/devtools/infrastructure/domains/health/domain/entities/issue.js @@ -14,6 +14,9 @@ class Issue { PROPERTY_MISMATCH: 'PROPERTY_MISMATCH', DRIFTED_RESOURCE: 'DRIFTED_RESOURCE', MISSING_TAG: 'MISSING_TAG', + INVALID_STACK_STATE: 'INVALID_STACK_STATE', + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + MISSING_DEPENDENCY: 'MISSING_DEPENDENCY', }; /** diff --git a/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.js b/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.js new file mode 100644 index 000000000..63b586721 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.js @@ -0,0 +1,114 @@ +const BlockingCategory = require('../value-objects/blocking-category'); +const Issue = require('../entities/issue'); + +class PreDeploymentCategorizer { + static BLOCKING_STACK_STATES = [ + 'CREATE_FAILED', + 'ROLLBACK_COMPLETE', + 'ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_FAILED', + 'DELETE_IN_PROGRESS', + 'DELETE_FAILED', + ]; + + static BLOCKING_ORPHAN_TYPES = [ + 'AWS::KMS::Alias', + 'AWS::KMS::Key', + 'AWS::EC2::VPC', + 'AWS::S3::Bucket', + 'AWS::Lambda::Function', + 'AWS::RDS::DBInstance', + 'AWS::DynamoDB::Table', + ]; + + categorize(issue) { + if (this._isInvalidStackState(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: `Stack state ${issue.stackStatus} prevents deployment`, + }); + } + + if (this._isBlockingOrphan(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.ORPHANED_RESOURCE, + description: `Orphaned ${issue.resourceType} will cause AlreadyExistsException`, + }); + } + + if (this._isQuotaExceeded(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.QUOTA_EXCEEDED, + description: `${issue.resourceType} quota exceeded`, + }); + } + + if (this._isMissingDependency(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.MISSING_DEPENDENCY, + description: `Missing dependency: ${issue.description}`, + }); + } + + if (this._isImmutablePropertyMismatch(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: 'IMMUTABLE_PROPERTY_DRIFT', + description: `Immutable property drift requires replacement`, + }); + } + + if (this._isMutablePropertyMismatch(issue)) { + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'PROPERTY_DRIFT', + description: 'Mutable property drift detected', + }); + } + + return new BlockingCategory({ + category: BlockingCategory.CATEGORIES.INFO, + reason: 'OTHER', + description: issue.description, + }); + } + + _isInvalidStackState(issue) { + return issue.stackStatus && + PreDeploymentCategorizer.BLOCKING_STACK_STATES.includes(issue.stackStatus); + } + + _isBlockingOrphan(issue) { + return issue.isOrphanedResource && + issue.isOrphanedResource() && + PreDeploymentCategorizer.BLOCKING_ORPHAN_TYPES.includes(issue.resourceType); + } + + _isQuotaExceeded(issue) { + return issue.type === 'QUOTA_EXCEEDED'; + } + + _isMissingDependency(issue) { + return issue.type === 'MISSING_DEPENDENCY'; + } + + _isImmutablePropertyMismatch(issue) { + return issue.isPropertyMismatch && + issue.isPropertyMismatch() && + issue.propertyMismatch && + issue.propertyMismatch.requiresReplacement(); + } + + _isMutablePropertyMismatch(issue) { + return issue.isPropertyMismatch && + issue.isPropertyMismatch() && + issue.propertyMismatch && + !issue.propertyMismatch.requiresReplacement(); + } +} + +module.exports = PreDeploymentCategorizer; diff --git a/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.test.js b/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.test.js new file mode 100644 index 000000000..b22fa6f09 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer.test.js @@ -0,0 +1,202 @@ +const PreDeploymentCategorizer = require('./pre-deployment-categorizer'); +const BlockingCategory = require('../value-objects/blocking-category'); +const Issue = require('../entities/issue'); +const PropertyMismatch = require('../entities/property-mismatch'); +const PropertyMutability = require('../value-objects/property-mutability'); + +describe('PreDeploymentCategorizer', () => { + let categorizer; + + beforeEach(() => { + categorizer = new PreDeploymentCategorizer(); + }); + + describe('categorize', () => { + describe('invalid stack state (BLOCKING)', () => { + PreDeploymentCategorizer.BLOCKING_STACK_STATES.forEach(stackStatus => { + it(`should categorize ${stackStatus} as BLOCKING`, () => { + const issue = { + type: 'INVALID_STACK_STATE', + stackStatus, + description: `Stack in ${stackStatus}`, + }; + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(true); + expect(category.reason).toBe(BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE); + expect(category.description).toContain(stackStatus); + }); + }); + + it('should not categorize UPDATE_COMPLETE as blocking', () => { + const issue = { + type: 'SOME_TYPE', + stackStatus: 'UPDATE_COMPLETE', + description: 'Stack in UPDATE_COMPLETE', + }; + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(false); + }); + }); + + describe('orphaned resources (BLOCKING)', () => { + PreDeploymentCategorizer.BLOCKING_ORPHAN_TYPES.forEach(resourceType => { + it(`should categorize orphaned ${resourceType} as BLOCKING`, () => { + const issue = Issue.orphanedResource({ + resourceType, + resourceId: `test-${resourceType}`, + description: `Orphaned ${resourceType}`, + }); + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(true); + expect(category.reason).toBe(BlockingCategory.BLOCKING_REASONS.ORPHANED_RESOURCE); + expect(category.description).toContain(resourceType); + }); + }); + + it('should not categorize orphaned non-blocking resource as BLOCKING', () => { + const issue = Issue.orphanedResource({ + resourceType: 'AWS::IAM::Role', + resourceId: 'test-role', + description: 'Orphaned role', + }); + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(false); + }); + }); + + describe('quota exceeded (BLOCKING)', () => { + it('should categorize quota exceeded as BLOCKING', () => { + const issue = { + type: 'QUOTA_EXCEEDED', + resourceType: 'AWS::EC2::EIP', + description: 'EIP quota exceeded', + }; + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(true); + expect(category.reason).toBe(BlockingCategory.BLOCKING_REASONS.QUOTA_EXCEEDED); + expect(category.description).toContain('AWS::EC2::EIP'); + }); + }); + + describe('missing dependency (BLOCKING)', () => { + it('should categorize missing dependency as BLOCKING', () => { + const issue = { + type: 'MISSING_DEPENDENCY', + resourceType: 'AWS::EC2::VPC', + description: 'Referenced VPC not found', + }; + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(true); + expect(category.reason).toBe(BlockingCategory.BLOCKING_REASONS.MISSING_DEPENDENCY); + }); + }); + + describe('property drift (WARNING)', () => { + it('should categorize mutable property drift as WARNING', () => { + const mismatch = new PropertyMismatch({ + propertyPath: 'Timeout', + expectedValue: 30, + actualValue: 60, + mutability: PropertyMutability.MUTABLE, + }); + + const issue = Issue.propertyMismatch({ + resourceType: 'AWS::Lambda::Function', + resourceId: 'my-function', + mismatch, + }); + + const category = categorizer.categorize(issue); + + expect(category.isWarning()).toBe(true); + expect(category.reason).toBe('PROPERTY_DRIFT'); + expect(category.description).toBe('Mutable property drift detected'); + }); + + it('should categorize immutable property drift as BLOCKING', () => { + const mismatch = new PropertyMismatch({ + propertyPath: 'BucketName', + expectedValue: 'bucket-a', + actualValue: 'bucket-b', + mutability: PropertyMutability.IMMUTABLE, + }); + + const issue = Issue.propertyMismatch({ + resourceType: 'AWS::S3::Bucket', + resourceId: 'my-bucket', + mismatch, + }); + + const category = categorizer.categorize(issue); + + expect(category.isBlocking()).toBe(true); + }); + }); + + describe('default to INFO', () => { + it('should categorize unknown issue type as INFO', () => { + const issue = { + type: 'UNKNOWN_TYPE', + resourceType: 'AWS::Lambda::Function', + resourceId: 'my-function', + description: 'Some other issue', + }; + + const category = categorizer.categorize(issue); + + expect(category.isInfo()).toBe(true); + expect(category.reason).toBe('OTHER'); + }); + + it('should categorize missing tag as INFO', () => { + const issue = { + type: Issue.TYPES.MISSING_TAG, + resourceType: 'AWS::KMS::Key', + resourceId: 'key-123', + description: 'Missing tags', + }; + + const category = categorizer.categorize(issue); + + expect(category.isInfo()).toBe(true); + }); + }); + }); + + describe('constants', () => { + it('should have correct blocking stack states', () => { + expect(PreDeploymentCategorizer.BLOCKING_STACK_STATES).toEqual([ + 'CREATE_FAILED', + 'ROLLBACK_COMPLETE', + 'ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_FAILED', + 'DELETE_IN_PROGRESS', + 'DELETE_FAILED', + ]); + }); + + it('should have correct blocking orphan types', () => { + expect(PreDeploymentCategorizer.BLOCKING_ORPHAN_TYPES).toEqual([ + 'AWS::KMS::Alias', + 'AWS::KMS::Key', + 'AWS::EC2::VPC', + 'AWS::S3::Bucket', + 'AWS::Lambda::Function', + 'AWS::RDS::DBInstance', + 'AWS::DynamoDB::Table', + ]); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.js b/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.js new file mode 100644 index 000000000..35499b400 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.js @@ -0,0 +1,52 @@ +class BlockingCategory { + static CATEGORIES = { + BLOCKING: 'BLOCKING', + WARNING: 'WARNING', + INFO: 'INFO', + }; + + static BLOCKING_REASONS = { + INVALID_STACK_STATE: 'INVALID_STACK_STATE', + ORPHANED_RESOURCE: 'ORPHANED_RESOURCE', + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + MISSING_DEPENDENCY: 'MISSING_DEPENDENCY', + }; + + constructor({ category, reason, description }) { + if (!category) { + throw new Error('category is required'); + } + + if (!reason) { + throw new Error('reason is required'); + } + + if (!description) { + throw new Error('description is required'); + } + + if (!Object.values(BlockingCategory.CATEGORIES).includes(category)) { + throw new Error(`Invalid category: ${category}`); + } + + this.category = category; + this.reason = reason; + this.description = description; + + Object.freeze(this); + } + + isBlocking() { + return this.category === BlockingCategory.CATEGORIES.BLOCKING; + } + + isWarning() { + return this.category === BlockingCategory.CATEGORIES.WARNING; + } + + isInfo() { + return this.category === BlockingCategory.CATEGORIES.INFO; + } +} + +module.exports = BlockingCategory; diff --git a/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.test.js b/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.test.js new file mode 100644 index 000000000..776a2db9b --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/value-objects/blocking-category.test.js @@ -0,0 +1,204 @@ +const BlockingCategory = require('./blocking-category'); + +describe('BlockingCategory', () => { + describe('constructor', () => { + it('should create blocking category for invalid stack state', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Stack in ROLLBACK_COMPLETE', + }); + + expect(category.category).toBe(BlockingCategory.CATEGORIES.BLOCKING); + expect(category.reason).toBe(BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE); + expect(category.description).toBe('Stack in ROLLBACK_COMPLETE'); + }); + + it('should create warning category for property drift', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'PROPERTY_DRIFT', + description: 'Mutable property drift detected', + }); + + expect(category.category).toBe(BlockingCategory.CATEGORIES.WARNING); + expect(category.reason).toBe('PROPERTY_DRIFT'); + expect(category.description).toBe('Mutable property drift detected'); + }); + + it('should create info category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.INFO, + reason: 'OTHER', + description: 'Informational message', + }); + + expect(category.category).toBe(BlockingCategory.CATEGORIES.INFO); + expect(category.reason).toBe('OTHER'); + }); + + it('should reject missing category', () => { + expect(() => { + new BlockingCategory({ + reason: 'SOME_REASON', + description: 'Test', + }); + }).toThrow('category is required'); + }); + + it('should reject invalid category', () => { + expect(() => { + new BlockingCategory({ + category: 'INVALID', + reason: 'SOME_REASON', + description: 'Test', + }); + }).toThrow('Invalid category: INVALID'); + }); + + it('should reject missing reason', () => { + expect(() => { + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + description: 'Test', + }); + }).toThrow('reason is required'); + }); + + it('should reject missing description', () => { + expect(() => { + new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + }); + }).toThrow('description is required'); + }); + }); + + describe('isBlocking', () => { + it('should return true for blocking category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.ORPHANED_RESOURCE, + description: 'Test blocking', + }); + + expect(category.isBlocking()).toBe(true); + }); + + it('should return false for warning category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'PROPERTY_DRIFT', + description: 'Test warning', + }); + + expect(category.isBlocking()).toBe(false); + }); + + it('should return false for info category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.INFO, + reason: 'OTHER', + description: 'Test info', + }); + + expect(category.isBlocking()).toBe(false); + }); + }); + + describe('isWarning', () => { + it('should return true for warning category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'PROPERTY_DRIFT', + description: 'Test warning', + }); + + expect(category.isWarning()).toBe(true); + }); + + it('should return false for blocking category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Test blocking', + }); + + expect(category.isWarning()).toBe(false); + }); + + it('should return false for info category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.INFO, + reason: 'OTHER', + description: 'Test info', + }); + + expect(category.isWarning()).toBe(false); + }); + }); + + describe('isInfo', () => { + it('should return true for info category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.INFO, + reason: 'OTHER', + description: 'Test info', + }); + + expect(category.isInfo()).toBe(true); + }); + + it('should return false for blocking category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Test blocking', + }); + + expect(category.isInfo()).toBe(false); + }); + + it('should return false for warning category', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.WARNING, + reason: 'PROPERTY_DRIFT', + description: 'Test warning', + }); + + expect(category.isInfo()).toBe(false); + }); + }); + + describe('immutability', () => { + it('should be frozen', () => { + const category = new BlockingCategory({ + category: BlockingCategory.CATEGORIES.BLOCKING, + reason: BlockingCategory.BLOCKING_REASONS.INVALID_STACK_STATE, + description: 'Test', + }); + + expect(Object.isFrozen(category)).toBe(true); + }); + }); + + describe('constants', () => { + it('should have BLOCKING, WARNING, INFO categories', () => { + expect(BlockingCategory.CATEGORIES).toEqual({ + BLOCKING: 'BLOCKING', + WARNING: 'WARNING', + INFO: 'INFO', + }); + }); + + it('should have blocking reasons', () => { + expect(BlockingCategory.BLOCKING_REASONS).toEqual({ + INVALID_STACK_STATE: 'INVALID_STACK_STATE', + ORPHANED_RESOURCE: 'ORPHANED_RESOURCE', + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + MISSING_DEPENDENCY: 'MISSING_DEPENDENCY', + }); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js index f35b1b346..45571ef77 100644 --- a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js @@ -72,6 +72,11 @@ class AWSResourceDetector extends IResourceDetector { 'AWS::EC2::RouteTable', 'AWS::RDS::DBCluster', 'AWS::KMS::Key', + 'AWS::KMS::Alias', + 'AWS::S3::Bucket', + 'AWS::Lambda::Function', + 'AWS::RDS::DBInstance', + 'AWS::DynamoDB::Table', ]; /** @@ -152,6 +157,13 @@ class AWSResourceDetector extends IResourceDetector { return await this._detectDBClusters(filters); case 'AWS::KMS::Key': return await this._detectKMSKeys(filters); + case 'AWS::KMS::Alias': + return await this._detectKMSAliases(filters); + case 'AWS::S3::Bucket': + case 'AWS::Lambda::Function': + case 'AWS::RDS::DBInstance': + case 'AWS::DynamoDB::Table': + return []; default: throw new Error(`Resource type ${resourceType} is not supported`); } @@ -211,36 +223,38 @@ class AWSResourceDetector extends IResourceDetector { /** * Find orphaned resources for a specific stack * - * Orphaned resources are resources that: - * 1. Have aws:cloudformation:stack-name tag matching target stack - * OR no CloudFormation tags but exist in region with stack resources - * 2. Physical ID is NOT in the actual CloudFormation stack resources - * 3. Are not default AWS resources (default VPC, AWS-managed KMS keys) - * - * NOTE: We DON'T trust CloudFormation tags alone. Resources can have - * CloudFormation tags but not actually be in the stack (manual tagging, - * failed imports, removed from stack but tags remain, etc.) - * - * Instead, we compare against the actual physical IDs from the stack. + * For pre-deployment: Detects resources in AWS that will conflict with template resources + * For post-deployment: Detects resources tagged for stack but not actually in stack * * @param {Object} params * @param {StackIdentifier} params.stackIdentifier - Target stack - * @param {Array} params.stackResources - Resources currently in stack template + * @param {Object} [params.expectedResources] - Resources from template (logical ID -> resource def) + * @param {Array} [params.stackResources] - Resources currently in stack (with physicalIds) * @returns {Promise} Orphaned resources */ - async findOrphanedResources({ stackIdentifier, stackResources }) { + async findOrphanedResources({ stackIdentifier, expectedResources, stackResources }) { const orphans = []; - // Build Set of physical IDs that are actually IN the CloudFormation stack - // This is the source of truth - not the tags! - const stackPhysicalIds = new Set( - stackResources.map((r) => r.physicalId).filter(Boolean) - ); + let stackPhysicalIds = new Set(); + if (stackResources) { + stackPhysicalIds = new Set( + stackResources.map((r) => r.physicalId).filter(Boolean) + ); + } + + const expectedResourceTypes = new Set(); + if (expectedResources) { + Object.values(expectedResources).forEach(resource => { + if (resource.Type) { + expectedResourceTypes.add(resource.Type); + } + }); + } - // Check ALL supported resource types, not just types in stack - // Orphaned resources are by definition NOT in the stack, so we need - // to check all types that could potentially be orphaned - const typesToCheck = AWSResourceDetector.SUPPORTED_TYPES; + const typesToCheck = expectedResourceTypes.size > 0 + ? Array.from(expectedResourceTypes).filter(type => + AWSResourceDetector.SUPPORTED_TYPES.includes(type)) + : AWSResourceDetector.SUPPORTED_TYPES; for (const resourceType of typesToCheck) { const resources = await this.detectResources({ @@ -330,6 +344,20 @@ class AWSResourceDetector extends IResourceDetector { return false; } + /** + * Check service quotas for resources in template + * + * @param {Object} params + * @param {StackIdentifier} params.stackIdentifier - Target stack + * @param {Object} params.expectedResources - Resources from template + * @returns {Promise} Array of quota-related issues + */ + async checkServiceQuotas({ stackIdentifier, expectedResources }) { + const issues = []; + + return issues; + } + // ======================================== // Private Resource Detection Methods // ======================================== @@ -485,23 +513,17 @@ class AWSResourceDetector extends IResourceDetector { async _detectKMSKeys(filters) { const client = this._getKMSClient(); - // List all keys const listCommand = new ListKeysCommand({}); const listResponse = await client.send(listCommand); const keys = listResponse.Keys || []; const resources = []; - // Get details for each key for (const key of keys) { const describeCommand = new DescribeKeyCommand({ KeyId: key.KeyId }); const describeResponse = await client.send(describeCommand); const keyMetadata = describeResponse.KeyMetadata; - // Get aliases for this key - const aliasCommand = new ListAliasesCommand({ KeyId: key.KeyId }); - const aliasResponse = await client.send(aliasCommand); - resources.push({ physicalId: keyMetadata.KeyId, resourceType: 'AWS::KMS::Key', @@ -512,7 +534,7 @@ class AWSResourceDetector extends IResourceDetector { KeyState: keyMetadata.KeyState, KeyManager: keyMetadata.KeyManager, }, - tags: {}, // KMS uses separate tagging API + tags: {}, createdTime: keyMetadata.CreationDate, }); } @@ -520,6 +542,33 @@ class AWSResourceDetector extends IResourceDetector { return resources; } + /** + * Detect KMS Aliases + * @private + */ + async _detectKMSAliases(filters) { + const client = this._getKMSClient(); + + const listCommand = new ListAliasesCommand({}); + const listResponse = await client.send(listCommand); + + const aliases = listResponse.Aliases || []; + + return aliases + .filter(alias => !alias.AliasName.startsWith('alias/aws/')) + .map(alias => ({ + physicalId: alias.AliasName, + resourceType: 'AWS::KMS::Alias', + properties: { + AliasName: alias.AliasName, + AliasArn: alias.AliasArn, + TargetKeyId: alias.TargetKeyId, + }, + tags: {}, + createdTime: new Date(), + })); + } + // ======================================== // Private Helper Methods // ========================================