Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions internal/api/handler_insurance_comparison.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,9 @@ func (h *Handler) getInsuranceComparison(ctx context.Context, req *events.Lambda
if err := h.requireInsuranceAccess(ctx, req); err != nil {
return nil, err
}
if h.insurance == nil {
return nil, notConfiguredError(archera.Settings{}.Missing())
}
if st := h.insurance.Status(); !st.Configured {
return nil, notConfiguredError(st.Missing)
}
// The raw client lives in this local only.
client, err := h.insurance.Client(ctx)
client, err := h.insuranceClient(ctx)
if err != nil {
if errors.Is(err, archera.ErrNotConfigured) {
return nil, notConfiguredError(h.insurance.Status().Missing)
}
return nil, NewClientError(503, "Archera API key could not be resolved from "+archera.EnvKeySecret)
return nil, err
}
cmp, err := client.Comparison(ctx, insurance.ComparisonRequest{PlanID: h.insurance.PlanID()})
if err != nil {
Expand All @@ -58,6 +48,25 @@ func (h *Handler) getInsuranceComparison(ctx context.Context, req *events.Lambda
return dto, nil
}

// insuranceClient resolves the Archera client, or a fixed client error. The raw
// client lives in the caller's local only.
func (h *Handler) insuranceClient(ctx context.Context) (insurance.QuoteClient, error) {
if h.insurance == nil {
return nil, notConfiguredError(archera.Settings{}.Missing())
}
if st := h.insurance.Status(); !st.Configured {
return nil, notConfiguredError(st.Missing)
}
client, err := h.insurance.Client(ctx)
if err != nil {
if errors.Is(err, archera.ErrNotConfigured) {
return nil, notConfiguredError(h.insurance.Status().Missing)
}
return nil, NewClientError(503, "Archera API key could not be resolved from "+archera.EnvKeySecret)
}
return client, nil
}

func notConfiguredError(missing []string) error {
return NewClientError(503, "Archera comparison is not configured; missing settings: "+strings.Join(missing, ", "))
}
Expand Down
66 changes: 66 additions & 0 deletions internal/api/handler_insurance_recommendation_fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package api

import (
"context"
"errors"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/insurance"

"github.com/LeanerCloud/cloud-commitments-platform/internal/archera"
)

// insuranceRecommendationTimeout bounds secret resolution and the vendor reads. A var so tests can shorten it.
var insuranceRecommendationTimeout = 20 * time.Second

// lineItemClient is the QuoteClient plus the line-item read that the published interface lacks.
type lineItemClient interface {
insurance.QuoteClient
LineItems(ctx context.Context, planID string) ([]insurance.LineItem, error)
}

// FetchRecommendationComparison reads every line item, selects the recommendation's own one and asks for
// a comparison scoped to it. No line item, or an ambiguous one, returns an empty comparison without a
// comparison call. Any failure returns no partial result.
func (h *Handler) FetchRecommendationComparison(ctx context.Context, rec *archera.RecommendationIdentity) (*insurance.Comparison, []insurance.LineItem, error) {
cctx, cancel := context.WithTimeout(ctx, insuranceRecommendationTimeout)
defer cancel()
c, items, err := h.readRecommendationVendor(cctx, rec)
if err == nil {
return c, items, nil
}
if cctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) {
return nil, nil, NewClientError(504, "Archera comparison timed out")
}
if ce, ok := IsClientError(err); ok {
return nil, nil, ce
}
return nil, nil, mapInsuranceError(err)
}

func (h *Handler) readRecommendationVendor(ctx context.Context, rec *archera.RecommendationIdentity) (*insurance.Comparison, []insurance.LineItem, error) {
quote, err := h.insuranceClient(ctx)
if err != nil {
return nil, nil, err
}
client, ok := quote.(lineItemClient)
if !ok {
return nil, nil, NewClientError(503, "Archera recommendation comparison is not supported")
}
planID := h.insurance.PlanID()
items, err := client.LineItems(ctx, planID)
if err != nil {
return nil, nil, err
}
_, identities, err := archera.ProjectContract(&insurance.Comparison{}, items)
if err != nil {
return nil, nil, err
}
selected, status := archera.SelectLineItem(rec, identities)
if status != archera.SelectOne {
return &insurance.Comparison{FetchedAt: time.Now()}, items, nil
}
c, err := client.Comparison(ctx, insurance.ComparisonRequest{PlanID: planID, LineItemIDs: []string{selected},
PaymentOptions: []insurance.PaymentOption{insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront}})
return c, items, err
}
161 changes: 161 additions & 0 deletions internal/api/handler_insurance_recommendation_fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package api

import (
"context"
"errors"
"strings"
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/insurance"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/archera"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

const (
recItem = "22222222-2222-4222-8222-222222222222"
recOffer = "11111111-1111-4111-8111-111111111111"
recSpace = "sensitive-vendor-text"
)

type recClient struct {
items []insurance.LineItem
cmp *insurance.Comparison
itemsErr error
cmpErr error
hang bool
itemCalls int
cmpCalls int
request insurance.ComparisonRequest
}

func (c *recClient) LineItems(ctx context.Context, _ string) ([]insurance.LineItem, error) {
c.itemCalls++
if c.hang {
<-ctx.Done()
return nil, ctx.Err()
}
return c.items, c.itemsErr
}

func (c *recClient) Comparison(_ context.Context, r insurance.ComparisonRequest) (*insurance.Comparison, error) {
c.cmpCalls++
c.request = r
return c.cmp, c.cmpErr
}

func (c *recClient) Plan(context.Context, string) (*insurance.Plan, error) {
panic("unexpected plan read")
}

type recProvider struct {
client insurance.QuoteClient
keys int
}

func (p *recProvider) Status() archera.Status { return archera.Status{Configured: true} }
func (p *recProvider) PlanID() string { return insTestPlan }
func (p *recProvider) Client(context.Context) (insurance.QuoteClient, error) {
p.keys++
return p.client, nil
}

type quoteOnly struct{ insurance.QuoteClient }

func recRecord() config.RecommendationRecord {
source, cloud := "Source-account-a", "cloud-account-a"
return config.RecommendationRecord{ID: "stored-a", Provider: "aws", Service: "savings-plans-ec2instance", Region: "us-east-1",
SourceAccountID: &source, CloudAccountID: &cloud, Term: 1, Payment: "no-upfront",
Details: []byte(`{"offering_id":"provider-offer-a","plan_type":"EC2Instance","region":"us-east-1","instance_family":"m5","hourly_commitment":0.5}`)}
}

func recVendor(t *testing.T) ([]insurance.LineItem, *insurance.Comparison) {
t.Helper()
item := `{"id":"` + recItem + `","plan_id":"` + insTestPlan + `","offer_id":"` + recOffer + `","account_id":"Source-account-a","selected_quantity":7,"selected_commitment":0.5,"is_selected":true,"contract_term":"one_year_gris","payment_option":"No Upfront","contract_spec":{"commitment_type":"x"},"offer":{"id":"` + recOffer + `","type":"savings_plan","provider":"aws","provider_offering_id":"provider-offer-a","offering_id":"cat","duration_seconds":31536000,"region":"us-east-1","az":null,"instance_type":null,"instance_family":"m5","product_description":null,"tenancy":null,"plan_type":"EC2Instance","is_multi_az":null,"is_flexible":null,"payment_option":"No Upfront"}}`
items, err := insurance.DecodeLineItems(strings.NewReader("["+item+"]"), insTestPlan)
require.NoError(t, err)
cur := `{"is_current":true,"offer_id":"` + recOffer + `","selected_amount":0.5,"commitment_type":"savings_plan","contract_term":"one_year_gris","payment_option":"no_upfront","offer":{"provider":"aws","region":"us-east-1"},"discount_rate":0,"commitment_upfront_cost":0,"commitment_financials_monthly_rate":{},"delta_vs_current":{"monthly_net_savings":0,"upfront_cost":0,"discount_rate":0}}`
body := `{"current_totals":{"commitment_financials_monthly_rate":{},"commitment_upfront_cost":0},"hypothetical_totals":[],"data":[{"line_item_id":"` + recItem + `","current":` + cur + `,"candidates":[]}]}`
cmp, err := insurance.DecodeComparison(strings.NewReader(body), "org", insTestPlan, time.Unix(1, 0))
require.NoError(t, err)
return items, cmp
}

func fetchRig(t *testing.T) (*Handler, *recClient, *recProvider, *archera.RecommendationIdentity) {
t.Helper()
items, cmp := recVendor(t)
c := &recClient{items: items, cmp: cmp}
p := &recProvider{client: c}
row := recRecord()
rec, err := archera.ProjectRecommendation(&row)
require.NoError(t, err)
return &Handler{insurance: p}, c, p, rec
}

func TestFetchRecommendationComparisonScopesToSelectedItemWithAllPayments(t *testing.T) {
h, c, _, rec := fetchRig(t)
cmp, items, err := h.FetchRecommendationComparison(context.Background(), rec)
require.NoError(t, err)
require.NotNil(t, cmp)
require.Len(t, items, 1)
require.Equal(t, []string{recItem}, c.request.LineItemIDs)
require.Equal(t, insTestPlan, c.request.PlanID)
require.ElementsMatch(t, []insurance.PaymentOption{insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront}, c.request.PaymentOptions)
}

func TestFetchRecommendationComparisonNoneOrAmbiguousSkipsComparison(t *testing.T) {
for name, edit := range map[string]func(*recClient){
"none": func(c *recClient) { c.items = nil },
"ambiguous": func(c *recClient) {
twin := c.items[0]
twin.ID = "33333333-3333-4333-8333-333333333333"
c.items = append(c.items, twin)
},
} {
t.Run(name, func(t *testing.T) {
h, c, _, rec := fetchRig(t)
edit(c)
cmp, _, err := h.FetchRecommendationComparison(context.Background(), rec)
require.NoError(t, err)
require.NotNil(t, cmp)
require.Empty(t, cmp.Rows)
require.Zero(t, c.cmpCalls)
})
}
}

func TestFetchRecommendationComparisonFailuresReturnNoPartialResult(t *testing.T) {
for name, tc := range map[string]struct {
setup func(*recClient, *recProvider)
code int
retry string
}{
"line_items": {func(c *recClient, _ *recProvider) { c.itemsErr = errors.New(recSpace) }, 502, ""},
"comparison": {func(c *recClient, _ *recProvider) { c.cmpErr = errors.New(recSpace) }, 502, ""},
"rate_limited": {func(c *recClient, _ *recProvider) {
c.itemsErr = &insurance.HTTPError{StatusCode: 429, RetryAfter: 3 * time.Second}
}, 429, "3"},
"unsupported": {func(c *recClient, p *recProvider) { p.client = quoteOnly{c} }, 503, ""},
"deadline": {func(c *recClient, _ *recProvider) { c.hang = true }, 504, ""},
} {
t.Run(name, func(t *testing.T) {
prev := insuranceRecommendationTimeout
insuranceRecommendationTimeout = 50 * time.Millisecond
t.Cleanup(func() { insuranceRecommendationTimeout = prev })
h, c, p, rec := fetchRig(t)
tc.setup(c, p)
cmp, items, err := h.FetchRecommendationComparison(context.Background(), rec)
require.Nil(t, cmp)
require.Nil(t, items)
ce, ok := IsClientError(err)
require.True(t, ok, "got %v", err)
require.Equal(t, tc.code, ce.code)
require.NotContains(t, ce.message, recSpace)
if tc.retry != "" {
require.Equal(t, tc.retry, ce.headers["Retry-After"])
}
})
}
}
Loading