diff --git a/cmd/main.go b/cmd/main.go index f9d1c4536..6c68b38db 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -51,7 +51,6 @@ type Config struct { Regions []string Services []string ExcludeAccounts []string - Providers []string IncludeRegions []string ExcludeRegions []string IncludeInstanceTypes []string diff --git a/cmd/multi_service.go b/cmd/multi_service.go index 59762d150..bdab60015 100644 --- a/cmd/multi_service.go +++ b/cmd/multi_service.go @@ -960,38 +960,6 @@ func requireFilterInputs(recs []common.Recommendation, cfg Config, engineData en return requireResolvedAccountNames(recs, &cfg) } -// processService processes a single service and returns recommendations and results. -// Used by legacy callers; new code should use fetchAllRecs + executePurchasePipeline. -func processService(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, accountCache *AccountAliasCache, service common.ServiceType, isDryRun bool, cfg Config, engineData engineVersionData) ([]common.Recommendation, []common.PurchaseResult) { //nolint:unparam // engineData always nil at current callsites but param is part of the API - regionsToProcess, err := determineRegionsForService(ctx, recClient, service, cfg) - if err != nil { - log.Printf("❌ Failed to determine regions: %v", err) - return nil, nil - } - - serviceRecs := make([]common.Recommendation, 0) - serviceResults := make([]common.PurchaseResult, 0) - - for i, region := range regionsToProcess { - // Legacy single-service entry point — no coverage map is fetched here, - // so sizing falls back to the no-existing-commitments formula. The new - // path (runToolMultiService) fetches coverage once and threads it through. - regionResult := processRegionRecommendations( - ctx, awsCfg, recClient, accountCache, - service, region, i+1, len(regionsToProcess), - engineData, isDryRun, cfg, nil, - ) - serviceRecs = append(serviceRecs, regionResult.recommendations...) - serviceResults = append(serviceResults, regionResult.results...) - if regionResult.auditErr != nil { - log.Printf("%s", auditStopMessage(len(serviceResults), len(serviceRecs), serviceResults, regionResult.auditErr)) - break - } - } - - return serviceRecs, serviceResults -} - // processPurchaseLoop processes purchases for a single region (used by CSV // mode). runID groups every recommendation processed across the whole CSV // run into one audit trail, matching how executePurchasePipeline (the main diff --git a/cmd/multi_service_coverage_test.go b/cmd/multi_service_coverage_test.go index 1cb30e5e2..813fc874d 100644 --- a/cmd/multi_service_coverage_test.go +++ b/cmd/multi_service_coverage_test.go @@ -13,7 +13,6 @@ import ( awsrds "github.com/aws/aws-sdk-go-v2/service/rds" rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -624,98 +623,6 @@ func TestValidateFlags_CSVPaths(t *testing.T) { }) } -// ==================== Tests for processService Error Paths ==================== - -func TestProcessService_GetRegionsError(t *testing.T) { - ctx := context.Background() - awsCfg := aws.Config{Region: "us-east-1"} - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 3 - toolCfg.Regions = []string{} // Empty - will trigger auto-discovery - - mockClient := &MockRecommendationsClient{} - accountCache := NewAccountAliasCache(awsCfg) - - // This test verifies behavior when region discovery is needed - // Since getAllAWSRegions requires real AWS config, we test with explicit regions instead - toolCfg.Regions = []string{"us-east-1"} - - // Setup mock to return empty recommendations - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return([]common.Recommendation{}, nil) - - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) - - // Should return empty recommendations - assert.Empty(t, recs) - assert.Empty(t, results) - - mockClient.AssertExpectations(t) -} - -func TestProcessService_GetRecommendationsError(t *testing.T) { - ctx := context.Background() - awsCfg := aws.Config{Region: "us-east-1"} - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 1 - toolCfg.Regions = []string{"us-east-1"} - - mockClient := &MockRecommendationsClient{} - accountCache := NewAccountAliasCache(awsCfg) - - // Setup mock to return error - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return([]common.Recommendation(nil), errors.New("API error")) - - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceEC2, true, toolCfg, engineVersionData{}) - - // Should continue with empty results after error - assert.Empty(t, recs) - assert.Empty(t, results) - - mockClient.AssertExpectations(t) -} - -func TestProcessService_AllRecommendationsFilteredOut(t *testing.T) { - ctx := context.Background() - awsCfg := aws.Config{Region: "us-east-1"} - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "no-upfront" - toolCfg.TermYears = 1 - toolCfg.Regions = []string{"us-east-1"} - toolCfg.IncludeInstanceTypes = []string{"db.r5.large"} // Filter to specific type - - mockClient := &MockRecommendationsClient{} - accountCache := NewAccountAliasCache(awsCfg) - - // Return recommendations that don't match the filter - mockRecs := []common.Recommendation{ - {ResourceType: "db.t3.small", Count: 5, Region: "us-east-1", EstimatedSavings: 100}, - {ResourceType: "db.t3.medium", Count: 3, Region: "us-east-1", EstimatedSavings: 200}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil) - - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) - - // All recommendations should be filtered out - assert.Empty(t, recs) - assert.Empty(t, results) - - mockClient.AssertExpectations(t) -} - // ==================== Tests for filterAndAdjustRecommendations Edge Cases ==================== func TestFilterAndAdjustRecommendations_ZeroCoverage(t *testing.T) { diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index 4cdc8798a..ec1f8acc8 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "maps" "slices" "sort" @@ -18,7 +17,6 @@ import ( azureprovider "github.com/LeanerCloud/cloud-commitments-go/providers/azure" "github.com/aws/aws-sdk-go-v2/aws" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/google/uuid" ) // EC2ClientInterface defines the interface for EC2 operations. @@ -137,11 +135,6 @@ func discoverRegionsForService(ctx context.Context, client provider.Recommendati return regions, nil } -// applyCommonCoverage applies coverage percentage to recommendations. -func applyCommonCoverage(recs []common.Recommendation, coverage float64) []common.Recommendation { - return ApplyCoverage(recs, coverage) -} - // determineServicesToProcess returns the list of services to process based on flags. func determineServicesToProcess(cfg Config) ([]common.ServiceType, error) { var services []common.ServiceType @@ -458,118 +451,6 @@ func requireRegionInventory(recs []common.Recommendation, cfg Config, data engin return fmt.Errorf("extended-support exclusion cannot be applied: RDS inventory unavailable in %s (re-run, or pass --include-extended-support to skip the check)", describeFailedRegions(affected)) } -// regionRecommendations holds the processed recommendations for a single region. -type regionRecommendations struct { - recommendations []common.Recommendation - results []common.PurchaseResult - auditErr error // errAuditTrailLost: stop buying -} - -// processRegionRecommendations fetches and processes recommendations for a single region. -func processRegionRecommendations( - ctx context.Context, - awsCfg aws.Config, - recClient provider.RecommendationsClient, - accountCache *AccountAliasCache, - service common.ServiceType, - region string, - regionIndex, totalRegions int, - engineData engineVersionData, - isDryRun bool, - cfg Config, - coverageMap recommendations.PoolCoverageMap, -) regionRecommendations { - result := regionRecommendations{ - recommendations: make([]common.Recommendation, 0), - results: make([]common.PurchaseResult, 0), - } - - AppLogger.Printf("\n 📍 [%d/%d] Region: %s\n", regionIndex, totalRegions, region) - - // Fetch recommendations - recs, fetchErr := fetchRecommendationsForRegion(ctx, recClient, service, region, cfg) - if fetchErr != nil { - // Legacy test-only path: the CLI run goes through fetchAndFilterRegionRecs, - // which returns this error to the caller. - AppLogger.Printf(" ❌ Failed to fetch recommendations: %v\n", fetchErr) - return result - } - if len(recs) == 0 { - AppLogger.Printf(" â„šī¸ No recommendations found\n") - return result - } - - AppLogger.Printf(" ✅ Found %d recommendations\n", len(recs)) - - // Populate account names - if err := populateRecommendationAccountNames(ctx, recs, accountCache, cfg); err != nil { - AppLogger.Printf(" ❌ %v\n", err) - return result - } - - // Apply filters (drops not tracked on this legacy test-only path). - recs = applyRegionFilters(recs, engineData, region, cfg, nil) - if len(recs) == 0 { - AppLogger.Printf(" â„šī¸ No recommendations after applying filters\n") - return result - } - - // Apply coverage and overrides. This legacy path (test-only) doesn't - // fetch expiring commitments — expiry-aware sizing only runs via the - // main pipeline in fetchAndFilterRegionRecs, which has access to the - // regional service client at the right moment. Drop tracking skipped (nil). - // nil commitments: no expiry deduction, so family sizing is identical to - // ApplyFamilyNUSizingRDS (documented on applyCoverageAndOverrides). - filteredRecs := applyCoverageAndOverrides(recs, cfg, coverageMap, nil, nil) - - result.recommendations = filteredRecs - - // --max-instances is a run-wide cap, and this legacy per-region entry point - // has no view of the other services and regions in the run, so it cannot - // evaluate the cap. Refuse to spend rather than purchase uncapped: an - // over-purchase of reserved capacity is not reversible. Dry runs continue - // so the recommendations are still reported. - // - // This is deliberately the first thing after the recommendations are - // recorded, ahead of building the service client and of the duplicate - // check. The decision depends only on cfg.MaxInstances and isDryRun, both - // already known, so reaching it through a cloud API call would be work - // done to arrive at an answer that was already determined -- and it would - // make the refusal path fail differently depending on whether the describe - // call happened to succeed. - if cfg.MaxInstances > 0 && !isDryRun { - log.Printf("❌ Refusing to purchase %s/%s: --max-instances is a run-wide cap and cannot be enforced on the per-region path. Use the multi-service pipeline (the default entry point).", - getServiceDisplayName(service), region) - return result - } - - // Get service client and process purchases - regionalCfg := awsCfg.Copy() - regionalCfg.Region = region - serviceClient := createServiceClient(service, regionalCfg) - - if serviceClient == nil { - AppLogger.Printf(" âš ī¸ Service client not yet implemented for %s\n", getServiceDisplayName(service)) - AppLogger.Printf(" (Skipping purchase phase for this service)\n") - return result - } - - // Check for duplicate RIs. Drop tracking skipped (nil). - adjustedRecs := checkDuplicates(ctx, filteredRecs, serviceClient, isDryRun, nil) - - // Process purchases. This legacy per-region entry point has no run-wide - // runID of its own (unlike runToolMultiService/runToolFromCSV, which mint - // one per invocation), so each call gets its own -- every recommendation - // it processes still ends up in cfg.AuditLog with a durable, groupable - // record; it is simply not grouped with a sibling region's run. - runID := uuid.New().String() - regionResults, auditErr := processPurchaseLoop(ctx, adjustedRecs, region, isDryRun, serviceClient, cfg, runID) - result.results = regionResults - result.auditErr = auditErr - - return result -} - // errRecommendationFetch marks a failure to obtain recommendations from the // provider, as opposed to a successful but empty answer. var errRecommendationFetch = errors.New("recommendation fetch failed") @@ -786,9 +667,8 @@ func logFamilyExpiry(expiry recommendations.FamilyExpiryReport, windowDays, look // purchase run: that would buy reserved capacity the account already owns. // isDryRun selects the behavior on error: a dry run logs a loud warning and // continues (nothing is bought, so reporting fidelity wins), while a purchase -// run refuses to spend and drops these recommendations entirely, mirroring -// the "refuse to spend rather than purchase uncapped" stance already taken -// for --max-instances in processRegionRecommendations. +// run refuses to spend and drops these recommendations entirely: an +// over-purchase of reserved capacity is not reversible. // // drops accumulates per-reason drop counts for the end-of-run summary; pass nil to skip. // diff --git a/cmd/multi_service_helpers_test.go b/cmd/multi_service_helpers_test.go index 8f0a740d8..8e54b640a 100644 --- a/cmd/multi_service_helpers_test.go +++ b/cmd/multi_service_helpers_test.go @@ -223,7 +223,7 @@ func TestGetServiceDisplayName(t *testing.T) { } } -func TestApplyCommonCoverage(t *testing.T) { +func TestApplyCoverage_FloorsPerRecommendationCounts(t *testing.T) { recs := []common.Recommendation{ {Count: 10, EstimatedSavings: 100}, {Count: 5, EstimatedSavings: 50}, @@ -264,7 +264,7 @@ func TestApplyCommonCoverage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := applyCommonCoverage(recs, tt.coverage) + result := ApplyCoverage(recs, tt.coverage) assert.Equal(t, tt.expectedCount, len(result)) for i, rec := range result { @@ -883,3 +883,17 @@ func TestDetermineServicesToProcess_SavingsPlanTypeFilters(t *testing.T) { }) } } + +// Moved from the deleted TestProcessService_WithOverrideCount: the override is +// applied by applyCoverageAndOverrides, which the live pipeline also calls. +func TestApplyCoverageAndOverrides_OverrideCount(t *testing.T) { + recs := []common.Recommendation{ + {ResourceType: "cache.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, + {ResourceType: "cache.t3.small", Count: 5, Region: "us-east-1", EstimatedSavings: 200}, + } + got := applyCoverageAndOverrides(recs, Config{Coverage: 100, OverrideCount: 3}, nil, nil, nil) + assert.Len(t, got, 2) + for _, rec := range got { + assert.Equal(t, 3, rec.Count) + } +} diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index 8e103e588..bdf653803 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -208,340 +208,6 @@ func TestProcessService_EdgeCases(t *testing.T) { } } -// TestProcessServiceWithMocks tests the processService function using mocks. -func TestProcessServiceWithMocks(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - awsCfg, awsStub := newAWSFixtureConfig(t) - // The real-purchase case below relies on a failed duplicate check refusing - // the purchase (fail closed); an empty fixture would instead let it reach - // the purchase call. - awsStub.fixtures["elasticache DescribeReservedCacheNodes"] = awsFixture{ - status: http.StatusForbidden, - body: `{"__type":"AccessDeniedException","message":"denied by test"}`, - } - - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - setupFunc func() - name string - service common.ServiceType - testRegions []string - mockRecs []common.Recommendation - isDryRun bool - }{ - { - name: "RDS dry run with recommendations", - service: common.ServiceRDS, - isDryRun: true, - testRegions: []string{"us-east-1"}, - mockRecs: []common.Recommendation{ - {ResourceType: "db.t3.micro", Count: 2, Region: "us-east-1", EstimatedSavings: 100}, - {ResourceType: "db.t3.small", Count: 1, Region: "us-east-1", EstimatedSavings: 200}, - }, - setupFunc: func() { - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 3 - }, - }, - { - name: "EC2 with no recommendations", - service: common.ServiceEC2, - isDryRun: true, - testRegions: []string{"us-west-2"}, - mockRecs: []common.Recommendation{}, - setupFunc: func() { - toolCfg.Coverage = 80.0 - toolCfg.PaymentOption = "no-upfront" - toolCfg.TermYears = 1 - }, - }, - { - name: "ElastiCache with 50% coverage", - service: common.ServiceElastiCache, - isDryRun: false, - testRegions: []string{"eu-west-1"}, - mockRecs: []common.Recommendation{ - {ResourceType: "cache.t3.micro", Count: 3, Region: "eu-west-1", EstimatedSavings: 150}, - {ResourceType: "cache.t3.small", Count: 2, Region: "eu-west-1", EstimatedSavings: 250}, - }, - setupFunc: func() { - toolCfg.Coverage = 50.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 3 - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.setupFunc() - - // Create mock client - mockClient := &MockRecommendationsClient{} - - // Setup expectations - for range tt.testRegions { - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(tt.mockRecs, nil) - } - - // Set regions in toolCfg for this test - toolCfg.Regions = tt.testRegions - - // Now we can use the actual function directly since it accepts an interface - accountCache := NewAccountAliasCache(awsCfg) - recs, results := processService(ctx, awsCfg, mockClient, accountCache, tt.service, tt.isDryRun, toolCfg, engineVersionData{}) - - if len(tt.mockRecs) > 0 { - // Should have recommendations based on coverage - expectedCount := int(float64(len(tt.mockRecs)) * toolCfg.Coverage / 100.0) - if expectedCount > 0 { - assert.NotEmpty(t, recs) - assert.LessOrEqual(t, len(recs), len(tt.mockRecs)) - } else { - assert.Empty(t, recs) - } - - // Check results for dry run - if tt.isDryRun && len(recs) > 0 { - assert.Equal(t, len(recs), len(results)) - for _, result := range results { - assert.True(t, result.Success) - assert.Nil(t, result.Error) // Dry runs are successful, so no error - assert.True(t, result.DryRun) - } - } - } else { - assert.Empty(t, recs) - assert.Empty(t, results) - } - if !tt.isDryRun { - assert.Empty(t, results, "a failed duplicate check must refuse a real purchase") - } - - mockClient.AssertExpectations(t) - }) - } - - awsStub.requireRequests(t, map[string]int{ - "rds.us-east-1.amazonaws.com DescribeReservedDBInstances": 1, - "elasticache.eu-west-1.amazonaws.com DescribeReservedCacheNodes": 1, - }) -} - -func TestProcessService_SavingsPlansAccountLevel(t *testing.T) { - ctx := context.Background() - awsCfg, awsStub := newAWSFixtureConfig(t) - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 3 - toolCfg.Regions = []string{} // Empty - should auto-detect for Savings Plans - - mockClient := &MockRecommendationsClient{} - - // Savings Plans should only query us-east-1 once (account-level). Use the - // per-plan-type Compute slug now that the legacy umbrella has been - // retired from createServiceClient dispatch. - mockRecs := []common.Recommendation{ - {Service: common.ServiceSavingsPlansCompute, ResourceType: "ComputeSP", Count: 1, Region: "us-east-1", EstimatedSavings: 1000}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil) - - accountCache := NewAccountAliasCache(awsCfg) - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceSavingsPlansCompute, true, toolCfg, engineVersionData{}) - - // Should get recommendations - assert.NotEmpty(t, recs) - assert.NotEmpty(t, results) - - // Verify Savings Plans queried only once - mockClient.AssertNumberOfCalls(t, "GetRecommendations", 1) - mockClient.AssertExpectations(t) - awsStub.requireRequests(t, map[string]int{"savingsplans.amazonaws.com DescribeSavingsPlans": 1}) -} - -func TestProcessService_WithInstanceLimit(t *testing.T) { - // --max-instances is a run-wide cap, so the per-region path deliberately - // does NOT apply it (that was #1608: applying it here capped each - // service/region pair independently). This legacy entry point therefore - // returns the uncapped recommendations; the cap is enforced once by - // scoreLimitAndDisplay on the real pipeline, covered by - // TestMaxInstancesCapsWholeRunAcrossServicesAndRegions. - // - // This run is a dry run, so the fail-closed purchase guard does not fire; - // TestProcessService_InstanceLimitRefusesRealPurchase covers that. - - ctx := context.Background() - awsCfg, awsStub := newAWSFixtureConfig(t) - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 1 - toolCfg.Regions = []string{"us-east-1"} - toolCfg.MaxInstances = 15 // Limit to 15 total instances - - mockClient := &MockRecommendationsClient{} - - mockRecs := []common.Recommendation{ - {ResourceType: "db.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, - {ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", EstimatedSavings: 200}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil) - - accountCache := NewAccountAliasCache(awsCfg) - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) - - assert.Len(t, recs, 2, "Should return recommendations") - - // Assert on the *results*, not on recs. processRegionRecommendations assigns - // result.recommendations before the cap block and did so pre-fix too, so a - // count taken from recs passes either way and would be a vacuous guard. - // The dry-run results carry the recommendations that were actually handed - // to processPurchaseLoop, which is what the cap used to shrink: pre-fix - // these totalled 15 (10 + 5 truncated), post-fix they total the full 20. - resultInstances := 0 - for i := range results { - resultInstances += results[i].Recommendation.Count - } - assert.Len(t, results, 2) - assert.Equal(t, 20, resultInstances, - "the per-region path must not apply --max-instances; the cap is run-wide") - - mockClient.AssertExpectations(t) - awsStub.requireDuplicateChecks(t, 1) -} - -// TestProcessService_InstanceLimitRefusesRealPurchase pins the fail-closed -// contract on the legacy per-region entry point: it cannot see the other -// services and regions in the run, so it cannot evaluate the run-wide -// --max-instances cap. Rather than purchase uncapped it must make no purchases -// at all. An uncapped over-purchase of reserved capacity is not reversible. -func TestProcessService_InstanceLimitRefusesRealPurchase(t *testing.T) { - ctx := context.Background() - awsCfg := aws.Config{Region: "us-east-1"} - - origCfg := toolCfg - t.Cleanup(func() { toolCfg = origCfg }) - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 1 - toolCfg.Regions = []string{"us-east-1"} - toolCfg.MaxInstances = 15 - - mockClient := &MockRecommendationsClient{} - mockRecs := []common.Recommendation{ - {ResourceType: "db.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, - {ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", EstimatedSavings: 200}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil) - t.Cleanup(func() { mockClient.AssertExpectations(t) }) - - accountCache := NewAccountAliasCache(awsCfg) - - var recs []common.Recommendation - var results []common.PurchaseResult - out := captureAppOutput(t, func() { - recs, results = processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, false, toolCfg, engineVersionData{}) - }) - - assert.NotEmpty(t, recs, "recommendations are still reported") - assert.Empty(t, results, "no purchase may be attempted when the cap cannot be evaluated") - - // The refusal must be reached without touching AWS. The duplicate check is - // the only cloud call on this path, and without credentials it logs this - // warning, so its absence is positive evidence that the guard returned - // before any client was built. Before the guard was hoisted above - // createServiceClient this assertion failed: the describe ran, 403'd, and - // emitted the warning on the way to a refusal that was already decided. - assert.NotContains(t, out, "Could not check for existing RIs", - "the refusal path must not reach a cloud API call") -} - -func TestProcessService_WithOverrideCount(t *testing.T) { - ctx := context.Background() - awsCfg, awsStub := newAWSFixtureConfig(t) - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "no-upfront" - toolCfg.TermYears = 1 - toolCfg.Regions = []string{"us-east-1"} - toolCfg.OverrideCount = 3 // Override all counts to 3 - - mockClient := &MockRecommendationsClient{} - - mockRecs := []common.Recommendation{ - {ResourceType: "cache.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, - {ResourceType: "cache.t3.small", Count: 5, Region: "us-east-1", EstimatedSavings: 200}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil) - - accountCache := NewAccountAliasCache(awsCfg) - recs, _ := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceElastiCache, true, toolCfg, engineVersionData{}) - - // All recommendations should have count=3 (override) - for _, rec := range recs { - assert.Equal(t, 3, rec.Count) - } - - mockClient.AssertExpectations(t) - awsStub.requireDuplicateChecks(t, 1) -} - -func TestProcessService_MultipleRegions(t *testing.T) { - ctx := context.Background() - awsCfg, awsStub := newAWSFixtureConfig(t) - - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 3 - toolCfg.Regions = []string{"us-east-1", "us-west-2", "eu-west-1"} - - mockClient := &MockRecommendationsClient{} - - // Setup mock for each region call in order; Once() ensures testify cycles - // through the returns so each region's call gets region-appropriate results. - for _, region := range toolCfg.Regions { - mockRecs := []common.Recommendation{ - {ResourceType: "db.t3.small", Count: 2, Region: region, EstimatedSavings: 100}, - } - mockClient.On("GetRecommendations", ctx, mock.AnythingOfType("*common.RecommendationParams")).Return(mockRecs, nil).Once() - } - - accountCache := NewAccountAliasCache(awsCfg) - recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) - - // Should get recommendations from all 3 regions - assert.NotEmpty(t, recs) - assert.Len(t, recs, 3) // One from each region - assert.Len(t, results, 3) - - // Verify each region was queried - mockClient.AssertNumberOfCalls(t, "GetRecommendations", 3) - mockClient.AssertExpectations(t) - awsStub.requireDuplicateChecks(t, 3) -} - // ==================== Helper Function Tests ==================== func TestApplyCoverageToRecommendations(t *testing.T) {