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
1 change: 1 addition & 0 deletions internal/archera/recommendation_match.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
type RecommendationIdentity struct {
Provider, SourceAccountID, Service, PlanType, OfferingID string
Region, TopLevelRegion, InstanceFamily, Payment string
CloudAccountID string
NativeDurationSeconds int64
HourlyCommitment *big.Rat
}
Expand Down
119 changes: 119 additions & 0 deletions internal/archera/recommendation_projection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package archera

import (
"encoding/json"
"errors"
"math/big"
"strconv"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/insurance"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// ErrRecommendationIdentity means a stored recommendation cannot be proven to be a supported savings plan.
var ErrRecommendationIdentity = errors.New("stored recommendation identity is unavailable")

const (
maxDetailsBytes = 64 << 10
maxHourlyBytes = 64
maxHourlyExponent = 100
storedNoUpfront = "no-upfront"
storedPartialUpfr = "partial-upfront"
storedAllUpfront = "all-upfront"
storedTermOneYear = 1
storedTermThreeYrs = 3
)

type storedDetails struct {
OfferingID string `json:"offering_id"`
PlanType string `json:"plan_type"`
Region string `json:"region"`
InstanceFamily string `json:"instance_family"`
HourlyCommitment json.RawMessage `json:"hourly_commitment"`
}

// ProjectRecommendation turns a stored recommendation into the identity the vendor line items are matched against.
func ProjectRecommendation(r *config.RecommendationRecord) (*RecommendationIdentity, error) {
if r == nil || len(r.Details) > maxDetailsBytes {
return nil, ErrRecommendationIdentity
}
var d storedDetails
if err := json.Unmarshal(r.Details, &d); err != nil {
return nil, ErrRecommendationIdentity
}
hourly := storedHourlyAmount(d.HourlyCommitment)
payment, duration := storedPayment(r.Payment), storedDuration(r.Term)
if hourly == nil || payment == "" || duration == 0 {
return nil, ErrRecommendationIdentity
}
out := &RecommendationIdentity{Provider: r.Provider, Service: r.Service, Payment: payment,
TopLevelRegion: r.Region, NativeDurationSeconds: duration, OfferingID: d.OfferingID,
PlanType: d.PlanType, Region: d.Region, InstanceFamily: d.InstanceFamily, HourlyCommitment: hourly}
if r.SourceAccountID != nil {
out.SourceAccountID = *r.SourceAccountID
}
if r.CloudAccountID != nil {
out.CloudAccountID = *r.CloudAccountID
}
if !validRecommendation(out) {
return nil, ErrRecommendationIdentity
}
return out, nil
}

// Equal reports whether both identities are non-nil and agree on every projected field.
func (r *RecommendationIdentity) Equal(o *RecommendationIdentity) bool {
if r == nil || o == nil {
return false
}
if (r.HourlyCommitment == nil) != (o.HourlyCommitment == nil) ||
r.HourlyCommitment != nil && r.HourlyCommitment.Cmp(o.HourlyCommitment) != 0 {
return false
}
a, b := *r, *o
a.HourlyCommitment, b.HourlyCommitment = nil, nil
return a == b
}

func storedPayment(stored string) string {
switch stored {
case storedNoUpfront:
return string(insurance.PaymentNoUpfront)
case storedPartialUpfr:
return string(insurance.PaymentPartialUpfront)
case storedAllUpfront:
return string(insurance.PaymentAllUpfront)
}
return ""
}

func storedDuration(term int) int64 {
switch term {
case storedTermOneYear:
return awsSPYearSeconds
case storedTermThreeYrs:
return awsSPThreeYearSeconds
}
return 0
}

// storedHourlyAmount accepts only a positive JSON number whose exponent is bounded before big.Rat parses it.
// Quoted numbers, null, booleans and containers are not valid big.Rat input, and the raw token is already valid JSON.
func storedHourlyAmount(raw []byte) *big.Rat {
if len(raw) > maxHourlyBytes {
return nil
}
text := string(raw)
if i := strings.IndexAny(text, "eE"); i >= 0 {
exp, err := strconv.Atoi(text[i+1:])
if err != nil || exp < -maxHourlyExponent || exp > maxHourlyExponent {
return nil
}
}
r, ok := new(big.Rat).SetString(text)
if !ok || r.Sign() <= 0 {
return nil
}
return r
}
210 changes: 210 additions & 0 deletions internal/archera/recommendation_projection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package archera

import (
"encoding/json"
"math/big"
"strings"
"testing"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/stretchr/testify/require"
)

const fixtureHourly = "0.50000000000000000001"

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

func withDetails(old, replacement string) config.RecommendationRecord {
rec := storedMatchFixture()
rec.Details = json.RawMessage(strings.Replace(string(rec.Details), old, replacement, 1))
return rec
}

func TestProjectRecommendationMapsEveryField(t *testing.T) {
rec := storedMatchFixture()
got, err := ProjectRecommendation(&rec)
require.NoError(t, err)
want := RecommendationIdentity{Provider: "aws", SourceAccountID: "Source-account-a", CloudAccountID: "cloud-account-a",
Service: "savings-plans-ec2instance", PlanType: "EC2Instance", OfferingID: "provider-offer-a",
Region: "us-east-1", TopLevelRegion: "us-east-1", InstanceFamily: "m5", Payment: "no_upfront",
NativeDurationSeconds: 31536000}
hourly := got.HourlyCommitment
require.Equal(t, "50000000000000000001/100000000000000000000", hourly.RatString())
got.HourlyCommitment = nil
require.Equal(t, want, *got)
}

func TestProjectRecommendationOptionalAccountsAndRegion(t *testing.T) {
rec := storedMatchFixture()
rec.CloudAccountID, rec.Region = nil, ""
got, err := ProjectRecommendation(&rec)
require.NoError(t, err)
require.Empty(t, got.CloudAccountID)
require.Empty(t, got.TopLevelRegion)
rec.SourceAccountID = nil
_, err = ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity)
}

