provider_test.go 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297
  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 PriceListEC2Response
  129. var pricingData PriceListEC2Response
  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: &PriceListEC2Term{
  197. Sku: "M6UGCCQ3CDJQAA37",
  198. OfferTermCode: "JRTCKXETXF",
  199. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  200. "M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": {
  201. Unit: "GB-Mo",
  202. PricePerUnit: PriceListEC2PricePerUnit{
  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: &PriceListEC2Term{
  225. Sku: "8D49XP354UEYTHGM",
  226. OfferTermCode: "MZU6U2429S",
  227. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  228. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  229. Unit: "Quantity",
  230. PricePerUnit: PriceListEC2PricePerUnit{
  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: &PriceListEC2Term{
  245. Sku: "8D49XP354UEYTHGM",
  246. OfferTermCode: "MZU6U2429S",
  247. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  248. "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
  249. Unit: "Quantity",
  250. PricePerUnit: PriceListEC2PricePerUnit{
  251. USD: "1161",
  252. CNY: "",
  253. },
  254. },
  255. },
  256. },
  257. }
  258. expectedProdTermsLoadbalancer := &AWSProductTerms{
  259. Sku: "Y9RYMSE644KDSV4S",
  260. OnDemand: &PriceListEC2Term{
  261. Sku: "Y9RYMSE644KDSV4S",
  262. OfferTermCode: "JRTCKXETXF",
  263. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  264. "Y9RYMSE644KDSV4S.JRTCKXETXF.6YS6EN2CT7": {
  265. Unit: "Hrs",
  266. PricePerUnit: PriceListEC2PricePerUnit{
  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: &PriceListEC2Term{
  316. Sku: "H7NGEAC6UEHNTKSJ",
  317. OfferTermCode: "JRTCKXETXF",
  318. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  319. "H7NGEAC6UEHNTKSJ.JRTCKXETXF.6YS6EN2CT7": {
  320. Unit: "Hrs",
  321. PricePerUnit: PriceListEC2PricePerUnit{
  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: &PriceListEC2Term{
  364. Sku: "R83VXG9NAPDASEGN",
  365. OfferTermCode: "5Y9WH78GDR",
  366. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  367. "R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": {
  368. Unit: "GB-Mo",
  369. PricePerUnit: PriceListEC2PricePerUnit{
  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. func TestAWS_getFargatePod(t *testing.T) {
  657. tests := []struct {
  658. name string
  659. pods []*clustercache.Pod
  660. awsKey *awsKey
  661. wantPod *clustercache.Pod
  662. wantBool bool
  663. }{
  664. {
  665. name: "pod found for node",
  666. pods: []*clustercache.Pod{
  667. {
  668. Name: "test-pod",
  669. Spec: clustercache.PodSpec{
  670. NodeName: "fargate-node-1",
  671. },
  672. },
  673. },
  674. awsKey: &awsKey{
  675. Name: "fargate-node-1",
  676. },
  677. wantPod: &clustercache.Pod{
  678. Name: "test-pod",
  679. Spec: clustercache.PodSpec{
  680. NodeName: "fargate-node-1",
  681. },
  682. },
  683. wantBool: true,
  684. },
  685. {
  686. name: "pod not found for node",
  687. pods: []*clustercache.Pod{
  688. {
  689. Name: "test-pod",
  690. Spec: clustercache.PodSpec{
  691. NodeName: "different-node",
  692. },
  693. },
  694. },
  695. awsKey: &awsKey{
  696. Name: "fargate-node-1",
  697. },
  698. wantPod: nil,
  699. wantBool: false,
  700. },
  701. {
  702. name: "no pods in cluster",
  703. pods: []*clustercache.Pod{},
  704. awsKey: &awsKey{
  705. Name: "fargate-node-1",
  706. },
  707. wantPod: nil,
  708. wantBool: false,
  709. },
  710. }
  711. for _, tt := range tests {
  712. t.Run(tt.name, func(t *testing.T) {
  713. aws := &AWS{
  714. Clientset: &clustercache.MockClusterCache{Pods: tt.pods},
  715. }
  716. gotPod, gotBool := aws.getFargatePod(tt.awsKey)
  717. if gotBool != tt.wantBool {
  718. t.Errorf("AWS.getFargatePod() gotBool = %v, want %v", gotBool, tt.wantBool)
  719. }
  720. if tt.wantPod == nil && gotPod != nil {
  721. t.Errorf("AWS.getFargatePod() gotPod = %v, want nil", gotPod)
  722. } else if tt.wantPod != nil && gotPod == nil {
  723. t.Errorf("AWS.getFargatePod() gotPod = nil, want %v", tt.wantPod)
  724. } else if tt.wantPod != nil && gotPod != nil {
  725. if gotPod.Name != tt.wantPod.Name || gotPod.Spec.NodeName != tt.wantPod.Spec.NodeName {
  726. t.Errorf("AWS.getFargatePod() gotPod = %v, want %v", gotPod, tt.wantPod)
  727. }
  728. }
  729. })
  730. }
  731. }
  732. // fakeProviderConfig implements models.ProviderConfig for testing
  733. type fakeProviderConfig struct {
  734. customPricing *models.CustomPricing
  735. }
  736. func (f *fakeProviderConfig) GetCustomPricingData() (*models.CustomPricing, error) {
  737. if f.customPricing != nil {
  738. return f.customPricing, nil
  739. }
  740. return &models.CustomPricing{}, nil
  741. }
  742. func (f *fakeProviderConfig) Update(func(*models.CustomPricing) error) (*models.CustomPricing, error) {
  743. return f.customPricing, nil
  744. }
  745. func (f *fakeProviderConfig) UpdateFromMap(map[string]string) (*models.CustomPricing, error) {
  746. return f.customPricing, nil
  747. }
  748. func (f *fakeProviderConfig) ConfigFileManager() *config.ConfigFileManager {
  749. return nil
  750. }
  751. func TestAWS_SpotFeedRefreshEnabled(t *testing.T) {
  752. tests := []struct {
  753. name string
  754. spotDataBucket string
  755. spotDataRegion string
  756. projectID string
  757. spotDataFeedEnabled string
  758. want bool
  759. }{
  760. {
  761. name: "disabled via config - with bucket",
  762. spotDataBucket: "my-bucket",
  763. spotDataRegion: "us-east-1",
  764. projectID: "123456789",
  765. spotDataFeedEnabled: "false",
  766. want: false,
  767. },
  768. {
  769. name: "disabled via config - with projectID only",
  770. projectID: "123456789",
  771. spotDataFeedEnabled: "false",
  772. want: false,
  773. },
  774. {
  775. name: "enabled by default - with bucket",
  776. spotDataBucket: "my-bucket",
  777. spotDataRegion: "us-east-1",
  778. projectID: "123456789",
  779. spotDataFeedEnabled: "",
  780. want: true,
  781. },
  782. {
  783. name: "enabled explicitly - with bucket",
  784. spotDataBucket: "my-bucket",
  785. spotDataRegion: "us-east-1",
  786. projectID: "123456789",
  787. spotDataFeedEnabled: "true",
  788. want: true,
  789. },
  790. {
  791. name: "no spot config - disabled",
  792. spotDataBucket: "",
  793. spotDataRegion: "",
  794. projectID: "",
  795. spotDataFeedEnabled: "",
  796. want: false,
  797. },
  798. {
  799. name: "no spot config - but explicitly enabled",
  800. spotDataBucket: "",
  801. spotDataRegion: "",
  802. projectID: "",
  803. spotDataFeedEnabled: "true",
  804. want: false,
  805. },
  806. {
  807. name: "only projectID set - enabled by default",
  808. projectID: "123456789",
  809. spotDataFeedEnabled: "",
  810. want: true,
  811. },
  812. {
  813. name: "only bucket set - enabled by default",
  814. spotDataBucket: "my-bucket",
  815. spotDataFeedEnabled: "",
  816. want: true,
  817. },
  818. {
  819. name: "only region set - enabled by default",
  820. spotDataRegion: "us-east-1",
  821. spotDataFeedEnabled: "",
  822. want: true,
  823. },
  824. }
  825. for _, tt := range tests {
  826. t.Run(tt.name, func(t *testing.T) {
  827. aws := &AWS{
  828. SpotDataBucket: tt.spotDataBucket,
  829. SpotDataRegion: tt.spotDataRegion,
  830. ProjectID: tt.projectID,
  831. Config: &fakeProviderConfig{
  832. customPricing: &models.CustomPricing{
  833. SpotDataFeedEnabled: tt.spotDataFeedEnabled,
  834. },
  835. },
  836. }
  837. got := aws.SpotFeedRefreshEnabled()
  838. if got != tt.want {
  839. t.Errorf("AWS.SpotFeedRefreshEnabled() = %v, want %v", got, tt.want)
  840. }
  841. })
  842. }
  843. // Test nil Config scenario to ensure no panic
  844. t.Run("nil config - falls back to field check", func(t *testing.T) {
  845. aws := &AWS{
  846. SpotDataBucket: "my-bucket",
  847. SpotDataRegion: "us-east-1",
  848. ProjectID: "123456789",
  849. Config: nil, // nil Config should not cause panic
  850. }
  851. got := aws.SpotFeedRefreshEnabled()
  852. want := true // Should fall back to field-based check
  853. if got != want {
  854. t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config = %v, want %v", got, want)
  855. }
  856. })
  857. t.Run("nil config - no spot fields", func(t *testing.T) {
  858. aws := &AWS{
  859. SpotDataBucket: "",
  860. SpotDataRegion: "",
  861. ProjectID: "",
  862. Config: nil, // nil Config should not cause panic
  863. }
  864. got := aws.SpotFeedRefreshEnabled()
  865. want := false // No fields set, should return false
  866. if got != want {
  867. t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config and no fields = %v, want %v", got, want)
  868. }
  869. })
  870. }
  871. func TestAWS_spotPricingFromHistory(t *testing.T) {
  872. t.Run("nil cache returns false", func(t *testing.T) {
  873. aws := &AWS{}
  874. key := &awsKey{
  875. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  876. Labels: map[string]string{
  877. "topology.kubernetes.io/region": "us-east-1",
  878. "topology.kubernetes.io/zone": "us-east-1a",
  879. "node.kubernetes.io/instance-type": "m5.large",
  880. "kubernetes.io/os": "linux",
  881. "eks.amazonaws.com/capacityType": "SPOT",
  882. },
  883. }
  884. _, ok := aws.spotPricingFromHistory(key)
  885. if ok {
  886. t.Error("Expected false when cache is nil")
  887. }
  888. })
  889. t.Run("missing region label returns false", func(t *testing.T) {
  890. mockFetcher := &mockSpotPriceHistoryFetcher{}
  891. aws := &AWS{
  892. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  893. }
  894. key := &awsKey{
  895. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  896. Labels: map[string]string{
  897. "topology.kubernetes.io/zone": "us-east-1a",
  898. "node.kubernetes.io/instance-type": "m5.large",
  899. },
  900. }
  901. _, ok := aws.spotPricingFromHistory(key)
  902. if ok {
  903. t.Error("Expected false when region label is missing")
  904. }
  905. })
  906. t.Run("missing instance type label returns false", func(t *testing.T) {
  907. mockFetcher := &mockSpotPriceHistoryFetcher{}
  908. aws := &AWS{
  909. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  910. }
  911. key := &awsKey{
  912. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  913. Labels: map[string]string{
  914. "topology.kubernetes.io/region": "us-east-1",
  915. "topology.kubernetes.io/zone": "us-east-1a",
  916. },
  917. }
  918. _, ok := aws.spotPricingFromHistory(key)
  919. if ok {
  920. t.Error("Expected false when instance type label is missing")
  921. }
  922. })
  923. t.Run("missing zone label returns false", func(t *testing.T) {
  924. mockFetcher := &mockSpotPriceHistoryFetcher{}
  925. aws := &AWS{
  926. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  927. }
  928. key := &awsKey{
  929. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  930. Labels: map[string]string{
  931. "topology.kubernetes.io/region": "us-east-1",
  932. "node.kubernetes.io/instance-type": "m5.large",
  933. },
  934. }
  935. _, ok := aws.spotPricingFromHistory(key)
  936. if ok {
  937. t.Error("Expected false when zone label is missing")
  938. }
  939. })
  940. t.Run("fetcher error returns false", func(t *testing.T) {
  941. mockFetcher := &mockSpotPriceHistoryFetcher{
  942. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  943. return nil, errors.New("api error")
  944. },
  945. }
  946. aws := &AWS{
  947. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  948. }
  949. key := &awsKey{
  950. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  951. Labels: map[string]string{
  952. "topology.kubernetes.io/region": "us-east-1",
  953. "topology.kubernetes.io/zone": "us-east-1a",
  954. "node.kubernetes.io/instance-type": "m5.large",
  955. },
  956. }
  957. _, ok := aws.spotPricingFromHistory(key)
  958. if ok {
  959. t.Error("Expected false when fetcher returns error")
  960. }
  961. })
  962. t.Run("successful lookup returns entry", func(t *testing.T) {
  963. mockFetcher := &mockSpotPriceHistoryFetcher{
  964. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  965. if key.Region != "us-east-1" || key.InstanceType != "m5.large" || key.AvailabilityZone != "us-east-1a" {
  966. t.Errorf("Unexpected key: %v", key)
  967. }
  968. return &SpotPriceHistoryEntry{
  969. SpotPrice: 0.042,
  970. Timestamp: time.Now(),
  971. RetrievedAt: time.Now(),
  972. }, nil
  973. },
  974. }
  975. aws := &AWS{
  976. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  977. }
  978. key := &awsKey{
  979. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  980. Labels: map[string]string{
  981. "topology.kubernetes.io/region": "us-east-1",
  982. "topology.kubernetes.io/zone": "us-east-1a",
  983. "node.kubernetes.io/instance-type": "m5.large",
  984. },
  985. }
  986. entry, ok := aws.spotPricingFromHistory(key)
  987. if !ok {
  988. t.Fatal("Expected true for successful lookup")
  989. }
  990. if entry.SpotPrice != 0.042 {
  991. t.Errorf("Expected spot price 0.042, got %f", entry.SpotPrice)
  992. }
  993. })
  994. }
  995. func TestAWS_createNode_spotHistoryFallback(t *testing.T) {
  996. // Helper to build AWSProductTerms with on-demand pricing
  997. makeTerms := func(sku, offerTermCode, cost string) *AWSProductTerms {
  998. priceKey := sku + "." + offerTermCode + "." + HourlyRateCode
  999. return &AWSProductTerms{
  1000. Sku: sku,
  1001. OnDemand: &PriceListEC2Term{
  1002. Sku: sku,
  1003. OfferTermCode: offerTermCode,
  1004. PriceDimensions: map[string]*PriceListEC2PriceDimension{
  1005. priceKey: {
  1006. Unit: "Hrs",
  1007. PricePerUnit: PriceListEC2PricePerUnit{USD: cost},
  1008. },
  1009. },
  1010. },
  1011. VCpu: "4",
  1012. Memory: "16",
  1013. }
  1014. }
  1015. t.Run("preemptible node uses spot history when available", func(t *testing.T) {
  1016. mockFetcher := &mockSpotPriceHistoryFetcher{
  1017. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1018. return &SpotPriceHistoryEntry{
  1019. SpotPrice: 0.035,
  1020. Timestamp: time.Now(),
  1021. RetrievedAt: time.Now(),
  1022. }, nil
  1023. },
  1024. }
  1025. aws := &AWS{
  1026. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1027. BaseCPUPrice: "0.04",
  1028. BaseRAMPrice: "0.01",
  1029. BaseGPUPrice: "0.95",
  1030. }
  1031. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1032. // Key with PreemptibleType suffix to trigger isPreemptible
  1033. key := &awsKey{
  1034. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1035. SpotLabelName: "eks.amazonaws.com/capacityType",
  1036. SpotLabelValue: "SPOT",
  1037. Labels: map[string]string{
  1038. "topology.kubernetes.io/region": "us-east-1",
  1039. "topology.kubernetes.io/zone": "us-east-1a",
  1040. "node.kubernetes.io/instance-type": "m5.large",
  1041. "kubernetes.io/os": "linux",
  1042. "eks.amazonaws.com/capacityType": "SPOT",
  1043. },
  1044. }
  1045. node, meta, err := aws.createNode(terms, PreemptibleType, key)
  1046. if err != nil {
  1047. t.Fatalf("Unexpected error: %v", err)
  1048. }
  1049. if node.Cost != "0.035000" {
  1050. t.Errorf("Expected spot history cost 0.035000, got %s", node.Cost)
  1051. }
  1052. if node.UsageType != PreemptibleType {
  1053. t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
  1054. }
  1055. if meta.Source != SpotPriceHistorySource {
  1056. t.Errorf("Expected source %s, got %s", SpotPriceHistorySource, meta.Source)
  1057. }
  1058. })
  1059. t.Run("preemptible node falls back to on-demand when history unavailable", func(t *testing.T) {
  1060. mockFetcher := &mockSpotPriceHistoryFetcher{
  1061. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1062. return nil, errors.New("no data")
  1063. },
  1064. }
  1065. aws := &AWS{
  1066. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1067. BaseCPUPrice: "0.04",
  1068. BaseRAMPrice: "0.01",
  1069. BaseGPUPrice: "0.95",
  1070. }
  1071. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1072. key := &awsKey{
  1073. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1074. SpotLabelName: "eks.amazonaws.com/capacityType",
  1075. SpotLabelValue: "SPOT",
  1076. Labels: map[string]string{
  1077. "topology.kubernetes.io/region": "us-east-1",
  1078. "topology.kubernetes.io/zone": "us-east-1a",
  1079. "node.kubernetes.io/instance-type": "m5.large",
  1080. "kubernetes.io/os": "linux",
  1081. "eks.amazonaws.com/capacityType": "SPOT",
  1082. },
  1083. }
  1084. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1085. if err != nil {
  1086. t.Fatalf("Unexpected error: %v", err)
  1087. }
  1088. if node.Cost != "0.096" {
  1089. t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
  1090. }
  1091. if node.UsageType != PreemptibleType {
  1092. t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
  1093. }
  1094. })
  1095. t.Run("preemptible node with nil cache falls back to on-demand", func(t *testing.T) {
  1096. aws := &AWS{
  1097. BaseCPUPrice: "0.04",
  1098. BaseRAMPrice: "0.01",
  1099. BaseGPUPrice: "0.95",
  1100. }
  1101. terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
  1102. key := &awsKey{
  1103. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1104. SpotLabelName: "eks.amazonaws.com/capacityType",
  1105. SpotLabelValue: "SPOT",
  1106. Labels: map[string]string{
  1107. "topology.kubernetes.io/region": "us-east-1",
  1108. "topology.kubernetes.io/zone": "us-east-1a",
  1109. "node.kubernetes.io/instance-type": "m5.large",
  1110. "kubernetes.io/os": "linux",
  1111. "eks.amazonaws.com/capacityType": "SPOT",
  1112. },
  1113. }
  1114. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1115. if err != nil {
  1116. t.Fatalf("Unexpected error: %v", err)
  1117. }
  1118. if node.Cost != "0.096" {
  1119. t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
  1120. }
  1121. })
  1122. t.Run("preemptible node uses base spot prices when no public pricing", func(t *testing.T) {
  1123. mockFetcher := &mockSpotPriceHistoryFetcher{
  1124. fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
  1125. return nil, errors.New("no data")
  1126. },
  1127. }
  1128. aws := &AWS{
  1129. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1130. BaseCPUPrice: "0.04",
  1131. BaseRAMPrice: "0.01",
  1132. BaseGPUPrice: "0.95",
  1133. BaseSpotCPUPrice: "0.02",
  1134. BaseSpotRAMPrice: "0.005",
  1135. }
  1136. // Terms without valid pricing dimensions
  1137. terms := &AWSProductTerms{
  1138. Sku: "SKU123",
  1139. OnDemand: &PriceListEC2Term{
  1140. Sku: "SKU123",
  1141. OfferTermCode: "JRTCKXETXF",
  1142. PriceDimensions: map[string]*PriceListEC2PriceDimension{},
  1143. },
  1144. VCpu: "4",
  1145. Memory: "16",
  1146. }
  1147. key := &awsKey{
  1148. ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
  1149. SpotLabelName: "eks.amazonaws.com/capacityType",
  1150. SpotLabelValue: "SPOT",
  1151. Labels: map[string]string{
  1152. "topology.kubernetes.io/region": "us-east-1",
  1153. "topology.kubernetes.io/zone": "us-east-1a",
  1154. "node.kubernetes.io/instance-type": "m5.large",
  1155. "kubernetes.io/os": "linux",
  1156. "eks.amazonaws.com/capacityType": "SPOT",
  1157. },
  1158. }
  1159. node, _, err := aws.createNode(terms, PreemptibleType, key)
  1160. if err != nil {
  1161. t.Fatalf("Unexpected error: %v", err)
  1162. }
  1163. if node.VCPUCost != "0.02" {
  1164. t.Errorf("Expected base spot CPU price 0.02, got %s", node.VCPUCost)
  1165. }
  1166. if node.RAMCost != "0.005" {
  1167. t.Errorf("Expected base spot RAM price 0.005, got %s", node.RAMCost)
  1168. }
  1169. })
  1170. }
  1171. func TestAWS_PricingSourceStatus_spotPriceHistory(t *testing.T) {
  1172. t.Run("not yet initialized", func(t *testing.T) {
  1173. aws := &AWS{
  1174. Config: &fakeProviderConfig{
  1175. customPricing: &models.CustomPricing{},
  1176. },
  1177. }
  1178. sources := aws.PricingSourceStatus()
  1179. sphs, ok := sources[SpotPriceHistorySource]
  1180. if !ok {
  1181. t.Fatal("Expected SpotPriceHistorySource in sources")
  1182. }
  1183. if sphs.Available {
  1184. t.Error("Expected Available=false when cache not initialized")
  1185. }
  1186. if sphs.Error != "Not yet initialized" {
  1187. t.Errorf("Expected 'Not yet initialized' error, got %q", sphs.Error)
  1188. }
  1189. })
  1190. t.Run("initialization error", func(t *testing.T) {
  1191. aws := &AWS{
  1192. SpotPriceHistoryError: errors.New("no cluster region configured"),
  1193. Config: &fakeProviderConfig{
  1194. customPricing: &models.CustomPricing{},
  1195. },
  1196. }
  1197. sources := aws.PricingSourceStatus()
  1198. sphs := sources[SpotPriceHistorySource]
  1199. if sphs.Available {
  1200. t.Error("Expected Available=false on error")
  1201. }
  1202. if sphs.Error != "no cluster region configured" {
  1203. t.Errorf("Expected error message, got %q", sphs.Error)
  1204. }
  1205. })
  1206. t.Run("successfully initialized", func(t *testing.T) {
  1207. mockFetcher := &mockSpotPriceHistoryFetcher{}
  1208. aws := &AWS{
  1209. SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
  1210. Config: &fakeProviderConfig{
  1211. customPricing: &models.CustomPricing{},
  1212. },
  1213. }
  1214. sources := aws.PricingSourceStatus()
  1215. sphs := sources[SpotPriceHistorySource]
  1216. if !sphs.Available {
  1217. t.Error("Expected Available=true when cache initialized")
  1218. }
  1219. })
  1220. }