diff --git a/packages/devtools/frigg-cli/cleanup-command/index.js b/packages/devtools/frigg-cli/cleanup-command/index.js new file mode 100644 index 000000000..949d6fa4c --- /dev/null +++ b/packages/devtools/frigg-cli/cleanup-command/index.js @@ -0,0 +1,328 @@ +const path = require('path'); +const fs = require('fs'); +const { select, confirm, input } = require('@inquirer/prompts'); +const { CloudFormationClient, ListStacksCommand } = require('@aws-sdk/client-cloudformation'); +const { EC2Client } = require('@aws-sdk/client-ec2'); +const { ElasticLoadBalancingV2Client } = require('@aws-sdk/client-elastic-load-balancing-v2'); +const { RDSClient } = require('@aws-sdk/client-rds'); +const { LambdaClient } = require('@aws-sdk/client-lambda'); + +const StackIdentifier = require('@friggframework/devtools/infrastructure/domains/health/domain/value-objects/stack-identifier'); +const CleanupOrphanedResourcesUseCase = require('@friggframework/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case'); + +const AWSResourceDetector = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector'); +const ResourceDependencyAnalyzer = require('@friggframework/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer'); +const ResourceDeletionPlanner = require('@friggframework/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner'); +const ResourceDeleterRepository = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository'); +const AuditLogRepository = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository'); + +function formatConsoleOutput(result, options = {}) { + const lines = []; + const { deletionPlan } = result; + + if (!deletionPlan) { + lines.push(''); + lines.push('โ”€'.repeat(80)); + lines.push(result.message || 'No orphaned resources found'); + lines.push(''); + return lines.join('\n'); + } + + lines.push('โ”€'.repeat(80)); + lines.push('๐Ÿ“Š CLEANUP SUMMARY'); + lines.push(''); + lines.push(`Total resources: ${deletionPlan.totalResources}`); + lines.push(` โ€ข Can delete: ${deletionPlan.deletableCount}`); + lines.push(` โ€ข Blocked: ${deletionPlan.blockedCount}`); + lines.push(''); + + if (deletionPlan.resourcesByType) { + lines.push('Resources by type:'); + Object.entries(deletionPlan.resourcesByType).forEach(([type, count]) => { + const shortType = type.replace('AWS::EC2::', ''); + lines.push(` โ€ข ${shortType}: ${count}`); + }); + lines.push(''); + } + + if (deletionPlan.costSavings && deletionPlan.costSavings.monthly > 0) { + lines.push( + `Estimated monthly savings: $${deletionPlan.costSavings.monthly.toFixed(2)}` + ); + lines.push(''); + } + + const totalPhaseResources = + deletionPlan.phases.phase1.length + + deletionPlan.phases.phase2.length + + deletionPlan.phases.phase3.length; + + if (totalPhaseResources > 0) { + lines.push('Deletion order:'); + if (deletionPlan.phases.phase1.length > 0) { + lines.push(` Phase 1: ${deletionPlan.phases.phase1.length} resources`); + } + if (deletionPlan.phases.phase2.length > 0) { + lines.push(` Phase 2: ${deletionPlan.phases.phase2.length} resources`); + } + if (deletionPlan.phases.phase3.length > 0) { + lines.push(` Phase 3: ${deletionPlan.phases.phase3.length} resources`); + } + lines.push(''); + } + + if (deletionPlan.blockedResources && deletionPlan.blockedResources.length > 0) { + lines.push('โ”€'.repeat(80)); + lines.push('โš ๏ธ BLOCKED RESOURCES (cannot be deleted):'); + lines.push(''); + deletionPlan.blockedResources.forEach((blocked) => { + const { resource, blockingDependencies } = blocked; + lines.push(` ${resource.physicalId} (${resource.resourceType})`); + blockingDependencies.forEach((dep) => { + lines.push(` โ€ข ${dep.type}: ${dep.count} resources`); + }); + lines.push(''); + }); + } + + if (deletionPlan.warnings && deletionPlan.warnings.length > 0) { + lines.push('โ”€'.repeat(80)); + lines.push('โš ๏ธ SAFETY WARNINGS:'); + lines.push(''); + deletionPlan.warnings.forEach((warning) => { + lines.push(` โ€ข ${warning}`); + }); + lines.push(''); + } + + if (result.dryRun) { + lines.push('โ”€'.repeat(80)); + lines.push(''); + lines.push('๐Ÿ’ก To delete these resources, run:'); + lines.push(` frigg cleanup ${result.stackName || ''} --execute`); + lines.push(''); + } else { + lines.push('โ”€'.repeat(80)); + lines.push(''); + lines.push('โœ… CLEANUP COMPLETE'); + lines.push(''); + lines.push(`Successfully deleted: ${result.deletedCount} resources`); + if (result.failedCount > 0) { + lines.push(`Failed: ${result.failedCount} resources`); + } + if (result.skippedCount > 0) { + lines.push(`Skipped (blocked): ${result.skippedCount} resources`); + } + lines.push(''); + + if (result.costSavings && result.costSavings.monthly > 0) { + lines.push( + `Estimated monthly savings: $${result.costSavings.monthly.toFixed(2)}` + ); + lines.push(''); + } + } + + return lines.join('\n'); +} + +function formatJsonOutput(result) { + return JSON.stringify(result, null, 2); +} + +async function getStackList(region) { + const client = new CloudFormationClient({ region }); + const command = new ListStacksCommand({ + StackStatusFilter: [ + 'CREATE_COMPLETE', + 'UPDATE_COMPLETE', + 'ROLLBACK_COMPLETE', + 'UPDATE_ROLLBACK_COMPLETE', + ], + }); + + const response = await client.send(command); + return response.StackSummaries || []; +} + +async function selectStackInteractively(region) { + const stacks = await getStackList(region); + + if (stacks.length === 0) { + throw new Error(`No CloudFormation stacks found in region ${region}`); + } + + const stackName = await select({ + message: 'Select a stack to clean up:', + choices: stacks.map((stack) => ({ + name: `${stack.StackName} (${stack.StackStatus})`, + value: stack.StackName, + })), + }); + + return stackName; +} + +async function confirmDeletion(deletionPlan, stackName) { + console.log(''); + console.log('โ•'.repeat(80)); + console.log('โš ๏ธ WARNING: You are about to DELETE AWS resources'); + console.log('โ•'.repeat(80)); + console.log(''); + console.log('This action:'); + console.log(' โ€ข Cannot be easily undone'); + console.log(' โ€ข Will permanently delete resources from AWS'); + console.log(' โ€ข May affect running applications if dependencies exist'); + console.log(''); + console.log(`Resources to delete: ${deletionPlan.deletableCount}`); + if (deletionPlan.resourcesByType) { + Object.entries(deletionPlan.resourcesByType).forEach(([type, count]) => { + const shortType = type.replace('AWS::EC2::', ''); + console.log(` โ€ข ${shortType}: ${count}`); + }); + } + console.log(''); + + const confirmationText = await input({ + message: `Type 'delete ${stackName}' to confirm:`, + }); + + return confirmationText === `delete ${stackName}`; +} + +async function runCleanupCommand(stackName, options) { + const region = options.region || process.env.AWS_REGION || 'us-east-1'; + const dryRun = !options.execute; + const autoConfirm = options.yes || false; + const outputFormat = options.output || options.format || 'console'; + const resourceTypeFilter = options['resourceType'] || null; + const logicalIdPattern = options['logicalId'] || null; + + if (outputFormat === 'console') { + console.log(''); + console.log('โ•'.repeat(80)); + console.log(' ๐Ÿงน FRIGG CLEANUP - Orphaned Resources'); + console.log('โ•'.repeat(80)); + console.log(''); + } + + let resolvedStackName = stackName; + if (!resolvedStackName) { + resolvedStackName = await selectStackInteractively(region); + } + + if (outputFormat === 'console') { + console.log(''); + console.log(`Stack: ${resolvedStackName}`); + console.log(`Region: ${region}`); + console.log(`Mode: ${dryRun ? 'DRY-RUN (no resources will be deleted)' : 'EXECUTE (resources WILL be deleted)'}`); + console.log(''); + } + + const stackIdentifier = new StackIdentifier({ + stackName: resolvedStackName, + region, + accountId: '000000000000', + }); + + const ec2Client = new EC2Client({ region }); + const elbClient = new ElasticLoadBalancingV2Client({ region }); + const rdsClient = new RDSClient({ region }); + const lambdaClient = new LambdaClient({ region }); + + const useCase = new CleanupOrphanedResourcesUseCase({ + resourceDetector: new AWSResourceDetector({ region }), + dependencyAnalyzer: new ResourceDependencyAnalyzer({ + ec2Client, + elbClient, + rdsClient, + lambdaClient, + }), + deletionPlanner: new ResourceDeletionPlanner(), + deleterRepository: new ResourceDeleterRepository({ ec2Client, region }), + auditRepository: new AuditLogRepository(), + }); + + if (!dryRun && outputFormat === 'console') { + console.log('Analyzing orphaned resources...'); + console.log(''); + + const dryRunResult = await useCase.execute({ + stackIdentifier, + dryRun: true, + resourceTypeFilter, + logicalIdPattern, + }); + + if ( + !dryRunResult.deletionPlan || + dryRunResult.deletionPlan.deletableCount === 0 + ) { + console.log(formatConsoleOutput(dryRunResult)); + return; + } + + console.log(formatConsoleOutput(dryRunResult)); + + if (!autoConfirm) { + const confirmed = await confirmDeletion( + dryRunResult.deletionPlan, + resolvedStackName + ); + if (!confirmed) { + console.log(''); + console.log('Cleanup cancelled.'); + console.log(''); + return; + } + } + + console.log(''); + console.log('Starting deletion...'); + } + + const progressHandler = + outputFormat === 'console' + ? (progress) => { + if (progress.phase) { + console.log(`\n${progress.message}`); + } else if (progress.current) { + const status = progress.success ? 'โœ“' : 'โœ—'; + console.log( + ` [${progress.current}/${progress.total}] ${status} ${progress.physicalId}` + ); + } + } + : null; + + if (dryRun && outputFormat === 'console') { + console.log('Analyzing orphaned resources...'); + console.log(''); + } + + const result = await useCase.execute({ + stackIdentifier, + dryRun, + resourceTypeFilter, + logicalIdPattern, + onProgress: progressHandler, + }); + + if (outputFormat === 'json') { + console.log(formatJsonOutput(result)); + } else { + console.log(formatConsoleOutput(result)); + } + + if (options.outputFile) { + const outputPath = path.resolve(options.outputFile); + const content = + outputFormat === 'json' + ? formatJsonOutput(result) + : formatConsoleOutput(result); + fs.writeFileSync(outputPath, content, 'utf8'); + console.log(`Report written to: ${outputPath}`); + } +} + +module.exports = runCleanupCommand; diff --git a/packages/devtools/frigg-cli/index.js b/packages/devtools/frigg-cli/index.js index 12d9d712d..169a403e9 100755 --- a/packages/devtools/frigg-cli/index.js +++ b/packages/devtools/frigg-cli/index.js @@ -85,6 +85,7 @@ const { uiCommand } = require('./ui-command'); const { dbSetupCommand } = require('./db-setup-command'); const { doctorCommand } = require('./doctor-command'); const { repairCommand } = require('./repair-command'); +const cleanupCommand = require('./cleanup-command'); const program = new Command(); @@ -168,6 +169,19 @@ program .option('-v, --verbose', 'enable verbose output') .action(repairCommand); +program + .command('cleanup [stackName]') + .description('Clean up duplicate orphaned resources not in current stack template') + .option('-r, --region ', 'AWS region (defaults to AWS_REGION env var or us-east-1)') + .option('--execute', 'execute deletion (default is dry-run)') + .option('--resource-type ', 'filter by resource type (e.g., AWS::EC2::VPC)') + .option('--logical-id ', 'filter by logical ID pattern (supports * wildcard)') + .option('-y, --yes', 'skip confirmation prompts') + .option('-f, --format ', 'output format (console or json)', 'console') + .option('--output-file ', 'save report to file') + .option('-v, --verbose', 'enable verbose output') + .action(cleanupCommand); + program.parse(process.argv); -module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand }; +module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand, cleanupCommand }; diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.js b/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.js new file mode 100644 index 000000000..a5f6700a5 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.js @@ -0,0 +1,157 @@ +class CleanupOrphanedResourcesUseCase { + constructor({ + resourceDetector, + dependencyAnalyzer, + deletionPlanner, + deleterRepository, + auditRepository, + }) { + if (!resourceDetector) { + throw new Error('resourceDetector is required'); + } + if (!dependencyAnalyzer) { + throw new Error('dependencyAnalyzer is required'); + } + if (!deletionPlanner) { + throw new Error('deletionPlanner is required'); + } + if (!deleterRepository) { + throw new Error('deleterRepository is required'); + } + if (!auditRepository) { + throw new Error('auditRepository is required'); + } + + this.resourceDetector = resourceDetector; + this.dependencyAnalyzer = dependencyAnalyzer; + this.deletionPlanner = deletionPlanner; + this.deleterRepository = deleterRepository; + this.auditRepository = auditRepository; + } + + async execute({ + stackIdentifier, + dryRun = true, + resourceTypeFilter = null, + logicalIdPattern = null, + onProgress = null, + }) { + const orphanedResources = await this.resourceDetector.findOrphanedResources( + stackIdentifier, + [] + ); + + let filteredResources = orphanedResources; + + if (resourceTypeFilter) { + filteredResources = filteredResources.filter( + (r) => r.resourceType === resourceTypeFilter + ); + } + + if (logicalIdPattern) { + const regex = new RegExp(logicalIdPattern.replace(/\*/g, '.*')); + filteredResources = filteredResources.filter((r) => regex.test(r.logicalId)); + } + + if (filteredResources.length === 0) { + return { + success: true, + message: 'No orphaned resources found', + deletedCount: 0, + skippedCount: 0, + }; + } + + const dependencyAnalysis = await this.dependencyAnalyzer.analyzeDependencies( + filteredResources + ); + + const deletionPlan = this.deletionPlanner.createDeletionPlan({ + resources: filteredResources, + dependencyAnalysis, + }); + + if (dryRun) { + return { + dryRun: true, + deletionPlan, + stackName: stackIdentifier.stackName, + region: stackIdentifier.region, + message: 'Dry-run complete. No resources were deleted.', + }; + } + + const deletionResult = await this._executeDeletionPlan( + deletionPlan, + stackIdentifier, + onProgress + ); + + await this.auditRepository.logCleanupOperation({ + stackIdentifier, + deletionPlan, + result: deletionResult, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + dryRun: false, + deletedCount: deletionResult.successCount, + failedCount: deletionResult.failedCount, + skippedCount: deletionPlan.blockedCount, + deletionResult, + costSavings: deletionPlan.costSavings, + }; + } + + async _executeDeletionPlan(deletionPlan, stackIdentifier, onProgress) { + const results = { + successCount: 0, + failedCount: 0, + phaseResults: {}, + }; + + const phases = ['phase1', 'phase2', 'phase3']; + + for (const phase of phases) { + const resources = deletionPlan.phases[phase]; + + if (resources.length === 0) { + continue; + } + + if (onProgress) { + onProgress({ + phase, + message: `Deleting ${resources.length} resources in ${phase}`, + }); + } + + const phaseResult = await this.deleterRepository.deleteResourceBatch( + resources, + onProgress + ); + + results.successCount += phaseResult.successCount; + results.failedCount += phaseResult.failedCount; + results.phaseResults[phase] = phaseResult; + + for (const result of phaseResult.results) { + await this.auditRepository.logDeletionAttempt({ + physicalId: result.physicalId, + resourceType: result.resourceType, + success: result.success, + error: result.error, + errorMessage: result.message, + timestamp: new Date().toISOString(), + }); + } + } + + return results; + } +} + +module.exports = CleanupOrphanedResourcesUseCase; diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.test.js new file mode 100644 index 000000000..bf0b9c430 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/cleanup-orphaned-resources-use-case.test.js @@ -0,0 +1,385 @@ +const CleanupOrphanedResourcesUseCase = require('./cleanup-orphaned-resources-use-case'); + +describe('CleanupOrphanedResourcesUseCase', () => { + let useCase; + let mockResourceDetector; + let mockDependencyAnalyzer; + let mockDeletionPlanner; + let mockDeleterRepository; + let mockAuditRepository; + + beforeEach(() => { + mockResourceDetector = { + findOrphanedResources: jest.fn(), + }; + + mockDependencyAnalyzer = { + analyzeDependencies: jest.fn(), + }; + + mockDeletionPlanner = { + createDeletionPlan: jest.fn(), + }; + + mockDeleterRepository = { + deleteResourceBatch: jest.fn(), + }; + + mockAuditRepository = { + logCleanupOperation: jest.fn().mockResolvedValue({ success: true }), + logDeletionAttempt: jest.fn().mockResolvedValue({ success: true }), + }; + + useCase = new CleanupOrphanedResourcesUseCase({ + resourceDetector: mockResourceDetector, + dependencyAnalyzer: mockDependencyAnalyzer, + deletionPlanner: mockDeletionPlanner, + deleterRepository: mockDeleterRepository, + auditRepository: mockAuditRepository, + }); + }); + + describe('constructor', () => { + it('should require resourceDetector', () => { + expect(() => { + new CleanupOrphanedResourcesUseCase({ + dependencyAnalyzer: mockDependencyAnalyzer, + deletionPlanner: mockDeletionPlanner, + deleterRepository: mockDeleterRepository, + auditRepository: mockAuditRepository, + }); + }).toThrow('resourceDetector is required'); + }); + + it('should require dependencyAnalyzer', () => { + expect(() => { + new CleanupOrphanedResourcesUseCase({ + resourceDetector: mockResourceDetector, + deletionPlanner: mockDeletionPlanner, + deleterRepository: mockDeleterRepository, + auditRepository: mockAuditRepository, + }); + }).toThrow('dependencyAnalyzer is required'); + }); + }); + + describe('execute - dry run mode', () => { + it('should return deletion plan without executing in dry-run mode', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const deletionPlan = { + totalResources: 2, + deletableCount: 2, + blockedCount: 0, + phases: { phase1: [], phase2: [], phase3: orphanedResources }, + costSavings: { monthly: 72, annual: 864 }, + }; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue(dependencyAnalysis); + mockDeletionPlanner.createDeletionPlan.mockReturnValue(deletionPlan); + + const result = await useCase.execute({ + stackIdentifier, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.deletionPlan).toEqual(deletionPlan); + expect(mockDeleterRepository.deleteResourceBatch).not.toHaveBeenCalled(); + }); + + it('should filter orphaned resources by resource type', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue({ + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }); + mockDeletionPlanner.createDeletionPlan.mockReturnValue({ + totalResources: 1, + deletableCount: 1, + blockedCount: 0, + }); + + await useCase.execute({ + stackIdentifier, + dryRun: true, + resourceTypeFilter: 'AWS::EC2::VPC', + }); + + expect(mockDependencyAnalyzer.analyzeDependencies).toHaveBeenCalledWith([ + orphanedResources[0], + ]); + }); + + it('should filter orphaned resources by logical ID pattern', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', logicalId: 'CustomVPC' }, + ]; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue({ + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }); + mockDeletionPlanner.createDeletionPlan.mockReturnValue({ + totalResources: 1, + deletableCount: 1, + blockedCount: 0, + }); + + await useCase.execute({ + stackIdentifier, + dryRun: true, + logicalIdPattern: 'Frigg*', + }); + + expect(mockDependencyAnalyzer.analyzeDependencies).toHaveBeenCalledWith([ + orphanedResources[0], + ]); + }); + + it('should return success message when no orphaned resources found', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + mockResourceDetector.findOrphanedResources.mockResolvedValue([]); + + const result = await useCase.execute({ + stackIdentifier, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('No orphaned resources found'); + }); + }); + + describe('execute - deletion mode', () => { + it('should execute deletion and log to audit trail', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const deletionPlan = { + totalResources: 1, + deletableCount: 1, + blockedCount: 0, + phases: { + phase1: [], + phase2: [orphanedResources[0]], + phase3: [], + }, + costSavings: { monthly: 0, annual: 0 }, + }; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue(dependencyAnalysis); + mockDeletionPlanner.createDeletionPlan.mockReturnValue(deletionPlan); + mockDeleterRepository.deleteResourceBatch.mockResolvedValue({ + successCount: 1, + failedCount: 0, + results: [{ success: true, physicalId: 'subnet-123' }], + }); + + const result = await useCase.execute({ + stackIdentifier, + dryRun: false, + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(result.failedCount).toBe(0); + expect(mockDeleterRepository.deleteResourceBatch).toHaveBeenCalled(); + expect(mockAuditRepository.logCleanupOperation).toHaveBeenCalled(); + }); + + it('should delete resources in correct phase order', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + { physicalId: 'vpce-123', resourceType: 'AWS::EC2::VPCEndpoint', logicalId: 'FriggVPCE' }, + ]; + + const deletionPlan = { + totalResources: 3, + deletableCount: 3, + blockedCount: 0, + phases: { + phase1: [orphanedResources[2]], + phase2: [orphanedResources[1]], + phase3: [orphanedResources[0]], + }, + costSavings: { monthly: 36, annual: 432 }, + }; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue({ + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }); + mockDeletionPlanner.createDeletionPlan.mockReturnValue(deletionPlan); + mockDeleterRepository.deleteResourceBatch.mockResolvedValue({ + successCount: 1, + failedCount: 0, + results: [], + }); + + await useCase.execute({ + stackIdentifier, + dryRun: false, + }); + + expect(mockDeleterRepository.deleteResourceBatch).toHaveBeenCalledTimes(3); + expect(mockDeleterRepository.deleteResourceBatch.mock.calls[0][0]).toEqual([ + orphanedResources[2], + ]); + expect(mockDeleterRepository.deleteResourceBatch.mock.calls[1][0]).toEqual([ + orphanedResources[1], + ]); + expect(mockDeleterRepository.deleteResourceBatch.mock.calls[2][0]).toEqual([ + orphanedResources[0], + ]); + }); + + it('should handle partial deletion failures', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet1' }, + { physicalId: 'subnet-456', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet2' }, + ]; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue({ + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }); + mockDeletionPlanner.createDeletionPlan.mockReturnValue({ + totalResources: 2, + deletableCount: 2, + blockedCount: 0, + phases: { + phase1: [], + phase2: orphanedResources, + phase3: [], + }, + }); + mockDeleterRepository.deleteResourceBatch.mockResolvedValue({ + successCount: 1, + failedCount: 1, + results: [ + { success: true, physicalId: 'subnet-123' }, + { success: false, physicalId: 'subnet-456', error: 'DependencyViolation' }, + ], + }); + + const result = await useCase.execute({ + stackIdentifier, + dryRun: false, + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(result.failedCount).toBe(1); + }); + + it('should invoke progress callback during deletion', async () => { + const stackIdentifier = { + stackName: 'test-stack', + region: 'us-east-1', + }; + + const orphanedResources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + mockResourceDetector.findOrphanedResources.mockResolvedValue(orphanedResources); + mockDependencyAnalyzer.analyzeDependencies.mockResolvedValue({ + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }); + mockDeletionPlanner.createDeletionPlan.mockReturnValue({ + totalResources: 1, + deletableCount: 1, + blockedCount: 0, + phases: { + phase1: [], + phase2: orphanedResources, + phase3: [], + }, + }); + mockDeleterRepository.deleteResourceBatch.mockResolvedValue({ + successCount: 1, + failedCount: 0, + results: [], + }); + + const progressCallback = jest.fn(); + + await useCase.execute({ + stackIdentifier, + dryRun: false, + onProgress: progressCallback, + }); + + expect(progressCallback).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.js b/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.js new file mode 100644 index 000000000..d8c3301c1 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.js @@ -0,0 +1,94 @@ +const ResourceDependencyAnalyzer = require('./resource-dependency-analyzer'); + +class ResourceDeletionPlanner { + createDeletionPlan({ resources, dependencyAnalysis }) { + const deletableResources = resources.filter( + (r) => + !dependencyAnalysis.blockedResources.some( + (b) => b.resource.physicalId === r.physicalId + ) + ); + + const dependencyAnalyzer = new ResourceDependencyAnalyzer({ + ec2Client: null, + elbClient: null, + rdsClient: null, + lambdaClient: null, + }); + + const deletionPhases = dependencyAnalyzer.determineDeletionOrder(deletableResources); + + const costSavings = this._calculateCostSavings(deletableResources); + const warnings = this._generateWarnings(resources, dependencyAnalysis); + const resourcesByType = this._countResourcesByType(deletableResources); + + return { + totalResources: resources.length, + deletableCount: deletableResources.length, + blockedCount: dependencyAnalysis.blockedResources.length, + phases: deletionPhases, + costSavings, + warnings, + blockedResources: dependencyAnalysis.blockedResources, + resourcesByType, + }; + } + + _calculateCostSavings(resources) { + let monthlyCost = 0; + + for (const resource of resources) { + switch (resource.resourceType) { + case 'AWS::EC2::VPC': + monthlyCost += 36; + break; + case 'AWS::EC2::NatGateway': + monthlyCost += 36; + break; + case 'AWS::EC2::EIP': + monthlyCost += 3.65; + break; + } + } + + return { + monthly: monthlyCost, + annual: monthlyCost * 12, + }; + } + + _generateWarnings(resources, dependencyAnalysis) { + const warnings = [ + 'This operation cannot be easily undone', + 'Resources will be permanently deleted from AWS', + 'Verify no applications depend on these resources', + ]; + + if (resources.some((r) => r.resourceType === 'AWS::EC2::VPC')) { + warnings.push('Deleting VPCs will also delete associated default resources'); + } + + if (dependencyAnalysis.blockedResources.length > 0) { + warnings.push( + `${dependencyAnalysis.blockedResources.length} resources cannot be deleted due to dependencies` + ); + } + + return warnings; + } + + _countResourcesByType(resources) { + const counts = {}; + + for (const resource of resources) { + if (!counts[resource.resourceType]) { + counts[resource.resourceType] = 0; + } + counts[resource.resourceType]++; + } + + return counts; + } +} + +module.exports = ResourceDeletionPlanner; diff --git a/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.test.js b/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.test.js new file mode 100644 index 000000000..dfd02b737 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/resource-deletion-planner.test.js @@ -0,0 +1,263 @@ +const ResourceDeletionPlanner = require('./resource-deletion-planner'); + +describe('ResourceDeletionPlanner', () => { + let planner; + + beforeEach(() => { + planner = new ResourceDeletionPlanner(); + }); + + describe('createDeletionPlan', () => { + it('should create plan with all deletable resources when no blocking dependencies', () => { + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + { physicalId: 'sg-123', resourceType: 'AWS::EC2::SecurityGroup', logicalId: 'FriggSG' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: { + 'subnet-123': { hasBlockingDependencies: false, blocking: [], dependent: [] }, + 'sg-123': { hasBlockingDependencies: false, blocking: [], dependent: [] }, + }, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.totalResources).toBe(2); + expect(plan.deletableCount).toBe(2); + expect(plan.blockedCount).toBe(0); + expect(plan.phases.phase2).toHaveLength(2); + }); + + it('should filter out blocked resources from deletion plan', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: false, + blockedResources: [ + { + resource: resources[0], + blockingDependencies: [{ type: 'subnets', count: 1, ids: ['subnet-456'] }], + }, + ], + dependencies: { + 'vpc-123': { + hasBlockingDependencies: true, + blocking: [{ type: 'subnets', count: 1 }], + dependent: [], + }, + 'subnet-123': { hasBlockingDependencies: false, blocking: [], dependent: [] }, + }, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.totalResources).toBe(2); + expect(plan.deletableCount).toBe(1); + expect(plan.blockedCount).toBe(1); + expect(plan.phases.phase2).toHaveLength(1); + expect(plan.phases.phase2[0].physicalId).toBe('subnet-123'); + expect(plan.blockedResources).toHaveLength(1); + }); + + it('should organize resources into correct deletion phases', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + { physicalId: 'vpce-123', resourceType: 'AWS::EC2::VPCEndpoint', logicalId: 'FriggVPCE' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.phases.phase1).toHaveLength(1); + expect(plan.phases.phase1[0].resourceType).toBe('AWS::EC2::VPCEndpoint'); + expect(plan.phases.phase2).toHaveLength(1); + expect(plan.phases.phase2[0].resourceType).toBe('AWS::EC2::Subnet'); + expect(plan.phases.phase3).toHaveLength(1); + expect(plan.phases.phase3[0].resourceType).toBe('AWS::EC2::VPC'); + }); + + it('should calculate cost savings for VPC resources', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC2' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.costSavings.monthly).toBe(72); + expect(plan.costSavings.annual).toBe(864); + }); + + it('should calculate cost savings for NAT Gateways', () => { + const resources = [ + { physicalId: 'nat-123', resourceType: 'AWS::EC2::NatGateway', logicalId: 'FriggNAT' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.costSavings.monthly).toBe(36); + expect(plan.costSavings.annual).toBe(432); + }); + + it('should calculate cost savings for Elastic IPs', () => { + const resources = [ + { physicalId: 'eip-123', resourceType: 'AWS::EC2::EIP', logicalId: 'FriggEIP' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.costSavings.monthly).toBeCloseTo(3.65, 2); + }); + + it('should generate standard warnings', () => { + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.warnings).toContain('This operation cannot be easily undone'); + expect(plan.warnings).toContain('Resources will be permanently deleted from AWS'); + expect(plan.warnings).toContain('Verify no applications depend on these resources'); + }); + + it('should add VPC-specific warning when deleting VPCs', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.warnings).toContain( + 'Deleting VPCs will also delete associated default resources' + ); + }); + + it('should add warning when resources are blocked', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: false, + blockedResources: [ + { + resource: resources[0], + blockingDependencies: [{ type: 'subnets', count: 1 }], + }, + ], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.warnings).toContain( + '1 resources cannot be deleted due to dependencies' + ); + }); + + it('should handle empty resource list', () => { + const resources = []; + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.totalResources).toBe(0); + expect(plan.deletableCount).toBe(0); + expect(plan.blockedCount).toBe(0); + expect(plan.costSavings.monthly).toBe(0); + }); + + it('should handle mixed resource types with varying costs', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'nat-123', resourceType: 'AWS::EC2::NatGateway', logicalId: 'FriggNAT' }, + { physicalId: 'eip-123', resourceType: 'AWS::EC2::EIP', logicalId: 'FriggEIP' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + const expectedMonthlyCost = 36 + 36 + 3.65; + expect(plan.costSavings.monthly).toBeCloseTo(expectedMonthlyCost, 2); + expect(plan.costSavings.annual).toBeCloseTo(expectedMonthlyCost * 12, 2); + }); + }); + + describe('resource counting by type', () => { + it('should count resources by type in deletion plan', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet1' }, + { physicalId: 'subnet-456', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet2' }, + { physicalId: 'sg-123', resourceType: 'AWS::EC2::SecurityGroup', logicalId: 'FriggSG' }, + ]; + + const dependencyAnalysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const plan = planner.createDeletionPlan({ resources, dependencyAnalysis }); + + expect(plan.resourcesByType).toEqual({ + 'AWS::EC2::VPC': 1, + 'AWS::EC2::Subnet': 2, + 'AWS::EC2::SecurityGroup': 1, + }); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.js b/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.js new file mode 100644 index 000000000..b63bc3590 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.js @@ -0,0 +1,290 @@ +class ResourceDependencyAnalyzer { + constructor({ ec2Client, elbClient, rdsClient, lambdaClient }) { + if (!ec2Client) { + throw new Error('ec2Client is required'); + } + + this.ec2Client = ec2Client; + this.elbClient = elbClient; + this.rdsClient = rdsClient; + this.lambdaClient = lambdaClient; + } + + async analyzeDependencies(resources) { + const analysis = { + canDeleteAll: true, + blockedResources: [], + dependencies: {}, + }; + + const dependencyPromises = resources.map(async (resource) => { + const deps = await this._checkResourceDependencies(resource); + analysis.dependencies[resource.physicalId] = deps; + + if (deps.hasBlockingDependencies) { + analysis.canDeleteAll = false; + analysis.blockedResources.push({ + resource, + blockingDependencies: deps.blocking, + }); + } + }); + + await Promise.all(dependencyPromises); + + return analysis; + } + + determineDeletionOrder(resources) { + const phases = { + phase1: [], + phase2: [], + phase3: [], + }; + + for (const resource of resources) { + const phase = this._getDeletionPhase(resource.resourceType); + phases[`phase${phase}`].push(resource); + } + + phases.phase1.sort(this._compareDeletionPriority); + phases.phase2.sort(this._compareDeletionPriority); + phases.phase3.sort(this._compareDeletionPriority); + + return phases; + } + + async _checkResourceDependencies(resource) { + switch (resource.resourceType) { + case 'AWS::EC2::VPC': + return await this._checkVpcDependencies(resource.physicalId); + case 'AWS::EC2::Subnet': + return await this._checkSubnetDependencies(resource.physicalId); + case 'AWS::EC2::SecurityGroup': + return await this._checkSecurityGroupDependencies(resource.physicalId); + default: + return { hasBlockingDependencies: false, blocking: [], dependent: [] }; + } + } + + async _checkVpcDependencies(vpcId) { + const { + DescribeSubnetsCommand, + DescribeSecurityGroupsCommand, + DescribeNatGatewaysCommand, + DescribeInternetGatewaysCommand, + DescribeVpcEndpointsCommand, + } = this._loadEC2Commands(); + + const [subnets, securityGroups, natGateways, igws, endpoints] = await Promise.all([ + this.ec2Client.send( + new DescribeSubnetsCommand({ + Filters: [{ Name: 'vpc-id', Values: [vpcId] }], + }) + ), + this.ec2Client.send( + new DescribeSecurityGroupsCommand({ + Filters: [{ Name: 'vpc-id', Values: [vpcId] }], + }) + ), + this.ec2Client.send( + new DescribeNatGatewaysCommand({ + Filter: [{ Name: 'vpc-id', Values: [vpcId] }], + }) + ), + this.ec2Client.send( + new DescribeInternetGatewaysCommand({ + Filters: [{ Name: 'attachment.vpc-id', Values: [vpcId] }], + }) + ), + this.ec2Client.send( + new DescribeVpcEndpointsCommand({ + Filters: [{ Name: 'vpc-id', Values: [vpcId] }], + }) + ), + ]); + + const blocking = []; + + const customSubnets = (subnets.Subnets || []).filter((s) => !s.DefaultForAz); + const customSGs = (securityGroups.SecurityGroups || []).filter((sg) => sg.GroupName !== 'default'); + + if (customSubnets.length > 0) { + blocking.push({ + type: 'subnets', + count: customSubnets.length, + ids: customSubnets.map((s) => s.SubnetId), + }); + } + if (customSGs.length > 0) { + blocking.push({ + type: 'security_groups', + count: customSGs.length, + ids: customSGs.map((sg) => sg.GroupId), + }); + } + if (natGateways.NatGateways?.length > 0) { + blocking.push({ type: 'nat_gateways', count: natGateways.NatGateways.length }); + } + if (igws.InternetGateways?.length > 0) { + blocking.push({ type: 'internet_gateways', count: igws.InternetGateways.length }); + } + if (endpoints.VpcEndpoints?.length > 0) { + blocking.push({ type: 'vpc_endpoints', count: endpoints.VpcEndpoints.length }); + } + + return { + hasBlockingDependencies: blocking.length > 0, + blocking, + dependent: [], + }; + } + + async _checkSubnetDependencies(subnetId) { + const { DescribeInstancesCommand } = this._loadEC2Commands(); + + const instances = await this.ec2Client.send( + new DescribeInstancesCommand({ + Filters: [{ Name: 'subnet-id', Values: [subnetId] }], + }) + ); + + const blocking = []; + const allInstances = (instances.Reservations || []).flatMap((r) => r.Instances || []); + + if (allInstances.length > 0) { + blocking.push({ + type: 'ec2_instances', + count: allInstances.length, + ids: allInstances.map((i) => i.InstanceId), + }); + } + + return { + hasBlockingDependencies: blocking.length > 0, + blocking, + dependent: [], + }; + } + + async _checkSecurityGroupDependencies(sgId) { + const { DescribeInstancesCommand } = this._loadEC2Commands(); + const { DescribeDBInstancesCommand } = this._loadRDSCommands(); + const { ListFunctionsCommand } = this._loadLambdaCommands(); + const { DescribeLoadBalancersCommand } = this._loadELBCommands(); + + const [ec2Instances, rdsInstances, lambdaFunctions, loadBalancers] = await Promise.all([ + this.ec2Client.send( + new DescribeInstancesCommand({ + Filters: [{ Name: 'instance.group-id', Values: [sgId] }], + }) + ), + this.rdsClient.send(new DescribeDBInstancesCommand({})), + this.lambdaClient.send(new ListFunctionsCommand({})), + this.elbClient.send(new DescribeLoadBalancersCommand({})), + ]); + + const blocking = []; + + const instances = (ec2Instances.Reservations || []).flatMap((r) => r.Instances || []); + if (instances.length > 0) { + blocking.push({ + type: 'ec2_instances', + count: instances.length, + ids: instances.map((i) => i.InstanceId), + }); + } + + const rdsUsingThisSG = (rdsInstances.DBInstances || []).filter((db) => + db.VpcSecurityGroups?.some((sg) => sg.VpcSecurityGroupId === sgId) + ); + if (rdsUsingThisSG.length > 0) { + blocking.push({ + type: 'rds_instances', + count: rdsUsingThisSG.length, + ids: rdsUsingThisSG.map((db) => db.DBInstanceIdentifier), + }); + } + + const lambdaUsingThisSG = (lambdaFunctions.Functions || []).filter((fn) => + fn.VpcConfig?.SecurityGroupIds?.includes(sgId) + ); + if (lambdaUsingThisSG.length > 0) { + blocking.push({ + type: 'lambda_functions', + count: lambdaUsingThisSG.length, + ids: lambdaUsingThisSG.map((fn) => fn.FunctionName), + }); + } + + return { + hasBlockingDependencies: blocking.length > 0, + blocking, + dependent: [], + }; + } + + _getDeletionPhase(resourceType) { + const phaseMap = { + 'AWS::EC2::VPCEndpoint': 1, + 'AWS::EC2::NatGateway': 2, + 'AWS::EC2::InternetGateway': 2, + 'AWS::EC2::RouteTable': 2, + 'AWS::EC2::NetworkAcl': 2, + 'AWS::EC2::Subnet': 2, + 'AWS::EC2::SecurityGroup': 2, + 'AWS::EC2::VPC': 3, + }; + + return phaseMap[resourceType] || 2; + } + + _compareDeletionPriority(a, b) { + const priorityMap = { + 'AWS::EC2::VPCEndpoint': 1, + 'AWS::EC2::NatGateway': 2, + 'AWS::EC2::InternetGateway': 3, + 'AWS::EC2::RouteTable': 4, + 'AWS::EC2::Subnet': 5, + 'AWS::EC2::SecurityGroup': 6, + 'AWS::EC2::VPC': 7, + }; + + return (priorityMap[a.resourceType] || 99) - (priorityMap[b.resourceType] || 99); + } + + _loadEC2Commands() { + const ec2 = require('@aws-sdk/client-ec2'); + return { + DescribeSubnetsCommand: ec2.DescribeSubnetsCommand, + DescribeSecurityGroupsCommand: ec2.DescribeSecurityGroupsCommand, + DescribeNatGatewaysCommand: ec2.DescribeNatGatewaysCommand, + DescribeInternetGatewaysCommand: ec2.DescribeInternetGatewaysCommand, + DescribeVpcEndpointsCommand: ec2.DescribeVpcEndpointsCommand, + DescribeInstancesCommand: ec2.DescribeInstancesCommand, + }; + } + + _loadRDSCommands() { + const rds = require('@aws-sdk/client-rds'); + return { + DescribeDBInstancesCommand: rds.DescribeDBInstancesCommand, + }; + } + + _loadLambdaCommands() { + const lambda = require('@aws-sdk/client-lambda'); + return { + ListFunctionsCommand: lambda.ListFunctionsCommand, + }; + } + + _loadELBCommands() { + const elb = require('@aws-sdk/client-elastic-load-balancing-v2'); + return { + DescribeLoadBalancersCommand: elb.DescribeLoadBalancersCommand, + }; + } +} + +module.exports = ResourceDependencyAnalyzer; diff --git a/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.test.js b/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.test.js new file mode 100644 index 000000000..2b96919c8 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/domain/services/resource-dependency-analyzer.test.js @@ -0,0 +1,337 @@ +const ResourceDependencyAnalyzer = require('./resource-dependency-analyzer'); + +describe('ResourceDependencyAnalyzer', () => { + let analyzer; + let mockEc2Client; + let mockElbClient; + let mockRdsClient; + let mockLambdaClient; + + beforeEach(() => { + mockEc2Client = { + send: jest.fn(), + }; + mockElbClient = { + send: jest.fn(), + }; + mockRdsClient = { + send: jest.fn(), + }; + mockLambdaClient = { + send: jest.fn(), + }; + + analyzer = new ResourceDependencyAnalyzer({ + ec2Client: mockEc2Client, + elbClient: mockElbClient, + rdsClient: mockRdsClient, + lambdaClient: mockLambdaClient, + }); + }); + + describe('constructor', () => { + it('should require ec2Client', () => { + expect(() => { + new ResourceDependencyAnalyzer({ + elbClient: mockElbClient, + rdsClient: mockRdsClient, + lambdaClient: mockLambdaClient, + }); + }).toThrow('ec2Client is required'); + }); + + it('should initialize with all required clients', () => { + expect(analyzer.ec2Client).toBe(mockEc2Client); + expect(analyzer.elbClient).toBe(mockElbClient); + expect(analyzer.rdsClient).toBe(mockRdsClient); + expect(analyzer.lambdaClient).toBe(mockLambdaClient); + }); + }); + + describe('analyzeDependencies', () => { + it('should return no blocking dependencies for resources without dependencies', async () => { + const resources = [ + { + physicalId: 'subnet-123', + resourceType: 'AWS::EC2::Subnet', + logicalId: 'FriggPrivateSubnet1', + }, + ]; + + mockEc2Client.send.mockResolvedValue({ + Instances: [], + }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(true); + expect(result.blockedResources).toHaveLength(0); + expect(result.dependencies['subnet-123']).toEqual({ + hasBlockingDependencies: false, + blocking: [], + dependent: [], + }); + }); + + it('should detect VPC with subnet dependencies', async () => { + const resources = [ + { + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + logicalId: 'FriggVPC', + }, + ]; + + mockEc2Client.send + .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-456', DefaultForAz: false }] }) + .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default', GroupName: 'default' }] }) + .mockResolvedValueOnce({ NatGateways: [] }) + .mockResolvedValueOnce({ InternetGateways: [] }) + .mockResolvedValueOnce({ VpcEndpoints: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(false); + expect(result.blockedResources).toHaveLength(1); + expect(result.blockedResources[0].resource.physicalId).toBe('vpc-123'); + expect(result.blockedResources[0].blockingDependencies).toEqual([ + { type: 'subnets', count: 1, ids: ['subnet-456'] }, + ]); + }); + + it('should detect security group in use by EC2 instances', async () => { + const resources = [ + { + physicalId: 'sg-123', + resourceType: 'AWS::EC2::SecurityGroup', + logicalId: 'FriggLambdaSecurityGroup', + }, + ]; + + mockEc2Client.send.mockResolvedValueOnce({ + Reservations: [ + { + Instances: [{ InstanceId: 'i-123', SecurityGroups: [{ GroupId: 'sg-123' }] }], + }, + ], + }); + + mockRdsClient.send.mockResolvedValue({ DBInstances: [] }); + mockLambdaClient.send.mockResolvedValue({ Functions: [] }); + mockElbClient.send.mockResolvedValue({ LoadBalancers: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(false); + expect(result.blockedResources).toHaveLength(1); + expect(result.dependencies['sg-123'].blocking).toEqual([ + { type: 'ec2_instances', count: 1, ids: ['i-123'] }, + ]); + }); + + it('should analyze multiple resources concurrently', async () => { + const resources = [ + { + physicalId: 'subnet-123', + resourceType: 'AWS::EC2::Subnet', + logicalId: 'FriggPrivateSubnet1', + }, + { + physicalId: 'subnet-456', + resourceType: 'AWS::EC2::Subnet', + logicalId: 'FriggPrivateSubnet2', + }, + ]; + + mockEc2Client.send.mockResolvedValue({ Instances: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(true); + expect(result.dependencies['subnet-123']).toBeDefined(); + expect(result.dependencies['subnet-456']).toBeDefined(); + }); + }); + + describe('determineDeletionOrder', () => { + it('should organize resources into deletion phases', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + { physicalId: 'sg-123', resourceType: 'AWS::EC2::SecurityGroup', logicalId: 'FriggSG' }, + { physicalId: 'vpce-123', resourceType: 'AWS::EC2::VPCEndpoint', logicalId: 'FriggVPCE' }, + ]; + + const plan = analyzer.determineDeletionOrder(resources); + + expect(plan.phase1).toHaveLength(1); + expect(plan.phase1[0].resourceType).toBe('AWS::EC2::VPCEndpoint'); + + expect(plan.phase2).toHaveLength(2); + expect(plan.phase2.map(r => r.resourceType)).toContain('AWS::EC2::Subnet'); + expect(plan.phase2.map(r => r.resourceType)).toContain('AWS::EC2::SecurityGroup'); + + expect(plan.phase3).toHaveLength(1); + expect(plan.phase3[0].resourceType).toBe('AWS::EC2::VPC'); + }); + + it('should sort resources within phases by priority', () => { + const resources = [ + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', logicalId: 'FriggVPC' }, + { physicalId: 'sg-123', resourceType: 'AWS::EC2::SecurityGroup', logicalId: 'FriggSG' }, + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet', logicalId: 'FriggSubnet' }, + ]; + + const plan = analyzer.determineDeletionOrder(resources); + + expect(plan.phase2[0].resourceType).toBe('AWS::EC2::Subnet'); + expect(plan.phase2[1].resourceType).toBe('AWS::EC2::SecurityGroup'); + }); + + it('should handle empty resource list', () => { + const plan = analyzer.determineDeletionOrder([]); + + expect(plan.phase1).toHaveLength(0); + expect(plan.phase2).toHaveLength(0); + expect(plan.phase3).toHaveLength(0); + }); + }); + + describe('VPC dependency checking', () => { + it('should ignore default subnets and security groups', async () => { + const resources = [ + { + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + logicalId: 'FriggVPC', + }, + ]; + + mockEc2Client.send + .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-456', DefaultForAz: true }] }) + .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-123', GroupName: 'default' }] }) + .mockResolvedValueOnce({ NatGateways: [] }) + .mockResolvedValueOnce({ InternetGateways: [] }) + .mockResolvedValueOnce({ VpcEndpoints: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(true); + expect(result.blockedResources).toHaveLength(0); + }); + + it('should detect NAT gateway dependencies', async () => { + const resources = [ + { + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + logicalId: 'FriggVPC', + }, + ]; + + mockEc2Client.send + .mockResolvedValueOnce({ Subnets: [] }) + .mockResolvedValueOnce({ SecurityGroups: [] }) + .mockResolvedValueOnce({ NatGateways: [{ NatGatewayId: 'nat-123', State: 'available' }] }) + .mockResolvedValueOnce({ InternetGateways: [] }) + .mockResolvedValueOnce({ VpcEndpoints: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(false); + expect(result.dependencies['vpc-123'].blocking).toContainEqual({ + type: 'nat_gateways', + count: 1, + }); + }); + }); + + describe('SecurityGroup dependency checking', () => { + it('should detect Lambda function dependencies', async () => { + const resources = [ + { + physicalId: 'sg-123', + resourceType: 'AWS::EC2::SecurityGroup', + logicalId: 'FriggLambdaSecurityGroup', + }, + ]; + + mockEc2Client.send.mockResolvedValue({ Reservations: [] }); + mockRdsClient.send.mockResolvedValue({ DBInstances: [] }); + mockLambdaClient.send.mockResolvedValue({ + Functions: [ + { + FunctionName: 'my-function', + VpcConfig: { + SecurityGroupIds: ['sg-123'], + }, + }, + ], + }); + mockElbClient.send.mockResolvedValue({ LoadBalancers: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(false); + expect(result.dependencies['sg-123'].blocking).toContainEqual({ + type: 'lambda_functions', + count: 1, + ids: ['my-function'], + }); + }); + + it('should detect RDS instance dependencies', async () => { + const resources = [ + { + physicalId: 'sg-123', + resourceType: 'AWS::EC2::SecurityGroup', + logicalId: 'FriggRDSSecurityGroup', + }, + ]; + + mockEc2Client.send.mockResolvedValue({ Reservations: [] }); + mockRdsClient.send.mockResolvedValue({ + DBInstances: [ + { + DBInstanceIdentifier: 'my-db', + VpcSecurityGroups: [{ VpcSecurityGroupId: 'sg-123' }], + }, + ], + }); + mockLambdaClient.send.mockResolvedValue({ Functions: [] }); + mockElbClient.send.mockResolvedValue({ LoadBalancers: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(false); + expect(result.dependencies['sg-123'].blocking).toContainEqual({ + type: 'rds_instances', + count: 1, + ids: ['my-db'], + }); + }); + }); + + describe('Subnet dependency checking', () => { + it('should return no dependencies for unused subnet', async () => { + const resources = [ + { + physicalId: 'subnet-123', + resourceType: 'AWS::EC2::Subnet', + logicalId: 'FriggPrivateSubnet1', + }, + ]; + + mockEc2Client.send.mockResolvedValue({ Instances: [] }); + + const result = await analyzer.analyzeDependencies(resources); + + expect(result.canDeleteAll).toBe(true); + expect(result.dependencies['subnet-123']).toEqual({ + hasBlockingDependencies: false, + blocking: [], + dependent: [], + }); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.js new file mode 100644 index 000000000..42f98967b --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +class AuditLogRepository { + constructor({ logDirectory } = {}) { + this.logDirectory = + logDirectory || path.join(os.homedir(), '.frigg', 'audit-logs'); + + if (!fs.existsSync(this.logDirectory)) { + fs.mkdirSync(this.logDirectory, { recursive: true }); + } + } + + async logCleanupOperation(operation) { + try { + const logEntry = { + timestamp: operation.timestamp, + stackIdentifier: operation.stackIdentifier, + dryRun: operation.dryRun || false, + deletionPlan: operation.deletionPlan, + result: operation.result, + }; + + const logLine = JSON.stringify(logEntry) + '\n'; + const logPath = this._getCleanupLogPath(); + + fs.appendFileSync(logPath, logLine, 'utf8'); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } + } + + async logDeletionAttempt(attempt) { + try { + const logEntry = { + timestamp: attempt.timestamp, + physicalId: attempt.physicalId, + resourceType: attempt.resourceType, + logicalId: attempt.logicalId, + success: attempt.success, + error: attempt.error, + errorMessage: attempt.errorMessage, + }; + + const logLine = JSON.stringify(logEntry) + '\n'; + const logPath = this._getDeletionLogPath(); + + fs.appendFileSync(logPath, logLine, 'utf8'); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } + } + + async getRecentOperations(limit = 100) { + try { + const logPath = this._getCleanupLogPath(); + + if (!fs.existsSync(logPath)) { + return []; + } + + const content = fs.readFileSync(logPath, 'utf8'); + const lines = content.trim().split('\n').filter(Boolean); + + const operations = []; + for (const line of lines) { + try { + operations.push(JSON.parse(line)); + } catch (error) { + continue; + } + } + + return operations.reverse().slice(0, limit); + } catch (error) { + return []; + } + } + + _getCleanupLogPath() { + return path.join(this.logDirectory, 'cleanup-operations.log'); + } + + _getDeletionLogPath() { + return path.join(this.logDirectory, 'deletion-attempts.log'); + } +} + +module.exports = AuditLogRepository; diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.test.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.test.js new file mode 100644 index 000000000..26ba17fd5 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/audit-log-repository.test.js @@ -0,0 +1,231 @@ +const AuditLogRepository = require('./audit-log-repository'); +const fs = require('fs'); +const path = require('path'); + +jest.mock('fs'); + +describe('AuditLogRepository', () => { + let repository; + const testLogDir = '/tmp/frigg-audit-logs'; + + beforeEach(() => { + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(true); + fs.mkdirSync.mockReturnValue(undefined); + fs.appendFileSync.mockReturnValue(undefined); + + repository = new AuditLogRepository({ + logDirectory: testLogDir, + }); + }); + + describe('constructor', () => { + it('should initialize with default log directory', () => { + fs.existsSync.mockReturnValue(true); + const repo = new AuditLogRepository({}); + + expect(repo.logDirectory).toContain('.frigg'); + expect(repo.logDirectory).toContain('audit-logs'); + }); + + it('should initialize with provided log directory', () => { + const repo = new AuditLogRepository({ + logDirectory: '/custom/path', + }); + + expect(repo.logDirectory).toBe('/custom/path'); + }); + + it('should create log directory if it does not exist', () => { + fs.existsSync.mockReturnValue(false); + + new AuditLogRepository({ + logDirectory: testLogDir, + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith(testLogDir, { recursive: true }); + }); + }); + + describe('logCleanupOperation', () => { + it('should log cleanup operation to file', async () => { + const operation = { + stackIdentifier: { + stackName: 'test-stack', + region: 'us-east-1', + accountId: '123456789012', + }, + deletionPlan: { + totalResources: 5, + deletableCount: 5, + blockedCount: 0, + }, + result: { + successCount: 5, + failedCount: 0, + }, + timestamp: '2025-10-27T10:30:00Z', + }; + + await repository.logCleanupOperation(operation); + + expect(fs.appendFileSync).toHaveBeenCalledTimes(1); + const [filepath, content] = fs.appendFileSync.mock.calls[0]; + + expect(filepath).toContain('cleanup-operations.log'); + expect(content).toContain('test-stack'); + expect(content).toContain('us-east-1'); + expect(content).toContain('"successCount":5'); + }); + + it('should include dry-run flag in log', async () => { + const operation = { + stackIdentifier: { + stackName: 'test-stack', + region: 'us-east-1', + }, + dryRun: true, + timestamp: '2025-10-27T10:30:00Z', + }; + + await repository.logCleanupOperation(operation); + + const [, content] = fs.appendFileSync.mock.calls[0]; + expect(content).toContain('"dryRun":true'); + }); + + it('should handle logging errors gracefully', async () => { + fs.appendFileSync.mockImplementation(() => { + throw new Error('Disk full'); + }); + + const operation = { + stackIdentifier: { stackName: 'test-stack' }, + timestamp: '2025-10-27T10:30:00Z', + }; + + await expect(repository.logCleanupOperation(operation)).resolves.toEqual({ + success: false, + error: 'Disk full', + }); + }); + + it('should return success when log is written', async () => { + const operation = { + stackIdentifier: { stackName: 'test-stack' }, + timestamp: '2025-10-27T10:30:00Z', + }; + + const result = await repository.logCleanupOperation(operation); + + expect(result.success).toBe(true); + }); + }); + + describe('logDeletionAttempt', () => { + it('should log individual deletion attempt', async () => { + const attempt = { + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + logicalId: 'FriggVPC', + success: true, + timestamp: '2025-10-27T10:30:00Z', + }; + + await repository.logDeletionAttempt(attempt); + + expect(fs.appendFileSync).toHaveBeenCalledTimes(1); + const [filepath, content] = fs.appendFileSync.mock.calls[0]; + + expect(filepath).toContain('deletion-attempts.log'); + expect(content).toContain('vpc-123'); + expect(content).toContain('AWS::EC2::VPC'); + expect(content).toContain('"success":true'); + }); + + it('should log failure with error details', async () => { + const attempt = { + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + success: false, + error: 'DependencyViolation', + errorMessage: 'The vpc has dependencies', + timestamp: '2025-10-27T10:30:00Z', + }; + + await repository.logDeletionAttempt(attempt); + + const [, content] = fs.appendFileSync.mock.calls[0]; + expect(content).toContain('"success":false'); + expect(content).toContain('DependencyViolation'); + expect(content).toContain('The vpc has dependencies'); + }); + }); + + describe('getRecentOperations', () => { + it('should read and parse recent operations from log file', async () => { + const logContent = + '{"timestamp":"2025-10-27T10:30:00Z","stackName":"test-stack-1"}\n' + + '{"timestamp":"2025-10-27T10:31:00Z","stackName":"test-stack-2"}\n' + + '{"timestamp":"2025-10-27T10:32:00Z","stackName":"test-stack-3"}\n'; + + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(logContent); + + const operations = await repository.getRecentOperations(2); + + expect(operations).toHaveLength(2); + expect(operations[0].stackName).toBe('test-stack-3'); + expect(operations[1].stackName).toBe('test-stack-2'); + }); + + it('should return empty array if log file does not exist', async () => { + fs.existsSync.mockReturnValue(false); + + const operations = await repository.getRecentOperations(10); + + expect(operations).toEqual([]); + }); + + it('should handle corrupted log entries', async () => { + const logContent = + '{"timestamp":"2025-10-27T10:30:00Z","stackName":"test-stack-1"}\n' + + 'CORRUPTED LINE\n' + + '{"timestamp":"2025-10-27T10:32:00Z","stackName":"test-stack-3"}\n'; + + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(logContent); + + const operations = await repository.getRecentOperations(10); + + expect(operations).toHaveLength(2); + expect(operations[0].stackName).toBe('test-stack-3'); + expect(operations[1].stackName).toBe('test-stack-1'); + }); + + it('should default to 100 operations if limit not specified', async () => { + const logLines = Array.from({ length: 150 }, (_, i) => + JSON.stringify({ timestamp: `2025-10-27T${String(i).padStart(2, '0')}:00:00Z`, index: i }) + ).join('\n'); + + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(logLines); + + const operations = await repository.getRecentOperations(); + + expect(operations.length).toBeLessThanOrEqual(100); + }); + }); + + describe('file paths', () => { + it('should use correct file path for cleanup operations', () => { + const expectedPath = path.join(testLogDir, 'cleanup-operations.log'); + expect(repository._getCleanupLogPath()).toBe(expectedPath); + }); + + it('should use correct file path for deletion attempts', () => { + const expectedPath = path.join(testLogDir, 'deletion-attempts.log'); + expect(repository._getDeletionLogPath()).toBe(expectedPath); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.js new file mode 100644 index 000000000..4dd953498 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.js @@ -0,0 +1,133 @@ +class ResourceDeleterRepository { + constructor({ ec2Client, region }) { + this.ec2Client = ec2Client; + this.region = region || process.env.AWS_REGION || 'us-east-1'; + } + + async deleteResource({ physicalId, resourceType }) { + try { + const deleteCommand = this._getDeleteCommand(resourceType, physicalId); + + if (!deleteCommand) { + return { + success: false, + physicalId, + resourceType, + error: `Unsupported resource type: ${resourceType}`, + retryable: false, + }; + } + + await this.ec2Client.send(deleteCommand); + + return { + success: true, + physicalId, + resourceType, + }; + } catch (error) { + return { + success: false, + physicalId, + resourceType, + error: error.name || error.Code || error.message, + message: error.message, + retryable: this._isRetryableError(error), + }; + } + } + + async deleteResourceBatch(resources, progressCallback) { + const results = []; + let successCount = 0; + let failedCount = 0; + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const result = await this.deleteResource(resource); + + results.push(result); + + if (result.success) { + successCount++; + } else { + failedCount++; + } + + if (progressCallback) { + progressCallback({ + current: i + 1, + total: resources.length, + physicalId: resource.physicalId, + resourceType: resource.resourceType, + success: result.success, + error: result.error, + }); + } + } + + return { + successCount, + failedCount, + results, + }; + } + + _getDeleteCommand(resourceType, physicalId) { + const { + DeleteVpcCommand, + DeleteSubnetCommand, + DeleteSecurityGroupCommand, + DeleteNatGatewayCommand, + DeleteInternetGatewayCommand, + DeleteRouteTableCommand, + DeleteVpcEndpointsCommand, + } = this._loadEC2Commands(); + + switch (resourceType) { + case 'AWS::EC2::VPC': + return new DeleteVpcCommand({ VpcId: physicalId }); + case 'AWS::EC2::Subnet': + return new DeleteSubnetCommand({ SubnetId: physicalId }); + case 'AWS::EC2::SecurityGroup': + return new DeleteSecurityGroupCommand({ GroupId: physicalId }); + case 'AWS::EC2::NatGateway': + return new DeleteNatGatewayCommand({ NatGatewayId: physicalId }); + case 'AWS::EC2::InternetGateway': + return new DeleteInternetGatewayCommand({ InternetGatewayId: physicalId }); + case 'AWS::EC2::RouteTable': + return new DeleteRouteTableCommand({ RouteTableId: physicalId }); + case 'AWS::EC2::VPCEndpoint': + return new DeleteVpcEndpointsCommand({ VpcEndpointIds: [physicalId] }); + default: + return null; + } + } + + _isRetryableError(error) { + const retryableErrors = [ + 'DependencyViolation', + 'RequestLimitExceeded', + 'ThrottlingException', + 'ServiceUnavailable', + ]; + + const errorCode = error.name || error.Code || ''; + return retryableErrors.some((retryable) => errorCode.includes(retryable)); + } + + _loadEC2Commands() { + const ec2 = require('@aws-sdk/client-ec2'); + return { + DeleteVpcCommand: ec2.DeleteVpcCommand, + DeleteSubnetCommand: ec2.DeleteSubnetCommand, + DeleteSecurityGroupCommand: ec2.DeleteSecurityGroupCommand, + DeleteNatGatewayCommand: ec2.DeleteNatGatewayCommand, + DeleteInternetGatewayCommand: ec2.DeleteInternetGatewayCommand, + DeleteRouteTableCommand: ec2.DeleteRouteTableCommand, + DeleteVpcEndpointsCommand: ec2.DeleteVpcEndpointsCommand, + }; + } +} + +module.exports = ResourceDeleterRepository; diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.test.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.test.js new file mode 100644 index 000000000..bb10e4889 --- /dev/null +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/resource-deleter-repository.test.js @@ -0,0 +1,286 @@ +const ResourceDeleterRepository = require('./resource-deleter-repository'); + +describe('ResourceDeleterRepository', () => { + let repository; + let mockEc2Client; + + beforeEach(() => { + mockEc2Client = { + send: jest.fn(), + }; + + repository = new ResourceDeleterRepository({ + ec2Client: mockEc2Client, + region: 'us-east-1', + }); + }); + + describe('constructor', () => { + it('should initialize with default region', () => { + const repo = new ResourceDeleterRepository({ + ec2Client: mockEc2Client, + }); + + expect(repo.region).toBe('us-east-1'); + }); + + it('should initialize with provided region', () => { + const repo = new ResourceDeleterRepository({ + ec2Client: mockEc2Client, + region: 'eu-west-1', + }); + + expect(repo.region).toBe('eu-west-1'); + }); + }); + + describe('deleteResource', () => { + it('should delete VPC successfully', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const result = await repository.deleteResource({ + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + }); + + expect(result.success).toBe(true); + expect(result.physicalId).toBe('vpc-123'); + expect(mockEc2Client.send).toHaveBeenCalledTimes(1); + }); + + it('should delete Subnet successfully', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const result = await repository.deleteResource({ + physicalId: 'subnet-123', + resourceType: 'AWS::EC2::Subnet', + }); + + expect(result.success).toBe(true); + expect(result.physicalId).toBe('subnet-123'); + }); + + it('should delete SecurityGroup successfully', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const result = await repository.deleteResource({ + physicalId: 'sg-123', + resourceType: 'AWS::EC2::SecurityGroup', + }); + + expect(result.success).toBe(true); + expect(result.physicalId).toBe('sg-123'); + }); + + it('should handle DependencyViolation error gracefully', async () => { + const error = new Error('DependencyViolation'); + error.name = 'DependencyViolation'; + error.Code = 'DependencyViolation'; + mockEc2Client.send.mockRejectedValue(error); + + const result = await repository.deleteResource({ + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('DependencyViolation'); + expect(result.retryable).toBe(true); + }); + + it('should handle ResourceNotFound error', async () => { + const error = new Error('InvalidVpcID.NotFound'); + error.name = 'InvalidVpcID.NotFound'; + mockEc2Client.send.mockRejectedValue(error); + + const result = await repository.deleteResource({ + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('InvalidVpcID.NotFound'); + expect(result.retryable).toBe(false); + }); + + it('should handle UnauthorizedOperation error', async () => { + const error = new Error('UnauthorizedOperation'); + error.name = 'UnauthorizedOperation'; + mockEc2Client.send.mockRejectedValue(error); + + const result = await repository.deleteResource({ + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('UnauthorizedOperation'); + expect(result.retryable).toBe(false); + }); + + it('should handle unsupported resource type', async () => { + const result = await repository.deleteResource({ + physicalId: 'resource-123', + resourceType: 'AWS::S3::Bucket', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unsupported resource type'); + expect(mockEc2Client.send).not.toHaveBeenCalled(); + }); + }); + + describe('deleteResourceBatch', () => { + it('should delete multiple resources successfully', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet' }, + { physicalId: 'subnet-456', resourceType: 'AWS::EC2::Subnet' }, + ]; + + const result = await repository.deleteResourceBatch(resources); + + expect(result.successCount).toBe(2); + expect(result.failedCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(mockEc2Client.send).toHaveBeenCalledTimes(2); + }); + + it('should handle partial failures', async () => { + mockEc2Client.send + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('DependencyViolation')); + + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet' }, + { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC' }, + ]; + + const result = await repository.deleteResourceBatch(resources); + + expect(result.successCount).toBe(1); + expect(result.failedCount).toBe(1); + expect(result.results[0].success).toBe(true); + expect(result.results[1].success).toBe(false); + }); + + it('should delete resources sequentially for safety', async () => { + const deletionOrder = []; + mockEc2Client.send.mockImplementation((command) => { + deletionOrder.push(command.input.SubnetId || command.input.VpcId); + return Promise.resolve({}); + }); + + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet' }, + { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC' }, + ]; + + await repository.deleteResourceBatch(resources); + + expect(deletionOrder).toEqual(['subnet-123', 'vpc-456']); + }); + + it('should handle empty batch', async () => { + const result = await repository.deleteResourceBatch([]); + + expect(result.successCount).toBe(0); + expect(result.failedCount).toBe(0); + expect(result.results).toHaveLength(0); + }); + }); + + describe('VPC deletion', () => { + it('should use DeleteVpcCommand with correct parameters', async () => { + mockEc2Client.send.mockResolvedValue({}); + + await repository.deleteResource({ + physicalId: 'vpc-123', + resourceType: 'AWS::EC2::VPC', + }); + + expect(mockEc2Client.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { VpcId: 'vpc-123' }, + }) + ); + }); + }); + + describe('Subnet deletion', () => { + it('should use DeleteSubnetCommand with correct parameters', async () => { + mockEc2Client.send.mockResolvedValue({}); + + await repository.deleteResource({ + physicalId: 'subnet-123', + resourceType: 'AWS::EC2::Subnet', + }); + + expect(mockEc2Client.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { SubnetId: 'subnet-123' }, + }) + ); + }); + }); + + describe('SecurityGroup deletion', () => { + it('should use DeleteSecurityGroupCommand with correct parameters', async () => { + mockEc2Client.send.mockResolvedValue({}); + + await repository.deleteResource({ + physicalId: 'sg-123', + resourceType: 'AWS::EC2::SecurityGroup', + }); + + expect(mockEc2Client.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { GroupId: 'sg-123' }, + }) + ); + }); + }); + + describe('progress callback', () => { + it('should invoke progress callback during batch deletion', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const progressCallback = jest.fn(); + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet' }, + { physicalId: 'subnet-456', resourceType: 'AWS::EC2::Subnet' }, + ]; + + await repository.deleteResourceBatch(resources, progressCallback); + + expect(progressCallback).toHaveBeenCalledWith( + expect.objectContaining({ + current: 1, + total: 2, + physicalId: 'subnet-123', + success: true, + }) + ); + + expect(progressCallback).toHaveBeenCalledWith( + expect.objectContaining({ + current: 2, + total: 2, + physicalId: 'subnet-456', + success: true, + }) + ); + }); + + it('should not fail if progress callback is not provided', async () => { + mockEc2Client.send.mockResolvedValue({}); + + const resources = [ + { physicalId: 'subnet-123', resourceType: 'AWS::EC2::Subnet' }, + ]; + + await expect(repository.deleteResourceBatch(resources)).resolves.toBeDefined(); + }); + }); +});