func TestProjectRecommendationTermAndPayment(t *testing.T) {
for term, seconds := range map[int]int64{1: 31536000, 3: 94608000} {
rec := storedMatchFixture()
rec.Term = term
got, err := ProjectRecommendation(&rec)
require.NoError(t, err)
require.Equal(t, seconds, got.NativeDurationSeconds)
}
for stored, wire := range map[string]string{"no-upfront": "no_upfront", "partial-upfront": "partial_upfront", "all-upfront": "all_upfront"} {
rec := storedMatchFixture()
rec.Payment = stored
got, err := ProjectRecommendation(&rec)
require.NoError(t, err, stored)
require.Equal(t, wire, got.Payment)
}
for _, bad := range []string{"", "NO-UPFRONT", "partial", "monthly", "no_upfront"} {
rec := storedMatchFixture()
rec.Payment = bad
_, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity, bad)
}
for _, term := range []int{0, 2, 5, -1} {
rec := storedMatchFixture()
rec.Term = term
_, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity, term)
}
}

func TestProjectRecommendationHourlyAmount(t *testing.T) {
refused := map[string]string{
"nil": "null", "zero": "0", "negative": "-0.5", "string": `"abc"`, "quoted": `"0.5"`,
"boolean": "true", "object": "{}", "array": "[0.5]", "bytes_65": "0." + strings.Repeat("1", 63),
"huge_exponent": "1e999999", "huge_negative_exponent": "1e-999999", "exp_101": "1e101",
"exp_minus_101": "1e-101", "overflow_exponent": "1e999999999999999999999",
"leading_plus": "+1", "fraction": "1/2", "empty_exponent": "1e", "zero_exp": "0e5",
}
for name, value := range refused {
t.Run(name, func(t *testing.T) {
rec := withDetails(fixtureHourly, value)
got, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity)
require.Nil(t, got)
})
}
missing := withDetails(`,"hourly_commitment":`+fixtureHourly, "")
_, err := ProjectRecommendation(&missing)
require.ErrorIs(t, err, ErrRecommendationIdentity)
for _, value := range []string{"1e100", "1e-100", "1E2", "1e-07", "0." + strings.Repeat("1", 62), "0.25"} {
rec := withDetails(fixtureHourly, value)
got, err := ProjectRecommendation(&rec)
require.NoError(t, err, value)
want, ok := new(big.Rat).SetString(value)
require.True(t, ok)
require.Zero(t, got.HourlyCommitment.Cmp(want), value)
}
}

