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
42 changes: 42 additions & 0 deletions providers/gcp/internal/skulist/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package skulist

import (
"context"
"fmt"

"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/option"
)

// BillingService lists the SKUs of one Cloud Billing service (enables mocking).
type BillingService interface {
ListSKUs(serviceID string) (*cloudbilling.ListSkusResponse, error)
}

// realBillingService lists SKUs through the Cloud Billing API; name labels the
// owning GCP service in errors.
type realBillingService struct {
service *cloudbilling.APIService
name string
}

func (r *realBillingService) ListSKUs(serviceID string) (*cloudbilling.ListSkusResponse, error) {
skus, err := All(context.Background(), r.service.Services.Skus.List(serviceID), DefaultMaxPages)
if err != nil {
return nil, fmt.Errorf("%s: list SKUs for %s: %w", r.name, serviceID, err)
}
return &cloudbilling.ListSkusResponse{Skus: skus}, nil
}

// Resolve returns existing when set, else a BillingService backed by a new
// Cloud Billing client. name labels the owning GCP service in errors.
func Resolve(ctx context.Context, existing BillingService, name string, opts ...option.ClientOption) (BillingService, error) {
if existing != nil {
return existing, nil
}
service, err := cloudbilling.NewService(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create billing service: %w", err)
}
return &realBillingService{service: service, name: name}, nil
}
56 changes: 56 additions & 0 deletions providers/gcp/internal/skulist/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package skulist

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/option"
)

type stubBilling struct{}

func (stubBilling) ListSKUs(string) (*cloudbilling.ListSkusResponse, error) { return nil, nil }

