provider_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. package aws
  2. import (
  3. "encoding/json"
  4. "io"
  5. "net/http"
  6. "net/url"
  7. "os"
  8. "reflect"
  9. "testing"
  10. "github.com/opencost/opencost/pkg/cloud/models"
  11. v1 "k8s.io/api/core/v1"
  12. )
  13. func Test_awsKey_getUsageType(t *testing.T) {
  14. type fields struct {
  15. Labels map[string]string
  16. ProviderID string
  17. }
  18. type args struct {
  19. labels map[string]string
  20. }
  21. tests := []struct {
  22. name string
  23. fields fields
  24. args args
  25. want string
  26. }{
  27. {
  28. // test with no labels should return false
  29. name: "Label does not have the capacityType label associated with it",
  30. args: args{
  31. labels: map[string]string{},
  32. },
  33. want: "",
  34. },
  35. {
  36. name: "EKS label with a capacityType set to empty string should return empty string",
  37. args: args{
  38. labels: map[string]string{
  39. EKSCapacityTypeLabel: "",
  40. },
  41. },
  42. want: "",
  43. },
  44. {
  45. name: "EKS label with capacityType set to a random value should return empty string",
  46. args: args{
  47. labels: map[string]string{
  48. EKSCapacityTypeLabel: "TEST_ME",
  49. },
  50. },
  51. want: "",
  52. },
  53. {
  54. name: "EKS label with capacityType set to spot should return spot",
  55. args: args{
  56. labels: map[string]string{
  57. EKSCapacityTypeLabel: EKSCapacitySpotTypeValue,
  58. },
  59. },
  60. want: PreemptibleType,
  61. },
  62. {
  63. name: "Karpenter label with a capacityType set to empty string should return empty string",
  64. args: args{
  65. labels: map[string]string{
  66. models.KarpenterCapacityTypeLabel: "",
  67. },
  68. },
  69. want: "",
  70. },
  71. {
  72. name: "Karpenter label with capacityType set to a random value should return empty string",
  73. args: args{
  74. labels: map[string]string{
  75. models.KarpenterCapacityTypeLabel: "TEST_ME",
  76. },
  77. },
  78. want: "",
  79. },
  80. {
  81. name: "Karpenter label with capacityType set to spot should return spot",
  82. args: args{
  83. labels: map[string]string{
  84. models.KarpenterCapacityTypeLabel: models.KarpenterCapacitySpotTypeValue,
  85. },
  86. },
  87. want: PreemptibleType,
  88. },
  89. }
  90. for _, tt := range tests {
  91. t.Run(tt.name, func(t *testing.T) {
  92. k := &awsKey{
  93. Labels: tt.fields.Labels,
  94. ProviderID: tt.fields.ProviderID,
  95. }
  96. if got := k.getUsageType(tt.args.labels); got != tt.want {
  97. t.Errorf("getUsageType() = %v, want %v", got, tt.want)
  98. }
  99. })
  100. }
  101. }
  102. // Test_PricingData_Regression
  103. //
  104. // Objective: To test the pricing data download and validate the schema is still
  105. // as expected
  106. //
  107. // These tests may take a long time to complete. It is downloading AWS Pricing
  108. // data files (~500MB) for each region.
  109. func Test_PricingData_Regression(t *testing.T) {
  110. if os.Getenv("INTEGRATION") == "" {
  111. t.Skip("skipping integration tests, set environment variable INTEGRATION")
  112. }
  113. awsRegions := []string{"us-east-1", "eu-west-1"}
  114. // Check pricing data produced for each region
  115. for _, region := range awsRegions {
  116. node := v1.Node{}
  117. node.SetLabels(map[string]string{"topology.kubernetes.io/region": region})
  118. awsTest := AWS{}
  119. res, _, err := awsTest.getRegionPricing([]*v1.Node{&node})
  120. if err != nil {
  121. t.Errorf("Failed to download pricing data for region %s: %v", region, err)
  122. }
  123. // Unmarshal pricing data into AWSPricing
  124. var pricingData AWSPricing
  125. body, err := io.ReadAll(res.Body)
  126. if err != nil {
  127. t.Errorf("Failed to read pricing data for region %s: %v", region, err)
  128. }
  129. err = json.Unmarshal(body, &pricingData)
  130. if err != nil {
  131. t.Errorf("Failed to unmarshal pricing data for region %s: %v", region, err)
  132. }
  133. // ASSERTION. We only anticipate "OnDemand" or "CapacityBlock" in the
  134. // pricing data.
  135. //
  136. // Failing this test does not necessarily mean we have regressed. Just
  137. // that we need to revisit this code to ensure OnDemand pricing is still
  138. // functioning as expected.
  139. for _, product := range pricingData.Products {
  140. if product.Attributes.MarketOption != "OnDemand" && product.Attributes.MarketOption != "CapacityBlock" && product.Attributes.MarketOption != "" {
  141. t.Errorf("Invalid marketOption for product %s: %s", product.Sku, product.Attributes.MarketOption)
  142. }
  143. }
  144. }
  145. }
  146. // Test_populate_pricing
  147. //
  148. // Objective: To test core pricing population logic for AWS
  149. //
  150. // Case 0: US endpoints
  151. // Take a portion of json returned from ondemand terms in us endpoints load the
  152. // request into the http response and give it to the function inspect the
  153. // resulting aws object after the function returns and validate fields
  154. //
  155. // Case 1: Ensure marketOption=OnDemand
  156. // AWS introduced the field marketOption. We need to further filter for
  157. // marketOption=OnDemand to ensure we are not getting pricing from a line item
  158. // such as marketOption=CapacityBlock
  159. //
  160. // Case 2: Chinese endpoints
  161. // Same as above US test case, except using CN PV offer codes. Validate
  162. // populated fields in AWS object
  163. func Test_populate_pricing(t *testing.T) {
  164. awsTest := AWS{
  165. ValidPricingKeys: map[string]bool{},
  166. ClusterRegion: "us-east-2",
  167. }
  168. inputkeys := map[string]bool{
  169. "us-east-2,m5.large,linux": true,
  170. }
  171. fixture, err := os.Open("testdata/pricing-us-east-2.json")
  172. if err != nil {
  173. t.Fatalf("failed to load pricing fixture: %s", err)
  174. }
  175. testResponse := http.Response{
  176. Body: io.NopCloser(fixture),
  177. Request: &http.Request{
  178. URL: &url.URL{
  179. Scheme: "https",
  180. Host: "test-aws-http-endpoint:443",
  181. },
  182. },
  183. }
  184. awsTest.populatePricing(&testResponse, inputkeys)
  185. expectedProdTermsDisk := &AWSProductTerms{
  186. Sku: "M6UGCCQ3CDJQAA37",
  187. Memory: "",
  188. Storage: "",
  189. VCpu: "",
  190. GPU: "",
  191. OnDemand: &AWSOfferTerm{
  192. Sku: "M6UGCCQ3CDJQAA37",
  193. OfferTermCode: "JRTCKXETXF",
  194. PriceDimensions: map[string]*AWSRateCode{
  195. "M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": {
  196. Unit: "GB-Mo",
  197. PricePerUnit: AWSCurrencyCode{
  198. USD: "0.0800000000",
  199. CNY: "",
  200. },
  201. },
  202. },
  203. },
  204. PV: &models.PV{
  205. Cost: "0.00010958904109589041",
  206. CostPerIO: "",
  207. Class: "gp3",
  208. Size: "",
  209. Region: "us-east-2",
  210. ProviderID: "",
  211. },
  212. }
  213. expectedProdTermsInstanceOndemand := &AWSProductTerms{
  214. Sku: "8D49XP354UEYTHGM",
  215. Memory: "8 GiB",
  216. Storage: "EBS only",
  217. VCpu: "2",
  218. GPU: "",
  219. OnDemand: &AWSOfferTerm{
  220. Sku: "8D49XP354UEYTHGM",
  221. OfferTermCode: "MZU6U2429S",
  222. PriceDimensions: map[string]*AWSRateCode{
  223. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  224. Unit: "Quantity",
  225. PricePerUnit: AWSCurrencyCode{
  226. USD: "1161",
  227. CNY: "",
  228. },
  229. },
  230. },
  231. },
  232. }
  233. expectedProdTermsInstanceSpot := &AWSProductTerms{
  234. Sku: "8D49XP354UEYTHGM",
  235. Memory: "8 GiB",
  236. Storage: "EBS only",
  237. VCpu: "2",
  238. GPU: "",
  239. OnDemand: &AWSOfferTerm{
  240. Sku: "8D49XP354UEYTHGM",
  241. OfferTermCode: "MZU6U2429S",
  242. PriceDimensions: map[string]*AWSRateCode{
  243. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  244. Unit: "Quantity",
  245. PricePerUnit: AWSCurrencyCode{
  246. USD: "1161",
  247. CNY: "",
  248. },
  249. },
  250. },
  251. },
  252. }
  253. expectedProdTermsLoadbalancer := &AWSProductTerms{
  254. Sku: "Y9RYMSE644KDSV4S",
  255. OnDemand: &AWSOfferTerm{
  256. Sku: "Y9RYMSE644KDSV4S",
  257. OfferTermCode: "JRTCKXETXF",
  258. PriceDimensions: map[string]*AWSRateCode{
  259. "Y9RYMSE644KDSV4S.JRTCKXETXF.6YS6EN2CT7": {
  260. Unit: "Hrs",
  261. PricePerUnit: AWSCurrencyCode{
  262. USD: "0.0225000000",
  263. CNY: "",
  264. },
  265. },
  266. },
  267. },
  268. LoadBalancer: &models.LoadBalancer{
  269. Cost: 0.0225,
  270. },
  271. }
  272. expectedPricing := map[string]*AWSProductTerms{
  273. "us-east-2,EBS:VolumeUsage.gp3": expectedProdTermsDisk,
  274. "us-east-2,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
  275. "us-east-2,m5.large,linux": expectedProdTermsInstanceOndemand,
  276. "us-east-2,m5.large,linux,preemptible": expectedProdTermsInstanceSpot,
  277. "us-east-2,LoadBalancerUsage": expectedProdTermsLoadbalancer,
  278. }
  279. if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
  280. t.Fatalf("expected parsed pricing did not match actual parsed result (us-east-2)")
  281. }
  282. lbPricing, _ := awsTest.LoadBalancerPricing()
  283. if lbPricing.Cost != 0.0225 {
  284. t.Fatalf("expected loadbalancer pricing of 0.0225 but got %f (us-east-2)", lbPricing.Cost)
  285. }
  286. // Case 1 - Only accept `"marketoption":"OnDemand"`
  287. inputkeysCase1 := map[string]bool{
  288. "us-east-1,p4d.24xlarge,linux": true,
  289. }
  290. fixture, err = os.Open("testdata/pricing-us-east-1.json")
  291. if err != nil {
  292. t.Fatalf("failed to load pricing fixture: %s", err)
  293. }
  294. testResponseCase1 := http.Response{
  295. Body: io.NopCloser(fixture),
  296. Request: &http.Request{
  297. URL: &url.URL{
  298. Scheme: "https",
  299. Host: "test-aws-http-endpoint:443",
  300. },
  301. },
  302. }
  303. awsTest.populatePricing(&testResponseCase1, inputkeysCase1)
  304. expectedProdTermsInstanceOndemandCase1 := &AWSProductTerms{
  305. Sku: "H7NGEAC6UEHNTKSJ",
  306. Memory: "1152 GiB",
  307. Storage: "8 x 1000 SSD",
  308. VCpu: "96",
  309. GPU: "8",
  310. OnDemand: &AWSOfferTerm{
  311. Sku: "H7NGEAC6UEHNTKSJ",
  312. OfferTermCode: "JRTCKXETXF",
  313. PriceDimensions: map[string]*AWSRateCode{
  314. "H7NGEAC6UEHNTKSJ.JRTCKXETXF.6YS6EN2CT7": {
  315. Unit: "Hrs",
  316. PricePerUnit: AWSCurrencyCode{
  317. USD: "32.7726000000",
  318. },
  319. },
  320. },
  321. },
  322. }
  323. expectedPricingCase1 := map[string]*AWSProductTerms{
  324. "us-east-1,p4d.24xlarge,linux": expectedProdTermsInstanceOndemandCase1,
  325. "us-east-1,p4d.24xlarge,linux,preemptible": expectedProdTermsInstanceOndemandCase1,
  326. }
  327. if !reflect.DeepEqual(expectedPricingCase1, awsTest.Pricing) {
  328. expectedJsonString, _ := json.MarshalIndent(expectedPricingCase1, "", " ")
  329. resultJsonString, _ := json.MarshalIndent(awsTest.Pricing, "", " ")
  330. t.Logf("Expected: %s", string(expectedJsonString))
  331. t.Logf("Result: %s", string(resultJsonString))
  332. t.Fatalf("expected parsed pricing did not match actual parsed result (us-east-1)")
  333. }
  334. // Case 2
  335. awsTest = AWS{
  336. ValidPricingKeys: map[string]bool{},
  337. }
  338. fixture, err = os.Open("testdata/pricing-cn-northwest-1.json")
  339. if err != nil {
  340. t.Fatalf("failed to load pricing fixture: %s", err)
  341. }
  342. testResponse = http.Response{
  343. Body: io.NopCloser(fixture),
  344. Request: &http.Request{
  345. URL: &url.URL{
  346. Scheme: "https",
  347. Host: "test-aws-http-endpoint:443",
  348. },
  349. },
  350. }
  351. awsTest.populatePricing(&testResponse, inputkeys)
  352. expectedProdTermsDisk = &AWSProductTerms{
  353. Sku: "R83VXG9NAPDASEGN",
  354. Memory: "",
  355. Storage: "",
  356. VCpu: "",
  357. GPU: "",
  358. OnDemand: &AWSOfferTerm{
  359. Sku: "R83VXG9NAPDASEGN",
  360. OfferTermCode: "5Y9WH78GDR",
  361. PriceDimensions: map[string]*AWSRateCode{
  362. "R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": {
  363. Unit: "GB-Mo",
  364. PricePerUnit: AWSCurrencyCode{
  365. USD: "",
  366. CNY: "0.5312000000",
  367. },
  368. },
  369. },
  370. },
  371. PV: &models.PV{
  372. Cost: "0.0007276712328767123",
  373. CostPerIO: "",
  374. Class: "gp3",
  375. Size: "",
  376. Region: "cn-northwest-1",
  377. ProviderID: "",
  378. },
  379. }
  380. expectedPricing = map[string]*AWSProductTerms{
  381. "cn-northwest-1,EBS:VolumeUsage.gp3": expectedProdTermsDisk,
  382. "cn-northwest-1,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
  383. }
  384. if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
  385. t.Fatalf("expected parsed pricing did not match actual parsed result (cn)")
  386. }
  387. }
  388. func TestFeatures(t *testing.T) {
  389. testCases := map[string]struct {
  390. aws awsKey
  391. expected string
  392. }{
  393. "Spot from custom labels": {
  394. aws: awsKey{
  395. SpotLabelName: "node-type",
  396. SpotLabelValue: "node-spot",
  397. Labels: map[string]string{
  398. "node-type": "node-spot",
  399. v1.LabelOSStable: "linux",
  400. v1.LabelHostname: "my-hostname",
  401. v1.LabelTopologyRegion: "us-west-2",
  402. v1.LabelTopologyZone: "us-west-2b",
  403. v1.LabelInstanceTypeStable: "m5.large",
  404. },
  405. },
  406. expected: "us-west-2,m5.large,linux,preemptible",
  407. },
  408. }
  409. for name, tc := range testCases {
  410. t.Run(name, func(t *testing.T) {
  411. features := tc.aws.Features()
  412. if features != tc.expected {
  413. t.Errorf("expected %s, got %s", tc.expected, features)
  414. }
  415. })
  416. }
  417. }
  418. func Test_getStorageClassTypeFrom(t *testing.T) {
  419. tests := []struct {
  420. name string
  421. provisioner string
  422. want string
  423. }{
  424. {
  425. name: "empty-provisioner",
  426. provisioner: "",
  427. want: "",
  428. },
  429. {
  430. name: "ebs-default-provisioner",
  431. provisioner: "kubernetes.io/aws-ebs",
  432. want: "gp2",
  433. },
  434. {
  435. name: "ebs-csi-provisioner",
  436. provisioner: "ebs.csi.aws.com",
  437. want: "gp3",
  438. },
  439. {
  440. name: "unknown-provisioner",
  441. provisioner: "unknown",
  442. want: "",
  443. },
  444. }
  445. for _, tt := range tests {
  446. t.Run(tt.name, func(t *testing.T) {
  447. if got := getStorageClassTypeFrom(tt.provisioner); got != tt.want {
  448. t.Errorf("getStorageClassTypeFrom() = %v, want %v", got, tt.want)
  449. }
  450. })
  451. }
  452. }