From 44fab17d874dfa21aa4e44d8a9b101b4f947b72b Mon Sep 17 00:00:00 2001 From: Justin Lyons Date: Wed, 11 Feb 2026 16:05:04 -0500 Subject: [PATCH 1/3] fix(aws): RDS RI purchase failing on details assertion and invalid reservation ID Recommendations set rec.Details as pointers (e.g. &DatabaseDetails) but RDS/EC2/ElastiCache asserted to the value type, so the assertion always failed and purchases hit 'invalid service details'. Switched those clients to assert the pointer type and updated tests to pass pointers. Reservation IDs were built from rec.ResourceType (e.g. db.t3.small) so they contained dots; AWS only allows letters, digits, and hyphens. Added sanitization for the custom ID/name field in RDS, ElastiCache, OpenSearch, and MemoryDB so we only send valid identifiers. Co-authored-by: Cursor --- providers/aws/services/ec2/client.go | 4 +-- providers/aws/services/ec2/client_test.go | 6 ++-- providers/aws/services/elasticache/client.go | 30 +++++++++++++++-- .../aws/services/elasticache/client_test.go | 6 ++-- providers/aws/services/memorydb/client.go | 26 ++++++++++++++- providers/aws/services/opensearch/client.go | 26 ++++++++++++++- providers/aws/services/rds/client.go | 32 ++++++++++++++++--- providers/aws/services/rds/client_test.go | 10 +++--- 8 files changed, 118 insertions(+), 22 deletions(-) diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index 61bc71a7..b493d577 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -150,8 +150,8 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // findOfferingID finds the appropriate EC2 Reserved Instance offering ID func (c *Client) findOfferingID(ctx context.Context, rec common.Recommendation) (string, error) { - details, ok := rec.Details.(common.ComputeDetails) - if !ok { + details, ok := rec.Details.(*common.ComputeDetails) + if !ok || details == nil { return "", fmt.Errorf("invalid service details for EC2") } diff --git a/providers/aws/services/ec2/client_test.go b/providers/aws/services/ec2/client_test.go index 8dfe7eb0..0b3a0daf 100644 --- a/providers/aws/services/ec2/client_test.go +++ b/providers/aws/services/ec2/client_test.go @@ -264,7 +264,7 @@ func TestClient_ValidateOffering(t *testing.T) { ResourceType: "t3.micro", PaymentOption: "partial-upfront", Term: "3yr", - Details: common.ComputeDetails{ + Details: &common.ComputeDetails{ Platform: "Linux/UNIX", Tenancy: "default", Scope: "Region", @@ -303,7 +303,7 @@ func TestClient_PurchaseCommitment(t *testing.T) { Count: 2, PaymentOption: "partial-upfront", Term: "3yr", - Details: common.ComputeDetails{ + Details: &common.ComputeDetails{ Platform: "Linux/UNIX", Tenancy: "default", Scope: "Region", @@ -351,7 +351,7 @@ func TestClient_GetOfferingDetails(t *testing.T) { PaymentOption: "partial-upfront", Term: "3yr", Count: 1, - Details: common.ComputeDetails{ + Details: &common.ComputeDetails{ Platform: "Linux/UNIX", Tenancy: "default", Scope: "Region", diff --git a/providers/aws/services/elasticache/client.go b/providers/aws/services/elasticache/client.go index e81f9696..9f12e7c1 100644 --- a/providers/aws/services/elasticache/client.go +++ b/providers/aws/services/elasticache/client.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "sort" + "strconv" + "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -123,7 +125,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := fmt.Sprintf("elasticache-%s-%d", rec.ResourceType, time.Now().Unix()) + reservationID := c.sanitizeReservationID(fmt.Sprintf("elasticache-%s-%d", rec.ResourceType, time.Now().Unix())) input := &elasticache.PurchaseReservedCacheNodesOfferingInput{ ReservedCacheNodesOfferingId: aws.String(offeringID), @@ -154,8 +156,8 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // findOfferingID finds the appropriate Reserved Cache Node offering ID func (c *Client) findOfferingID(ctx context.Context, rec common.Recommendation) (string, error) { - details, ok := rec.Details.(common.CacheDetails) - if !ok { + details, ok := rec.Details.(*common.CacheDetails) + if !ok || details == nil { return "", fmt.Errorf("invalid service details for ElastiCache") } @@ -224,6 +226,28 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } +// sanitizeReservationID ensures the reservation identifier uses only allowed characters +// (letters, digits, hyphens), with no leading/trailing or consecutive hyphens. +func (c *Client) sanitizeReservationID(id string) string { + var b strings.Builder + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == '.' { + b.WriteRune('-') + } + } + s := b.String() + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + s = "elasticache-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) + } + return s +} + // GetValidResourceTypes returns valid ElastiCache node types func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { instanceTypesMap := make(map[string]bool) diff --git a/providers/aws/services/elasticache/client_test.go b/providers/aws/services/elasticache/client_test.go index 003be4b2..2f6361d5 100644 --- a/providers/aws/services/elasticache/client_test.go +++ b/providers/aws/services/elasticache/client_test.go @@ -246,7 +246,7 @@ func TestClient_ValidateOffering(t *testing.T) { ResourceType: "cache.r6g.large", PaymentOption: "no-upfront", Term: "3yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "cache.r6g.large", }, @@ -283,7 +283,7 @@ func TestClient_PurchaseCommitment(t *testing.T) { Count: 3, PaymentOption: "partial-upfront", Term: "3yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "cache.m6g.xlarge", }, @@ -336,7 +336,7 @@ func TestClient_GetOfferingDetails(t *testing.T) { ResourceType: "cache.r6g.xlarge", PaymentOption: "all-upfront", Term: "1yr", - Details: common.CacheDetails{ + Details: &common.CacheDetails{ Engine: "redis", NodeType: "cache.r6g.xlarge", }, diff --git a/providers/aws/services/memorydb/client.go b/providers/aws/services/memorydb/client.go index 66a078e7..c50d4d9d 100644 --- a/providers/aws/services/memorydb/client.go +++ b/providers/aws/services/memorydb/client.go @@ -4,6 +4,8 @@ package memorydb import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -118,7 +120,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := fmt.Sprintf("memorydb-%s-%d", rec.ResourceType, time.Now().Unix()) + reservationID := c.sanitizeReservationID(fmt.Sprintf("memorydb-%s-%d", rec.ResourceType, time.Now().Unix())) input := &memorydb.PurchaseReservedNodesOfferingInput{ ReservedNodesOfferingId: aws.String(offeringID), @@ -243,6 +245,28 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } +// sanitizeReservationID normalizes the reservation identifier for MemoryDB: +// letters, digits, and hyphens only, with no leading/trailing or consecutive hyphens. +func (c *Client) sanitizeReservationID(id string) string { + var b strings.Builder + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == '.' { + b.WriteRune('-') + } + } + s := b.String() + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + s = "memorydb-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) + } + return s +} + // GetValidResourceTypes returns valid MemoryDB node types (static list) func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return []string{ diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index e2684825..0e9867ea 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -4,6 +4,8 @@ package opensearch import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -118,7 +120,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := fmt.Sprintf("opensearch-%s-%d", rec.ResourceType, time.Now().Unix()) + reservationID := c.sanitizeReservationName(fmt.Sprintf("opensearch-%s-%d", rec.ResourceType, time.Now().Unix())) input := &opensearch.PurchaseReservedInstanceOfferingInput{ ReservedInstanceOfferingId: aws.String(offeringID), @@ -232,6 +234,28 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } +// sanitizeReservationName normalizes the reservation name for OpenSearch: +// letters, digits, and hyphens only, with no leading/trailing or consecutive hyphens. +func (c *Client) sanitizeReservationName(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == '.' { + b.WriteRune('-') + } + } + s := b.String() + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + s = "opensearch-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) + } + return s +} + // GetValidResourceTypes returns valid OpenSearch instance types (static list) func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return []string{ diff --git a/providers/aws/services/rds/client.go b/providers/aws/services/rds/client.go index 7271aaf9..b6a496e3 100644 --- a/providers/aws/services/rds/client.go +++ b/providers/aws/services/rds/client.go @@ -127,8 +127,8 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - // Generate reservation ID - reservationID := fmt.Sprintf("rds-%s-%d", rec.ResourceType, time.Now().Unix()) + // Generate reservation ID (letters, digits, hyphens only; no leading/trailing/double hyphen) + reservationID := c.sanitizeReservedDBInstanceID(fmt.Sprintf("rds-%s-%d", rec.ResourceType, time.Now().Unix())) // Create the purchase request input := &rds.PurchaseReservedDBInstancesOfferingInput{ @@ -160,8 +160,8 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // findOfferingID finds the appropriate RDS Reserved Instance offering ID func (c *Client) findOfferingID(ctx context.Context, rec common.Recommendation) (string, error) { - details, ok := rec.Details.(common.DatabaseDetails) - if !ok { + details, ok := rec.Details.(*common.DatabaseDetails) + if !ok || details == nil { return "", fmt.Errorf("invalid service details for RDS") } @@ -284,6 +284,30 @@ func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return instanceTypes, nil } +// sanitizeReservedDBInstanceID returns an ID valid for ReservedDBInstanceId: +// only ASCII letters, digits, hyphens; no leading/trailing hyphen; no consecutive hyphens. +func (c *Client) sanitizeReservedDBInstanceID(id string) string { + var b strings.Builder + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == '.' { + b.WriteRune('-') + } + // drop any other character + } + s := b.String() + // collapse consecutive hyphens + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + s = "rds-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) + } + return s +} + // getDurationString converts term string to duration string for RDS API func (c *Client) getDurationString(term string) string { if term == "3yr" || term == "3" { diff --git a/providers/aws/services/rds/client_test.go b/providers/aws/services/rds/client_test.go index e18e58c1..3bb8abbe 100644 --- a/providers/aws/services/rds/client_test.go +++ b/providers/aws/services/rds/client_test.go @@ -274,7 +274,7 @@ func TestClient_ValidateOffering(t *testing.T) { ResourceType: "db.t3.medium", PaymentOption: "no-upfront", Term: "3yr", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", AZConfig: "multi-az", }, @@ -311,7 +311,7 @@ func TestClient_ValidateOffering_NotFound(t *testing.T) { ResourceType: "db.t3.medium", PaymentOption: "no-upfront", Term: "3yr", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", AZConfig: "multi-az", }, @@ -341,7 +341,7 @@ func TestClient_PurchaseCommitment(t *testing.T) { Count: 2, PaymentOption: "partial-upfront", Term: "3yr", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "aurora-mysql", AZConfig: "multi-az", }, @@ -396,7 +396,7 @@ func TestClient_PurchaseCommitment_EmptyResponse(t *testing.T) { Count: 1, PaymentOption: "all-upfront", Term: "1yr", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "mysql", AZConfig: "single-az", }, @@ -441,7 +441,7 @@ func TestClient_GetOfferingDetails(t *testing.T) { ResourceType: "db.m6g.large", PaymentOption: "all-upfront", Term: "1yr", - Details: common.DatabaseDetails{ + Details: &common.DatabaseDetails{ Engine: "postgres", AZConfig: "multi-az", }, From 920b89ec7a9168dea6cfadd5fdbb03afd9f866ad Mon Sep 17 00:00:00 2001 From: Hannah Lyons Date: Sun, 15 Feb 2026 10:33:15 -0500 Subject: [PATCH 2/3] refactor(aws): deduplicate reservation ID sanitization into pkg/common Extract SanitizeReservationID into pkg/common/identifiers.go and use it from RDS, ElastiCache, OpenSearch, and MemoryDB instead of four per-service copies. Addresses PR review feedback. --- pkg/common/identifiers.go | 31 ++++++++++++++++++++ providers/aws/services/elasticache/client.go | 26 +--------------- providers/aws/services/memorydb/client.go | 26 +--------------- providers/aws/services/opensearch/client.go | 26 +--------------- providers/aws/services/rds/client.go | 26 +--------------- 5 files changed, 35 insertions(+), 100 deletions(-) create mode 100644 pkg/common/identifiers.go diff --git a/pkg/common/identifiers.go b/pkg/common/identifiers.go new file mode 100644 index 00000000..a329c195 --- /dev/null +++ b/pkg/common/identifiers.go @@ -0,0 +1,31 @@ +package common + +import ( + "strconv" + "strings" + "time" +) + +// SanitizeReservationID returns an identifier safe for AWS reservation/reserved-instance +// ID or name fields: only ASCII letters, digits, and hyphens; no leading/trailing +// hyphen; no consecutive hyphens. Dots are replaced with hyphens. If the result +// would be empty, returns fallbackPrefix plus a Unix timestamp. +func SanitizeReservationID(id, fallbackPrefix string) string { + var b strings.Builder + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == '.' { + b.WriteRune('-') + } + } + s := b.String() + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if s == "" { + s = fallbackPrefix + strconv.FormatInt(time.Now().Unix(), 10) + } + return s +} diff --git a/providers/aws/services/elasticache/client.go b/providers/aws/services/elasticache/client.go index 9f12e7c1..d8918657 100644 --- a/providers/aws/services/elasticache/client.go +++ b/providers/aws/services/elasticache/client.go @@ -5,8 +5,6 @@ import ( "context" "fmt" "sort" - "strconv" - "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -125,7 +123,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := c.sanitizeReservationID(fmt.Sprintf("elasticache-%s-%d", rec.ResourceType, time.Now().Unix())) + reservationID := common.SanitizeReservationID(fmt.Sprintf("elasticache-%s-%d", rec.ResourceType, time.Now().Unix()), "elasticache-reserved-") input := &elasticache.PurchaseReservedCacheNodesOfferingInput{ ReservedCacheNodesOfferingId: aws.String(offeringID), @@ -226,28 +224,6 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } -// sanitizeReservationID ensures the reservation identifier uses only allowed characters -// (letters, digits, hyphens), with no leading/trailing or consecutive hyphens. -func (c *Client) sanitizeReservationID(id string) string { - var b strings.Builder - for _, r := range id { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else if r == '.' { - b.WriteRune('-') - } - } - s := b.String() - for strings.Contains(s, "--") { - s = strings.ReplaceAll(s, "--", "-") - } - s = strings.Trim(s, "-") - if s == "" { - s = "elasticache-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) - } - return s -} - // GetValidResourceTypes returns valid ElastiCache node types func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { instanceTypesMap := make(map[string]bool) diff --git a/providers/aws/services/memorydb/client.go b/providers/aws/services/memorydb/client.go index c50d4d9d..a1babe74 100644 --- a/providers/aws/services/memorydb/client.go +++ b/providers/aws/services/memorydb/client.go @@ -4,8 +4,6 @@ package memorydb import ( "context" "fmt" - "strconv" - "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -120,7 +118,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := c.sanitizeReservationID(fmt.Sprintf("memorydb-%s-%d", rec.ResourceType, time.Now().Unix())) + reservationID := common.SanitizeReservationID(fmt.Sprintf("memorydb-%s-%d", rec.ResourceType, time.Now().Unix()), "memorydb-reserved-") input := &memorydb.PurchaseReservedNodesOfferingInput{ ReservedNodesOfferingId: aws.String(offeringID), @@ -245,28 +243,6 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } -// sanitizeReservationID normalizes the reservation identifier for MemoryDB: -// letters, digits, and hyphens only, with no leading/trailing or consecutive hyphens. -func (c *Client) sanitizeReservationID(id string) string { - var b strings.Builder - for _, r := range id { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else if r == '.' { - b.WriteRune('-') - } - } - s := b.String() - for strings.Contains(s, "--") { - s = strings.ReplaceAll(s, "--", "-") - } - s = strings.Trim(s, "-") - if s == "" { - s = "memorydb-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) - } - return s -} - // GetValidResourceTypes returns valid MemoryDB node types (static list) func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return []string{ diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index 0e9867ea..958e905f 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -4,8 +4,6 @@ package opensearch import ( "context" "fmt" - "strconv" - "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -120,7 +118,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati return result, result.Error } - reservationID := c.sanitizeReservationName(fmt.Sprintf("opensearch-%s-%d", rec.ResourceType, time.Now().Unix())) + reservationID := common.SanitizeReservationID(fmt.Sprintf("opensearch-%s-%d", rec.ResourceType, time.Now().Unix()), "opensearch-reserved-") input := &opensearch.PurchaseReservedInstanceOfferingInput{ ReservedInstanceOfferingId: aws.String(offeringID), @@ -234,28 +232,6 @@ func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendati return details, nil } -// sanitizeReservationName normalizes the reservation name for OpenSearch: -// letters, digits, and hyphens only, with no leading/trailing or consecutive hyphens. -func (c *Client) sanitizeReservationName(name string) string { - var b strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else if r == '.' { - b.WriteRune('-') - } - } - s := b.String() - for strings.Contains(s, "--") { - s = strings.ReplaceAll(s, "--", "-") - } - s = strings.Trim(s, "-") - if s == "" { - s = "opensearch-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) - } - return s -} - // GetValidResourceTypes returns valid OpenSearch instance types (static list) func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return []string{ diff --git a/providers/aws/services/rds/client.go b/providers/aws/services/rds/client.go index b6a496e3..c7476ccb 100644 --- a/providers/aws/services/rds/client.go +++ b/providers/aws/services/rds/client.go @@ -128,7 +128,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati } // Generate reservation ID (letters, digits, hyphens only; no leading/trailing/double hyphen) - reservationID := c.sanitizeReservedDBInstanceID(fmt.Sprintf("rds-%s-%d", rec.ResourceType, time.Now().Unix())) + reservationID := common.SanitizeReservationID(fmt.Sprintf("rds-%s-%d", rec.ResourceType, time.Now().Unix()), "rds-reserved-") // Create the purchase request input := &rds.PurchaseReservedDBInstancesOfferingInput{ @@ -284,30 +284,6 @@ func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { return instanceTypes, nil } -// sanitizeReservedDBInstanceID returns an ID valid for ReservedDBInstanceId: -// only ASCII letters, digits, hyphens; no leading/trailing hyphen; no consecutive hyphens. -func (c *Client) sanitizeReservedDBInstanceID(id string) string { - var b strings.Builder - for _, r := range id { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else if r == '.' { - b.WriteRune('-') - } - // drop any other character - } - s := b.String() - // collapse consecutive hyphens - for strings.Contains(s, "--") { - s = strings.ReplaceAll(s, "--", "-") - } - s = strings.Trim(s, "-") - if s == "" { - s = "rds-reserved-" + strconv.FormatInt(time.Now().Unix(), 10) - } - return s -} - // getDurationString converts term string to duration string for RDS API func (c *Client) getDurationString(term string) string { if term == "3yr" || term == "3" { From 8f791d569a9bb44f147a1a6216e84aec6d3d9afa Mon Sep 17 00:00:00 2001 From: Hannah Lyons Date: Sun, 15 Feb 2026 11:40:50 -0500 Subject: [PATCH 3/3] fix(aws): OpenSearch RI resource type and offering lookup Cost Explorer can return InstanceSize as the full type (e.g. t3.medium.search). Concatenating with InstanceClass produced duplicates (t3.medium.t3.medium.search). Use InstanceSize as-is when it already ends with .search, otherwise build InstanceClass.InstanceSize.search. findOfferingID only read the first page of DescribeReservedInstanceOfferings; offerings for types like i3.large.search or t3.medium.search can be on later pages. Paginate with NextToken until a matching offering is found or pages are exhausted. --- providers/aws/recommendations/client.go | 9 +++++- providers/aws/services/opensearch/client.go | 33 +++++++++++++-------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/providers/aws/recommendations/client.go b/providers/aws/recommendations/client.go index cfa253e8..91c9a4ba 100644 --- a/providers/aws/recommendations/client.go +++ b/providers/aws/recommendations/client.go @@ -322,7 +322,14 @@ func (c *Client) parseOpenSearchDetails(rec *common.Recommendation, details *typ osInfo := &common.SearchDetails{} if esDetails.InstanceClass != nil && esDetails.InstanceSize != nil { - rec.ResourceType = fmt.Sprintf("%s.%s", *esDetails.InstanceClass, *esDetails.InstanceSize) + instanceSize := *esDetails.InstanceSize + // Cost Explorer may return InstanceSize as the full type (e.g. "t3.medium.search"); + // concatenating with InstanceClass would duplicate (e.g. "t3.medium.t3.medium.search"). + if strings.HasSuffix(instanceSize, ".search") { + rec.ResourceType = instanceSize + } else { + rec.ResourceType = fmt.Sprintf("%s.%s.search", *esDetails.InstanceClass, instanceSize) + } osInfo.InstanceType = rec.ResourceType } if esDetails.Region != nil { diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index 958e905f..abdbc42e 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -145,22 +145,31 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // findOfferingID finds the appropriate Reserved Instance offering ID func (c *Client) findOfferingID(ctx context.Context, rec common.Recommendation) (string, error) { - input := &opensearch.DescribeReservedInstanceOfferingsInput{ - MaxResults: 100, - } + var nextToken *string + for { + input := &opensearch.DescribeReservedInstanceOfferingsInput{ + MaxResults: 100, + NextToken: nextToken, + } - result, err := c.client.DescribeReservedInstanceOfferings(ctx, input) - if err != nil { - return "", fmt.Errorf("failed to describe offerings: %w", err) - } + result, err := c.client.DescribeReservedInstanceOfferings(ctx, input) + if err != nil { + return "", fmt.Errorf("failed to describe offerings: %w", err) + } - for _, offering := range result.ReservedInstanceOfferings { - if string(offering.InstanceType) == rec.ResourceType { - if c.matchesPaymentOption(offering.PaymentOption, rec.PaymentOption) && - c.matchesDuration(offering.Duration, rec.Term) { - return aws.ToString(offering.ReservedInstanceOfferingId), nil + for _, offering := range result.ReservedInstanceOfferings { + if string(offering.InstanceType) == rec.ResourceType { + if c.matchesPaymentOption(offering.PaymentOption, rec.PaymentOption) && + c.matchesDuration(offering.Duration, rec.Term) { + return aws.ToString(offering.ReservedInstanceOfferingId), nil + } } } + + if result.NextToken == nil || aws.ToString(result.NextToken) == "" { + break + } + nextToken = result.NextToken } return "", fmt.Errorf("no offerings found for %s", rec.ResourceType)