Skip to content

Commit aa90e53

Browse files
authored
feat(gcp): add Cloud DNS catalog pricing (RC3-023c, #78) (#93)
* feat(gcp): add Cloud DNS catalog pricing (RC3-023c, #78) Adds GCP Cloud DNS pricing (managed zone monthly fee, DNS query volume) sourced from the live Cloud Billing Catalog (service FA26-5236-B8B5): $0.20/$0.10/$0.03 per zone-month across the 1-25/26-10,000/10,001+ tiers, and $0.40/$0.20 per million queries below/above 1B queries per month. Both SKUs are global (region-invariant). Public, private, forwarding, and peering zone types share a single rate ladder per the live pricing page, so zone_type is validated but does not affect rate selection. Routing-policy queries, health checks, DNS Armor, DNSSEC, and Response Policies have no catalog SKU under this service and are out of scope for this pass. * fix(gcp): address code-review findings for Cloud DNS pricing (#78) Fix a wire-format bug where DNS zone/query tier breakdowns reported counts under a GB-specific "gbs" JSON field (renamed to "quantity", shared with External IP egress breakdowns which were already GB-denominated); wire DNS zone_count/queries_per_month through to monthly_estimate in get_price the same way storage specs already are; extract a shared SKU tieredRates unwrap helper used by KMS/BigQuery/AI and DNS pricing; extract shared fallback-resolution, price-builder, and tiered-estimate helpers to remove duplication between the zone and query pricing paths; relax zone_type from a hard-rejecting enum to an informational field since it never affects price; add a defensive GLOBAL-scope filter and duplicate-match warning when matching Cloud DNS SKUs by description; and consolidate five duplicated DecisionMatrix catalog-hint entries into one.
1 parent 19ae750 commit aa90e53

11 files changed

Lines changed: 1931 additions & 90 deletions

File tree

‎opencloudcosts-go/internal/models/models.go‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const (
5959
PricingDomainObservability PricingDomain = "observability"
6060
PricingDomainInterRegionEgress PricingDomain = "inter_region_egress"
6161
PricingDomainSecurity PricingDomain = "security"
62+
PricingDomainDNS PricingDomain = "dns"
6263
)
6364

6465
// PriceUnit describes the unit of a price.
@@ -77,6 +78,7 @@ const (
7778
PriceUnitPerUnit PriceUnit = "per_unit"
7879
PriceUnitPerKeyVersionMonth PriceUnit = "per_key_version_month"
7980
PriceUnitPerOperation PriceUnit = "per_operation"
81+
PriceUnitPerZoneMonth PriceUnit = "per_zone_month"
8082
)
8183

8284
// PricingSpec is the marker interface for all pricing specification variants.
@@ -667,6 +669,67 @@ func (s *KMSPricingSpec) UnmarshalJSON(data []byte) error {
667669
return json.Unmarshal(data, (*alias)(s))
668670
}
669671

672+
// DNSPricingSpec prices GCP Cloud DNS managed zones and DNS queries
673+
// (domain=dns, service=cloud_dns).
674+
//
675+
// Cloud DNS bills along two independent, region-invariant dimensions:
676+
// - a per-zone-month charge, volume-discounted by the total managed-zone
677+
// count (zones 1-25, 26-10,000, 10,001+); and
678+
// - a per-query charge for standard DNS resolution (port 53) queries,
679+
// volume-discounted above 1,000,000,000 queries/month.
680+
//
681+
// zone_type does NOT change price: public, private, forwarding, and (by
682+
// inference) peering zones all resolve to the single shared ManagedZone tier
683+
// ladder (verified live against the GCP Cloud Billing Catalog API, issue
684+
// #78). ZoneType is retained as a validated, informational field only —
685+
// mirroring the role KeyType plays in KMSPricingSpec — not because it
686+
// selects a different rate.
687+
//
688+
// "Routing policy queries" ($0.70/$0.35 per million) are a real, documented
689+
// GCP charge but have no catalog SKU under this service ID; they are out of
690+
// scope for this spec.
691+
//
692+
// Pricing is region-invariant (scope="global" on every returned
693+
// NormalizedPrice); Region is accepted but ignored by the GCP provider.
694+
type DNSPricingSpec struct {
695+
BasePricingSpec
696+
// ZoneType is informational only — every zone type shares the single
697+
// ManagedZone tier ladder, so this field never changes price.
698+
// "public" (default) | "private" | "forwarding" | "peering".
699+
ZoneType string `json:"zone_type,omitempty"`
700+
// ZoneCount is the optional total managed-zone count (across all zone
701+
// types) for a monthly cost estimate.
702+
ZoneCount *float64 `json:"zone_count,omitempty"`
703+
// QueriesPerMonth is the optional monthly DNS query volume (port 53) for
704+
// a monthly cost estimate.
705+
QueriesPerMonth *float64 `json:"queries_per_month,omitempty"`
706+
}
707+
708+
var _ PricingSpec = (*DNSPricingSpec)(nil)
709+
710+
// CacheKey implements PricingSpec.
711+
func (s *DNSPricingSpec) CacheKey() string {
712+
zoneCount := ""
713+
if s.ZoneCount != nil {
714+
zoneCount = fmt.Sprintf("%v", *s.ZoneCount)
715+
}
716+
queries := ""
717+
if s.QueriesPerMonth != nil {
718+
queries = fmt.Sprintf("%v", *s.QueriesPerMonth)
719+
}
720+
return fmt.Sprintf("%s:%s:%s:%s", s.baseCacheKey(), s.ZoneType, zoneCount, queries)
721+
}
722+
723+
// UnmarshalJSON pre-populates defaults then decodes.
724+
func (s *DNSPricingSpec) UnmarshalJSON(data []byte) error {
725+
*s = DNSPricingSpec{
726+
BasePricingSpec: defaultBase(),
727+
ZoneType: "public",
728+
}
729+
type alias DNSPricingSpec
730+
return json.Unmarshal(data, (*alias)(s))
731+
}
732+
670733
// NormalizePaymentOption returns the canonical SP payment option string or an
671734
// error for unknown values. The SP JSON uses these exact case-sensitive strings.
672735
func NormalizePaymentOption(s string) (string, error) {
@@ -697,7 +760,7 @@ type domainPeek struct {
697760
// Pydantic's discriminated-union behaviour on the PricingSpec type in models.py.
698761
//
699762
// Supported domains: compute, storage, database, container, ai, serverless,
700-
// analytics, network, observability, inter_region_egress, security.
763+
// analytics, network, observability, inter_region_egress, security, dns.
701764
func UnmarshalPricingSpec(data []byte) (PricingSpec, error) {
702765
var peek domainPeek
703766
if err := json.Unmarshal(data, &peek); err != nil {
@@ -782,6 +845,13 @@ func UnmarshalPricingSpec(data []byte) (PricingSpec, error) {
782845
}
783846
return &s, nil
784847

848+
case PricingDomainDNS:
849+
var s DNSPricingSpec
850+
if err := json.Unmarshal(data, &s); err != nil {
851+
return nil, fmt.Errorf("models: DNSPricingSpec: %w", err)
852+
}
853+
return &s, nil
854+
785855
default:
786856
return nil, fmt.Errorf("models: unknown pricing domain %q", peek.Domain)
787857
}

‎opencloudcosts-go/internal/models/models_test.go‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,61 @@ func TestUnmarshal_KMSFieldsAndDispatch(t *testing.T) {
857857
}
858858
}
859859

860+
func TestUnmarshal_DNSDefaults(t *testing.T) {
861+
data := []byte(`{"domain":"dns","provider":"gcp","service":"cloud_dns","region":"us-central1"}`)
862+
got, err := UnmarshalPricingSpec(data)
863+
if err != nil {
864+
t.Fatalf("UnmarshalPricingSpec: %v", err)
865+
}
866+
d, ok := got.(*DNSPricingSpec)
867+
if !ok {
868+
t.Fatalf("expected *DNSPricingSpec, got %T", got)
869+
}
870+
if d.ZoneType != "public" {
871+
t.Errorf("ZoneType default: got %q, want %q", d.ZoneType, "public")
872+
}
873+
if d.ZoneCount != nil {
874+
t.Errorf("ZoneCount default: got %v, want nil", d.ZoneCount)
875+
}
876+
if d.QueriesPerMonth != nil {
877+
t.Errorf("QueriesPerMonth default: got %v, want nil", d.QueriesPerMonth)
878+
}
879+
}
880+
881+
func TestUnmarshal_DNSFieldsAndDispatch(t *testing.T) {
882+
// Exercises the real MCP entry path (JSON -> UnmarshalPricingSpec ->
883+
// DNSPricingSpec.UnmarshalJSON) with explicit non-default fields, so a
884+
// discriminator or default-seeding regression here would be caught
885+
// rather than only in tests that construct DNSPricingSpec directly.
886+
data := []byte(`{"provider":"gcp","domain":"dns","service":"cloud_dns","zone_type":"private","zone_count":10,"queries_per_month":5000000}`)
887+
got, err := UnmarshalPricingSpec(data)
888+
if err != nil {
889+
t.Fatalf("UnmarshalPricingSpec: %v", err)
890+
}
891+
d, ok := got.(*DNSPricingSpec)
892+
if !ok {
893+
t.Fatalf("expected *DNSPricingSpec, got %T", got)
894+
}
895+
if d.Provider != CloudProviderGCP {
896+
t.Errorf("Provider: got %q, want %q", d.Provider, CloudProviderGCP)
897+
}
898+
if d.Domain != PricingDomainDNS {
899+
t.Errorf("Domain: got %q, want %q", d.Domain, PricingDomainDNS)
900+
}
901+
if d.Service != "cloud_dns" {
902+
t.Errorf("Service: got %q, want %q", d.Service, "cloud_dns")
903+
}
904+
if d.ZoneType != "private" {
905+
t.Errorf("ZoneType: got %q, want %q", d.ZoneType, "private")
906+
}
907+
if d.ZoneCount == nil || *d.ZoneCount != 10 {
908+
t.Errorf("ZoneCount: got %v, want 10", d.ZoneCount)
909+
}
910+
if d.QueriesPerMonth == nil || *d.QueriesPerMonth != 5000000 {
911+
t.Errorf("QueriesPerMonth: got %v, want 5000000", d.QueriesPerMonth)
912+
}
913+
}
914+
860915
func TestUnmarshal_ContainerDefaults(t *testing.T) {
861916
data := []byte(`{"domain":"container","provider":"aws","region":"us-east-1"}`)
862917
got, err := UnmarshalPricingSpec(data)
@@ -1365,6 +1420,72 @@ func TestCacheKey_EgressPricingSpec(t *testing.T) {
13651420
}
13661421
}
13671422

1423+
// TestCacheKey_DNSPricingSpec verifies zone_type is embedded in the cache key
1424+
// (differentiating an informational-but-attribute-tagged request), and that
1425+
// ZoneCount/QueriesPerMonth — being optional *float64 fields that directly
1426+
// change the computed monthly_cost — are also embedded, mirroring the
1427+
// nil-safe pointer-field convention used by StoragePricingSpec.CacheKey.
1428+
// Omitting these from the cache key would let two requests that differ only
1429+
// in zone_count or queries_per_month collide on the same key despite pricing
1430+
// to different monthly costs.
1431+
func TestCacheKey_DNSPricingSpec(t *testing.T) {
1432+
zoneCount := 10.0
1433+
queries := 5_000_000.0
1434+
1435+
tests := []struct {
1436+
name string
1437+
spec DNSPricingSpec
1438+
want string
1439+
}{
1440+
{
1441+
name: "zone_type only, no quantities",
1442+
spec: DNSPricingSpec{
1443+
BasePricingSpec: BasePricingSpec{
1444+
Provider: CloudProviderGCP,
1445+
Domain: PricingDomainDNS,
1446+
Service: "cloud_dns",
1447+
Term: PricingTermOnDemand,
1448+
},
1449+
ZoneType: "public",
1450+
},
1451+
want: "gcp:dns:cloud_dns::on_demand:public::",
1452+
},
1453+
{
1454+
name: "zone_count and queries_per_month set",
1455+
spec: DNSPricingSpec{
1456+
BasePricingSpec: BasePricingSpec{
1457+
Provider: CloudProviderGCP,
1458+
Domain: PricingDomainDNS,
1459+
Service: "cloud_dns",
1460+
Term: PricingTermOnDemand,
1461+
},
1462+
ZoneType: "private",
1463+
ZoneCount: &zoneCount,
1464+
QueriesPerMonth: &queries,
1465+
},
1466+
want: "gcp:dns:cloud_dns::on_demand:private:10:5e+06",
1467+
},
1468+
}
1469+
1470+
for _, tc := range tests {
1471+
tc := tc
1472+
t.Run(tc.name, func(t *testing.T) {
1473+
got := tc.spec.CacheKey()
1474+
if got != tc.want {
1475+
t.Errorf("CacheKey() = %q, want %q", got, tc.want)
1476+
}
1477+
})
1478+
}
1479+
1480+
// Different zone_count values must not collide on the same cache key.
1481+
other := 20.0
1482+
a := DNSPricingSpec{ZoneType: "public", ZoneCount: &zoneCount}
1483+
b := DNSPricingSpec{ZoneType: "public", ZoneCount: &other}
1484+
if a.CacheKey() == b.CacheKey() {
1485+
t.Errorf("CacheKey() collided for different zone_count values: %q", a.CacheKey())
1486+
}
1487+
}
1488+
13681489
// --------------------------------------------------------------------------
13691490
// UnmarshalPricingSpec error cases.
13701491
// --------------------------------------------------------------------------

‎opencloudcosts-go/internal/providers/gcp/gcp.go‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"io"
1616
"log/slog"
1717
"net/http"
18+
"sort"
1819
"strings"
1920
"time"
2021

@@ -375,40 +376,64 @@ func skuMatchesRegion(regions []any, region string) bool {
375376
return false
376377
}
377378

378-
// skuPrice extracts the first-tier unit price (startUsageAmount == 0) from a raw
379-
// GCP SKU map[string]any returned by the Billing Catalog API.
380-
func skuPrice(sku map[string]any) float64 {
379+
// tierRate is one parsed (startUsageAmount, unitPrice) pair from a raw GCP
380+
// SKU's pricingInfo[0].pricingExpression.tieredRates.
381+
type tierRate struct {
382+
start float64
383+
price float64
384+
}
385+
386+
// skuTierList parses every tiered unit price out of a raw GCP SKU
387+
// (map[string]any as returned by the Billing Catalog API), sorted ascending
388+
// by startUsageAmount (ties keep their original relative order). It is the
389+
// single shared JSON-unwrap step behind skuPrice (below — first zero-start
390+
// tier), skuPaidPrice (gcp_ai.go — first tier with startUsageAmount > 0), and
391+
// skuAllTierRates (gcp_dns.go — every tier, for SKUs with more than two
392+
// tiers); previously each reimplemented this same
393+
// pricingInfo->pricingExpression->tieredRates unwrap independently.
394+
func skuTierList(sku map[string]any) []tierRate {
381395
pi, _ := sku["pricingInfo"].([]any)
382396
if len(pi) == 0 {
383-
return 0
397+
return nil
384398
}
385399
pe, _ := pi[0].(map[string]any)
386400
if pe == nil {
387-
return 0
401+
return nil
388402
}
389403
expr, _ := pe["pricingExpression"].(map[string]any)
390404
if expr == nil {
391-
return 0
405+
return nil
392406
}
393407
tiers, _ := expr["tieredRates"].([]any)
408+
409+
out := make([]tierRate, 0, len(tiers))
394410
for _, t := range tiers {
395411
tier, _ := t.(map[string]any)
396412
if tier == nil {
397413
continue
398414
}
399-
start, _ := tier["startUsageAmount"].(float64)
400-
if start != 0 {
401-
continue
402-
}
403415
up, _ := tier["unitPrice"].(map[string]any)
404416
if up == nil {
405417
continue
406418
}
419+
start, _ := tier["startUsageAmount"].(float64)
407420
units, _ := up["units"].(string)
408421
nanos, _ := up["nanos"].(float64)
409-
price := gcpMoney(units, int(nanos))
410-
if price > 0 {
411-
return price
422+
out = append(out, tierRate{start: start, price: gcpMoney(units, int(nanos))})
423+
}
424+
sort.SliceStable(out, func(i, j int) bool { return out[i].start < out[j].start })
425+
return out
426+
}
427+
428+
// skuPrice extracts the first-tier unit price (startUsageAmount == 0) from a raw
429+
// GCP SKU map[string]any returned by the Billing Catalog API.
430+
func skuPrice(sku map[string]any) float64 {
431+
for _, t := range skuTierList(sku) {
432+
if t.start != 0 {
433+
continue
434+
}
435+
if t.price > 0 {
436+
return t.price
412437
}
413438
}
414439
return 0

‎opencloudcosts-go/internal/providers/gcp/gcp_ai.go‎

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -396,35 +396,10 @@ func (p *Provider) buildBigQueryIndex(ctx context.Context, region string) (bigqu
396396
// skuPaidPrice extracts the first paid-tier unit price (startUsageAmount > 0) from a SKU.
397397
// Used for BigQuery SKUs that have a free-quota tier at startUsageAmount == 0.
398398
func skuPaidPrice(sku map[string]any) float64 {
399-
pi, _ := sku["pricingInfo"].([]any)
400-
if len(pi) == 0 {
401-
return 0
402-
}
403-
pe, _ := pi[0].(map[string]any)
404-
if pe == nil {
405-
return 0
406-
}
407-
expr, _ := pe["pricingExpression"].(map[string]any)
408-
if expr == nil {
409-
return 0
410-
}
411-
tiers, _ := expr["tieredRates"].([]any)
412-
for _, t := range tiers {
413-
tier, _ := t.(map[string]any)
414-
if tier == nil {
415-
continue
416-
}
417-
start, _ := tier["startUsageAmount"].(float64)
418-
if start <= 0 {
419-
continue
420-
}
421-
up, _ := tier["unitPrice"].(map[string]any)
422-
if up == nil {
423-
continue
399+
for _, t := range skuTierList(sku) {
400+
if t.start > 0 {
401+
return t.price
424402
}
425-
units, _ := up["units"].(string)
426-
nanos, _ := up["nanos"].(float64)
427-
return gcpMoney(units, int(nanos))
428403
}
429404
return 0
430405
}

0 commit comments

Comments
 (0)