diff --git a/cmd/helpers.go b/cmd/helpers.go index 03251c62e..09ea2e02c 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -690,19 +690,30 @@ func adjustSingleRecommendation(rec common.Recommendation, existingMap map[strin } // getEngineFromRecommendation extracts the engine from recommendation details. +// DatabaseDetails/CacheDetails are always pointers (every producer -- AWS, +// Azure, the CSV loader, and the JSON codec -- constructs them that way); see +// pkg/common/service_details_codec.go's package doc for the pointer +// invariant. The nil-pointer guards are not dead code: `rec.Details == nil` +// only catches an untyped nil, so a typed nil (a (*common.DatabaseDetails)(nil) +// stored in the interface) reaches the switch and would panic on the field +// read. Returning blank matches the sibling helpers (extractDeployment and +// extractEngine in multi_service_csv.go, rdsEngineDeploymentFromRec in the +// AWS coverage package), which already guard the same way. func getEngineFromRecommendation(rec common.Recommendation) string { if rec.Details == nil { return "" } var engine string switch details := rec.Details.(type) { - case common.DatabaseDetails: - engine = details.Engine case *common.DatabaseDetails: - engine = details.Engine - case common.CacheDetails: + if details == nil { + return "" + } engine = details.Engine case *common.CacheDetails: + if details == nil { + return "" + } engine = details.Engine default: return "" diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index cc8deba76..82f9de211 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -589,19 +589,17 @@ func TestNormalizeEngineName(t *testing.T) { } } +// TestGetEngineFromRecommendation only exercises pointer-typed +// DatabaseDetails/CacheDetails: every producer (AWS, Azure, the CSV +// loader, and the JSON codec) constructs them that way, so there is no +// value-typed case to cover -- see +// pkg/common/service_details_codec.go's package doc for the invariant. func TestGetEngineFromRecommendation(t *testing.T) { tests := []struct { name string expected string rec common.Recommendation }{ - { - name: "DatabaseDetails value type", - rec: common.Recommendation{ - Details: common.DatabaseDetails{Engine: "mysql"}, - }, - expected: "mysql", - }, { name: "DatabaseDetails pointer type", rec: common.Recommendation{ @@ -609,13 +607,6 @@ func TestGetEngineFromRecommendation(t *testing.T) { }, expected: "postgresql", }, - { - name: "CacheDetails value type", - rec: common.Recommendation{ - Details: common.CacheDetails{Engine: "redis"}, - }, - expected: "redis", - }, { name: "CacheDetails pointer type", rec: common.Recommendation{ @@ -637,6 +628,23 @@ func TestGetEngineFromRecommendation(t *testing.T) { }, expected: "", }, + // Typed nils: rec.Details != nil (the interface holds a type) but the + // pointer inside is nil, so the `rec.Details == nil` guard does not + // catch it and the field read would panic without the per-case check. + { + name: "typed nil *DatabaseDetails - returns empty, no panic", + rec: common.Recommendation{ + Details: (*common.DatabaseDetails)(nil), + }, + expected: "", + }, + { + name: "typed nil *CacheDetails - returns empty, no panic", + rec: common.Recommendation{ + Details: (*common.CacheDetails)(nil), + }, + expected: "", + }, } for _, tt := range tests { diff --git a/cmd/main.go b/cmd/main.go index 2d3ec4fbd..509b1d58b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -279,20 +279,19 @@ func effectiveSizingPct(cfg Config) float64 { return cfg.Coverage } -// extractEngineLabel returns the engine or platform string from the polymorphic -// Details field, handling both value and pointer forms. The live parser and the -// CSV loader store pointers (*DatabaseDetails is required by findOfferingID), -// while some test callers construct values directly. +// extractEngineLabel returns the engine or platform string from the +// polymorphic Details field. DatabaseDetails/CacheDetails are always +// pointers (every producer constructs them that way; see +// pkg/common/service_details_codec.go's package doc for the pointer +// invariant). ComputeDetails is still accepted as a value because the +// Azure compute client and the GCP compute-engine client both construct +// it that way. func extractEngineLabel(details interface{}) string { switch d := details.(type) { - case common.DatabaseDetails: - return d.Engine case *common.DatabaseDetails: if d != nil { return d.Engine } - case common.CacheDetails: - return d.Engine case *common.CacheDetails: if d != nil { return d.Engine diff --git a/cmd/main_test.go b/cmd/main_test.go index 78019ae28..4432a749e 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -111,7 +111,7 @@ func TestGeneratePurchaseID(t *testing.T) { Service: common.ServiceRDS, ResourceType: "db.t3.micro", Count: 2, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", AZConfig: "single-az", }, @@ -141,7 +141,7 @@ func TestGeneratePurchaseID(t *testing.T) { Service: common.ServiceElastiCache, ResourceType: "cache.r5.large", Count: 1, - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", }, }, @@ -157,7 +157,7 @@ func TestGeneratePurchaseID(t *testing.T) { Service: common.ServiceRDS, ResourceType: "db.m5.xlarge", Count: 3, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "postgres", AZConfig: "multi-az", }, @@ -373,7 +373,7 @@ func TestGeneratePurchaseIDEdgeCases(t *testing.T) { Service: common.ServiceRDS, ResourceType: "db.r5b.2xlarge", Count: 10, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "MySQL 8.0", AZConfig: "single-az", }, @@ -414,7 +414,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { ResourceType: "db.r5.large", Count: 3, AccountName: "Production Account", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "PostgreSQL", }, }, @@ -431,7 +431,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { Service: common.ServiceElastiCache, ResourceType: "cache.r5.xlarge", Count: 5, - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "Redis", }, }, @@ -465,7 +465,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { Service: common.ServiceMemoryDB, ResourceType: "db.r6g.large", Count: 2, - Details: common.CacheDetails{Engine: "redis"}, + Details: &common.CacheDetails{Engine: "redis"}, }, region: "us-east-1", isDryRun: false, @@ -523,7 +523,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { ResourceType: "db.r6g.xlarge", Count: 15, AccountName: "Staging", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "aurora-mysql", AZConfig: "multi-az", }, @@ -541,7 +541,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { Service: common.ServiceElastiCache, ResourceType: "cache.m5.large", Count: 1, - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", }, }, @@ -558,7 +558,7 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { Service: common.ServiceRDS, ResourceType: "db.t3.micro", Count: 20, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "MySQL_8.0_Community", }, }, @@ -610,7 +610,7 @@ func TestGeneratePurchaseIDCoverageVariations(t *testing.T) { Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 1, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, } diff --git a/cmd/multi_service_coverage_test.go b/cmd/multi_service_coverage_test.go index 1adebebea..bfe0644d8 100644 --- a/cmd/multi_service_coverage_test.go +++ b/cmd/multi_service_coverage_test.go @@ -339,7 +339,7 @@ func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, }, @@ -352,7 +352,7 @@ func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, }, @@ -380,7 +380,7 @@ func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, }, @@ -739,7 +739,7 @@ func TestFilterAndAdjustRecommendations_WithEngineVersionFiltering(t *testing.T) ResourceType: "db.t3.small", Count: 5, Region: "us-east-1", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, }, diff --git a/cmd/multi_service_csv.go b/cmd/multi_service_csv.go index cc5813908..b230d56ae 100644 --- a/cmd/multi_service_csv.go +++ b/cmd/multi_service_csv.go @@ -385,15 +385,12 @@ func formatNormalizedUnitsOrBlank(rec common.Recommendation) string { // operators need to see the deployment alongside the upfront figure to // confirm a $X upfront row is for the deployment they expect. // -// Both value and pointer Details are accepted to mirror extractEngine -// (parser path stores pointers; CSV-loader path constructs values). +// DatabaseDetails is always a pointer: every producer (AWS, Azure, the CSV +// loader, and the JSON codec used on the purchase-execution round trip) +// constructs it that way -- see pkg/common/service_details_codec.go's +// package doc for the invariant. func extractDeployment(rec common.Recommendation) string { - switch details := rec.Details.(type) { - case *common.DatabaseDetails: - if details != nil { - return details.AZConfig - } - case common.DatabaseDetails: + if details, ok := rec.Details.(*common.DatabaseDetails); ok && details != nil { return details.AZConfig } return "" @@ -404,25 +401,21 @@ func extractDeployment(rec common.Recommendation) string { // CacheDetails), Platform for EC2 (ComputeDetails), empty for SP and other // commitment types that don't carry an engine field. // -// Both value and pointer Details are accepted because the parser stores -// *DatabaseDetails / *CacheDetails / *ComputeDetails while the CSV-loader -// path constructs the value forms; the dispatch in generatePurchaseID does -// the same trick. Without the pointer cases the column silently blanks -// every row coming from the live parser path. +// DatabaseDetails/CacheDetails are always pointers (see extractDeployment's +// godoc for the invariant). ComputeDetails is still accepted as a value +// because the Azure compute client and the GCP compute-engine client both +// construct it that way; without that case the column silently blanks every +// Azure VM and GCP compute row. func extractEngine(rec common.Recommendation) string { switch details := rec.Details.(type) { case *common.DatabaseDetails: if details != nil { return details.Engine } - case common.DatabaseDetails: - return details.Engine case *common.CacheDetails: if details != nil { return details.Engine } - case common.CacheDetails: - return details.Engine case *common.ComputeDetails: if details != nil { return details.Platform diff --git a/cmd/multi_service_csv_test.go b/cmd/multi_service_csv_test.go index b120b67da..6fc24eae6 100644 --- a/cmd/multi_service_csv_test.go +++ b/cmd/multi_service_csv_test.go @@ -86,7 +86,7 @@ func TestWriteMultiServiceCSVReport(t *testing.T) { EstimatedSavings: 100, SavingsPercentage: 30, Timestamp: time.Now(), - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", AZConfig: "multi-az", }, @@ -109,7 +109,7 @@ func TestWriteMultiServiceCSVReport(t *testing.T) { ResourceType: "cache.t3.micro", Count: 1, Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "cache.t3.micro", }, @@ -495,8 +495,9 @@ func TestFormatNormalizedUnitsOrBlank(t *testing.T) { // TestExtractDeployment covers the deployment-extraction helper used by // the RDS row in the CSV. Single-AZ / Multi-AZ is critical context for // pricing verification (Multi-AZ list price is ~2x Single-AZ) so the -// column should land for every RDS rec regardless of which Details form -// the upstream path used. +// column should land for every RDS rec. DatabaseDetails is always a +// pointer (every producer constructs it that way); no value-typed case +// is needed. func TestExtractDeployment(t *testing.T) { tests := []struct { name string @@ -505,7 +506,6 @@ func TestExtractDeployment(t *testing.T) { }{ {"*DatabaseDetails Single-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "single-az"}}, "single-az"}, {"*DatabaseDetails Multi-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, - {"DatabaseDetails (value) Multi-AZ", common.Recommendation{Details: common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, {"DatabaseDetails empty AZConfig", common.Recommendation{Details: &common.DatabaseDetails{Engine: "mysql"}}, ""}, // Non-RDS Details → blank (column is RDS-only data). {"CacheDetails -> empty", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, ""}, @@ -523,19 +523,22 @@ func TestExtractDeployment(t *testing.T) { // TestExtractEngine covers the four cases the helper dispatches on: // DatabaseDetails (RDS engine), CacheDetails (ElastiCache engine), // ComputeDetails (EC2 platform), and unset/other Details (blank). +// DatabaseDetails/CacheDetails are always pointers (every producer +// constructs them that way); ComputeDetails still accepts a value because +// the Azure compute client and the GCP compute-engine client both construct +// it that way. func TestExtractEngine(t *testing.T) { tests := []struct { name string rec common.Recommendation want string }{ - // Pointer forms — what the live parser actually emits. {"*DatabaseDetails -> Engine", common.Recommendation{Details: &common.DatabaseDetails{Engine: "aurora-postgresql"}}, "aurora-postgresql"}, {"*CacheDetails -> Engine", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, "redis"}, {"*ComputeDetails -> Platform", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, "Linux/UNIX"}, - // Value forms — what the CSV-loader path constructs. - {"DatabaseDetails (value) -> Engine", common.Recommendation{Details: common.DatabaseDetails{Engine: "mysql"}}, "mysql"}, - {"CacheDetails (value) -> Engine", common.Recommendation{Details: common.CacheDetails{Engine: "memcached"}}, "memcached"}, + // The Azure compute client and GCP's compute-engine client both + // still construct ComputeDetails as a value; keep coverage for + // that form. {"ComputeDetails (value) -> Platform", common.Recommendation{Details: common.ComputeDetails{Platform: "Windows"}}, "Windows"}, // Fallbacks. {"nil Details -> empty", common.Recommendation{}, ""}, diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index 2f6a80350..d6f395b55 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -426,12 +426,17 @@ func adjustRecommendationForExcludedVersions(rec common.Recommendation, instance return rec } - // Get the engine name from the recommendation + // Get the engine name from the recommendation. DatabaseDetails is + // always a pointer (every producer constructs it that way; see + // pkg/common/service_details_codec.go's package doc). A typed nil + // pointer is treated like a non-RDS rec rather than panicking on the + // field read. var recEngine string switch details := rec.Details.(type) { - case common.DatabaseDetails: - recEngine = details.Engine case *common.DatabaseDetails: + if details == nil { + return rec + } recEngine = details.Engine default: return rec // Not RDS, no engine version filtering diff --git a/cmd/multi_service_engine_versions_test.go b/cmd/multi_service_engine_versions_test.go index 499316d3a..d1273570b 100644 --- a/cmd/multi_service_engine_versions_test.go +++ b/cmd/multi_service_engine_versions_test.go @@ -255,6 +255,34 @@ func TestAdjustRecommendationForExcludedVersions_NonRDSService(t *testing.T) { assert.Equal(t, 5, result.Count, "Non-RDS services should not be adjusted") } +// TestAdjustRecommendationForExcludedVersions_TypedNilDetails pins the typed-nil +// guard on the *common.DatabaseDetails case. A nil interface is caught by the +// caller's own nil checks, but a (*common.DatabaseDetails)(nil) stored in the +// interface reaches the type switch, and reading details.Engine there panics. +// instanceVersions must contain an entry for the rec's ResourceType so the +// function gets past its early "no running instances" return and actually +// reaches the switch. +func TestAdjustRecommendationForExcludedVersions_TypedNilDetails(t *testing.T) { + recommendation := common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 7, + Details: (*common.DatabaseDetails)(nil), + } + + instanceVersions := map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + } + + assert.NotPanics(t, func() { + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo()) + assert.Equal(t, 7, result.Count, "typed-nil Details must pass through unadjusted") + }) +} + func TestExtractMajorVersion_Additional(t *testing.T) { tests := []struct { name string diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index 1605482e6..b30394a91 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -895,9 +895,9 @@ func TestApplyFilters_EngineFiltering(t *testing.T) { { name: "Include specific engines", recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "postgresql"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "mysql"}}, - {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: common.CacheDetails{Engine: "redis"}}, + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, + {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, }, includeEngines: []string{"postgresql", "mysql"}, excludeEngines: []string{}, @@ -906,9 +906,9 @@ func TestApplyFilters_EngineFiltering(t *testing.T) { { name: "Exclude specific engines", recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "postgresql"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "mysql"}}, - {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: common.CacheDetails{Engine: "redis"}}, + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, + {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, }, includeEngines: []string{}, excludeEngines: []string{"redis"}, @@ -917,8 +917,8 @@ func TestApplyFilters_EngineFiltering(t *testing.T) { { name: "Case insensitive engine matching", recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "PostgreSQL"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "MySQL"}}, + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "PostgreSQL"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "MySQL"}}, }, includeEngines: []string{"postgresql", "mysql"}, excludeEngines: []string{}, @@ -928,7 +928,7 @@ func TestApplyFilters_EngineFiltering(t *testing.T) { name: "No engine details - with include list", recs: []common.Recommendation{ {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "mysql"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, }, includeEngines: []string{"mysql"}, excludeEngines: []string{}, @@ -1025,10 +1025,10 @@ func TestApplyFilters_AccountFiltering(t *testing.T) { func TestApplyFilters_CombinedFilters(t *testing.T) { recs := []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "postgresql"}, AccountName: "prod-account"}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "mysql"}, AccountName: "dev-account"}, - {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS, Details: common.DatabaseDetails{Engine: "postgresql"}, AccountName: "staging-account"}, - {Region: "eu-west-1", ResourceType: "cache.t3.small", Count: 4, Service: common.ServiceElastiCache, Details: common.CacheDetails{Engine: "redis"}, AccountName: "prod-account"}, + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "prod-account"}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}, AccountName: "dev-account"}, + {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "staging-account"}, + {Region: "eu-west-1", ResourceType: "cache.t3.small", Count: 4, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}, AccountName: "prod-account"}, } cfg := Config{ diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index abb427cb9..3697f729b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -1244,23 +1244,29 @@ func nonZeroPtr(v float64) *float64 { } // extractEngine pulls the engine string out of a polymorphic -// common.ServiceDetails value when one is present, supporting both value -// and pointer receivers as the provider parsers historically used either -// shape. Returns "" for any other Details type (or nil). Extracted from -// convertRecommendations to keep that function under the gocyclo budget -// (min-complexity: 10 in .pre-commit-config.yaml). +// common.ServiceDetails value when one is present. DatabaseDetails/ +// CacheDetails are always pointers (every producer constructs them that +// way; see pkg/common/service_details_codec.go's package doc for the +// invariant). Returns "" for any other Details type (or nil). The +// nil-pointer guards are not dead code: the `details == nil` check above +// only catches an untyped nil, so a typed nil reaches the switch and would +// panic on the field read. Extracted from convertRecommendations to keep +// that function under the gocyclo budget (min-complexity: 10 in +// .pre-commit-config.yaml). func extractEngine(details common.ServiceDetails) string { if details == nil { return "" } switch d := details.(type) { - case common.DatabaseDetails: - return d.Engine case *common.DatabaseDetails: - return d.Engine - case common.CacheDetails: + if d == nil { + return "" + } return d.Engine case *common.CacheDetails: + if d == nil { + return "" + } return d.Engine } return "" diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 6ee6249f3..a96dd8aab 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -1200,7 +1200,7 @@ func TestScheduler_ConvertRecommendations(t *testing.T) { PaymentOption: "partial-upfront", CommitmentCost: 500.0, EstimatedSavings: 200.0, - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", }, }, @@ -1463,9 +1463,9 @@ func TestScheduler_ConvertRecommendations_IDUniqueness(t *testing.T) { rdsBase := base rdsBase.Service = common.ServiceRDS rdsBase.ResourceType = "db.m5.large" - rdsBase.Details = common.DatabaseDetails{Engine: "mysql"} + rdsBase.Details = &common.DatabaseDetails{Engine: "mysql"} rdsTwin := rdsBase - rdsTwin.Details = common.DatabaseDetails{Engine: "postgres"} + rdsTwin.Details = &common.DatabaseDetails{Engine: "postgres"} return rdsBase, rdsTwin }, }, @@ -2343,3 +2343,38 @@ func TestScheduler_ResolveAmbientAccountID_StoreError(t *testing.T) { got := sched.resolveAmbientAccountID(context.Background(), "gcp", "some-project") assert.Empty(t, got, "store error must collapse to empty (don't fail the collection)") } + +// TestExtractEngine covers the pointer-only dispatch documented in +// pkg/common/service_details_codec.go's package doc, plus the typed-nil +// guard. The typed-nil cases are the regression bar: the `details == nil` +// check only catches an untyped nil, so a (*common.DatabaseDetails)(nil) +// stored in the interface reaches the type switch and the field read +// panics without the per-case guard. extractEngine feeds the persisted +// engine column and the scheduler recommendation ID, so a panic here +// aborts an entire collection run. +func TestExtractEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + details common.ServiceDetails + name string + want string + }{ + {name: "nil interface", details: nil, want: ""}, + {name: "*DatabaseDetails", details: &common.DatabaseDetails{Engine: "mysql"}, want: "mysql"}, + {name: "*CacheDetails", details: &common.CacheDetails{Engine: "redis"}, want: "redis"}, + {name: "*ComputeDetails carries no engine", details: &common.ComputeDetails{Platform: "Linux/UNIX"}, want: ""}, + {name: "typed nil *DatabaseDetails", details: (*common.DatabaseDetails)(nil), want: ""}, + {name: "typed nil *CacheDetails", details: (*common.CacheDetails)(nil), want: ""}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + assert.Equal(t, tt.want, extractEngine(tt.details)) + }) + }) + } +} diff --git a/pkg/common/engine.go b/pkg/common/engine.go index f7b6ee848..b9c99ca80 100644 --- a/pkg/common/engine.go +++ b/pkg/common/engine.go @@ -40,21 +40,28 @@ func NormalizeEngineName(engine string) string { return strings.ToLower(engine) } -// EngineFromDetails extracts and normalizes the engine name from recommendation details. -// Returns an empty string for non-database/cache service types. +// EngineFromDetails extracts and normalizes the engine name from +// recommendation details. Returns an empty string for non-database/cache +// service types. DatabaseDetails/CacheDetails are always pointers (every +// producer constructs them that way; see service_details_codec.go's +// package doc for the invariant). The nil-pointer guards are not dead +// code: the `details == nil` check above only catches an untyped nil, so a +// typed nil reaches the switch and would panic on the field read. func EngineFromDetails(details ServiceDetails) string { if details == nil { return "" } var engine string switch d := details.(type) { - case DatabaseDetails: - engine = d.Engine case *DatabaseDetails: - engine = d.Engine - case CacheDetails: + if d == nil { + return "" + } engine = d.Engine case *CacheDetails: + if d == nil { + return "" + } engine = d.Engine default: return "" diff --git a/pkg/common/engine_test.go b/pkg/common/engine_test.go new file mode 100644 index 000000000..19347424e --- /dev/null +++ b/pkg/common/engine_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEngineFromDetails covers the pointer-only dispatch documented in +// service_details_codec.go's package doc, plus the typed-nil guard. +// +// The typed-nil cases are the regression bar: a (*DatabaseDetails)(nil) +// stored in the ServiceDetails interface is NOT caught by the +// `details == nil` check (the interface itself is non-nil once it carries a +// type), so it reaches the type switch and the field read panics without the +// per-case guard. Matches() calls EngineFromDetails on every +// recommendation/commitment pair, so a panic here takes down coverage +// matching for the whole run rather than skipping one row. +func TestEngineFromDetails(t *testing.T) { + t.Parallel() + + tests := []struct { + details ServiceDetails + name string + want string + }{ + {name: "nil interface", details: nil, want: ""}, + {name: "*DatabaseDetails", details: &DatabaseDetails{Engine: "PostgreSQL"}, want: "postgresql"}, + {name: "*CacheDetails", details: &CacheDetails{Engine: "Redis"}, want: "redis"}, + {name: "*ComputeDetails is not a DB/cache type", details: &ComputeDetails{Platform: "Linux/UNIX"}, want: ""}, + {name: "typed nil *DatabaseDetails", details: (*DatabaseDetails)(nil), want: ""}, + {name: "typed nil *CacheDetails", details: (*CacheDetails)(nil), want: ""}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + assert.Equal(t, tt.want, EngineFromDetails(tt.details)) + }) + }) + } +} diff --git a/pkg/common/service_details_codec.go b/pkg/common/service_details_codec.go index 238775d7b..7bbcf1dda 100644 --- a/pkg/common/service_details_codec.go +++ b/pkg/common/service_details_codec.go @@ -18,6 +18,18 @@ // type definitions (ComputeDetails / DatabaseDetails / …). internal/config // (where RecommendationRecord lives) is deliberately kept free of // pkg/common imports so the dependency graph stays a strict DAG. +// +// Pointer invariant: this codec always reconstructs *DatabaseDetails / +// *CacheDetails, and every other producer (AWS, Azure, the CSV loader) +// constructs them as pointers too, so Recommendation.Details never holds a +// bare DatabaseDetails / CacheDetails value anywhere in the codebase. +// Consumers that type-switch on Details only need the pointer case for +// these two types. ComputeDetails is the one remaining exception: the +// Azure compute client (providers/azure/services/compute) and the GCP +// compute-engine client (providers/gcp/services/computeengine) both still +// construct it as a value, so consumers that handle ComputeDetails must +// keep accepting both forms. Dropping the value case would silently blank +// every Azure VM and GCP compute row instead of failing to compile. package common import ( diff --git a/providers/aws/recommendations/coverage.go b/providers/aws/recommendations/coverage.go index 9e5f10bda..62732a986 100644 --- a/providers/aws/recommendations/coverage.go +++ b/providers/aws/recommendations/coverage.go @@ -469,16 +469,11 @@ func lookupPoolKey(rec common.Recommendation) string { // rdsEngineDeploymentFromRec extracts the RDS engine and deployment // strings from a recommendation's polymorphic Details, returning ("", "") -// when the rec isn't an RDS rec. Handles both pointer and value forms of -// DatabaseDetails because the live parser uses pointers and the CSV -// loader uses values. +// when the rec isn't an RDS rec. DatabaseDetails is always a pointer +// (every producer constructs it that way; see +// pkg/common/service_details_codec.go's package doc for the invariant). func rdsEngineDeploymentFromRec(rec common.Recommendation) (engine, deployment string) { - switch details := rec.Details.(type) { - case *common.DatabaseDetails: - if details != nil { - return details.Engine, details.AZConfig - } - case common.DatabaseDetails: + if details, ok := rec.Details.(*common.DatabaseDetails); ok && details != nil { return details.Engine, details.AZConfig } return "", "" diff --git a/providers/aws/services/memorydb/client_test.go b/providers/aws/services/memorydb/client_test.go index 5e10cb36c..61649d2be 100644 --- a/providers/aws/services/memorydb/client_test.go +++ b/providers/aws/services/memorydb/client_test.go @@ -203,7 +203,7 @@ func TestClient_ValidateOffering(t *testing.T) { ResourceType: "db.r6gd.xlarge", PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -239,7 +239,7 @@ func TestClient_PurchaseCommitment(t *testing.T) { Count: 3, PaymentOption: "all-upfront", Term: "3yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.2xlarge", }, @@ -458,7 +458,7 @@ func TestClient_GetOfferingDetails(t *testing.T) { ResourceType: "db.r6gd.xlarge", PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -507,7 +507,7 @@ func TestClient_GetOfferingDetails_NotFound(t *testing.T) { ResourceType: "db.r6gd.xlarge", PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -552,7 +552,7 @@ func TestClient_GetOfferingDetails_APIError(t *testing.T) { ResourceType: "db.r6gd.xlarge", PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -595,7 +595,7 @@ func TestClient_PurchaseCommitment_OfferingNotFound(t *testing.T) { ResourceType: "db.r6gd.xlarge", PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -627,7 +627,7 @@ func TestClient_PurchaseCommitment_PurchaseError(t *testing.T) { Count: 1, PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -669,7 +669,7 @@ func TestClient_PurchaseCommitment_EmptyResponse(t *testing.T) { Count: 1, PaymentOption: "partial-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "db.r6gd.xlarge", }, @@ -774,7 +774,7 @@ func mdbIdemRec() common.Recommendation { Count: 1, PaymentOption: "all-upfront", Term: "1yr", - Details: common.CacheDetails{Engine: "redis", NodeType: "db.r6gd.large"}, + Details: &common.CacheDetails{Engine: "redis", NodeType: "db.r6gd.large"}, } } @@ -910,7 +910,7 @@ func TestClient_PurchaseCommitment_NoToken_RichReservationName(t *testing.T) { Count: 3, PaymentOption: "all-upfront", // must match expectMDBOffering's OfferingType Term: "1yr", - Details: common.CacheDetails{Engine: "redis", NodeType: "db.r6gd.large"}, + Details: &common.CacheDetails{Engine: "redis", NodeType: "db.r6gd.large"}, } expectMDBOffering(mockMDB) diff --git a/providers/azure/services/cache/client.go b/providers/azure/services/cache/client.go index 753ae2e82..995a77447 100644 --- a/providers/azure/services/cache/client.go +++ b/providers/azure/services/cache/client.go @@ -624,7 +624,7 @@ func (c *CacheClient) convertAzureRedisRecommendation(ctx context.Context, azure if f == nil { return nil } - details := common.CacheDetails{ + details := &common.CacheDetails{ Engine: "redis", NodeType: f.ResourceType, } diff --git a/providers/azure/services/cache/client_test.go b/providers/azure/services/cache/client_test.go index a95814ed7..9dbcd0fde 100644 --- a/providers/azure/services/cache/client_test.go +++ b/providers/azure/services/cache/client_test.go @@ -842,8 +842,8 @@ func TestCacheClient_ConvertAzureRedisRecommendation_PopulatesAllFields(t *testi // stays 0 when no cache instance matches in the subscription (the // catalogue's only signal source for shard counts). require.NotNil(t, out.Details) - details, ok := out.Details.(common.CacheDetails) - require.True(t, ok, "Details must be a common.CacheDetails value") + details, ok := out.Details.(*common.CacheDetails) + require.True(t, ok, "Details must be a *common.CacheDetails pointer") assert.Equal(t, "redis", details.Engine) assert.Equal(t, "Premium_P3", details.NodeType) assert.Equal(t, 0, details.Shards, "Shards is 0 when no matching cache exists in the subscription") @@ -892,7 +892,7 @@ func TestCacheClient_ConvertAzureRedisRecommendation_PopulatesShardsFromSKUCache ) out := client.convertAzureRedisRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.CacheDetails) + details, ok := out.Details.(*common.CacheDetails) require.True(t, ok) assert.Equal(t, "redis", details.Engine) assert.Equal(t, "Premium_P3", details.NodeType) @@ -918,7 +918,7 @@ func TestCacheClient_ConvertAzureRedisRecommendation_PagerErrorFallsBack(t *test ) out := client.convertAzureRedisRecommendation(context.Background(), rec) require.NotNil(t, out, "conversion must NOT fail on catalogue-fetch error") - details, ok := out.Details.(common.CacheDetails) + details, ok := out.Details.(*common.CacheDetails) require.True(t, ok) assert.Equal(t, "redis", details.Engine) assert.Equal(t, "Premium_P3", details.NodeType) diff --git a/providers/azure/services/database/client.go b/providers/azure/services/database/client.go index 88614306c..5c27e2943 100644 --- a/providers/azure/services/database/client.go +++ b/providers/azure/services/database/client.go @@ -890,7 +890,7 @@ func (c *DatabaseClient) createServersPager() (SQLServersPager, error) { } // detailsFromSQLSKU parses an Azure SQL SKU string into a -// common.DatabaseDetails value. The Azure Reservation Recommendations +// *common.DatabaseDetails. The Azure Reservation Recommendations // API returns SKU strings like "GeneralPurpose_Gen5_2" (edition, compute // generation, vcore count) or "BusinessCritical_Gen5_4". The parser is // permissive: unknown formats populate InstanceClass and leave the rest @@ -900,11 +900,10 @@ func (c *DatabaseClient) createServersPager() (SQLServersPager, error) { // Engine is always "sqlserver". EngineVersion, AZConfig, and Deployment // are enriched by the lazy subscription-wide cached lookups in // convertAzureSQLRecommendation; they are left empty here. -func detailsFromSQLSKU(sku string) common.DatabaseDetails { +func detailsFromSQLSKU(sku string) *common.DatabaseDetails { // Engine is always SQL Server for an Azure SQL Database reservation. - d := common.DatabaseDetails{ + return &common.DatabaseDetails{ Engine: "sqlserver", InstanceClass: sku, } - return d } diff --git a/providers/azure/services/database/client_test.go b/providers/azure/services/database/client_test.go index 7b0d84173..d53f86b72 100644 --- a/providers/azure/services/database/client_test.go +++ b/providers/azure/services/database/client_test.go @@ -754,8 +754,8 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_PopulatesAllFields(t *test // matching SKU (no signal); AZConfig/Deployment still need a // per-server lookup and remain deferred. require.NotNil(t, out.Details) - details, ok := out.Details.(common.DatabaseDetails) - require.True(t, ok, "Details must be a common.DatabaseDetails value") + details, ok := out.Details.(*common.DatabaseDetails) + require.True(t, ok, "Details must be a *common.DatabaseDetails pointer") assert.Equal(t, "sqlserver", details.Engine) assert.Equal(t, "GeneralPurpose_Gen5_2", details.InstanceClass) assert.Empty(t, details.EngineVersion, "EngineVersion empty when no matching SKU in catalogue") @@ -800,7 +800,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_PopulatesEngineVersion(t * ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Equal(t, "sqlserver", details.Engine) assert.Equal(t, skuName, details.InstanceClass) @@ -822,7 +822,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_CapabilitiesErrorFallsBack ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out, "conversion must NOT fail on capabilities-fetch error") - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Equal(t, "sqlserver", details.Engine) assert.Equal(t, "GeneralPurpose_Gen5_2", details.InstanceClass) @@ -1408,7 +1408,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_PopulatesAZConfig(t *testi ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Equal(t, "zoneRedundant", details.AZConfig) } @@ -1429,7 +1429,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_AmbiguousAZConfig(t *testi ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Empty(t, details.AZConfig, "ambiguous zone-redundancy must leave AZConfig empty") } @@ -1451,7 +1451,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_AZConfigPagerErrorFallsBac ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out, "conversion must NOT fail on pager error") - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Empty(t, details.AZConfig, "AZConfig empty on pager error") } @@ -1480,7 +1480,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_PopulatesDeployment(t *tes ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Equal(t, "managed", details.Deployment) } @@ -1502,7 +1502,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_DeploymentSingle(t *testin ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out) - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Equal(t, "single", details.Deployment) } @@ -1524,7 +1524,7 @@ func TestDatabaseClient_ConvertAzureSQLRecommendation_DeploymentPagerErrorFallsB ) out := client.convertAzureSQLRecommendation(context.Background(), rec) require.NotNil(t, out, "conversion must NOT fail on servers pager error") - details, ok := out.Details.(common.DatabaseDetails) + details, ok := out.Details.(*common.DatabaseDetails) require.True(t, ok) assert.Empty(t, details.Deployment, "Deployment empty on pager error") }