From bc9ebc166a9eef0cbd5e8ac602156cdf4cd9e455 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 26 Oct 2026 13:57:49 +0100 Subject: [PATCH 1/2] fix(scheduler): keep GCP recommendations when some are skipped and tell the user The GCP provider now returns the recommendations it could convert plus an error with Partial() bool when it skipped invalid ones (non-USD or malformed amount, malformed plan, unrecognized term). Treat that as a partial result instead of a failed collection: keep the recommendations, log the message and record the skipped count in recommendations_state.last_collection_partial (skipped_recommendations). The check matches the Partial() method, so it is provider-agnostic. A skip-only gap leaves the account in the eviction roster: a skipped recommendation is invalid, so its previous-cycle row should be evicted, not kept for purchase. Lost scopes still withhold eviction as before. The recommendations page and dashboard show a warning toast with the count. Closes #1472 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/recommendations.test.ts | 22 +++++ frontend/src/api/recommendations.ts | 13 +++ frontend/src/recommendations.ts | 16 ++++ internal/api/handler_recommendations_test.go | 9 +- internal/api/openapi.yaml | 8 +- internal/config/types.go | 4 + internal/scheduler/partial_skip_test.go | 91 +++++++++++++++++++ internal/scheduler/partial_sweep.go | 69 ++++++++++++-- internal/scheduler/scheduler.go | 22 ++--- 9 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 internal/scheduler/partial_skip_test.go diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index dd7560f3f..a9e6f9324 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1875,6 +1875,28 @@ describe('Recommendations Module', () => { await pendingRefresh; }); + test('warns how many GCP recommendations the last collection skipped (#1472)', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: new Date().toISOString(), + last_collection_error: null, + last_collection_partial: { + providers: [{ + provider: 'gcp', incomplete_accounts: 0, attempted_scopes: 0, failed_scopes: 0, + failed_details: 0, skipped_recommendations: 3, reasons: {}, + }], + }, + }); + + await loadRecommendations(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'warning', message: expect.stringContaining('3 GCP recommendation(s) skipped') }), + ); + }); + test('persistent in-flight toast — shown with timeout: null so it stays until settled', async () => { mockGetRecs(); (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ diff --git a/frontend/src/api/recommendations.ts b/frontend/src/api/recommendations.ts index bc9aba568..810c48056 100644 --- a/frontend/src/api/recommendations.ts +++ b/frontend/src/api/recommendations.ts @@ -75,6 +75,18 @@ export async function refreshRecommendations(): Promise; +} + export interface RecommendationsFreshness { last_collected_at: string | null; last_collection_error: string | null; @@ -84,6 +96,7 @@ export interface RecommendationsFreshness { // tolerate `undefined` for legacy callsites that haven't been updated // and treat undefined the same as null. last_collection_started_at?: string | null; + last_collection_partial?: { providers: PartialProviderSweep[] } | null; } /** diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 1a3daa4c8..53a8b2622 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -321,6 +321,19 @@ export function setupRecommendationsHandlers(): void { state.subscribeAccount(scheduleReload); } +/** + * Names the recommendations a provider dropped as invalid in the last + * collection (issue #1472), or null when none were. The rest of that + * provider's recommendations are shown normally. + */ +export function describeSkippedRecommendations(f: RecommendationsFreshness): string | null { + const parts = (f.last_collection_partial?.providers ?? []) + .filter((p) => (p.skipped_recommendations ?? 0) > 0) + .map((p) => `${p.skipped_recommendations} ${p.provider.toUpperCase()}`); + if (parts.length === 0) return null; + return `${parts.join(', ')} recommendation(s) skipped as invalid (for example a non-USD price or an unrecognized term) in the last collection. The others are shown.`; +} + /** * Check freshness and fire a background refresh if the cache is stale or cold. * Fire-and-forget: does not block the table render that already happened. @@ -357,6 +370,9 @@ export async function triggerAutoRefreshIfStale( return; } + const skipNotice = describeSkippedRecommendations(freshness); + if (skipNotice) showToast({ message: skipNotice, kind: 'warning' }); + const lastCollectedMs = freshness.last_collected_at === null ? null diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index e19679a6a..776e37227 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -152,6 +152,11 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { AttemptedScopes: 10, FailedScopes: 9, Reasons: map[string]int{config.PartialReasonThrottled: 9}, + }, { + // GCP skipped recommendations (issue #1472): complete sweep, N dropped. + Provider: "gcp", + SkippedRecommendations: 3, + Reasons: map[string]int{}, }}, } mockStore.On("GetRecommendationsFreshness", ctx). @@ -167,7 +172,9 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { body, err := json.Marshal(got) require.NoError(t, err) assert.JSONEq(t, `{"providers":[{"provider":"azure","incomplete_accounts":1,"attempted_scopes":10,`+ - `"failed_scopes":9,"failed_details":0,"reasons":{"throttled":9}}]}`, + `"failed_scopes":9,"failed_details":0,"skipped_recommendations":0,"reasons":{"throttled":9}},`+ + `{"provider":"gcp","incomplete_accounts":0,"attempted_scopes":0,"failed_scopes":0,"failed_details":0,`+ + `"skipped_recommendations":3,"reasons":{}}]}`, string(mustField(t, body, "last_collection_partial"))) }) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 0280ff99c..63d727194 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -6806,7 +6806,7 @@ components: PartialProviderSweep: type: object - required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, reasons] + required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, skipped_recommendations, reasons] properties: provider: type: string @@ -6825,6 +6825,12 @@ components: failed_details: type: integer description: Individual recommendation details rejected inside answering scopes (AWS). + skipped_recommendations: + type: integer + description: > + Recommendations the provider dropped as invalid (GCP: non-USD, + malformed amount or plan, unrecognized term) while returning the + rest. Their accounts' sweeps were complete. reasons: type: object description: > diff --git a/internal/config/types.go b/internal/config/types.go index 71f3c73c2..7041f89be 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -956,6 +956,10 @@ type PartialProviderSweep struct { // FailedDetails is how many individual recommendation details were // rejected inside scopes that otherwise answered (AWS only). FailedDetails int `json:"failed_details"` + // SkippedRecommendations is how many recommendations the provider + // dropped as invalid (GCP: non-USD, malformed amount or plan, unknown + // term) while returning the rest. Those accounts' sweeps were complete. + SkippedRecommendations int `json:"skipped_recommendations"` // Reasons counts the failure causes by PartialReason* category. Reasons map[string]int `json:"reasons"` } diff --git a/internal/scheduler/partial_skip_test.go b/internal/scheduler/partial_skip_test.go new file mode 100644 index 000000000..ad947df78 --- /dev/null +++ b/internal/scheduler/partial_skip_test.go @@ -0,0 +1,91 @@ +package scheduler + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// skipErr stands in for the GCP provider's unexported partial-result error: +// the scheduler must match the Partial() method, not the type (issue #1472). +type skipErr struct { + msg string + partial bool +} + +func (e *skipErr) Error() string { return e.msg } +func (e *skipErr) Partial() bool { return e.partial } + +const skipMsg = "gcp recommendations: 3 recommendation(s) skipped: computeengine us-east1: non-USD; cloudsql us-east1: bad plan" + +func gcpAmbient(s *Scheduler) ([]config.RecommendationRecord, []string, *sweepGap, error) { + return s.collectGCPRecommendations(context.Background(), &config.GlobalConfig{}) +} + +func TestGCPPartialResult_KeepsRecsAndRecordsSkips(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "proj-a") + s, _ := newPartialSweepScheduler(t, "gcp", fmt.Errorf("collect: %w", &skipErr{msg: skipMsg, partial: true})) + + recs, roster, gap, err := gcpAmbient(s) + + require.NoError(t, err, "a partial GCP result must not fail the collection run") + assert.NotEmpty(t, recs, "the recommendations that converted must be kept") + require.NotNil(t, gap, "the skip must be recorded for the user") + assert.Equal(t, 3, gap.skippedRecommendations) + assert.Equal(t, 3, gap.toProviderSweep("gcp").SkippedRecommendations) + assert.NotEmpty(t, roster, + "skipped recommendations are invalid, so the account stays eviction-eligible and their stale rows go") + assert.Zero(t, gap.forAccount().incompleteAccounts, "a skip is not an incomplete account") +} + +func TestGCPPartialResult_NonPartialErrorsStayHard(t *testing.T) { + for name, err := range map[string]error{ + "plain error": errors.New("boom"), + "Partial() == false": &skipErr{msg: skipMsg, partial: false}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "proj-a") + s, _ := newPartialSweepScheduler(t, "gcp", err) + + recs, roster, gap, gotErr := gcpAmbient(s) + + require.Error(t, gotErr, "a non-partial error must fail the run") + assert.Empty(t, recs) + assert.Empty(t, roster) + assert.Nil(t, gap) + }) + } +} + +func TestGCPPartialResult_FanOutKeepsAccountEvictable(t *testing.T) { + accounts := []config.CloudAccount{{ID: "a1", Name: "a1", ExternalID: "p1"}, {ID: "a2", Name: "a2", ExternalID: "p2"}} + fn := func(_ context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, *sweepGap, error) { + gap, err := tolerateIncompleteSweep("gcp", &skipErr{msg: skipMsg, partial: true}) + if acct.ID == "a2" { + // A lost project in the same account hold back eviction as before. + gap = mergeSweepGaps(gap, gapFromPartialSubscriptions(partialSweepErr("p-x"))) + } + return []config.RecommendationRecord{{ID: acct.ID}}, gap, err + } + + recs, outcome := fanOutPerAccount(context.Background(), "GCP", accounts, fn) + + assert.Len(t, recs, 2) + assert.Equal(t, []string{"a1"}, outcome.SucceededAccountIDs) + assert.Equal(t, []string{"a2"}, outcome.IncompleteAccountIDs) + require.NotNil(t, outcome.Gap) + assert.Equal(t, 6, outcome.Gap.skippedRecommendations) + assert.Equal(t, 1, outcome.Gap.incompleteAccounts) +} + +func TestGCPPartialResult_UnparsableCountStillRecorded(t *testing.T) { + gap, err := tolerateIncompleteSweep("gcp", &skipErr{msg: "something skipped", partial: true}) + require.NoError(t, err) + require.NotNil(t, gap) + assert.Zero(t, gap.skippedRecommendations) +} diff --git a/internal/scheduler/partial_sweep.go b/internal/scheduler/partial_sweep.go index 516a16217..43cfcfd2b 100644 --- a/internal/scheduler/partial_sweep.go +++ b/internal/scheduler/partial_sweep.go @@ -4,9 +4,12 @@ import ( "context" "errors" "net/http" + "regexp" + "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" awsrecommendations "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" azureprovider "github.com/LeanerCloud/cloud-commitments-go/providers/azure" gcpprovider "github.com/LeanerCloud/cloud-commitments-go/providers/gcp" @@ -30,7 +33,50 @@ type sweepGap struct { attemptedScopes int failedScopes int failedDetails int - reasons map[string]int + // skippedRecommendations counts recommendations the provider dropped as + // invalid (for example non-USD) while returning the rest. A gap made only + // of these does not hold back stale-row eviction; see holdsEviction. + skippedRecommendations int + // skippedOnly is true when every sweep merged into this gap lost nothing + // but invalid recommendations, so the account's sweep itself was complete. + skippedOnly bool + reasons map[string]int +} + +// holdsEviction reports whether the sweep must stay out of the succeeded- +// account roster. A nil gap (complete sweep) and a skipped-only gap do not: +// a skipped recommendation is invalid, so its previous-cycle row is exactly +// what stale-row eviction should remove rather than keep offering for +// purchase. Every other gap lost whole scopes, whose rows must survive. +func (g *sweepGap) holdsEviction() bool { + return g != nil && !g.skippedOnly +} + +var skippedCountRE = regexp.MustCompile(`(\d+) recommendation\(s\) skipped`) + +// partialResult is implemented by provider errors that accompany usable +// results (GCP's skipped recommendations). Matching the method, not the +// provider's internal type, keeps this provider-agnostic. +type partialResult interface{ Partial() bool } + +// tolerateSkippedRecommendations keeps the recommendations returned alongside +// a Partial() error and records how many were skipped. Any other error is +// returned unchanged. +func tolerateSkippedRecommendations(providerName string, err error) (*sweepGap, error) { + var p partialResult + if err == nil || !errors.As(err, &p) || !p.Partial() { + return nil, err + } + logging.Warnf("%s recommendations partial, keeping the rest: %v", providerName, err) + // The count is parsed from the message because the contract exposes only + // Partial(); an unparsable message still yields a gap, with count 0. + n := 0 + if m := skippedCountRE.FindStringSubmatch(err.Error()); m != nil { + if v, convErr := strconv.Atoi(m[1]); convErr == nil { + n = v + } + } + return &sweepGap{skippedRecommendations: n, skippedOnly: true, reasons: map[string]int{}}, nil } // gapFromAWSIncomplete summarizes an AWS IncompleteRecommendationsError. @@ -70,7 +116,9 @@ func (g *sweepGap) forAccount() *sweepGap { return nil } out := mergeSweepGaps(nil, g) - out.incompleteAccounts = 1 + if g.holdsEviction() { + out.incompleteAccounts = 1 + } return out } @@ -80,11 +128,13 @@ func mergeSweepGaps(a, b *sweepGap) *sweepGap { if a == nil && b == nil { return nil } - out := &sweepGap{reasons: map[string]int{}} + out := &sweepGap{reasons: map[string]int{}, skippedOnly: true} for _, g := range []*sweepGap{a, b} { if g == nil { continue } + out.skippedOnly = out.skippedOnly && g.skippedOnly + out.skippedRecommendations += g.skippedRecommendations out.incompleteAccounts += g.incompleteAccounts out.attemptedScopes += g.attemptedScopes out.failedScopes += g.failedScopes @@ -103,12 +153,13 @@ func (g *sweepGap) toProviderSweep(providerName string) config.PartialProviderSw reasons[reason] = n } return config.PartialProviderSweep{ - Provider: providerName, - IncompleteAccounts: g.incompleteAccounts, - AttemptedScopes: g.attemptedScopes, - FailedScopes: g.failedScopes, - FailedDetails: g.failedDetails, - Reasons: reasons, + Provider: providerName, + IncompleteAccounts: g.incompleteAccounts, + AttemptedScopes: g.attemptedScopes, + FailedScopes: g.failedScopes, + FailedDetails: g.failedDetails, + SkippedRecommendations: g.skippedRecommendations, + Reasons: reasons, } } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 35848f40c..b756dd37b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -547,9 +547,9 @@ func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *co // in those cases. if acctID := s.resolveAmbientHostAccountID(ctx); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "AWS", accounts, func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, *sweepGap, error) { @@ -653,12 +653,12 @@ func fanOutPerAccount( // SucceededAccountIDs, the roster that authorizes stale-row // eviction. See tolerateIncompleteSweep for why (issue #1652). outcome.SucceededCount++ - if gap == nil { - outcome.SucceededAccountIDs = append(outcome.SucceededAccountIDs, acct.ID) - } else { + if gap.holdsEviction() { outcome.IncompleteAccountIDs = append(outcome.IncompleteAccountIDs, acct.ID) - outcome.Gap = mergeSweepGaps(outcome.Gap, gap.forAccount()) + } else { + outcome.SucceededAccountIDs = append(outcome.SucceededAccountIDs, acct.ID) } + outcome.Gap = mergeSweepGaps(outcome.Gap, gap.forAccount()) mu.Unlock() return nil }) @@ -886,9 +886,9 @@ func (s *Scheduler) collectAzureRecommendations(ctx context.Context, _ *config.G } if acctID := s.resolveAmbientAccountID(ctx, "azure", subscriptionID); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "Azure", accounts, s.collectAzureForAccount) @@ -961,9 +961,9 @@ func (s *Scheduler) collectGCPRecommendations(ctx context.Context, _ *config.Glo } if acctID := s.resolveAmbientAccountID(ctx, "gcp", projectID); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "GCP", accounts, s.collectGCPForAccount) @@ -1080,7 +1080,7 @@ func tolerateIncompleteSweep(providerName string, err error) (*sweepGap, error) } partial := azureprovider.AsPartialSubscriptionFailure(err) if partial == nil { - return nil, err + return tolerateSkippedRecommendations(providerName, err) } logging.Warnf( "%s recommendations incomplete: %d of %d subscriptions succeeded; not queried: %s (keeping the %d that did, "+ From 256dd55c40783e6f5bf35d8742544f22cb8aa983 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 27 Oct 2026 14:08:27 +0000 Subject: [PATCH 2/2] chore(deps): bump cloud commitment modules and keep current SDKs --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 22a35b612..5fb3a2f61 100644 --- a/go.mod +++ b/go.mod @@ -81,10 +81,10 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043 github.com/aws/aws-lambda-go v1.55.1 github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 github.com/aws/aws-sdk-go-v2/service/lambda v1.113.0 diff --git a/go.sum b/go.sum index 35075673e..06deac0b9 100644 --- a/go.sum +++ b/go.sum @@ -92,14 +92,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531 h1:x7bdeCDu+GdtVQ5Mcx8j0u09qiCssTPKtjru5nVAzQ0= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531/go.mod h1:rEJErEsL40Np/ttf0RDoNNC2XQsN1GmWyFf34y0JLD8= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531 h1:aZhRGSwaGxZ3gnPuR9iqHo069z0o0rJSoA7cgs9xqbg= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531/go.mod h1:yVHEL5ceNS/CsDatzLH0+Qmwtoi7yY4tIHvqyvhdtig= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531 h1:cWndlu++2ZWeSxvZthojAsTEHOdGU7BlIufJ60ssfAY= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531/go.mod h1:zA51HKXtBvHHMYd+owRDmn4p8ialZrLFzI9I052uUmo= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531 h1:Y7mztemGOd3kChFN236+zG4XLQ72E/MNiEdh+SU3Sv8= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531/go.mod h1:/HxBqXBb5v69+EHLsibHnekkEYRP8TvyCmT6d9QRxt0= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043 h1:aNxMUAEefkT973eGTUbVppd1X99zLKWRUYmJWfO1BUI= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043/go.mod h1:rEJErEsL40Np/ttf0RDoNNC2XQsN1GmWyFf34y0JLD8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043 h1:UjP0mxD+iv/ZIZg6hIm6dWHILzNX+0T/WCgiDUk5c+8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043/go.mod h1:yVHEL5ceNS/CsDatzLH0+Qmwtoi7yY4tIHvqyvhdtig= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043 h1:uhHCm5B8G6sGGoRCOyUp41tbza4W3pkh2EeUwT8UVKo= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043/go.mod h1:zA51HKXtBvHHMYd+owRDmn4p8ialZrLFzI9I052uUmo= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043 h1:ryqPNyihinNu9065Ysi4c9sWP90yX9jeBP7fDOrO6jw= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043/go.mod h1:/HxBqXBb5v69+EHLsibHnekkEYRP8TvyCmT6d9QRxt0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=