billingpricingsource_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package gcp
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/model/pricingmodel"
  8. "github.com/opencost/opencost/core/pkg/model/shared"
  9. )
  10. func TestGCPNormalizeFamily(t *testing.T) {
  11. cases := []struct{ in, want string }{
  12. {"N1Standard", "n1"},
  13. {"N2Standard", "n2"},
  14. {"N2DStandard", "n2d"},
  15. {"E2", "e2"},
  16. {"A2", "a2"},
  17. {"A3", "a3"},
  18. {"G2", "g2"},
  19. {"C2Standard", "c2"},
  20. {"C2DStandard", "c2d"},
  21. {"C3Standard", "c3"},
  22. {"C3DStandard", "c3d"},
  23. {"M1", "m1"},
  24. {"M2", "m2"},
  25. {"M3", "m3"},
  26. {"T2DStandard", "t2d"},
  27. {"T2AStandard", "t2a"},
  28. {"N4Standard", "n4"},
  29. {"H3Standard", "h3"},
  30. }
  31. for _, tc := range cases {
  32. t.Run(tc.in, func(t *testing.T) {
  33. got := gcpNormalizeFamily(tc.in)
  34. if got != tc.want {
  35. t.Errorf("gcpNormalizeFamily(%q) = %q, want %q", tc.in, got, tc.want)
  36. }
  37. })
  38. }
  39. }
  40. func TestGCPUsageType(t *testing.T) {
  41. cases := []struct {
  42. in string
  43. want shared.UsageType
  44. }{
  45. {"OnDemand", shared.UsageTypeOnDemand},
  46. {"Preemptible", shared.UsageTypeSpot},
  47. {"Commit1Yr", shared.UsageTypeEmpty},
  48. {"Commit3Yr", shared.UsageTypeEmpty},
  49. {"", shared.UsageTypeEmpty},
  50. {"unknown", shared.UsageTypeEmpty},
  51. }
  52. for _, tc := range cases {
  53. t.Run(tc.in, func(t *testing.T) {
  54. got := gcpUsageType(tc.in)
  55. if got != tc.want {
  56. t.Errorf("gcpUsageType(%q) = %q, want %q", tc.in, got, tc.want)
  57. }
  58. })
  59. }
  60. }
  61. func TestGCPExtractHourlyRate(t *testing.T) {
  62. cases := []struct {
  63. name string
  64. sku *GCPPricing
  65. want float64
  66. wantOK bool
  67. }{
  68. {
  69. name: "nanos only",
  70. sku: skuWithRate("0", 48000000),
  71. want: 0.048,
  72. wantOK: true,
  73. },
  74. {
  75. name: "units and nanos",
  76. sku: skuWithRate("1", 500000000),
  77. want: 1.5,
  78. wantOK: true,
  79. },
  80. {
  81. name: "whole units no nanos",
  82. sku: skuWithRate("2", 0),
  83. want: 2.0,
  84. wantOK: true,
  85. },
  86. {
  87. name: "no pricing info",
  88. sku: &GCPPricing{PricingInfo: []*PricingInfo{}},
  89. want: 0,
  90. wantOK: false,
  91. },
  92. {
  93. name: "no tiered rates",
  94. sku: skuWithRates([]*TieredRates{}),
  95. want: 0,
  96. wantOK: false,
  97. },
  98. {
  99. name: "nil pricing info",
  100. sku: &GCPPricing{},
  101. want: 0,
  102. wantOK: false,
  103. },
  104. }
  105. for _, tc := range cases {
  106. t.Run(tc.name, func(t *testing.T) {
  107. got, ok := gcpExtractHourlyRate(tc.sku)
  108. if ok != tc.wantOK {
  109. t.Errorf("ok = %v, want %v", ok, tc.wantOK)
  110. }
  111. if ok && abs(got-tc.want) > 1e-9 {
  112. t.Errorf("rate = %v, want %v", got, tc.want)
  113. }
  114. })
  115. }
  116. }
  117. func TestGCPBillingPricingSource_ParsePage(t *testing.T) {
  118. page := gcpBillingPage{
  119. NextPageToken: "token-xyz",
  120. SKUs: []*GCPPricing{
  121. // N1 CPU OnDemand — us-central1
  122. {
  123. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "OnDemand"},
  124. ServiceRegions: []string{"us-central1"},
  125. Description: "N1 Predefined Instance Core running in Americas",
  126. PricingInfo: []*PricingInfo{pricingInfo("0", 31611000)},
  127. },
  128. // N1 RAM OnDemand — us-central1
  129. {
  130. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "OnDemand"},
  131. ServiceRegions: []string{"us-central1"},
  132. Description: "N1 Predefined Instance RAM running in Americas",
  133. PricingInfo: []*PricingInfo{pricingInfo("0", 4237000)},
  134. },
  135. // T4 GPU OnDemand — us-central1
  136. {
  137. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "GPU", UsageType: "OnDemand"},
  138. ServiceRegions: []string{"us-central1"},
  139. Description: "NVIDIA Tesla T4 running in Americas",
  140. PricingInfo: []*PricingInfo{pricingInfo("0", 400000000)},
  141. },
  142. // N1 CPU Commit1Yr — should be skipped
  143. {
  144. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "Commit1Yr"},
  145. ServiceRegions: []string{"us-central1"},
  146. Description: "Commitment v1: N1 in Americas for 1 year",
  147. PricingInfo: []*PricingInfo{pricingInfo("0", 20000000)},
  148. },
  149. // Non-Compute resourceFamily — should be skipped
  150. {
  151. Category: &GCPResourceInfo{ResourceFamily: "Storage", ResourceGroup: "SSD", UsageType: "OnDemand"},
  152. ServiceRegions: []string{"us-central1"},
  153. Description: "SSD backed Local Storage",
  154. PricingInfo: []*PricingInfo{pricingInfo("0", 17000000)},
  155. },
  156. // GPU SKU with zero rate — should be skipped (reserved)
  157. {
  158. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "GPU", UsageType: "OnDemand"},
  159. ServiceRegions: []string{"us-central1"},
  160. Description: "NVIDIA Tesla V100 running in Americas",
  161. PricingInfo: []*PricingInfo{pricingInfo("0", 0)},
  162. },
  163. },
  164. }
  165. body, _ := json.Marshal(page)
  166. src, err := NewGCPBillingPricingSource(GCPBillingPricingSourceConfig{APIKey: "test-key"})
  167. if err != nil {
  168. t.Fatalf("NewGCPBillingPricingSource error: %v", err)
  169. }
  170. pms := pricingmodel.NewPricingModelSet(time.Now().UTC(), src.PricingSourceType(), src.PricingSourceKey())
  171. nextToken, err := src.parsePage(strings.NewReader(string(body)), pms)
  172. if err != nil {
  173. t.Fatalf("parsePage error: %v", err)
  174. }
  175. if nextToken != "token-xyz" {
  176. t.Errorf("nextToken = %q, want %q", nextToken, "token-xyz")
  177. }
  178. if len(pms.NodePricing) != 3 {
  179. t.Errorf("expected 3 pricing entries (CPU, RAM, GPU), got %d", len(pms.NodePricing))
  180. }
  181. cpuKey := pricingmodel.NodeKey{
  182. Provider: shared.ProviderGCP,
  183. Region: "us-central1",
  184. Family: "n1",
  185. UsageType: shared.UsageTypeOnDemand,
  186. PricingType: pricingmodel.NodePricingTypeCPUCore,
  187. }
  188. if _, ok := pms.NodePricing[cpuKey]; !ok {
  189. t.Error("missing CPU entry for n1/us-central1")
  190. }
  191. ramKey := pricingmodel.NodeKey{
  192. Provider: shared.ProviderGCP,
  193. Region: "us-central1",
  194. Family: "n1",
  195. UsageType: shared.UsageTypeOnDemand,
  196. PricingType: pricingmodel.NodePricingTypeRamGB,
  197. }
  198. if _, ok := pms.NodePricing[ramKey]; !ok {
  199. t.Error("missing RAM entry for n1/us-central1")
  200. }
  201. gpuKey := pricingmodel.NodeKey{
  202. Provider: shared.ProviderGCP,
  203. Region: "us-central1",
  204. UsageType: shared.UsageTypeOnDemand,
  205. DeviceType: "nvidia-tesla-t4",
  206. PricingType: pricingmodel.NodePricingTypeDevice,
  207. }
  208. if entry, ok := pms.NodePricing[gpuKey]; !ok {
  209. t.Error("missing GPU entry for T4/us-central1")
  210. } else if abs(entry.HourlyRate-0.4) > 1e-9 {
  211. t.Errorf("GPU HourlyRate = %v, want 0.4", entry.HourlyRate)
  212. }
  213. }
  214. func TestGCPBillingPricingSource_ParsePage_Preemptible(t *testing.T) {
  215. page := gcpBillingPage{
  216. SKUs: []*GCPPricing{
  217. {
  218. Category: &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "Preemptible"},
  219. ServiceRegions: []string{"us-east1"},
  220. Description: "Preemptible N1 Predefined Instance Core",
  221. PricingInfo: []*PricingInfo{pricingInfo("0", 6655000)},
  222. },
  223. },
  224. }
  225. body, _ := json.Marshal(page)
  226. src, err := NewGCPBillingPricingSource(GCPBillingPricingSourceConfig{APIKey: "test-key"})
  227. if err != nil {
  228. t.Fatalf("NewGCPBillingPricingSource error: %v", err)
  229. }
  230. pms := pricingmodel.NewPricingModelSet(time.Now().UTC(), src.PricingSourceType(), src.PricingSourceKey())
  231. if _, err := src.parsePage(strings.NewReader(string(body)), pms); err != nil {
  232. t.Fatalf("parsePage error: %v", err)
  233. }
  234. key := pricingmodel.NodeKey{
  235. Provider: shared.ProviderGCP,
  236. Region: "us-east1",
  237. Family: "n1",
  238. UsageType: shared.UsageTypeSpot,
  239. PricingType: pricingmodel.NodePricingTypeCPUCore,
  240. }
  241. if _, ok := pms.NodePricing[key]; !ok {
  242. t.Error("missing Preemptible CPU entry")
  243. }
  244. }
  245. // --- helpers ---
  246. func skuWithRate(units string, nanos float64) *GCPPricing {
  247. return skuWithRates([]*TieredRates{
  248. {UnitPrice: &UnitPriceInfo{Units: units, Nanos: nanos}},
  249. })
  250. }
  251. func skuWithRates(rates []*TieredRates) *GCPPricing {
  252. return &GCPPricing{
  253. PricingInfo: []*PricingInfo{
  254. {PricingExpression: &PricingExpression{TieredRates: rates}},
  255. },
  256. }
  257. }
  258. func pricingInfo(units string, nanos float64) *PricingInfo {
  259. return &PricingInfo{
  260. PricingExpression: &PricingExpression{
  261. TieredRates: []*TieredRates{
  262. {UnitPrice: &UnitPriceInfo{Units: units, Nanos: nanos}},
  263. },
  264. },
  265. }
  266. }
  267. func abs(x float64) float64 {
  268. if x < 0 {
  269. return -x
  270. }
  271. return x
  272. }