From 57aa3996983d36f1a592460a3350b9fee348f7cf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 3 Oct 2026 23:38:06 +0200 Subject: [PATCH] test(recommendations): verify Savings Plans completeness --- cmd/recommendation_completeness_proxy_test.go | 185 ++++++++++++++++-- cmd/recommendation_completeness_test.go | 140 ++++++++++--- go.mod | 2 +- go.sum | 4 +- 4 files changed, 280 insertions(+), 51 deletions(-) diff --git a/cmd/recommendation_completeness_proxy_test.go b/cmd/recommendation_completeness_proxy_test.go index ec8a71b5e..55f5e8880 100644 --- a/cmd/recommendation_completeness_proxy_test.go +++ b/cmd/recommendation_completeness_proxy_test.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -26,22 +27,34 @@ import ( ) type completenessProxy struct { - t *testing.T - mu sync.Mutex - handlers sync.WaitGroup - regions string - details string - requests map[string]int - cert tls.Certificate + t *testing.T + mu sync.Mutex + handlers sync.WaitGroup + regions string + details string + service string + requests map[string]int + spRequests []completenessSPRequest + engines []string + cert tls.Certificate } -func newCompletenessProxy(t *testing.T, dir, regions, details string) (*completenessProxy, string, string) { +type completenessSPRequest struct { + SavingsPlansType string + NextPageToken string + TermInYears string + PaymentOption string + LookbackPeriodInDays string + AccountScope string +} + +func newCompletenessProxy(t *testing.T, dir, regions, details, service string) (*completenessProxy, string, string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) template := &x509.Certificate{ SerialNumber: big.NewInt(54), Subject: pkix.Name{CommonName: "recommendation fixture"}, - DNSNames: []string{"ce.us-east-1.amazonaws.com", "ec2.us-east-1.amazonaws.com", "rds.us-east-1.amazonaws.com"}, + DNSNames: []string{"ce.us-east-1.amazonaws.com", "ec2.us-east-1.amazonaws.com", "rds.us-east-1.amazonaws.com", "savingsplans.amazonaws.com"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, @@ -52,7 +65,7 @@ func newCompletenessProxy(t *testing.T, dir, regions, details string) (*complete certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) caPath := filepath.Join(dir, "ca.pem") require.NoError(t, os.WriteFile(caPath, certPEM, 0600)) - fixture := &completenessProxy{t: t, regions: regions, details: details, requests: make(map[string]int), + fixture := &completenessProxy{t: t, regions: regions, details: details, service: service, requests: make(map[string]int), cert: tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}} server := httptest.NewServer(http.HandlerFunc(fixture.serveConnect)) t.Cleanup(func() { @@ -65,7 +78,7 @@ func newCompletenessProxy(t *testing.T, dir, regions, details string) (*complete func (p *completenessProxy) serveConnect(w http.ResponseWriter, r *http.Request) { p.handlers.Add(1) defer p.handlers.Done() - allowed := r.Host == "ce.us-east-1.amazonaws.com:443" || r.Host == "ec2.us-east-1.amazonaws.com:443" || r.Host == "rds.us-east-1.amazonaws.com:443" + allowed := r.Host == "ce.us-east-1.amazonaws.com:443" || r.Host == "ec2.us-east-1.amazonaws.com:443" || r.Host == "rds.us-east-1.amazonaws.com:443" || r.Host == "savingsplans.amazonaws.com:443" if r.Method != http.MethodConnect || !allowed { p.t.Errorf("unexpected CONNECT %s %s", r.Method, r.Host) http.Error(w, "forbidden", http.StatusForbidden) @@ -121,24 +134,78 @@ func (p *completenessProxy) respond(req *http.Request) *http.Response { p.t.Error(err) } op := req.Header.Get("X-Amz-Target") - if op == "" { + if req.Host == "savingsplans.amazonaws.com" && op == "" { + op = strings.TrimPrefix(req.URL.Path, "/") + if req.Method != http.MethodPost || req.URL.Path != "/DescribeSavingsPlans" || req.URL.RawQuery != "" { + p.t.Errorf("unexpected Savings Plans REST request %s %s", req.Method, req.URL) + op = "fixture-rejected" + } else { + var inventory struct { + States []string `json:"states"` + MaxResults int `json:"maxResults"` + NextToken string `json:"nextToken"` + } + if decodeErr := json.Unmarshal(body, &inventory); decodeErr != nil || inventory.MaxResults != 100 || inventory.NextToken != "" || strings.Join(inventory.States, ",") != "active,payment-pending,pending-return,queued" { + p.t.Errorf("unexpected SP inventory request: %s (%v)", body, decodeErr) + op = "fixture-rejected" + } + } + } else if op == "" { values, parseErr := url.ParseQuery(string(body)) if parseErr != nil { p.t.Error(parseErr) } op = values.Get("Action") + if p.service != "rds" && req.Host == "rds.us-east-1.amazonaws.com" { + want := url.Values{"Action": {op}, "Version": {"2014-10-31"}} + if op == "DescribeDBMajorEngineVersions" { + engine := values.Get("Engine") + want.Set("Engine", engine) + p.mu.Lock() + p.engines = append(p.engines, engine) + p.mu.Unlock() + } + if req.Method != http.MethodPost || req.URL.Path != "/" || req.URL.RawQuery != "" || values.Encode() != want.Encode() { + p.t.Errorf("unexpected SP ancillary RDS request %s %s: %s", req.Method, req.URL, body) + op = "fixture-rejected" + } + } } p.mu.Lock() p.requests[op]++ call := p.requests[op] p.mu.Unlock() status, contentType, payload := p.operation(req.Host, op, call) + if p.service != "rds" && req.Host == "ce.us-east-1.amazonaws.com" && op == "AWSInsightsIndexService.GetSavingsPlansPurchaseRecommendation" { + status, contentType, payload = p.spRecommendation(body) + } return &http.Response{StatusCode: status, ProtoMajor: 1, ProtoMinor: 1, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(payload)), ContentLength: int64(len(payload))} } func (p *completenessProxy) operation(host, op string, call int) (int, string, string) { + responses := map[string]string{ + "DescribeDBInstances": "DBInstances", + "DescribeDBMajorEngineVersions": "DBMajorEngineVersions", + "DescribeReservedDBInstances": "ReservedDBInstances", + } + if element, ok := responses[op]; ok && host == "rds.us-east-1.amazonaws.com" && (p.service == "rds" || op != "DescribeReservedDBInstances") { + return 200, "text/xml", fmt.Sprintf(`<%sResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/"><%sResult><%s/>`, op, op, element, op, op) + } + if p.service != "rds" { + if host == "ce.us-east-1.amazonaws.com" && op == "AWSInsightsIndexService.GetSavingsPlansPurchaseRecommendation" { + return 200, "application/x-amz-json-1.1", "" + } + if host == "savingsplans.amazonaws.com" && op == "DescribeSavingsPlans" { + return 200, "application/json", `{"savingsPlans":[]}` + } + if host == "ec2.us-east-1.amazonaws.com" && op == "DescribeRegions" { + return 200, "text/xml", `us-east-1` + } + p.t.Errorf("unexpected SP operation %s %s", host, op) + return 403, "text/plain", "fixture rejected operation" + } if host == "ce.us-east-1.amazonaws.com" && op == "AWSInsightsIndexService.GetReservationCoverage" { return 200, "application/x-amz-json-1.1", `{"CoveragesByTime":[]}` } @@ -164,14 +231,6 @@ func (p *completenessProxy) operation(host, op string, call int) (int, string, s } return 200, "text/xml", `us-east-1` } - responses := map[string]string{ - "DescribeDBInstances": "DBInstances", - "DescribeDBMajorEngineVersions": "DBMajorEngineVersions", - "DescribeReservedDBInstances": "ReservedDBInstances", - } - if element, ok := responses[op]; ok && host == "rds.us-east-1.amazonaws.com" { - return 200, "text/xml", fmt.Sprintf(`<%sResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/"><%sResult><%s/>`, op, op, element, op, op) - } p.t.Errorf("unexpected operation %s %s", host, op) return 403, "text/plain", "fixture rejected operation" } @@ -181,6 +240,10 @@ func (p *completenessProxy) assertRequests(t *testing.T) { p.handlers.Wait() p.mu.Lock() defer p.mu.Unlock() + if p.service != "rds" { + p.assertSPRequests(t) + return + } ceCalls := 1 if p.regions == "fallback" { ceCalls = 6 @@ -196,3 +259,85 @@ func (p *completenessProxy) assertRequests(t *testing.T) { require.Equal(t, wantRegions, p.requests["DescribeRegions"], "region calls") t.Logf("actual root command, config loader, SDK and CSV; synthetic operations=%v", p.requests) } + +func completenessSPTypes(service string) []string { + if service == "savingsplans" { + return []string{"COMPUTE_SP", "EC2_INSTANCE_SP", "SAGEMAKER_SP", "DATABASE_SP"} + } + for _, plan := range []string{"COMPUTE_SP", "EC2_INSTANCE_SP", "SAGEMAKER_SP", "DATABASE_SP"} { + if completenessSPService(plan) == strings.Replace(service, "savingsplans-", "savings-plans-", 1) { + return []string{plan} + } + } + panic("unexpected fixture service: " + service) +} + +func completenessSPService(plan string) string { + return map[string]string{"COMPUTE_SP": "savings-plans-compute", "EC2_INSTANCE_SP": "savings-plans-ec2instance", "SAGEMAKER_SP": "savings-plans-sagemaker", "DATABASE_SP": "savings-plans-database"}[plan] +} + +func (p *completenessProxy) spRecommendation(body []byte) (int, string, string) { + var request completenessSPRequest + if err := json.Unmarshal(body, &request); err != nil { + p.t.Errorf("decode SP request: %v", err) + return 403, "application/json", `{}` + } + p.mu.Lock() + p.spRequests = append(p.spRequests, request) + p.mu.Unlock() + if p.details == "api-error" || (p.details == "failed-type" && (p.service != "savingsplans" || request.SavingsPlansType == "DATABASE_SP")) || request.NextPageToken == "unfinished" { + return 400, "application/x-amz-json-1.1", `{"__type":"AccessDeniedException","message":"fixture denied recommendations"}` + } + const valid = `{"HourlyCommitmentToPurchase":"2","EstimatedMonthlySavingsAmount":"10","UpfrontCost":"3","CurrentAverageHourlyOnDemandSpend":"4"}` + const invalid = `{"HourlyCommitmentToPurchase":"not-a-number","EstimatedMonthlySavingsAmount":"10","UpfrontCost":"3"}` + details := valid + if request.SavingsPlansType == "EC2_INSTANCE_SP" { + details = strings.TrimSuffix(valid, "}") + `,"SavingsPlansDetails":{"Region":"us-east-1"}}` + } + switch p.details { + case "mixed": + details += "," + invalid + case "all-invalid": + details = invalid + case "empty": + details = "" + } + token := "" + if p.details == "late-page" { + token = `,"NextPageToken":"unfinished"` + } + return 200, "application/x-amz-json-1.1", `{"SavingsPlansPurchaseRecommendation":{"SavingsPlansPurchaseRecommendationDetails":[` + details + `]}` + token + `}` +} + +func (p *completenessProxy) assertSPRequests(t *testing.T) { + t.Helper() + types := completenessSPTypes(p.service) + expected := make([]completenessSPRequest, 0, len(types)*2) + for _, plan := range types { + request := completenessSPRequest{SavingsPlansType: plan, TermInYears: "ONE_YEAR", PaymentOption: "NO_UPFRONT", LookbackPeriodInDays: "SEVEN_DAYS", AccountScope: "LINKED"} + expected = append(expected, request) + if p.details == "late-page" { + request.NextPageToken = "unfinished" + expected = append(expected, request) + } + } + require.Equal(t, expected, p.spRequests, "actual SDK SP request tuples") + rows := len(types) + switch p.details { + case "empty", "all-invalid", "api-error": + rows = 0 + case "failed-type": + if p.service == "savingsplans" { + rows-- + } else { + rows = 0 + } + } + operations := map[string]int{"DescribeRegions": 1, "DescribeDBInstances": 1, "DescribeDBMajorEngineVersions": 4, "AWSInsightsIndexService.GetSavingsPlansPurchaseRecommendation": len(expected)} + if rows > 0 { + operations["DescribeSavingsPlans"] = rows + } + require.Equal(t, operations, p.requests, "unexpected read or purchase") + require.Equal(t, []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"}, p.engines, "ancillary RDS engine reads") + t.Logf("actual root command, SP SDK and CSV; synthetic operations=%v", p.requests) +} diff --git a/cmd/recommendation_completeness_test.go b/cmd/recommendation_completeness_test.go index 1292cd3fe..f1fa8caba 100644 --- a/cmd/recommendation_completeness_test.go +++ b/cmd/recommendation_completeness_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "testing" "time" @@ -30,38 +31,55 @@ func TestRecommendationCompletenessCommand(t *testing.T) { for _, regionMode := range []string{"explicit", "default", "fallback"} { for _, details := range []string{"valid", "mixed", "all-invalid", "empty", "api-error"} { t.Run(regionMode+"/"+details, func(t *testing.T) { - dir := t.TempDir() - fixture, proxyURL, caPath := newCompletenessProxy(t, dir, regionMode, details) - output := filepath.Join(dir, "recommendations.csv") - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRecommendationCompletenessCommand$", "-test.v") - child.Env = []string{ - "PATH=/usr/bin:/bin", "HOME=" + dir, "TMPDIR=" + dir, - "CUDLY_COMPLETENESS_CHILD=1", "CUDLY_COMPLETENESS_OUTPUT=" + output, - "CUDLY_COMPLETENESS_AUDIT=" + filepath.Join(dir, "audit.jsonl"), - "CUDLY_COMPLETENESS_REGIONS=" + regionMode, - "AWS_ACCESS_KEY_ID=synthetic", "AWS_SECRET_ACCESS_KEY=synthetic", "AWS_REGION=us-east-1", - "AWS_EC2_METADATA_DISABLED=true", "AWS_MAX_ATTEMPTS=1", - "AWS_CONFIG_FILE=" + filepath.Join(dir, "absent-config"), - "AWS_SHARED_CREDENTIALS_FILE=" + filepath.Join(dir, "absent-credentials"), - "HTTPS_PROXY=" + proxyURL, "HTTP_PROXY=" + proxyURL, "AWS_CA_BUNDLE=" + caPath, - } - var stdout, stderr bytes.Buffer - child.Stdout, child.Stderr = &stdout, &stderr - require.NoError(t, child.Run(), "stdout: %s\nstderr: %s", &stdout, &stderr) - fixture.assertRequests(t) - logs := stdout.String() + stderr.String() - for _, failure := range []string{"Could not check", "Failed to query", "request send failed", "certificate", "TLS handshake"} { - require.NotContains(t, logs, failure, "unexpected ancillary error") - } - assertCompletenessDiagnostics(t, regionMode, details, stdout.String(), stderr.String()) - assertCompletenessCSV(t, output, details, logs) + runCompletenessScenario(t, "rds", regionMode, details) + }) + } + } + for _, service := range []string{"savingsplans-compute", "savingsplans-ec2instance", "savingsplans-sagemaker", "savingsplans-database", "savingsplans"} { + for _, details := range []string{"valid", "mixed", "all-invalid", "empty", "api-error", "failed-type", "late-page"} { + t.Run(service+"/"+details, func(t *testing.T) { + runCompletenessScenario(t, service, "default", details) }) } } } +func runCompletenessScenario(t *testing.T, service, regionMode, details string) { + t.Helper() + dir := t.TempDir() + fixture, proxyURL, caPath := newCompletenessProxy(t, dir, regionMode, details, service) + output := filepath.Join(dir, "recommendations.csv") + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRecommendationCompletenessCommand$", "-test.v") + child.Env = []string{ + "PATH=/usr/bin:/bin", "HOME=" + dir, "TMPDIR=" + dir, + "CUDLY_COMPLETENESS_CHILD=1", "CUDLY_COMPLETENESS_OUTPUT=" + output, + "CUDLY_COMPLETENESS_AUDIT=" + filepath.Join(dir, "audit.jsonl"), + "CUDLY_COMPLETENESS_REGIONS=" + regionMode, + "CUDLY_COMPLETENESS_SERVICE=" + service, + "AWS_ACCESS_KEY_ID=synthetic", "AWS_SECRET_ACCESS_KEY=synthetic", "AWS_REGION=us-east-1", + "AWS_EC2_METADATA_DISABLED=true", "AWS_MAX_ATTEMPTS=1", + "AWS_CONFIG_FILE=" + filepath.Join(dir, "absent-config"), + "AWS_SHARED_CREDENTIALS_FILE=" + filepath.Join(dir, "absent-credentials"), + "HTTPS_PROXY=" + proxyURL, "HTTP_PROXY=" + proxyURL, "AWS_CA_BUNDLE=" + caPath, + } + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + require.NoError(t, child.Run(), "stdout: %s\nstderr: %s", &stdout, &stderr) + fixture.assertRequests(t) + logs := stdout.String() + stderr.String() + for _, failure := range []string{"Could not check", "Failed to query", "request send failed", "certificate", "TLS handshake"} { + require.NotContains(t, logs, failure, "unexpected ancillary error") + } + if service == "rds" { + assertCompletenessDiagnostics(t, regionMode, details, stdout.String(), stderr.String()) + assertCompletenessCSV(t, output, details, logs) + } else { + assertSPCompleteness(t, output, service, details, logs) + } +} + func runCompletenessCommandChild(t *testing.T) { t.Helper() ca, err := os.ReadFile(os.Getenv("AWS_CA_BUNDLE")) @@ -72,7 +90,7 @@ func runCompletenessCommandChild(t *testing.T) { transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} http.DefaultTransport = transport t.Cleanup(transport.CloseIdleConnections) - args := []string{"--services", "rds", "--term", "1", "--include-extended-support", "--coverage", "100", + args := []string{"--services", os.Getenv("CUDLY_COMPLETENESS_SERVICE"), "--term", "1", "--include-extended-support", "--coverage", "100", "--output", os.Getenv("CUDLY_COMPLETENESS_OUTPUT"), "--audit-log", os.Getenv("CUDLY_COMPLETENESS_AUDIT")} if os.Getenv("CUDLY_COMPLETENESS_REGIONS") == "explicit" { args = append(args, "--regions", "us-east-1") @@ -81,6 +99,72 @@ func runCompletenessCommandChild(t *testing.T) { require.NoError(t, rootCmd.Execute()) } +func assertSPCompleteness(t *testing.T, output, service, details, logs string) { + t.Helper() + types := completenessSPTypes(service) + warnings, failedDetails, failedScopes := 0, 0, 0 + survivors := append([]string(nil), types...) + switch details { + case "mixed", "all-invalid": + warnings, failedDetails = len(types), 1 + case "late-page": + warnings, failedScopes = len(types), 1 + case "failed-type": + if service == "savingsplans" { + survivors = types[:3] + } else { + survivors = nil + } + } + if details == "empty" || details == "all-invalid" || details == "api-error" { + survivors = nil + } + require.Equal(t, warnings, strings.Count(logs, "incomplete AWS recommendations:"), logs) + if warnings > 0 { + want := fmt.Sprintf("incomplete AWS recommendations: %d failed details, %d failed scopes:", failedDetails, failedScopes) + require.Equal(t, warnings, strings.Count(logs, want), logs) + if failedDetails > 0 { + require.Contains(t, logs, "not-a-number") + } + } + failures := 0 + switch details { + case "api-error": + failures = len(types) + case "failed-type": + failures = 1 + } + require.Equal(t, failures, strings.Count(logs, "Failed to fetch recommendations:"), logs) + if failures > 0 || details == "late-page" { + require.Contains(t, logs, "fixture denied recommendations") + } + require.NotContains(t, logs, "Region discovery incomplete") + data, err := os.ReadFile(output) + if len(survivors) == 0 { + require.ErrorIs(t, err, os.ErrNotExist, "unexpected CSV: %s\n%s", data, logs) + require.NotContains(t, logs, "CSV report written") + return + } + require.NoError(t, err, logs) + rows, err := csv.NewReader(bytes.NewReader(data)).ReadAll() + require.NoError(t, err) + require.Len(t, rows, len(survivors)+2, logs) + require.Equal(t, "TOTAL", rows[len(rows)-1][0]) + sort.Slice(survivors, func(i, j int) bool { + return completenessSPService(survivors[i]) < completenessSPService(survivors[j]) + }) + for i, plan := range survivors { + columns := make(map[string]string) + for j, header := range rows[0] { + columns[header] = rows[i+1][j] + } + for field, want := range map[string]string{"Service": completenessSPService(plan), "Region": "", "ResourceType": "", "Count": "1", "Term": "1yr", "PaymentOption": "no-upfront", "UpfrontPayment": "3.00", "RecurringMonthlyCost": "1460.00", "EstimatedSavings": "10.00", "Success": "true", "Error": ""} { + require.Equal(t, want, columns[field], "CSV %s; row=%v", field, rows[i+1]) + } + } + require.Contains(t, logs, "CSV report written") +} + func assertCompletenessDiagnostics(t *testing.T, regionMode, details, stdout, stderr string) { t.Helper() malformed := details == "mixed" || details == "all-invalid" diff --git a/go.mod b/go.mod index e8166a8a4..c973ecf9e 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695 github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901 github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901 github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3 diff --git a/go.sum b/go.sum index b0f0bd421..86de8e6cb 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80 h1:wVKlMokfaME/Lw525Qz3F155R3nY4VmuaIyh+6d4sCU= github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2 h1:rYxXq0G0O2lkf8AB1ni9m4pVhIdb6Z0y52sTxS1RKIA= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2/go.mod h1:d4nsy61/Ptib0SxqMg0ble3yksVm3+QbtUtmGja82PA= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695 h1:DfNEBzFS7/MaBZljLGRRRUaarfnXXZyBE/6iozzp9/k= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695/go.mod h1:d4nsy61/Ptib0SxqMg0ble3yksVm3+QbtUtmGja82PA= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901 h1:iSdHYdmGUjcjtSmgGZxptBzDuLrJ9KR2mh1/x7FNvSY= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901/go.mod h1:zgCL/ozOkcZUDbEC7a2UwW+6MPcLoBlEU2WUZ0TORUs= github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901 h1:i6OwXLUheudN3GfwnYXdKuEq8vPdE9qkNJ+r71LRLKA=