diff --git a/providers/aws/go.mod b/providers/aws/go.mod index 29ad8236..147e29a7 100644 --- a/providers/aws/go.mod +++ b/providers/aws/go.mod @@ -3,7 +3,7 @@ module github.com/LeanerCloud/cloud-commitments-go/providers/aws go 1.26.9 require ( - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261016194019-89772cf80f67 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261017020613-36d8b1100c05 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.29.12 github.com/aws/aws-sdk-go-v2/credentials v1.17.65 diff --git a/providers/aws/go.sum b/providers/aws/go.sum index 33275e77..78318a17 100644 --- a/providers/aws/go.sum +++ b/providers/aws/go.sum @@ -1,5 +1,5 @@ -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261016194019-89772cf80f67 h1:zpr+lPSdlXeA9ySU7FqHtgn4ADinmRhfJ5Y6jm9ankw= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261016194019-89772cf80f67/go.mod h1:SCDEgHG6evjPh0RsoXof+J3aMmGXNFMrwbJIle7rMpQ= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261017020613-36d8b1100c05 h1:YtSRyJHVUTDnxykCGYpGX7Pt/z+mq4/sCphPmO6Pj5M= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261017020613-36d8b1100c05/go.mod h1:SCDEgHG6evjPh0RsoXof+J3aMmGXNFMrwbJIle7rMpQ= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo= diff --git a/providers/aws/ladder/reshape.go b/providers/aws/ladder/reshape.go index 0cd2019e..d427a677 100644 --- a/providers/aws/ladder/reshape.go +++ b/providers/aws/ladder/reshape.go @@ -5,6 +5,7 @@ import ( "fmt" "math" + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-go/pkg/exchange" "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" ) @@ -165,6 +166,8 @@ func summarizeExchangeResult(result *exchange.AutoExchangeResult) (ladder.Reshap len(result.Completed)+len(result.Pending)+len(result.Failed)+len(result.Skipped)), } + unknown := 0 + firstUnknown := -1 for i := range result.Completed { o := &result.Completed[i] summary.Details = append(summary.Details, fmt.Sprintf( @@ -179,9 +182,17 @@ func summarizeExchangeResult(result *exchange.AutoExchangeResult) (ladder.Reshap } for i := range result.Failed { o := &result.Failed[i] + label := "failed" + if o.OutcomeUnknown { + unknown++ + if firstUnknown < 0 { + firstUnknown = i + } + label = "outcome unknown, reconcile before retrying" + } summary.Details = append(summary.Details, fmt.Sprintf( - "failed: %s (%s) -> %s: %s", - o.SourceRIID, o.SourceInstanceType, o.TargetInstanceType, o.Error)) + "%s: %s (%s) -> %s: %s", + label, o.SourceRIID, o.SourceInstanceType, o.TargetInstanceType, o.Error)) } for i := range result.Skipped { s := &result.Skipped[i] @@ -194,9 +205,16 @@ func summarizeExchangeResult(result *exchange.AutoExchangeResult) (ladder.Reshap // summary.Analyzed: pending and skipped items were never attempted, // and an inflated denominator would misread during an incident. attempts := len(result.Completed) + len(result.Failed) - return summary, fmt.Errorf( - "ReshapeBuffer: %d of %d exchange attempt(s) failed (first: %s: %s); see summary details for the full list", - len(result.Failed), attempts, result.Failed[0].SourceRIID, result.Failed[0].Error) + const format = "ReshapeBuffer: %d of %d exchange attempt(s) failed (first: %s: %s)%s; see summary details for the full list" + if unknown == 0 { + first := &result.Failed[0] + return summary, fmt.Errorf(format, len(result.Failed), attempts, first.SourceRIID, first.Error, "") + } + // The unknown entry is the one needing reconciliation, so name it + // and wrap the sentinel for errors.Is. + first := &result.Failed[firstUnknown] + return summary, fmt.Errorf(format+": %w", len(result.Failed), attempts, first.SourceRIID, first.Error, + fmt.Sprintf("; %d with unknown outcome", unknown), common.ErrOutcomeUnknown) } return summary, nil } diff --git a/providers/aws/ladder/reshape_test.go b/providers/aws/ladder/reshape_test.go index 1e3c5638..68e8f44e 100644 --- a/providers/aws/ladder/reshape_test.go +++ b/providers/aws/ladder/reshape_test.go @@ -260,6 +260,8 @@ func TestReshapeBuffer_PartialFailure_SummaryPlusError(t *testing.T) { assert.Contains(t, err.Error(), "1 of 2 exchange attempt(s) failed", "denominator must be completed+failed attempts, not Analyzed") assert.Contains(t, err.Error(), "ri-bad") + assert.NotContains(t, err.Error(), "unknown outcome", "suffix only when K > 0") + require.NotErrorIs(t, err, common.ErrOutcomeUnknown, "definite failures must not claim an unknown outcome") // The summary is still populated for audit alongside the error. assert.Equal(t, 3, summary.Analyzed) @@ -310,3 +312,41 @@ func TestReshapeBuffer_WrongScope_ReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "does not match configured account") assert.Equal(t, 0, ex.calls) } + +func TestReshapeBuffer_UnknownOutcome_RenderedAsUnknownNotFailed(t *testing.T) { + ex := &fakeExchangeRunner{result: &exchange.AutoExchangeResult{ + Mode: string(exchange.ExchangeModeAuto), + Failed: []exchange.ExchangeOutcome{ + {SourceRIID: "ri-bad", SourceInstanceType: "m5.large", TargetInstanceType: "m5.xlarge", Error: "rejected"}, + {SourceRIID: "ri-unk", SourceInstanceType: "c5.large", TargetInstanceType: "c5.xlarge", Error: "accept timed out", OutcomeUnknown: true}, + }, + }} + a := newWiredLadder(t, &fakePurchaser{}, &fakePurchaser{}, ex) + + summary, err := a.ReshapeBuffer(context.Background(), testScope(), validReshapeCfg()) + require.Error(t, err) + require.ErrorIs(t, err, common.ErrOutcomeUnknown) + assert.Contains(t, err.Error(), "2 of 2 exchange attempt(s) failed (first: ri-unk: accept timed out)", + "first: names the unknown entry even when a definite failure precedes it") + assert.Contains(t, err.Error(), "; 1 with unknown outcome") + + require.Len(t, summary.Details, 2) + assert.Contains(t, summary.Details[0], "failed: ri-bad") + assert.NotContains(t, summary.Details[0], "outcome unknown") + assert.Contains(t, summary.Details[1], "outcome unknown, reconcile before retrying: ri-unk") + assert.NotContains(t, summary.Details[1], "failed:") +} + +func TestReshapeBuffer_UnknownOnly_WrapsErrOutcomeUnknown(t *testing.T) { + ex := &fakeExchangeRunner{result: &exchange.AutoExchangeResult{ + Mode: string(exchange.ExchangeModeAuto), + Failed: []exchange.ExchangeOutcome{ + {SourceRIID: "ri-unk", SourceInstanceType: "c5.large", TargetInstanceType: "c5.xlarge", Error: "accept timed out", OutcomeUnknown: true}, + }, + }} + a := newWiredLadder(t, &fakePurchaser{}, &fakePurchaser{}, ex) + + _, err := a.ReshapeBuffer(context.Background(), testScope(), validReshapeCfg()) + require.ErrorIs(t, err, common.ErrOutcomeUnknown) + assert.Contains(t, err.Error(), "first: ri-unk: accept timed out") +}