From d10cf52ec341bef5e123f0d28df8cbb0d21436bd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 21:04:12 +0200 Subject: [PATCH 1/3] refactor(cmd): split extended-support logic out of multi_service_engine_versions.go Pure move of declarations, no code change (part of #1834). Co-Authored-By: Claude Opus 5.5 --- cmd/multi_service_engine_versions.go | 168 ----------------- cmd/multi_service_engine_versions_extended.go | 178 ++++++++++++++++++ 2 files changed, 178 insertions(+), 168 deletions(-) create mode 100644 cmd/multi_service_engine_versions_extended.go diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index c4cae83d1..0a0e2c1ef 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -10,7 +10,6 @@ import ( "sync" "time" - "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" @@ -435,170 +434,3 @@ func extractNumericPrefix(s string) string { } return numericPrefix } - -// isInExtendedSupport checks if a version is currently in extended support based on lifecycle dates. -// The second return value reports whether lifecycle data was available for the -// lookup: known is false when the major version cannot be extracted or the -// engine:majorVersion key is missing from versionInfo. Callers on money paths -// must not treat known=false as "not in extended support" (issue #1313). -// An extended-support entry with no start date is an anomalous payload: it is -// skipped with a warning rather than read as "already started", and if no -// other entry proves extended support is active, known is false (issue #1315). -func isInExtendedSupport(engine, fullVersion string, versionInfo map[string]MajorEngineVersionInfo) (extended, known bool) { - majorVersion := extractMajorVersion(engine, fullVersion) - if majorVersion == "" { - return false, false - } - - // Normalize engine name for lookup - normalizedEngine := strings.ToLower(engine) - normalizedEngine = strings.ReplaceAll(normalizedEngine, " ", "") - - // Look up the version info - key := fmt.Sprintf("%s:%s", normalizedEngine, majorVersion) - info, exists := versionInfo[key] - if !exists { - return false, false - } - - // Check if current date falls within extended support period - now := time.Now() - missingStart := false - for _, lifecycle := range info.SupportedEngineLifecycles { - if lifecycle.LifecycleSupportName != string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport) { - continue - } - // A zero start date would pass the start gate below and claim the - // window is open with no evidence; skip it loudly (issue #1315). - if lifecycle.LifecycleSupportStartDate.IsZero() { - missingStart = true - log.Printf("⚠️ Extended-support lifecycle for %s %s has no start date: ignoring the entry, extended-support window unknown", - normalizedEngine, majorVersion) - continue - } - // Not in extended support before its start date - if now.Before(lifecycle.LifecycleSupportStartDate) { - continue - } - // Past the end date means extended support is over; a zero end date - // is open-ended (issue #1182) - if lifecycle.LifecycleSupportEndDate.IsZero() || now.Before(lifecycle.LifecycleSupportEndDate) { - return true, true - } - } - - return false, !missingStart -} - -// adjustRecommendationForExcludedVersions reduces the instance count in a recommendation -// by the number of instances running versions in extended support. -func adjustRecommendationForExcludedVersions(rec common.Recommendation, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo) common.Recommendation { - // Check if this instance type has any running instances - versions, exists := instanceVersions[rec.ResourceType] - if !exists { - // No running instances of this type, return unchanged - return rec - } - - // 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: - if details == nil { - return rec - } - recEngine = details.Engine - default: - return rec // Not RDS, no engine version filtering - } - - // Count how many instances in this region are running versions in extended support - excludedCount := 0 - - // Warn once per engine:version when lifecycle data is missing instead of - // silently treating the instance as not on extended support (issue #1313). - unknownLogged := make(map[string]bool) - - for _, version := range versions { - if shouldExcludeForExtendedSupport(version, rec, recEngine, versionInfo, unknownLogged) { - excludedCount++ - } - } - - // If we found excluded instances, reduce the recommendation count - if excludedCount > 0 { - originalCount := rec.Count - newCount := max(0, rec.Count-excludedCount) - - if newCount != originalCount { - log.Printf("📉 Adjusting recommendation for %s %s in %s: %d instances → %d instances (excluded %d extended support instances)", - recEngine, rec.ResourceType, rec.Region, originalCount, newCount, excludedCount) - return resizeAfterExclusion(rec, newCount) - } - } - - return rec -} - -// resizeAfterExclusion sets rec to newCount instances with its money fields -// scaled to match. A count of zero is the caller's drop signal and carries no -// money. The caller derives newCount as max(0, rec.Count-excluded) with -// excluded > 0, so for newCount >= 1 it is always within [1, rec.Count] and -// common.ResizeCount cannot refuse it; a refusal means that invariant broke, -// which must not turn into a count with unscaled money. -func resizeAfterExclusion(rec common.Recommendation, newCount int) common.Recommendation { - if newCount < 1 { - rec.Count = 0 - return rec - } - resized, err := common.ResizeCount(rec, newCount) - if err != nil { - panic(fmt.Sprintf("extended-support resize invariant broken: %v", err)) - } - return resized -} - -// shouldExcludeForExtendedSupport reports whether a single running instance -// should be excluded from the recommendation because its engine version is in -// extended support. Instances in other regions or with other engines never -// match. When lifecycle data is missing the instance is kept (conservative -// skip) and a warning is logged once per engine:version via unknownLogged -// (issue #1313). Extracted from adjustRecommendationForExcludedVersions to -// keep both functions under the gocyclo cap. -func shouldExcludeForExtendedSupport(version InstanceEngineVersion, rec common.Recommendation, recEngine string, versionInfo map[string]MajorEngineVersionInfo, unknownLogged map[string]bool) bool { - // Only count instances in the same region - if version.Region != rec.Region { - return false - } - - // Match engine (normalize by removing spaces/hyphens and comparing lowercase) - if normalizeEngineNameForVersion(version.Engine) != normalizeEngineNameForVersion(recEngine) { - return false - } - - extended, known := isInExtendedSupport(version.Engine, version.EngineVersion, versionInfo) - if !known { - // Leave the instance in the count but make the gap visible so - // incomplete lifecycle data can't silently steer purchases (issue #1313). - warnKey := version.Engine + ":" + version.EngineVersion - if !unknownLogged[warnKey] { - unknownLogged[warnKey] = true - log.Printf("⚠️ No lifecycle data for %s %s in %s: extended-support status unknown, keeping instance in the count", - version.Engine, version.EngineVersion, version.Region) - } - return false - } - - if !extended { - return false - } - - majorVersion := extractMajorVersion(version.Engine, version.EngineVersion) - log.Printf("🚫 Found extended support instance: %s %s in %s running version %s (major version %s is in extended support)", - recEngine, rec.ResourceType, rec.Region, version.EngineVersion, majorVersion) - return true -} diff --git a/cmd/multi_service_engine_versions_extended.go b/cmd/multi_service_engine_versions_extended.go new file mode 100644 index 000000000..8f01bb614 --- /dev/null +++ b/cmd/multi_service_engine_versions_extended.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" +) + +// isInExtendedSupport checks if a version is currently in extended support based on lifecycle dates. +// The second return value reports whether lifecycle data was available for the +// lookup: known is false when the major version cannot be extracted or the +// engine:majorVersion key is missing from versionInfo. Callers on money paths +// must not treat known=false as "not in extended support" (issue #1313). +// An extended-support entry with no start date is an anomalous payload: it is +// skipped with a warning rather than read as "already started", and if no +// other entry proves extended support is active, known is false (issue #1315). +func isInExtendedSupport(engine, fullVersion string, versionInfo map[string]MajorEngineVersionInfo) (extended, known bool) { + majorVersion := extractMajorVersion(engine, fullVersion) + if majorVersion == "" { + return false, false + } + + // Normalize engine name for lookup + normalizedEngine := strings.ToLower(engine) + normalizedEngine = strings.ReplaceAll(normalizedEngine, " ", "") + + // Look up the version info + key := fmt.Sprintf("%s:%s", normalizedEngine, majorVersion) + info, exists := versionInfo[key] + if !exists { + return false, false + } + + // Check if current date falls within extended support period + now := time.Now() + missingStart := false + for _, lifecycle := range info.SupportedEngineLifecycles { + if lifecycle.LifecycleSupportName != string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport) { + continue + } + // A zero start date would pass the start gate below and claim the + // window is open with no evidence; skip it loudly (issue #1315). + if lifecycle.LifecycleSupportStartDate.IsZero() { + missingStart = true + log.Printf("⚠️ Extended-support lifecycle for %s %s has no start date: ignoring the entry, extended-support window unknown", + normalizedEngine, majorVersion) + continue + } + // Not in extended support before its start date + if now.Before(lifecycle.LifecycleSupportStartDate) { + continue + } + // Past the end date means extended support is over; a zero end date + // is open-ended (issue #1182) + if lifecycle.LifecycleSupportEndDate.IsZero() || now.Before(lifecycle.LifecycleSupportEndDate) { + return true, true + } + } + + return false, !missingStart +} + +// adjustRecommendationForExcludedVersions reduces the instance count in a recommendation +// by the number of instances running versions in extended support. +func adjustRecommendationForExcludedVersions(rec common.Recommendation, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo) common.Recommendation { + // Check if this instance type has any running instances + versions, exists := instanceVersions[rec.ResourceType] + if !exists { + // No running instances of this type, return unchanged + return rec + } + + // 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: + if details == nil { + return rec + } + recEngine = details.Engine + default: + return rec // Not RDS, no engine version filtering + } + + // Count how many instances in this region are running versions in extended support + excludedCount := 0 + + // Warn once per engine:version when lifecycle data is missing instead of + // silently treating the instance as not on extended support (issue #1313). + unknownLogged := make(map[string]bool) + + for _, version := range versions { + if shouldExcludeForExtendedSupport(version, rec, recEngine, versionInfo, unknownLogged) { + excludedCount++ + } + } + + // If we found excluded instances, reduce the recommendation count + if excludedCount > 0 { + originalCount := rec.Count + newCount := max(0, rec.Count-excludedCount) + + if newCount != originalCount { + log.Printf("📉 Adjusting recommendation for %s %s in %s: %d instances → %d instances (excluded %d extended support instances)", + recEngine, rec.ResourceType, rec.Region, originalCount, newCount, excludedCount) + return resizeAfterExclusion(rec, newCount) + } + } + + return rec +} + +// resizeAfterExclusion sets rec to newCount instances with its money fields +// scaled to match. A count of zero is the caller's drop signal and carries no +// money. The caller derives newCount as max(0, rec.Count-excluded) with +// excluded > 0, so for newCount >= 1 it is always within [1, rec.Count] and +// common.ResizeCount cannot refuse it; a refusal means that invariant broke, +// which must not turn into a count with unscaled money. +func resizeAfterExclusion(rec common.Recommendation, newCount int) common.Recommendation { + if newCount < 1 { + rec.Count = 0 + return rec + } + resized, err := common.ResizeCount(rec, newCount) + if err != nil { + panic(fmt.Sprintf("extended-support resize invariant broken: %v", err)) + } + return resized +} + +// shouldExcludeForExtendedSupport reports whether a single running instance +// should be excluded from the recommendation because its engine version is in +// extended support. Instances in other regions or with other engines never +// match. When lifecycle data is missing the instance is kept (conservative +// skip) and a warning is logged once per engine:version via unknownLogged +// (issue #1313). Extracted from adjustRecommendationForExcludedVersions to +// keep both functions under the gocyclo cap. +func shouldExcludeForExtendedSupport(version InstanceEngineVersion, rec common.Recommendation, recEngine string, versionInfo map[string]MajorEngineVersionInfo, unknownLogged map[string]bool) bool { + // Only count instances in the same region + if version.Region != rec.Region { + return false + } + + // Match engine (normalize by removing spaces/hyphens and comparing lowercase) + if normalizeEngineNameForVersion(version.Engine) != normalizeEngineNameForVersion(recEngine) { + return false + } + + extended, known := isInExtendedSupport(version.Engine, version.EngineVersion, versionInfo) + if !known { + // Leave the instance in the count but make the gap visible so + // incomplete lifecycle data can't silently steer purchases (issue #1313). + warnKey := version.Engine + ":" + version.EngineVersion + if !unknownLogged[warnKey] { + unknownLogged[warnKey] = true + log.Printf("⚠️ No lifecycle data for %s %s in %s: extended-support status unknown, keeping instance in the count", + version.Engine, version.EngineVersion, version.Region) + } + return false + } + + if !extended { + return false + } + + majorVersion := extractMajorVersion(version.Engine, version.EngineVersion) + log.Printf("🚫 Found extended support instance: %s %s in %s running version %s (major version %s is in extended support)", + recEngine, rec.ResourceType, rec.Region, version.EngineVersion, majorVersion) + return true +} From edfcde408d75cb70bba5b76d3cc05efa8ef5be3f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 21:04:25 +0200 Subject: [PATCH 2/3] refactor(cmd): split engine version parsing out of multi_service_engine_versions.go Pure move of declarations, no code change (part of #1834). Co-Authored-By: Claude Opus 5.5 --- cmd/multi_service_engine_versions.go | 84 --------------------- cmd/multi_service_engine_versions_parse.go | 88 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 84 deletions(-) create mode 100644 cmd/multi_service_engine_versions_parse.go diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index 0a0e2c1ef..b124bf44c 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "runtime" - "strings" "sync" "time" @@ -351,86 +350,3 @@ func parseDBMajorEngineVersion(version rdstypes.DBMajorEngineVersion) MajorEngin return info } - -// extractMajorVersion extracts the major version from a full engine version string -// Handles special cases like Aurora MySQL version mapping. -func extractMajorVersion(engine, fullVersion string) string { - if fullVersion == "" { - return "" - } - - normalizedEngine := normalizeEngineNameForVersion(engine) - - // Handle Aurora MySQL special format - if normalizedEngine == "auroramysql" { - if auroraVersion := extractAuroraMySQLVersion(fullVersion); auroraVersion != "" { - return auroraVersion - } - } - - // For standard versions, extract "X.Y" or "X" - return extractStandardVersion(fullVersion) -} - -// normalizeEngineNameForVersion normalizes an engine name by removing spaces and hyphens. -func normalizeEngineNameForVersion(engine string) string { - normalized := strings.ToLower(engine) - normalized = strings.ReplaceAll(normalized, "-", "") - normalized = strings.ReplaceAll(normalized, " ", "") - return normalized -} - -// extractAuroraMySQLVersion extracts the MySQL-compatible version from Aurora MySQL. -func extractAuroraMySQLVersion(fullVersion string) string { - // Aurora MySQL 2.x is compatible with MySQL 5.7 - if strings.Contains(fullVersion, "mysql_aurora.2.") { - return "5.7" - } - // Aurora MySQL 3.x is compatible with MySQL 8.0 - if strings.Contains(fullVersion, "mysql_aurora.3.") { - return "8.0" - } - // Check if it starts with a version number - if strings.HasPrefix(fullVersion, "5.7") { - return "5.7" - } - if strings.HasPrefix(fullVersion, "8.0") { - return "8.0" - } - return "" -} - -// extractStandardVersion extracts major.minor version from a standard version string. -func extractStandardVersion(fullVersion string) string { - parts := strings.Split(fullVersion, ".") - if len(parts) >= 2 { - return extractMajorMinorVersion(parts[0], parts[1]) - } - if len(parts) >= 1 { - return parts[0] - } - return "" -} - -// extractMajorMinorVersion combines major and minor version parts. -func extractMajorMinorVersion(major, minor string) string { - // Filter out non-numeric parts in minor version - numericMinor := extractNumericPrefix(minor) - if numericMinor != "" { - return major + "." + numericMinor - } - return major -} - -// extractNumericPrefix extracts the numeric prefix from a string. -func extractNumericPrefix(s string) string { - numericPrefix := "" - for _, ch := range s { - if ch >= '0' && ch <= '9' { - numericPrefix += string(ch) - } else { - break - } - } - return numericPrefix -} diff --git a/cmd/multi_service_engine_versions_parse.go b/cmd/multi_service_engine_versions_parse.go new file mode 100644 index 000000000..693a2f849 --- /dev/null +++ b/cmd/multi_service_engine_versions_parse.go @@ -0,0 +1,88 @@ +package main + +import ( + "strings" +) + +// extractMajorVersion extracts the major version from a full engine version string +// Handles special cases like Aurora MySQL version mapping. +func extractMajorVersion(engine, fullVersion string) string { + if fullVersion == "" { + return "" + } + + normalizedEngine := normalizeEngineNameForVersion(engine) + + // Handle Aurora MySQL special format + if normalizedEngine == "auroramysql" { + if auroraVersion := extractAuroraMySQLVersion(fullVersion); auroraVersion != "" { + return auroraVersion + } + } + + // For standard versions, extract "X.Y" or "X" + return extractStandardVersion(fullVersion) +} + +// normalizeEngineNameForVersion normalizes an engine name by removing spaces and hyphens. +func normalizeEngineNameForVersion(engine string) string { + normalized := strings.ToLower(engine) + normalized = strings.ReplaceAll(normalized, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + return normalized +} + +// extractAuroraMySQLVersion extracts the MySQL-compatible version from Aurora MySQL. +func extractAuroraMySQLVersion(fullVersion string) string { + // Aurora MySQL 2.x is compatible with MySQL 5.7 + if strings.Contains(fullVersion, "mysql_aurora.2.") { + return "5.7" + } + // Aurora MySQL 3.x is compatible with MySQL 8.0 + if strings.Contains(fullVersion, "mysql_aurora.3.") { + return "8.0" + } + // Check if it starts with a version number + if strings.HasPrefix(fullVersion, "5.7") { + return "5.7" + } + if strings.HasPrefix(fullVersion, "8.0") { + return "8.0" + } + return "" +} + +// extractStandardVersion extracts major.minor version from a standard version string. +func extractStandardVersion(fullVersion string) string { + parts := strings.Split(fullVersion, ".") + if len(parts) >= 2 { + return extractMajorMinorVersion(parts[0], parts[1]) + } + if len(parts) >= 1 { + return parts[0] + } + return "" +} + +// extractMajorMinorVersion combines major and minor version parts. +func extractMajorMinorVersion(major, minor string) string { + // Filter out non-numeric parts in minor version + numericMinor := extractNumericPrefix(minor) + if numericMinor != "" { + return major + "." + numericMinor + } + return major +} + +// extractNumericPrefix extracts the numeric prefix from a string. +func extractNumericPrefix(s string) string { + numericPrefix := "" + for _, ch := range s { + if ch >= '0' && ch <= '9' { + numericPrefix += string(ch) + } else { + break + } + } + return numericPrefix +} From 4fabcc51ae28f336ba699a2dab3303c4841316b0 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 21:04:38 +0200 Subject: [PATCH 3/3] refactor(cmd): split major engine version queries out of multi_service_engine_versions.go Pure move of declarations, no code change (part of #1834). Co-Authored-By: Claude Opus 5.5 --- cmd/multi_service_engine_versions.go | 128 ------------------- cmd/multi_service_engine_versions_major.go | 138 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 128 deletions(-) create mode 100644 cmd/multi_service_engine_versions_major.go diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index b124bf44c..c5b03ebcf 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "log" "runtime" @@ -14,7 +13,6 @@ import ( awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" awsrds "github.com/aws/aws-sdk-go-v2/service/rds" - rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" ) // InstanceEngineVersion stores engine version information for an instance. @@ -224,129 +222,3 @@ func queryRDSInstancesPage(ctx context.Context, rdsClient RDSInstancesClient, ma return } - -// queryMajorEngineVersions queries AWS for major engine version lifecycle support information. -func queryMajorEngineVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { - // Determine which profile to use - profile := cfg.ValidationProfile - if profile == "" { - profile = cfg.Profile - } - - // Load AWS configuration - var configOptions []func(*config.LoadOptions) error - configOptions = append(configOptions, config.WithRegion("us-east-1")) - if profile != "" { - configOptions = append(configOptions, config.WithSharedConfigProfile(profile)) - } - awsCfg, err := config.LoadDefaultConfig(ctx, configOptions...) - if err != nil { - return nil, fmt.Errorf("failed to load AWS config: %w", err) - } - - return queryMajorEngineVersionsWithClient(ctx, awsrds.NewFromConfig(awsCfg)) -} - -// queryMajorEngineVersionsWithClient is the testable core of queryMajorEngineVersions. -// It accepts a RDSMajorVersionsClient so tests can inject a mock without real AWS creds. -func queryMajorEngineVersionsWithClient(ctx context.Context, rdsClient RDSMajorVersionsClient) (map[string]MajorEngineVersionInfo, error) { - // Map of "engine:majorVersion" -> MajorEngineVersionInfo - versionInfo := make(map[string]MajorEngineVersionInfo) - - // Query all engine types we care about - engines := []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"} - var engineErrs []error - - for _, engine := range engines { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := fetchMajorEngineVersionsForEngine(ctx, rdsClient, engine, versionInfo); err != nil { - // Canceled caller context is terminal (issue #1325); check ctx.Err(), - // not the wrapped API error, so SDK-internal timeouts are reported as engine failures. - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - // Lifecycle data for an engine is required to tell whether its - // instances are on extended support; keep trying the remaining - // engines so the error reports every unavailable one (issue #2147). - engineErrs = append(engineErrs, fmt.Errorf("major engine versions for %s: %w", engine, err)) - } - } - - if err := ctx.Err(); err != nil { - return nil, err - } - if len(engineErrs) > 0 { - return nil, errors.Join(engineErrs...) - } - return versionInfo, nil -} - -// fetchMajorEngineVersionsForEngine fetches all pages of major engine version -// info for a single engine and merges results into versionInfo. Returns an error -// only on API failure or pagination cap exceeded (issue #692). -func fetchMajorEngineVersionsForEngine(ctx context.Context, rdsClient RDSMajorVersionsClient, engine string, versionInfo map[string]MajorEngineVersionInfo) error { - var marker *string - - for pageIdx := 0; ; pageIdx++ { - if err := ctx.Err(); err != nil { - return err - } - if pageIdx >= maxEngineVersionPages { - return fmt.Errorf( - "pagination cap reached after %d pages for engine %s (issue #692)", - maxEngineVersionPages, engine, - ) - } - - output, err := rdsClient.DescribeDBMajorEngineVersions(ctx, &awsrds.DescribeDBMajorEngineVersionsInput{ - Engine: aws.String(engine), - Marker: marker, - }) - if err != nil { - return err - } - - for _, version := range output.DBMajorEngineVersions { - info := parseDBMajorEngineVersion(version) - key := fmt.Sprintf("%s:%s", info.Engine, info.MajorEngineVersion) - versionInfo[key] = info - } - - if output.Marker == nil || aws.ToString(output.Marker) == "" { - break - } - marker = output.Marker - } - - return nil -} - -// parseDBMajorEngineVersion converts an RDS DBMajorEngineVersion into a -// MajorEngineVersionInfo, extracting lifecycle support dates. Extracted from -// fetchMajorEngineVersionsForEngine to keep its cyclomatic complexity below -// the gocyclo cap. -func parseDBMajorEngineVersion(version rdstypes.DBMajorEngineVersion) MajorEngineVersionInfo { - info := MajorEngineVersionInfo{ - Engine: aws.ToString(version.Engine), - MajorEngineVersion: aws.ToString(version.MajorEngineVersion), - } - - for _, lifecycle := range version.SupportedEngineLifecycles { - lifecycleInfo := EngineLifecycleInfo{ - LifecycleSupportName: string(lifecycle.LifecycleSupportName), - } - - if lifecycle.LifecycleSupportStartDate != nil { - lifecycleInfo.LifecycleSupportStartDate = *lifecycle.LifecycleSupportStartDate - } - if lifecycle.LifecycleSupportEndDate != nil { - lifecycleInfo.LifecycleSupportEndDate = *lifecycle.LifecycleSupportEndDate - } - - info.SupportedEngineLifecycles = append(info.SupportedEngineLifecycles, lifecycleInfo) - } - - return info -} diff --git a/cmd/multi_service_engine_versions_major.go b/cmd/multi_service_engine_versions_major.go new file mode 100644 index 000000000..3b3effd05 --- /dev/null +++ b/cmd/multi_service_engine_versions_major.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + awsrds "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" +) + +// queryMajorEngineVersions queries AWS for major engine version lifecycle support information. +func queryMajorEngineVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { + // Determine which profile to use + profile := cfg.ValidationProfile + if profile == "" { + profile = cfg.Profile + } + + // Load AWS configuration + var configOptions []func(*config.LoadOptions) error + configOptions = append(configOptions, config.WithRegion("us-east-1")) + if profile != "" { + configOptions = append(configOptions, config.WithSharedConfigProfile(profile)) + } + awsCfg, err := config.LoadDefaultConfig(ctx, configOptions...) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + return queryMajorEngineVersionsWithClient(ctx, awsrds.NewFromConfig(awsCfg)) +} + +// queryMajorEngineVersionsWithClient is the testable core of queryMajorEngineVersions. +// It accepts a RDSMajorVersionsClient so tests can inject a mock without real AWS creds. +func queryMajorEngineVersionsWithClient(ctx context.Context, rdsClient RDSMajorVersionsClient) (map[string]MajorEngineVersionInfo, error) { + // Map of "engine:majorVersion" -> MajorEngineVersionInfo + versionInfo := make(map[string]MajorEngineVersionInfo) + + // Query all engine types we care about + engines := []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"} + var engineErrs []error + + for _, engine := range engines { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := fetchMajorEngineVersionsForEngine(ctx, rdsClient, engine, versionInfo); err != nil { + // Canceled caller context is terminal (issue #1325); check ctx.Err(), + // not the wrapped API error, so SDK-internal timeouts are reported as engine failures. + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + // Lifecycle data for an engine is required to tell whether its + // instances are on extended support; keep trying the remaining + // engines so the error reports every unavailable one (issue #2147). + engineErrs = append(engineErrs, fmt.Errorf("major engine versions for %s: %w", engine, err)) + } + } + + if err := ctx.Err(); err != nil { + return nil, err + } + if len(engineErrs) > 0 { + return nil, errors.Join(engineErrs...) + } + return versionInfo, nil +} + +// fetchMajorEngineVersionsForEngine fetches all pages of major engine version +// info for a single engine and merges results into versionInfo. Returns an error +// only on API failure or pagination cap exceeded (issue #692). +func fetchMajorEngineVersionsForEngine(ctx context.Context, rdsClient RDSMajorVersionsClient, engine string, versionInfo map[string]MajorEngineVersionInfo) error { + var marker *string + + for pageIdx := 0; ; pageIdx++ { + if err := ctx.Err(); err != nil { + return err + } + if pageIdx >= maxEngineVersionPages { + return fmt.Errorf( + "pagination cap reached after %d pages for engine %s (issue #692)", + maxEngineVersionPages, engine, + ) + } + + output, err := rdsClient.DescribeDBMajorEngineVersions(ctx, &awsrds.DescribeDBMajorEngineVersionsInput{ + Engine: aws.String(engine), + Marker: marker, + }) + if err != nil { + return err + } + + for _, version := range output.DBMajorEngineVersions { + info := parseDBMajorEngineVersion(version) + key := fmt.Sprintf("%s:%s", info.Engine, info.MajorEngineVersion) + versionInfo[key] = info + } + + if output.Marker == nil || aws.ToString(output.Marker) == "" { + break + } + marker = output.Marker + } + + return nil +} + +// parseDBMajorEngineVersion converts an RDS DBMajorEngineVersion into a +// MajorEngineVersionInfo, extracting lifecycle support dates. Extracted from +// fetchMajorEngineVersionsForEngine to keep its cyclomatic complexity below +// the gocyclo cap. +func parseDBMajorEngineVersion(version rdstypes.DBMajorEngineVersion) MajorEngineVersionInfo { + info := MajorEngineVersionInfo{ + Engine: aws.ToString(version.Engine), + MajorEngineVersion: aws.ToString(version.MajorEngineVersion), + } + + for _, lifecycle := range version.SupportedEngineLifecycles { + lifecycleInfo := EngineLifecycleInfo{ + LifecycleSupportName: string(lifecycle.LifecycleSupportName), + } + + if lifecycle.LifecycleSupportStartDate != nil { + lifecycleInfo.LifecycleSupportStartDate = *lifecycle.LifecycleSupportStartDate + } + if lifecycle.LifecycleSupportEndDate != nil { + lifecycleInfo.LifecycleSupportEndDate = *lifecycle.LifecycleSupportEndDate + } + + info.SupportedEngineLifecycles = append(info.SupportedEngineLifecycles, lifecycleInfo) + } + + return info +}