func TestResolve(t *testing.T) {
t.Parallel()

t.Run("existing service is returned as is", func(t *testing.T) {
t.Parallel()
existing := stubBilling{}
got, err := Resolve(context.Background(), existing, "cloudsql")
require.NoError(t, err)
assert.Equal(t, existing, got)
})

t.Run("lists SKUs through the API", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"skus":[{"skuId":"A"}]}`))
}))
t.Cleanup(srv.Close)
svc, err := Resolve(context.Background(), nil, "cloudsql", option.WithEndpoint(srv.URL), option.WithoutAuthentication())
require.NoError(t, err)
resp, err := svc.ListSKUs("services/X")
require.NoError(t, err)
require.Len(t, resp.Skus, 1)
assert.Equal(t, "A", resp.Skus[0].SkuId)
})

t.Run("list error names the owning service and the billing service", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc, err := Resolve(context.Background(), nil, "memorystore", option.WithEndpoint(srv.URL), option.WithoutAuthentication())
require.NoError(t, err)
_, err = svc.ListSKUs("services/X")
require.Error(t, err)
assert.Contains(t, err.Error(), "memorystore: list SKUs for services/X")
})
}
28 changes: 28 additions & 0 deletions providers/gcp/internal/skumatch/paid_rate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,31 @@ func TestPaidRate(t *testing.T) {
})
}
}

func TestUnitPrice(t *testing.T) {
t.Parallel()
info := func(rates ...*cloudbilling.TierRate) []*cloudbilling.PricingInfo {
return []*cloudbilling.PricingInfo{{PricingExpression: &cloudbilling.PricingExpression{TieredRates: rates}}}
}
tests := []struct {
name string
sku *cloudbilling.Sku
wantPrice float64
wantCurrency string
}{
{"no pricing info", &cloudbilling.Sku{}, 0, ""},
{"nil expression", &cloudbilling.Sku{PricingInfo: []*cloudbilling.PricingInfo{{}}}, 0, ""},
{"all tiers free", &cloudbilling.Sku{PricingInfo: info(tier(0, 0))}, 0, ""},
{"units and nanos", &cloudbilling.Sku{PricingInfo: info(tier(2, 500000000))}, 2.5, "USD"},
{"free tier skipped", &cloudbilling.Sku{PricingInfo: info(tier(0, 0), tier(0, 50000000))}, 0.05, "USD"},
{"only first pricing info is read", &cloudbilling.Sku{PricingInfo: append(info(tier(1, 0)), info(tier(9, 0))...)}, 1, "USD"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
price, currency := UnitPrice(tt.sku)
assert.InDelta(t, tt.wantPrice, price, 1e-12)
assert.Equal(t, tt.wantCurrency, currency)
})
}
}
11 changes: 11 additions & 0 deletions providers/gcp/internal/skumatch/savings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package skumatch

// SavingsPercent returns the percentage a commitment saves against on-demand
// over a term. commitmentTotal must already be a term total (per-hour price *
// hoursInTerm), on the same basis as onDemandPrice * hoursInTerm. A zero
// on-demand total yields NaN or Inf, so callers must reject a zero on-demand
// price first.
func SavingsPercent(onDemandPrice, hoursInTerm, commitmentTotal float64) float64 {
onDemandTotal := onDemandPrice * hoursInTerm
return ((onDemandTotal - commitmentTotal) / onDemandTotal) * 100
}
71 changes: 71 additions & 0 deletions providers/gcp/internal/skumatch/savings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package skumatch

import (
"math"
"testing"

"github.com/stretchr/testify/assert"
"google.golang.org/api/cloudbilling/v1"
)

func TestSavingsPercent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
onDemand, hours, cm float64
want float64
}{
{"typical 16 percent", 0.05, 8760, 0.042 * 8760, 16},
{"commitment equals on-demand", 1, 100, 100, 0},
{"commitment above on-demand is negative", 1, 100, 150, -50},
{"free commitment saves everything", 1, 100, 0, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.InDelta(t, tt.want, SavingsPercent(tt.onDemand, tt.hours, tt.cm), 1e-9)
})
}

t.Run("zero on-demand with commitment is -Inf", func(t *testing.T) {
t.Parallel()
assert.True(t, math.IsInf(SavingsPercent(0, 8760, 100), -1))
})
t.Run("zero hours with commitment is -Inf", func(t *testing.T) {
t.Parallel()
assert.True(t, math.IsInf(SavingsPercent(1, 0, 100), -1))
})
t.Run("zero on-demand and zero commitment is NaN", func(t *testing.T) {
t.Parallel()
assert.True(t, math.IsNaN(SavingsPercent(0, 8760, 0)))
})
t.Run("negative on-demand flips the sign", func(t *testing.T) {
t.Parallel()
assert.InDelta(t, 200, SavingsPercent(-1, 100, 100), 1e-9)
})
}

func TestMatchesTier(t *testing.T) {
t.Parallel()
global := &cloudbilling.GeoTaxonomy{Type: "GLOBAL"}
tests := []struct {
name string
sku *cloudbilling.Sku
tier, region string
want bool
}{
{"token and region match", &cloudbilling.Sku{Description: "Commitment db-n1-standard-1", ServiceRegions: []string{"us-central1"}}, "db-n1-standard-1", "us-central1", true},
{"case-insensitive", &cloudbilling.Sku{Description: "COMMITMENT DB-N1-STANDARD-1", ServiceRegions: []string{"US-CENTRAL1"}}, "db-n1-standard-1", "us-central1", true},
{"tier is a prefix of another tier", &cloudbilling.Sku{Description: "db-n1-standard-16", ServiceRegions: []string{"us-central1"}}, "db-n1-standard-1", "us-central1", false},
{"wrong region", &cloudbilling.Sku{Description: "db-n1-standard-1", ServiceRegions: []string{"europe-west1"}}, "db-n1-standard-1", "us-central1", false},
{"global SKU", &cloudbilling.Sku{Description: "db-n1-standard-1", GeoTaxonomy: global}, "db-n1-standard-1", "us-central1", true},
{"empty tier never matches", &cloudbilling.Sku{Description: "anything", ServiceRegions: []string{"us-central1"}}, "", "us-central1", false},
{"empty regions without taxonomy", &cloudbilling.Sku{Description: "db-n1-standard-1"}, "db-n1-standard-1", "us-central1", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, MatchesTier(tt.sku, tt.tier, tt.region))
})
}
}
20 changes: 20 additions & 0 deletions providers/gcp/internal/skumatch/skumatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ func InRegion(sku *cloudbilling.Sku, region string) bool {
return false
}

// MatchesTier reports whether the SKU description names tier as a whole token
// and the SKU applies in region.
func MatchesTier(sku *cloudbilling.Sku, tier, region string) bool {
return HasToken(sku.Description, tier) && InRegion(sku, region)
}

// HasToken reports whether the SKU description contains token as a whole
// identifier, case-insensitively. "db-n1-standard-1" does not match inside
// "db-n1-standard-16" and "STANDARD" does not match inside "STANDARD_HA".
Expand Down Expand Up @@ -118,3 +124,17 @@ func PaidRate(expr *cloudbilling.PricingExpression) *cloudbilling.TierRate {
}
return nil
}

// UnitPrice returns the first paid unit price of the SKU's first pricing info
// and its currency, or 0 and "" when the SKU has no paid rate. It does not
// convert usage units.
func UnitPrice(sku *cloudbilling.Sku) (price float64, currency string) {
if len(sku.PricingInfo) == 0 {
return 0, ""
}
rate := PaidRate(sku.PricingInfo[0].PricingExpression)
if rate == nil {
return 0, ""
}
return float64(rate.UnitPrice.Units) + float64(rate.UnitPrice.Nanos)/1e9, rate.UnitPrice.CurrencyCode
}
74 changes: 9 additions & 65 deletions providers/gcp/services/cloudsql/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,16 @@ import (
"github.com/LeanerCloud/cloud-commitments-go/providers/gcp/internal/skumatch"
)

// BillingService lists SKUs of one Cloud Billing service (enables mocking).
type BillingService = skulist.BillingService

// SQLAdminService interface for SQL admin operations (enables mocking).
type SQLAdminService interface {
ListInstances(projectID string) (*sqladmin.InstancesListResponse, error)
InsertInstance(projectID string, instance *sqladmin.DatabaseInstance) (*sqladmin.Operation, error)
ListTiers(projectID string) (*sqladmin.TiersListResponse, error)
}

// BillingService interface for billing operations (enables mocking).
type BillingService interface {
ListSKUs(serviceID string) (*cloudbilling.ListSkusResponse, error)
}

// RecommenderIterator preserves the legacy iteration API for source compatibility.
//
// Deprecated: Cloud SQL has no project-scoped commitment recommender (issue #294).
Expand Down Expand Up @@ -102,19 +100,6 @@ func (r *realSQLAdminService) ListTiers(projectID string) (*sqladmin.TiersListRe
return r.service.Tiers.List(projectID).Do()
}

// realBillingService wraps the real cloudbilling.APIService.
type realBillingService struct {
service *cloudbilling.APIService
}

func (r *realBillingService) ListSKUs(serviceID string) (*cloudbilling.ListSkusResponse, error) {
skus, err := skulist.All(context.Background(), r.service.Services.Skus.List(serviceID), skulist.DefaultMaxPages)
if err != nil {
return nil, fmt.Errorf("%s: list SKUs for %s: %w", "cloudsql", serviceID, err)
}
return &cloudbilling.ListSkusResponse{Skus: skus}, nil
}

// GetServiceType returns the service type.
func (c *Client) GetServiceType() common.ServiceType {
return common.ServiceRelationalDB
Expand Down Expand Up @@ -272,7 +257,7 @@ type SQLPricing struct {
// It returns an error when commitment pricing is absent from the catalog rather
// than fabricating a price from a hardcoded discount factor (issue #1020).
func (c *Client) getSQLPricing(ctx context.Context, tier, region string, termYears int) (*SQLPricing, error) {
svc, err := c.getOrCreateBillingService(ctx)
svc, err := skulist.Resolve(ctx, c.billingService, "cloudsql", c.clientOpts...)
if err != nil {
return nil, err
}
Expand All @@ -286,10 +271,10 @@ func (c *Client) getSQLPricing(ctx context.Context, tier, region string, termYea
if err != nil {
return nil, err
}
if onDemandPrice == 0 {
if onDemandPrice <= 0 {
return nil, fmt.Errorf("no pricing found for Cloud SQL tier %s", tier)
}
if commitmentPrice == 0 {
if commitmentPrice <= 0 {
return nil, fmt.Errorf("no commitment pricing found for Cloud SQL tier %s in region %s: catalog has no CUD SKU; cannot compute savings percentage", tier, region)
}

Expand All @@ -298,7 +283,7 @@ func (c *Client) getSQLPricing(ctx context.Context, tier, region string, termYea
// same basis as onDemandPrice * hoursInTerm. Without this, the savings
// percentage would be nearly 100% (per-hour price vs term total).
commitmentPriceTerm := commitmentPrice * hoursInTerm
savingsPercentage := calculateSQLSavingsPercentage(onDemandPrice, hoursInTerm, commitmentPriceTerm)
savingsPercentage := skumatch.SavingsPercent(onDemandPrice, hoursInTerm, commitmentPriceTerm)

return &SQLPricing{
HourlyRate: commitmentPrice,
Expand All @@ -309,30 +294,16 @@ func (c *Client) getSQLPricing(ctx context.Context, tier, region string, termYea
}, nil
}

// getOrCreateBillingService returns the billing service, creating it if needed.
func (c *Client) getOrCreateBillingService(ctx context.Context) (BillingService, error) {
if c.billingService != nil {
return c.billingService, nil
}

service, err := cloudbilling.NewService(ctx, c.clientOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create billing service: %w", err)
}

return &realBillingService{service: service}, nil
}

// extractSQLPricingFromSKUs extracts on-demand and commitment pricing from the SKU list.
// Cloud SQL committed-use discounts are SKUs whose Category.UsageType is Commit1Yr or Commit3Yr.
func extractSQLPricingFromSKUs(skus []*cloudbilling.Sku, tier, region string, termYears int) (onDemand, commitment float64, currency string, err error) {
var prices skumatch.SlotPrices
for _, sku := range skus {
if !skuMatchesTier(sku, tier, region) {
if !skumatch.MatchesTier(sku, tier, region) {
continue
}

price, curr := extractSQLPriceFromSKU(sku)
price, curr := skumatch.UnitPrice(sku)
if price == 0 {
continue
}
Expand All @@ -358,33 +329,6 @@ func extractSQLPricingFromSKUs(skus []*cloudbilling.Sku, tier, region string, te
return prices.OnDemand, commitmentPrice, currency, nil
}

// extractSQLPriceFromSKU extracts the unit price from a SKU.
func extractSQLPriceFromSKU(sku *cloudbilling.Sku) (price float64, currency string) {
if len(sku.PricingInfo) == 0 {
return 0, ""
}

pricingInfo := sku.PricingInfo[0]
rate := skumatch.PaidRate(pricingInfo.PricingExpression)
if rate == nil {
return 0, ""
}

price = float64(rate.UnitPrice.Units) + float64(rate.UnitPrice.Nanos)/1e9
return price, rate.UnitPrice.CurrencyCode
}

// calculateSQLSavingsPercentage calculates the savings percentage.
func calculateSQLSavingsPercentage(onDemandPrice, hoursInTerm, commitmentPrice float64) float64 {
onDemandTotal := onDemandPrice * hoursInTerm
return ((onDemandTotal - commitmentPrice) / onDemandTotal) * 100
}

// skuMatchesTier checks if a SKU matches the tier and region.
func skuMatchesTier(sku *cloudbilling.Sku, tier, region string) bool {
return skumatch.HasToken(sku.Description, tier) && skumatch.InRegion(sku, region)
}

// contains checks if a slice contains a string.
func contains(slice []string, str string) bool {
for _, s := range slice {
Expand Down
Loading
Loading