provider_test.go 36 KB

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