func TestProjectRecommendationRefusesBadDetails(t *testing.T) {
tests := []struct{ name, old, replacement string }{
{"missing_offering", `"offering_id":"provider-offer-a",`, ""},
{"null_offering", `"offering_id":"provider-offer-a"`, `"offering_id":null`},
{"missing_plan", `"plan_type":"EC2Instance",`, ""},
{"wrong_plan", `"plan_type":"EC2Instance"`, `"plan_type":"Compute"`},
{"empty_region", `"region":"us-east-1"`, `"region":""`},
{"wrong_region_kind", `"region":"us-east-1"`, `"region":["us-east-1"]`},
{"missing_family", `"instance_family":"m5",`, ""},
{"wrong_family_kind", `"instance_family":"m5"`, `"instance_family":2`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := withDetails(tt.old, tt.replacement)
got, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity)
require.Nil(t, got)
})
}
for _, raw := range []string{"", "null", "[]", "{}", string(storedMatchFixture().Details) + "{}", strings.Repeat(" ", 65537)} {
rec := storedMatchFixture()
rec.Details = json.RawMessage(raw)
_, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity)
}
padded := storedMatchFixture()
padded.Details = json.RawMessage(string(padded.Details) + strings.Repeat(" ", 65537))
_, err := ProjectRecommendation(&padded)
require.ErrorIs(t, err, ErrRecommendationIdentity)
_, err = ProjectRecommendation(nil)
require.ErrorIs(t, err, ErrRecommendationIdentity)
}

func TestProjectRecommendationRequiresSupportedProduct(t *testing.T) {
for field, mutate := range map[string]func(*config.RecommendationRecord){
"provider": func(r *config.RecommendationRecord) { r.Provider = "gcp" },
"service": func(r *config.RecommendationRecord) { r.Service = "savings-plans-compute" },
"top_region": func(r *config.RecommendationRecord) { r.Region = "us-west-2" },
"source": func(r *config.RecommendationRecord) { r.SourceAccountID = nil },
} {
rec := storedMatchFixture()
mutate(&rec)
_, err := ProjectRecommendation(&rec)
require.ErrorIs(t, err, ErrRecommendationIdentity, field)
}
}

func TestRecommendationIdentityEqual(t *testing.T) {
project := func(mutate func(*config.RecommendationRecord)) *RecommendationIdentity {
rec := storedMatchFixture()
mutate(&rec)
got, err := ProjectRecommendation(&rec)
require.NoError(t, err)
return got
}
base := project(func(*config.RecommendationRecord) {})
same := project(func(r *config.RecommendationRecord) {
r.Details = json.RawMessage(strings.Replace(string(r.Details), fixtureHourly, "0.500000000000000000010", 1))
})
require.True(t, base.Equal(same), "numerically equal hourly amounts are equal")
require.True(t, base.Equal(base))
other := "other"
changed := map[string]*RecommendationIdentity{
"hourly": project(func(r *config.RecommendationRecord) {
r.Details = json.RawMessage(strings.Replace(string(r.Details), fixtureHourly, "0.50000000000000000002", 1))
}),
"offering_id": project(func(r *config.RecommendationRecord) {
r.Details = json.RawMessage(strings.Replace(string(r.Details), "provider-offer-a", "provider-offer-b", 1))
}),
"plan_type": {},
"cloud_account": project(func(r *config.RecommendationRecord) { r.CloudAccountID = &other }),
"cloud_nil": project(func(r *config.RecommendationRecord) { r.CloudAccountID = nil }),
"source": project(func(r *config.RecommendationRecord) { r.SourceAccountID = &other }),
"term": project(func(r *config.RecommendationRecord) { r.Term = 3 }),
"payment": project(func(r *config.RecommendationRecord) { r.Payment = "all-upfront" }),
"family": project(func(r *config.RecommendationRecord) {
r.Details = json.RawMessage(strings.Replace(string(r.Details), `"m5"`, `"c5"`, 1))
}),
"region": project(func(r *config.RecommendationRecord) {
r.Region = ""
r.Details = json.RawMessage(strings.Replace(string(r.Details), `"region":"us-east-1"`, `"region":"us-west-2"`, 1))
}),
"top_region": project(func(r *config.RecommendationRecord) { r.Region = "" }),
}
for name, got := range changed {
require.False(t, base.Equal(got), name)
require.False(t, got.Equal(base), name)
}
noHourly := *base
noHourly.HourlyCommitment = nil
for name, pair := range map[string][2]*RecommendationIdentity{
"nil_receiver": {nil, base}, "nil_arg": {base, nil}, "both_nil": {nil, nil},
"nil_hourly_recv": {&noHourly, base}, "nil_hourly_arg": {base, &noHourly},
} {
require.False(t, pair[0].Equal(pair[1]), name)
}
require.True(t, noHourly.Equal(&noHourly))
}
Loading