provider_test.go 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494
  1. package aws
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "net/url"
  8. "os"
  9. "reflect"
  10. "strings"
  11. "testing"
  12. "time"
  13. ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
  14. "github.com/opencost/opencost/core/pkg/clustercache"
  15. "github.com/opencost/opencost/pkg/cloud/models"
  16. "github.com/opencost/opencost/pkg/config"
  17. v1 "k8s.io/api/core/v1"
  18. )
  19. func Test_awsKey_getUsageType(t *testing.T) {
  20. type fields struct {
  21. Labels map[string]string
  22. ProviderID string
  23. }
  24. type args struct {
  25. labels map[string]string
  26. }
  27. tests := []struct {
  28. name string
  29. fields fields
  30. args args
  31. want string
  32. }{
  33. {
  34. // test with no labels should return false
  35. name: "Label does not have the capacityType label associated with it",
  36. args: args{
  37. labels: map[string]string{},
  38. },
  39. want: "",
  40. },
  41. {
  42. name: "EKS label with a capacityType set to empty string should return empty string",
  43. args: args{
  44. labels: map[string]string{
  45. EKSCapacityTypeLabel: "",
  46. },
  47. },
  48. want: "",
  49. },
  50. {
  51. name: "EKS label with capacityType set to a random value should return empty string",
  52. args: args{
  53. labels: map[string]string{
  54. EKSCapacityTypeLabel: "TEST_ME",
  55. },
  56. },
  57. want: "",
  58. },
  59. {
  60. name: "EKS label with capacityType set to spot should return spot",
  61. args: args{
  62. labels: map[string]string{
  63. EKSCapacityTypeLabel: EKSCapacitySpotTypeValue,
  64. },
  65. },
  66. want: PreemptibleType,
  67. },
  68. {
  69. name: "Karpenter label with a capacityType set to empty string should return empty string",
  70. args: args{
  71. labels: map[string]string{
  72. models.KarpenterCapacityTypeLabel: "",
  73. },
  74. },
  75. want: "",
  76. },
  77. {
  78. name: "Karpenter label with capacityType set to a random value should return empty string",
  79. args: args{
  80. labels: map[string]string{
  81. models.KarpenterCapacityTypeLabel: "TEST_ME",
  82. },
  83. },
  84. want: "",
  85. },
  86. {
  87. name: "Karpenter label with capacityType set to spot should return spot",
  88. args: args{
  89. labels: map[string]string{
  90. models.KarpenterCapacityTypeLabel: models.KarpenterCapacitySpotTypeValue,
  91. },
  92. },
  93. want: PreemptibleType,
  94. },
  95. }
  96. for _, tt := range tests {
  97. t.Run(tt.name, func(t *testing.T) {
  98. k := &awsKey{
  99. Labels: tt.fields.Labels,
  100. ProviderID: tt.fields.ProviderID,
  101. }
  102. if got := k.getUsageType(tt.args.labels); got != tt.want {
  103. t.Errorf("getUsageType() = %v, want %v", got, tt.want)
  104. }
  105. })
  106. }
  107. }
  108. // Test_PricingData_Regression
  109. //
  110. // Objective: To test the pricing data download and validate the schema is still
  111. // as expected
  112. //
  113. // These tests may take a long time to complete. It is downloading AWS Pricing
  114. // data files (~500MB) for each region.
  115. func Test_PricingData_Regression(t *testing.T) {
  116. if os.Getenv("INTEGRATION") == "" {
  117. t.Skip("skipping integration tests, set environment variable INTEGRATION")
  118. }
  119. awsRegions := []string{"us-east-1", "eu-west-1"}
  120. // Check pricing data produced for each region
  121. for _, region := range awsRegions {
  122. awsTest := AWS{}
  123. res, _, err := awsTest.getRegionPricing([]*clustercache.Node{
  124. {
  125. Labels: map[string]string{"topology.kubernetes.io/region": region},
  126. }})
  127. if err != nil {
  128. t.Errorf("Failed to download pricing data for region %s: %v", region, err)
  129. }
  130. // Unmarshal pricing data into AWSPricing
  131. var pricingData AWSPricing
  132. body, err := io.ReadAll(res.Body)
  133. if err != nil {
  134. t.Errorf("Failed to read pricing data for region %s: %v", region, err)
  135. }
  136. err = json.Unmarshal(body, &pricingData)
  137. if err != nil {
  138. t.Errorf("Failed to unmarshal pricing data for region %s: %v", region, err)
  139. }
  140. // ASSERTION. We only anticipate "OnDemand" or "CapacityBlock" in the
  141. // pricing data.
  142. //
  143. // Failing this test does not necessarily mean we have regressed. Just
  144. // that we need to revisit this code to ensure OnDemand pricing is still
  145. // functioning as expected.
  146. for _, product := range pricingData.Products {
  147. if product.Attributes.MarketOption != "OnDemand" && product.Attributes.MarketOption != "CapacityBlock" && product.Attributes.MarketOption != "" {
  148. t.Errorf("Invalid marketOption for product %s: %s", product.Sku, product.Attributes.MarketOption)
  149. }
  150. }
  151. }
  152. }
  153. // Test_populate_pricing
  154. //
  155. // Objective: To test core pricing population logic for AWS
  156. //
  157. // Case 0: US endpoints
  158. // Take a portion of json returned from ondemand terms in us endpoints load the
  159. // request into the http response and give it to the function inspect the
  160. // resulting aws object after the function returns and validate fields
  161. //
  162. // Case 1: Ensure marketOption=OnDemand
  163. // AWS introduced the field marketOption. We need to further filter for
  164. // marketOption=OnDemand to ensure we are not getting pricing from a line item
  165. // such as marketOption=CapacityBlock
  166. //
  167. // Case 2: Chinese endpoints
  168. // Same as above US test case, except using CN PV offer codes. Validate
  169. // populated fields in AWS object
  170. func Test_populate_pricing(t *testing.T) {
  171. awsTest := AWS{
  172. ValidPricingKeys: map[string]bool{},
  173. ClusterRegion: "us-east-2",
  174. }
  175. inputkeys := map[string]bool{
  176. "us-east-2,m5.large,linux": true,
  177. }
  178. fixture, err := os.Open("testdata/pricing-us-east-2.json")
  179. if err != nil {
  180. t.Fatalf("failed to load pricing fixture: %s", err)
  181. }
  182. testResponse := http.Response{
  183. Body: io.NopCloser(fixture),
  184. Request: &http.Request{
  185. URL: &url.URL{
  186. Scheme: "https",
  187. Host: "test-aws-http-endpoint:443",
  188. },
  189. },
  190. }
  191. awsTest.populatePricing(&testResponse, inputkeys)
  192. expectedProdTermsDisk := &AWSProductTerms{
  193. Sku: "M6UGCCQ3CDJQAA37",
  194. Memory: "",
  195. Storage: "",
  196. VCpu: "",
  197. GPU: "",
  198. OnDemand: &AWSOfferTerm{
  199. Sku: "M6UGCCQ3CDJQAA37",
  200. OfferTermCode: "JRTCKXETXF",
  201. PriceDimensions: map[string]*AWSRateCode{
  202. "M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": {
  203. Unit: "GB-Mo",
  204. PricePerUnit: AWSCurrencyCode{
  205. USD: "0.0800000000",
  206. CNY: "",
  207. },
  208. },
  209. },
  210. },
  211. PV: &models.PV{
  212. Cost: "0.00010958904109589041",
  213. CostPerIO: "",
  214. Class: "gp3",
  215. Size: "",
  216. Region: "us-east-2",
  217. ProviderID: "",
  218. },
  219. }
  220. expectedProdTermsInstanceOndemand := &AWSProductTerms{
  221. Sku: "8D49XP354UEYTHGM",
  222. Memory: "8 GiB",
  223. Storage: "EBS only",
  224. VCpu: "2",
  225. GPU: "",
  226. OnDemand: &AWSOfferTerm{
  227. Sku: "8D49XP354UEYTHGM",
  228. OfferTermCode: "MZU6U2429S",
  229. PriceDimensions: map[string]*AWSRateCode{
  230. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  231. Unit: "Quantity",
  232. PricePerUnit: AWSCurrencyCode{
  233. USD: "1161",
  234. CNY: "",
  235. },
  236. },
  237. },
  238. },
  239. }
  240. expectedProdTermsInstanceSpot := &AWSProductTerms{
  241. Sku: "8D49XP354UEYTHGM",
  242. Memory: "8 GiB",
  243. Storage: "EBS only",
  244. VCpu: "2",
  245. GPU: "",
  246. OnDemand: &AWSOfferTerm{
  247. Sku: "8D49XP354UEYTHGM",
  248. OfferTermCode: "MZU6U2429S",
  249. PriceDimensions: map[string]*AWSRateCode{
  250. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  251. Unit: "Quantity",
  252. PricePerUnit: AWSCurrencyCode{
  253. USD: "1161",
  254. CNY: "",
  255. },
  256. },
  257. },
  258. },
  259. }
  260. expectedProdTermsLoadbalancer := &AWSProductTerms{
  261. Sku: "Y9RYMSE644KDSV4S",
  262. OnDemand: &AWSOfferTerm{
  263. Sku: "Y9RYMSE644KDSV4S",
  264. OfferTermCode: "JRTCKXETXF",
  265. PriceDimensions: map[string]*AWSRateCode{
  266. "Y9RYMSE644KDSV4S.JRTCKXETXF.6YS6EN2CT7": {
  267. Unit: "Hrs",
  268. PricePerUnit: AWSCurrencyCode{
  269. USD: "0.0225000000",
  270. CNY: "",
  271. },
  272. },
  273. },
  274. },
  275. LoadBalancer: &models.LoadBalancer{
  276. Cost: 0.0225,
  277. },
  278. }
  279. expectedPricing := map[string]*AWSProductTerms{
  280. "us-east-2,EBS:VolumeUsage.gp3": expectedProdTermsDisk,
  281. "us-east-2,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
  282. "us-east-2,m5.large,linux": expectedProdTermsInstanceOndemand,
  283. "us-east-2,m5.large,linux,preemptible": expectedProdTermsInstanceSpot,
  284. "us-east-2,LoadBalancerUsage": expectedProdTermsLoadbalancer,
  285. }
  286. if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
  287. t.Fatalf("expected parsed pricing did not match actual parsed result (us-east-2)")
  288. }
  289. lbPricing, _ := awsTest.LoadBalancerPricing()
  290. if lbPricing.Cost != 0.0225 {
  291. t.Fatalf("expected loadbalancer pricing of 0.0225 but got %f (us-east-2)", lbPricing.Cost)
  292. }
  293. // Case 1 - Only accept `"marketoption":"OnDemand"`
  294. inputkeysCase1 := map[string]bool{
  295. "us-east-1,p4d.24xlarge,linux": true,
  296. }
  297. fixture, err = os.Open("testdata/pricing-us-east-1.json")
  298. if err != nil {
  299. t.Fatalf("failed to load pricing fixture: %s", err)
  300. }
  301. testResponseCase1 := http.Response{
  302. Body: io.NopCloser(fixture),
  303. Request: &http.Request{
  304. URL: &url.URL{
  305. Scheme: "https",
  306. Host: "test-aws-http-endpoint:443",
  307. },
  308. },
  309. }
  310. awsTest.populatePricing(&testResponseCase1, inputkeysCase1)
  311. expectedProdTermsInstanceOndemandCase1 := &AWSProductTerms{
  312. Sku: "H7NGEAC6UEHNTKSJ",
  313. Memory: "1152 GiB",
  314. Storage: "8 x 1000 SSD",
  315. VCpu: "96",
  316. GPU: "8",
  317. OnDemand: &AWSOfferTerm{
  318. Sku: "H7NGEAC6UEHNTKSJ",
  319. OfferTermCode: "JRTCKXETXF",
  320. PriceDimensions: map[string]*AWSRateCode{
  321. "H7NGEAC6UEHNTKSJ.JRTCKXETXF.6YS6EN2CT7": {
  322. Unit: "Hrs",
  323. PricePerUnit: AWSCurrencyCode{
  324. USD: "32.7726000000",
  325. },
  326. },
  327. },
  328. },
  329. }
  330. expectedPricingCase1 := map[string]*AWSProductTerms{
  331. "us-east-1,p4d.24xlarge,linux": expectedProdTermsInstanceOndemandCase1,
  332. "us-east-1,p4d.24xlarge,linux,preemptible": expectedProdTermsInstanceOndemandCase1,
  333. }
  334. if !reflect.DeepEqual(expectedPricingCase1, awsTest.Pricing) {
  335. expectedJsonString, _ := json.MarshalIndent(expectedPricingCase1, "", " ")
  336. resultJsonString, _ := json.MarshalIndent(awsTest.Pricing, "", " ")
  337. t.Logf("Expected: %s", string(expectedJsonString))
  338. t.Logf("Result: %s", string(resultJsonString))
  339. t.Fatalf("expected parsed pricing did not match actual parsed result (us-east-1)")
  340. }
  341. // Case 2
  342. awsTest = AWS{
  343. ValidPricingKeys: map[string]bool{},
  344. }
  345. fixture, err = os.Open("testdata/pricing-cn-northwest-1.json")
  346. if err != nil {
  347. t.Fatalf("failed to load pricing fixture: %s", err)
  348. }
  349. testResponse = http.Response{
  350. Body: io.NopCloser(fixture),
  351. Request: &http.Request{
  352. URL: &url.URL{
  353. Scheme: "https",
  354. Host: "test-aws-http-endpoint:443",
  355. },
  356. },
  357. }
  358. awsTest.populatePricing(&testResponse, inputkeys)
  359. expectedProdTermsDisk = &AWSProductTerms{
  360. Sku: "R83VXG9NAPDASEGN",
  361. Memory: "",
  362. Storage: "",
  363. VCpu: "",
  364. GPU: "",
  365. OnDemand: &AWSOfferTerm{
  366. Sku: "R83VXG9NAPDASEGN",
  367. OfferTermCode: "5Y9WH78GDR",
  368. PriceDimensions: map[string]*AWSRateCode{
  369. "R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": {
  370. Unit: "GB-Mo",
  371. PricePerUnit: AWSCurrencyCode{
  372. USD: "",
  373. CNY: "0.5312000000",
  374. },
  375. },
  376. },
  377. },
  378. PV: &models.PV{
  379. Cost: "0.0007276712328767123",
  380. CostPerIO: "",
  381. Class: "gp3",
  382. Size: "",
  383. Region: "cn-northwest-1",
  384. ProviderID: "",
  385. },
  386. }
  387. expectedPricing = map[string]*AWSProductTerms{
  388. "cn-northwest-1,EBS:VolumeUsage.gp3": expectedProdTermsDisk,
  389. "cn-northwest-1,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
  390. }
  391. if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
  392. t.Fatalf("expected parsed pricing did not match actual parsed result (cn)")
  393. }
  394. }
  395. func TestFeatures(t *testing.T) {
  396. testCases := map[string]struct {
  397. aws awsKey
  398. expected string
  399. }{
  400. "Spot from custom labels": {
  401. aws: awsKey{
  402. SpotLabelName: "node-type",
  403. SpotLabelValue: "node-spot",
  404. Labels: map[string]string{
  405. "node-type": "node-spot",
  406. v1.LabelOSStable: "linux",
  407. v1.LabelHostname: "my-hostname",
  408. v1.LabelTopologyRegion: "us-west-2",
  409. v1.LabelTopologyZone: "us-west-2b",
  410. v1.LabelInstanceTypeStable: "m5.large",
  411. },
  412. },
  413. expected: "us-west-2,m5.large,linux,preemptible",
  414. },
  415. }
  416. for name, tc := range testCases {
  417. t.Run(name, func(t *testing.T) {
  418. features := tc.aws.Features()
  419. if features != tc.expected {
  420. t.Errorf("expected %s, got %s", tc.expected, features)
  421. }
  422. })
  423. }
  424. }
  425. func Test_getStorageClassTypeFrom(t *testing.T) {
  426. tests := []struct {
  427. name string
  428. provisioner string
  429. want string
  430. }{
  431. {
  432. name: "empty-provisioner",
  433. provisioner: "",
  434. want: "",
  435. },
  436. {
  437. name: "ebs-default-provisioner",
  438. provisioner: "kubernetes.io/aws-ebs",
  439. want: "gp2",
  440. },
  441. {
  442. name: "ebs-csi-provisioner",
  443. provisioner: "ebs.csi.aws.com",
  444. want: "gp3",
  445. },
  446. {
  447. name: "unknown-provisioner",
  448. provisioner: "unknown",
  449. want: "",
  450. },
  451. }
  452. for _, tt := range tests {
  453. t.Run(tt.name, func(t *testing.T) {
  454. if got := getStorageClassTypeFrom(tt.provisioner); got != tt.want {
  455. t.Errorf("getStorageClassTypeFrom() = %v, want %v", got, tt.want)
  456. }
  457. })
  458. }
  459. }
  460. func Test_awsKey_isFargateNode(t *testing.T) {
  461. tests := []struct {
  462. name string
  463. labels map[string]string
  464. want bool
  465. }{
  466. {
  467. name: "fargate node with correct label",
  468. labels: map[string]string{
  469. eksComputeTypeLabel: "fargate",
  470. },
  471. want: true,
  472. },
  473. {
  474. name: "ec2 node with different compute type",
  475. labels: map[string]string{
  476. eksComputeTypeLabel: "ec2",
  477. },
  478. want: false,
  479. },
  480. {
  481. name: "node without compute type label",
  482. labels: map[string]string{
  483. "some.other.label": "value",
  484. },
  485. want: false,
  486. },
  487. {
  488. name: "node with empty labels",
  489. labels: map[string]string{},
  490. want: false,
  491. },
  492. {
  493. name: "node with nil labels",
  494. labels: nil,
  495. want: false,
  496. },
  497. }
  498. for _, tt := range tests {
  499. t.Run(tt.name, func(t *testing.T) {
  500. k := &awsKey{
  501. Labels: tt.labels,
  502. }
  503. if got := k.isFargateNode(); got != tt.want {
  504. t.Errorf("awsKey.isFargateNode() = %v, want %v", got, tt.want)
  505. }
  506. })
  507. }
  508. }
  509. func TestGetPricingListURL(t *testing.T) {
  510. tests := []struct {
  511. name string
  512. serviceCode string
  513. nodeList []*clustercache.Node
  514. expected string
  515. }{
  516. {
  517. name: "AmazonEC2 service with us-east-1 region",
  518. serviceCode: "AmazonEC2",
  519. nodeList: []*clustercache.Node{
  520. {
  521. Name: "test-node",
  522. Labels: map[string]string{
  523. "topology.kubernetes.io/region": "us-east-1",
  524. },
  525. },
  526. },
  527. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/us-east-1/index.json",
  528. },
  529. {
  530. name: "AmazonECS service with us-west-2 region",
  531. serviceCode: "AmazonECS",
  532. nodeList: []*clustercache.Node{
  533. {
  534. Name: "test-node",
  535. Labels: map[string]string{
  536. "topology.kubernetes.io/region": "us-west-2",
  537. },
  538. },
  539. },
  540. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/us-west-2/index.json",
  541. },
  542. {
  543. name: "Chinese region cn-north-1",
  544. serviceCode: "AmazonEC2",
  545. nodeList: []*clustercache.Node{
  546. {
  547. Name: "test-node",
  548. Labels: map[string]string{
  549. "topology.kubernetes.io/region": "cn-north-1",
  550. },
  551. },
  552. },
  553. expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonEC2/current/cn-north-1/index.json",
  554. },
  555. {
  556. name: "Chinese region cn-northwest-1",
  557. serviceCode: "AmazonECS",
  558. nodeList: []*clustercache.Node{
  559. {
  560. Name: "test-node",
  561. Labels: map[string]string{
  562. "topology.kubernetes.io/region": "cn-northwest-1",
  563. },
  564. },
  565. },
  566. expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonECS/current/cn-northwest-1/index.json",
  567. },
  568. {
  569. name: "empty node list - multiregion",
  570. serviceCode: "AmazonEC2",
  571. nodeList: []*clustercache.Node{},
  572. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json",
  573. },
  574. {
  575. name: "multiple regions - multiregion",
  576. serviceCode: "AmazonECS",
  577. nodeList: []*clustercache.Node{
  578. {
  579. Name: "test-node-1",
  580. Labels: map[string]string{
  581. "topology.kubernetes.io/region": "us-east-1",
  582. },
  583. },
  584. {
  585. Name: "test-node-2",
  586. Labels: map[string]string{
  587. "topology.kubernetes.io/region": "us-west-2",
  588. },
  589. },
  590. },
  591. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/index.json",
  592. },
  593. {
  594. name: "node without region label",
  595. serviceCode: "AmazonEC2",
  596. nodeList: []*clustercache.Node{
  597. {
  598. Name: "test-node",
  599. Labels: map[string]string{
  600. "some.other.label": "value",
  601. },
  602. },
  603. },
  604. expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json",
  605. },
  606. }
  607. for _, tt := range tests {
  608. t.Run(tt.name, func(t *testing.T) {
  609. result := getPricingListURL(tt.serviceCode, tt.nodeList)
  610. if result != tt.expected {
  611. t.Errorf("getPricingListURL() = %v, expected %v", result, tt.expected)
  612. }
  613. })
  614. }
  615. }
  616. func Test_configUpdaterWithReaderAndType_forSpotValues(t *testing.T) {
  617. fixture, err := os.Open("testdata/aws-config.json")
  618. if err != nil {
  619. t.Fatalf("failed to load aws config fixture: %s", err)
  620. }
  621. defer fixture.Close()
  622. c := &models.CustomPricing{}
  623. callback := configUpdaterWithReaderAndType(fixture, "otherupdatetype")
  624. err = callback(c)
  625. if err != nil {
  626. t.Fatalf("failed to load aws config: %s", err)
  627. }
  628. if c.AwsSpotDataBucket != "mybucket" {
  629. t.Fatalf("Expected %s but got %s", "mybucket", c.AwsSpotDataBucket)
  630. }
  631. if c.AwsSpotDataPrefix != "myprefix" {
  632. t.Fatalf("Expected %s but got %s", "myprefix", c.AwsSpotDataPrefix)
  633. }
  634. if c.AwsSpotDataRegion != "us-east-1" {
  635. t.Fatalf("Expected %s but got %s", "us-east-1", c.AwsSpotDataRegion)
  636. }
  637. fixture2, err := os.Open("testdata/aws-config-empty.json")
  638. if err != nil {
  639. t.Fatalf("failed to load aws config fixture: %s", err)
  640. }
  641. defer fixture2.Close()
  642. c = &models.CustomPricing{}
  643. callback = configUpdaterWithReaderAndType(fixture2, "otherupdatetype")
  644. err = callback(c)
  645. if err != nil {
  646. t.Fatalf("failed to load aws config: %s", err)
  647. }
  648. if c.AwsSpotDataBucket != "" {
  649. t.Fatalf("Expected empty string but got %s", c.AwsSpotDataBucket)
  650. }
  651. if c.AwsSpotDataPrefix != "" {
  652. t.Fatalf("Expected empty string but got %s", c.AwsSpotDataPrefix)
  653. }
  654. if c.AwsSpotDataRegion != "" {
  655. t.Fatalf("Expected empty string but got %s", c.AwsSpotDataRegion)
  656. }
  657. }
  658. func TestAWS_getFargatePod(t *testing.T) {
  659. tests := []struct {
  660. name string
  661. pods []*clustercache.Pod
  662. awsKey *awsKey
  663. wantPod *clustercache.Pod
  664. wantBool bool
  665. }{
  666. {
  667. name: "pod found for node",
  668. pods: []*clustercache.Pod{
  669. {
  670. Name: "test-pod",
  671. Spec: clustercache.PodSpec{
  672. NodeName: "fargate-node-1",
  673. },
  674. },
  675. },
  676. awsKey: &awsKey{
  677. Name: "fargate-node-1",
  678. },
  679. wantPod: &clustercache.Pod{
  680. Name: "test-pod",
  681. Spec: clustercache.PodSpec{
  682. NodeName: "fargate-node-1",
  683. },
  684. },
  685. wantBool: true,
  686. },
  687. {
  688. name: "pod not found for node",
  689. pods: []*clustercache.Pod{
  690. {
  691. Name: "test-pod",
  692. Spec: clustercache.PodSpec{
  693. NodeName: "different-node",
  694. },
  695. },
  696. },
  697. awsKey: &awsKey{
  698. Name: "fargate-node-1",
  699. },
  700. wantPod: nil,
  701. wantBool: false,
  702. },
  703. {
  704. name: "no pods in cluster",
  705. pods: []*clustercache.Pod{},
  706. awsKey: &awsKey{
  707. Name: "fargate-node-1",
  708. },
  709. wantPod: nil,
  710. wantBool: false,
  711. },
  712. }
  713. for _, tt := range tests {
  714. t.Run(tt.name, func(t *testing.T) {
  715. aws := &AWS{
  716. Clientset: &clustercache.MockClusterCache{Pods: tt.pods},
  717. }
  718. gotPod, gotBool := aws.getFargatePod(tt.awsKey)
  719. if gotBool != tt.wantBool {
  720. t.Errorf("AWS.getFargatePod() gotBool = %v, want %v", gotBool, tt.wantBool)
  721. }
  722. if tt.wantPod == nil && gotPod != nil {
  723. t.Errorf("AWS.getFargatePod() gotPod = %v, want nil", gotPod)
  724. } else if tt.wantPod != nil && gotPod == nil {
  725. t.Errorf("AWS.getFargatePod() gotPod = nil, want %v", tt.wantPod)
  726. } else if tt.wantPod != nil && gotPod != nil {
  727. if gotPod.Name != tt.wantPod.Name || gotPod.Spec.NodeName != tt.wantPod.Spec.NodeName {
  728. t.Errorf("AWS.getFargatePod() gotPod = %v, want %v", gotPod, tt.wantPod)
  729. }
  730. }
  731. })
  732. }
  733. }
  734. // fakeProviderConfig implements models.ProviderConfig for testing
  735. type fakeProviderConfig struct {
  736. customPricing *models.CustomPricing
  737. }
  738. func (f *fakeProviderConfig) GetCustomPricingData() (*models.CustomPricing, error) {
  739. if f.customPricing != nil {
  740. return f.customPricing, nil
  741. }
  742. return &models.CustomPricing{}, nil
  743. }
  744. func (f *fakeProviderConfig) Update(func(*models.CustomPricing) error) (*models.CustomPricing, error) {
  745. return f.customPricing, nil
  746. }
  747. func (f *fakeProviderConfig) UpdateFromMap(map[string]string) (*models.CustomPricing, error) {
  748. return f.customPricing, nil
  749. }
  750. func (f *fakeProviderConfig) ConfigFileManager() *config.ConfigFileManager {
  751. return nil
  752. }
  753. func TestAWS_SpotFeedRefreshEnabled(t *testing.T) {
  754. tests := []struct {
  755. name string
  756. spotDataBucket string
  757. spotDataRegion string
  758. projectID string
  759. spotDataFeedEnabled string
  760. want bool
  761. }{
  762. {
  763. name: "disabled via config - with bucket",
  764. spotDataBucket: "my-bucket",
  765. spotDataRegion: "us-east-1",
  766. projectID: "123456789",
  767. spotDataFeedEnabled: "false",
  768. want: false,
  769. },
  770. {
  771. name: "disabled via config - with projectID only",
  772. projectID: "123456789",
  773. spotDataFeedEnabled: "false",
  774. want: false,
  775. },
  776. {
  777. name: "enabled by default - with bucket",
  778. spotDataBucket: "my-bucket",
  779. spotDataRegion: "us-east-1",
  780. projectID: "123456789",
  781. spotDataFeedEnabled: "",
  782. want: true,
  783. },
  784. {
  785. name: "enabled explicitly - with bucket",
  786. spotDataBucket: "my-bucket",
  787. spotDataRegion: "us-east-1",
  788. projectID: "123456789",
  789. spotDataFeedEnabled: "true",
  790. want: true,
  791. },
  792. {
  793. name: "no spot config - disabled",
  794. spotDataBucket: "",
  795. spotDataRegion: "",
  796. projectID: "",
  797. spotDataFeedEnabled: "",
  798. want: false,
  799. },
  800. {
  801. name: "no spot config - but explicitly enabled",
  802. spotDataBucket: "",
  803. spotDataRegion: "",
  804. projectID: "",
  805. spotDataFeedEnabled: "true",
  806. want: false,
  807. },
  808. {
  809. name: "only projectID set - enabled by default",
  810. projectID: "123456789",
  811. spotDataFeedEnabled: "",
  812. want: true,
  813. },
  814. {
  815. name: "only bucket set - enabled by default",
  816. spotDataBucket: "my-bucket",
  817. spotDataFeedEnabled: "",
  818. want: true,
  819. },
  820. {
  821. name: "only region set - enabled by default",
  822. spotDataRegion: "us-east-1",
  823. spotDataFeedEnabled: "",
  824. want: true,
  825. },
  826. }
  827. for _, tt := range tests {
  828. t.Run(tt.name, func(t *testing.T) {
  829. aws := &AWS{
  830. SpotDataBucket: tt.spotDataBucket,
  831. SpotDataRegion: tt.spotDataRegion,
  832. ProjectID: tt.projectID,
  833. Config: &fakeProviderConfig{
  834. customPricing: &models.CustomPricing{
  835. SpotDataFeedEnabled: tt.spotDataFeedEnabled,
  836. },
  837. },
  838. }
  839. got := aws.SpotFeedRefreshEnabled()
  840. if got != tt.want {
  841. t.Errorf("AWS.SpotFeedRefreshEnabled() = %v, want %v", got, tt.want)
  842. }
  843. })
  844. }
  845. // Test nil Config scenario to ensure no panic
  846. t.Run("nil config - falls back to field check", func(t *testing.T) {
  847. aws := &AWS{
  848. SpotDataBucket: "my-bucket",
  849. SpotDataRegion: "us-east-1",
  850. ProjectID: "123456789",
  851. Config: nil, // nil Config should not cause panic
  852. }
  853. got := aws.SpotFeedRefreshEnabled()
  854. want := true // Should fall back to field-based check
  855. if got != want {
  856. t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config = %v, want %v", got, want)
  857. }
  858. })
  859. t.Run("nil config - no spot fields", func(t *testing.T) {
  860. aws := &AWS{
  861. SpotDataBucket: "",
  862. SpotDataRegion: "",
  863. ProjectID: "",
  864. Config: nil, // nil Config should not cause panic
  865. }
  866. got := aws.SpotFeedRefreshEnabled()
  867. want := false // No fields set, should return false
  868. if got != want {
  869. t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config and no fields = %v, want %v", got, want)
  870. }
  871. })
  872. }
  873. func TestAWS_spotPricingFromHistory(t *testing.T) {
  874. t.Run("nil cache returns false", func(t *testing.T) {
  875. aws := &AWS{}
  876. key := &awsKey{
  877. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  878. Labels: map[string]string{
  879. "topology.kubernetes.io/region": "us-east-1",
  880. "topology.kubernetes.io/zone": "us-east-1a",
  881. "node.kubernetes.io/instance-type": "m5.large",
  882. "kubernetes.io/os": "linux",
  883. "eks.amazonaws.com/capacityType": "SPOT",
  884. },
  885. }
  886. _, ok := aws.spotPricingFromHistory(key)
  887. if ok {
  888. t.Error("Expected false when cache is nil")
  889. }
  890. })
  891. t.Run("missing region label returns false", func(t *testing.T) {
  892. mockFetcher := &mockSpotPriceHistoryFetcher{}
  893. aws := &AWS{
  894. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  895. }
  896. key := &awsKey{
  897. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  898. Labels: map[string]string{
  899. "topology.kubernetes.io/zone": "us-east-1a",
  900. "node.kubernetes.io/instance-type": "m5.large",
  901. },
  902. }
  903. _, ok := aws.spotPricingFromHistory(key)
  904. if ok {
  905. t.Error("Expected false when region label is missing")
  906. }
  907. })
  908. t.Run("missing instance type label returns false", func(t *testing.T) {
  909. mockFetcher := &mockSpotPriceHistoryFetcher{}
  910. aws := &AWS{
  911. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  912. }
  913. key := &awsKey{
  914. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  915. Labels: map[string]string{
  916. "topology.kubernetes.io/region": "us-east-1",
  917. "topology.kubernetes.io/zone": "us-east-1a",
  918. },
  919. }
  920. _, ok := aws.spotPricingFromHistory(key)
  921. if ok {
  922. t.Error("Expected false when instance type label is missing")
  923. }
  924. })
  925. t.Run("missing zone label returns false", func(t *testing.T) {
  926. mockFetcher := &mockSpotPriceHistoryFetcher{}
  927. aws := &AWS{
  928. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  929. }
  930. key := &awsKey{
  931. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  932. Labels: map[string]string{
  933. "topology.kubernetes.io/region": "us-east-1",
  934. "node.kubernetes.io/instance-type": "m5.large",
  935. },
  936. }
  937. _, ok := aws.spotPricingFromHistory(key)
  938. if ok {
  939. t.Error("Expected false when zone label is missing")
  940. }
  941. })
  942. t.Run("fetcher error returns false", func(t *testing.T) {
  943. mockFetcher := &mockSpotPriceHistoryFetcher{
  944. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  945. return nil, errors.New("api error")
  946. },
  947. }
  948. aws := &AWS{
  949. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  950. }
  951. key := &awsKey{
  952. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  953. Labels: map[string]string{
  954. "topology.kubernetes.io/region": "us-east-1",
  955. "topology.kubernetes.io/zone": "us-east-1a",
  956. "node.kubernetes.io/instance-type": "m5.large",
  957. },
  958. }
  959. _, ok := aws.spotPricingFromHistory(key)
  960. if ok {
  961. t.Error("Expected false when fetcher returns error")
  962. }
  963. })
  964. t.Run("successful lookup returns entry", func(t *testing.T) {
  965. mockFetcher := &mockSpotPriceHistoryFetcher{
  966. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  967. if key.Region != "us-east-1" || key.InstanceType != "m5.large" || key.AvailabilityZone != "us-east-1a" {
  968. t.Errorf("Unexpected key: %v", key)
  969. }
  970. return &SpotPriceHistoryEntry{
  971. SpotPrice: 0.042,
  972. Timestamp: time.Now(),
  973. RetrievedAt: time.Now(),
  974. }, nil
  975. },
  976. }
  977. aws := &AWS{
  978. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  979. }
  980. key := &awsKey{
  981. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  982. Labels: map[string]string{
  983. "topology.kubernetes.io/region": "us-east-1",
  984. "topology.kubernetes.io/zone": "us-east-1a",
  985. "node.kubernetes.io/instance-type": "m5.large",
  986. },
  987. }
  988. entry, ok := aws.spotPricingFromHistory(key)
  989. if !ok {
  990. t.Fatal("Expected true for successful lookup")
  991. }
  992. if entry.SpotPrice != 0.042 {
  993. t.Errorf("Expected spot price 0.042, got %f", entry.SpotPrice)
  994. }
  995. })
  996. }
  997. func TestAWS_createNode_spotHistoryFallback(t *testing.T) {
  998. // Helper to build AWSProductTerms with on-demand pricing
  999. makeTerms := func(sku, offerTermCode, cost string) *AWSProductTerms {
  1000. priceKey := sku + "." + offerTermCode + "." + HourlyRateCode
  1001. return &AWSProductTerms{
  1002. Sku: sku,
  1003. OnDemand: &AWSOfferTerm{
  1004. Sku: sku,
  1005. OfferTermCode: offerTermCode,
  1006. PriceDimensions: map[string]*AWSRateCode{
  1007. priceKey: {
  1008. Unit: "Hrs",
  1009. PricePerUnit: AWSCurrencyCode{USD: cost},
  1010. },
  1011. },
  1012. },
  1013. VCpu: "4",
  1014. Memory: "16",
  1015. }
  1016. }
  1017. t.Run("preemptible node uses spot history when available", func(t *testing.T) {
  1018. mockFetcher := &mockSpotPriceHistoryFetcher{
  1019. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1020. return &SpotPriceHistoryEntry{
  1021. SpotPrice: 0.035,
  1022. Timestamp: time.Now(),
  1023. RetrievedAt: time.Now(),
  1024. }, nil
  1025. },
  1026. }
  1027. aws := &AWS{
  1028. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1029. BaseCPUPrice: "0.04",
  1030. BaseRAMPrice: "0.01",
  1031. BaseGPUPrice: "0.95",
  1032. }
  1033. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1034. // Key with PreemptibleType suffix to trigger isPreemptible
  1035. key := &awsKey{
  1036. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1037. SpotLabelName: "eks.amazonaws.com/capacityType",
  1038. SpotLabelValue: "SPOT",
  1039. Labels: map[string]string{
  1040. "topology.kubernetes.io/region": "us-east-1",
  1041. "topology.kubernetes.io/zone": "us-east-1a",
  1042. "node.kubernetes.io/instance-type": "m5.large",
  1043. "kubernetes.io/os": "linux",
  1044. "eks.amazonaws.com/capacityType": "SPOT",
  1045. },
  1046. }
  1047. node, meta, err := aws.createNode(terms, PreemptibleType, key)
  1048. if err != nil {
  1049. t.Fatalf("Unexpected error: %v", err)
  1050. }
  1051. if node.Cost != "0.035000" {
  1052. t.Errorf("Expected spot history cost 0.035000, got %s", node.Cost)
  1053. }
  1054. if node.UsageType != PreemptibleType {
  1055. t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
  1056. }
  1057. if meta.Source != SpotPriceHistorySource {
  1058. t.Errorf("Expected source %s, got %s", SpotPriceHistorySource, meta.Source)
  1059. }
  1060. })
  1061. t.Run("preemptible node falls back to on-demand when history unavailable", func(t *testing.T) {
  1062. mockFetcher := &mockSpotPriceHistoryFetcher{
  1063. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1064. return nil, errors.New("no data")
  1065. },
  1066. }
  1067. aws := &AWS{
  1068. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1069. BaseCPUPrice: "0.04",
  1070. BaseRAMPrice: "0.01",
  1071. BaseGPUPrice: "0.95",
  1072. }
  1073. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1074. key := &awsKey{
  1075. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1076. SpotLabelName: "eks.amazonaws.com/capacityType",
  1077. SpotLabelValue: "SPOT",
  1078. Labels: map[string]string{
  1079. "topology.kubernetes.io/region": "us-east-1",
  1080. "topology.kubernetes.io/zone": "us-east-1a",
  1081. "node.kubernetes.io/instance-type": "m5.large",
  1082. "kubernetes.io/os": "linux",
  1083. "eks.amazonaws.com/capacityType": "SPOT",
  1084. },
  1085. }
  1086. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1087. if err != nil {
  1088. t.Fatalf("Unexpected error: %v", err)
  1089. }
  1090. if node.Cost != "0.096" {
  1091. t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
  1092. }
  1093. if node.UsageType != PreemptibleType {
  1094. t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
  1095. }
  1096. })
  1097. t.Run("preemptible node with nil cache falls back to on-demand", func(t *testing.T) {
  1098. aws := &AWS{
  1099. BaseCPUPrice: "0.04",
  1100. BaseRAMPrice: "0.01",
  1101. BaseGPUPrice: "0.95",
  1102. }
  1103. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1104. key := &awsKey{
  1105. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1106. SpotLabelName: "eks.amazonaws.com/capacityType",
  1107. SpotLabelValue: "SPOT",
  1108. Labels: map[string]string{
  1109. "topology.kubernetes.io/region": "us-east-1",
  1110. "topology.kubernetes.io/zone": "us-east-1a",
  1111. "node.kubernetes.io/instance-type": "m5.large",
  1112. "kubernetes.io/os": "linux",
  1113. "eks.amazonaws.com/capacityType": "SPOT",
  1114. },
  1115. }
  1116. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1117. if err != nil {
  1118. t.Fatalf("Unexpected error: %v", err)
  1119. }
  1120. if node.Cost != "0.096" {
  1121. t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
  1122. }
  1123. })
  1124. t.Run("preemptible node uses base spot prices when no public pricing", func(t *testing.T) {
  1125. mockFetcher := &mockSpotPriceHistoryFetcher{
  1126. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1127. return nil, errors.New("no data")
  1128. },
  1129. }
  1130. aws := &AWS{
  1131. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1132. BaseCPUPrice: "0.04",
  1133. BaseRAMPrice: "0.01",
  1134. BaseGPUPrice: "0.95",
  1135. BaseSpotCPUPrice: "0.02",
  1136. BaseSpotRAMPrice: "0.005",
  1137. }
  1138. // Terms without valid pricing dimensions
  1139. terms := &AWSProductTerms{
  1140. Sku: "SKU123",
  1141. OnDemand: &AWSOfferTerm{
  1142. Sku: "SKU123",
  1143. OfferTermCode: "JRTCKXETXF",
  1144. PriceDimensions: map[string]*AWSRateCode{},
  1145. },
  1146. VCpu: "4",
  1147. Memory: "16",
  1148. }
  1149. key := &awsKey{
  1150. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1151. SpotLabelName: "eks.amazonaws.com/capacityType",
  1152. SpotLabelValue: "SPOT",
  1153. Labels: map[string]string{
  1154. "topology.kubernetes.io/region": "us-east-1",
  1155. "topology.kubernetes.io/zone": "us-east-1a",
  1156. "node.kubernetes.io/instance-type": "m5.large",
  1157. "kubernetes.io/os": "linux",
  1158. "eks.amazonaws.com/capacityType": "SPOT",
  1159. },
  1160. }
  1161. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1162. if err != nil {
  1163. t.Fatalf("Unexpected error: %v", err)
  1164. }
  1165. if node.VCPUCost != "0.02" {
  1166. t.Errorf("Expected base spot CPU price 0.02, got %s", node.VCPUCost)
  1167. }
  1168. if node.RAMCost != "0.005" {
  1169. t.Errorf("Expected base spot RAM price 0.005, got %s", node.RAMCost)
  1170. }
  1171. })
  1172. }
  1173. func TestAWS_PricingSourceStatus_spotPriceHistory(t *testing.T) {
  1174. t.Run("not yet initialized", func(t *testing.T) {
  1175. aws := &AWS{
  1176. Config: &fakeProviderConfig{
  1177. customPricing: &models.CustomPricing{},
  1178. },
  1179. }
  1180. sources := aws.PricingSourceStatus()
  1181. sphs, ok := sources[SpotPriceHistorySource]
  1182. if !ok {
  1183. t.Fatal("Expected SpotPriceHistorySource in sources")
  1184. }
  1185. if sphs.Available {
  1186. t.Error("Expected Available=false when cache not initialized")
  1187. }
  1188. if sphs.Error != "Not yet initialized" {
  1189. t.Errorf("Expected 'Not yet initialized' error, got %q", sphs.Error)
  1190. }
  1191. })
  1192. t.Run("initialization error", func(t *testing.T) {
  1193. aws := &AWS{
  1194. SpotPriceHistoryError: errors.New("no cluster region configured"),
  1195. Config: &fakeProviderConfig{
  1196. customPricing: &models.CustomPricing{},
  1197. },
  1198. }
  1199. sources := aws.PricingSourceStatus()
  1200. sphs := sources[SpotPriceHistorySource]
  1201. if sphs.Available {
  1202. t.Error("Expected Available=false on error")
  1203. }
  1204. if sphs.Error != "no cluster region configured" {
  1205. t.Errorf("Expected error message, got %q", sphs.Error)
  1206. }
  1207. })
  1208. t.Run("successfully initialized", func(t *testing.T) {
  1209. mockFetcher := &mockSpotPriceHistoryFetcher{}
  1210. aws := &AWS{
  1211. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1212. Config: &fakeProviderConfig{
  1213. customPricing: &models.CustomPricing{},
  1214. },
  1215. }
  1216. sources := aws.PricingSourceStatus()
  1217. sphs := sources[SpotPriceHistorySource]
  1218. if !sphs.Available {
  1219. t.Error("Expected Available=true when cache initialized")
  1220. }
  1221. })
  1222. }
  1223. func TestAWS_findCostForDisk(t *testing.T) {
  1224. aws := &AWS{
  1225. ClusterRegion: "us-east-1",
  1226. Pricing: map[string]*AWSProductTerms{
  1227. "us-east-1,EBS:VolumeUsage.gp2": {
  1228. PV: &models.PV{
  1229. Cost: "0.10",
  1230. },
  1231. },
  1232. "us-west-2,EBS:VolumeUsage.gp2": {
  1233. PV: &models.PV{
  1234. Cost: "0.12",
  1235. },
  1236. },
  1237. "us-gov-west-1,EBS:VolumeUsage.gp2": {
  1238. PV: &models.PV{
  1239. Cost: "0.15",
  1240. },
  1241. },
  1242. },
  1243. }
  1244. size1 := int32(100)
  1245. expectedCost1 := 0.10 * 730.0 * 100.0
  1246. expectedCost2 := 0.12 * 730.0 * 100.0
  1247. expectedCostGov := 0.15 * 730.0 * 100.0
  1248. checkCost := func(t *testing.T, disk *ec2Types.Volume, expectedCost float64) {
  1249. cost, err := aws.findCostForDisk(disk)
  1250. if err != nil {
  1251. t.Fatalf("unexpected error: %v", err)
  1252. }
  1253. if cost == nil {
  1254. t.Fatalf("expected cost %v, got nil", expectedCost)
  1255. }
  1256. diff := *cost - expectedCost
  1257. if diff < 0 {
  1258. diff = -diff
  1259. }
  1260. if diff > 1e-9 {
  1261. t.Fatalf("expected cost %f, got %f", expectedCost, *cost)
  1262. }
  1263. }
  1264. // Case 1: Disk has AvailabilityZone matching ClusterRegion (e.g. us-east-1a)
  1265. t.Run("AZ matching ClusterRegion", func(t *testing.T) {
  1266. zone1 := "us-east-1a"
  1267. disk1 := &ec2Types.Volume{
  1268. AvailabilityZone: &zone1,
  1269. VolumeType: ec2Types.VolumeTypeGp2,
  1270. Size: &size1,
  1271. }
  1272. checkCost(t, disk1, expectedCost1)
  1273. })
  1274. // Case 2: Disk has AvailabilityZone from a different region (e.g. us-west-2b)
  1275. t.Run("AZ from different region", func(t *testing.T) {
  1276. zone2 := "us-west-2b"
  1277. disk2 := &ec2Types.Volume{
  1278. AvailabilityZone: &zone2,
  1279. VolumeType: ec2Types.VolumeTypeGp2,
  1280. Size: &size1,
  1281. }
  1282. checkCost(t, disk2, expectedCost2)
  1283. })
  1284. // Case 3: GovCloud region prefix match (e.g. us-gov-west-1a)
  1285. t.Run("GovCloud region prefix match", func(t *testing.T) {
  1286. zoneGov := "us-gov-west-1a"
  1287. diskGov := &ec2Types.Volume{
  1288. AvailabilityZone: &zoneGov,
  1289. VolumeType: ec2Types.VolumeTypeGp2,
  1290. Size: &size1,
  1291. }
  1292. checkCost(t, diskGov, expectedCostGov)
  1293. })
  1294. // Case 4: Invalid/unknown region format, should fall back to ClusterRegion
  1295. t.Run("Invalid AZ fallback", func(t *testing.T) {
  1296. zoneUnknown := "unknown-zone"
  1297. diskUnknown := &ec2Types.Volume{
  1298. AvailabilityZone: &zoneUnknown,
  1299. VolumeType: ec2Types.VolumeTypeGp2,
  1300. Size: &size1,
  1301. }
  1302. checkCost(t, diskUnknown, expectedCost1)
  1303. })
  1304. // Case 5: Valid region but pricing not loaded, should fall back to ClusterRegion
  1305. t.Run("No pricing loaded fallback", func(t *testing.T) {
  1306. zoneNoPrice := "eu-west-1b"
  1307. diskNoPrice := &ec2Types.Volume{
  1308. AvailabilityZone: &zoneNoPrice,
  1309. VolumeType: ec2Types.VolumeTypeGp2,
  1310. Size: &size1,
  1311. }
  1312. checkCost(t, diskNoPrice, expectedCost1)
  1313. })
  1314. // Case 6: Region-specific pricing key is present but value is nil.
  1315. // It should return an error, NOT fall back.
  1316. t.Run("Region pricing key present but value is nil", func(t *testing.T) {
  1317. awsCustom := &AWS{
  1318. ClusterRegion: "us-east-1",
  1319. Pricing: map[string]*AWSProductTerms{
  1320. "us-east-1,EBS:VolumeUsage.gp2": {
  1321. PV: &models.PV{
  1322. Cost: "0.10",
  1323. },
  1324. },
  1325. "us-west-2,EBS:VolumeUsage.gp2": nil,
  1326. },
  1327. }
  1328. zone := "us-west-2b"
  1329. disk := &ec2Types.Volume{
  1330. AvailabilityZone: &zone,
  1331. VolumeType: ec2Types.VolumeTypeGp2,
  1332. Size: &size1,
  1333. }
  1334. _, err := awsCustom.findCostForDisk(disk)
  1335. if err == nil {
  1336. t.Fatal("expected error because us-west-2 pricing is nil, but got nil error")
  1337. }
  1338. expectedErr := "nil pricing data for key 'us-west-2,EBS:VolumeUsage.gp2'"
  1339. if err.Error() != expectedErr {
  1340. t.Fatalf("expected error message %q, got %q", expectedErr, err.Error())
  1341. }
  1342. })
  1343. // Case 7: Region-specific pricing key is present but pricing.PV is nil.
  1344. // It should return an error, NOT fall back.
  1345. t.Run("Region pricing key present but PV is nil", func(t *testing.T) {
  1346. awsCustom := &AWS{
  1347. ClusterRegion: "us-east-1",
  1348. Pricing: map[string]*AWSProductTerms{
  1349. "us-east-1,EBS:VolumeUsage.gp2": {
  1350. PV: &models.PV{
  1351. Cost: "0.10",
  1352. },
  1353. },
  1354. "us-west-2,EBS:VolumeUsage.gp2": {
  1355. PV: nil,
  1356. },
  1357. },
  1358. }
  1359. zone := "us-west-2b"
  1360. disk := &ec2Types.Volume{
  1361. AvailabilityZone: &zone,
  1362. VolumeType: ec2Types.VolumeTypeGp2,
  1363. Size: &size1,
  1364. }
  1365. _, err := awsCustom.findCostForDisk(disk)
  1366. if err == nil {
  1367. t.Fatal("expected error because us-west-2 pricing PV is nil, but got nil error")
  1368. }
  1369. expectedErr := "pricing for key 'us-west-2,EBS:VolumeUsage.gp2' has nil PV"
  1370. if err.Error() != expectedErr {
  1371. t.Fatalf("expected error message %q, got %q", expectedErr, err.Error())
  1372. }
  1373. })
  1374. // Case 8: Region-specific pricing key is present but pricing.PV.Cost is unparsable.
  1375. // It should return an error, NOT fall back.
  1376. t.Run("Region pricing key present but Cost is unparsable", func(t *testing.T) {
  1377. awsCustom := &AWS{
  1378. ClusterRegion: "us-east-1",
  1379. Pricing: map[string]*AWSProductTerms{
  1380. "us-east-1,EBS:VolumeUsage.gp2": {
  1381. PV: &models.PV{
  1382. Cost: "0.10",
  1383. },
  1384. },
  1385. "us-west-2,EBS:VolumeUsage.gp2": {
  1386. PV: &models.PV{
  1387. Cost: "not-a-float",
  1388. },
  1389. },
  1390. },
  1391. }
  1392. zone := "us-west-2b"
  1393. disk := &ec2Types.Volume{
  1394. AvailabilityZone: &zone,
  1395. VolumeType: ec2Types.VolumeTypeGp2,
  1396. Size: &size1,
  1397. }
  1398. _, err := awsCustom.findCostForDisk(disk)
  1399. if err == nil {
  1400. t.Fatal("expected error because us-west-2 pricing cost is unparsable, but got nil error")
  1401. }
  1402. if !strings.Contains(err.Error(), "parsing \"not-a-float\"") {
  1403. t.Fatalf("expected parsing float error, got %q", err.Error())
  1404. }
  1405. })
  1406. }